feat: add favorites drawer management
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
const activeDialogs = new WeakMap<
|
||||
Document,
|
||||
{
|
||||
input: HTMLInputElement;
|
||||
promise: Promise<string | null>;
|
||||
}
|
||||
>();
|
||||
|
||||
export function promptForFavoriteFolderName(
|
||||
document: Document,
|
||||
options: {
|
||||
initialValue?: string;
|
||||
title: "新建收藏夹" | "重命名收藏夹";
|
||||
}
|
||||
): Promise<string | null> {
|
||||
const activeDialog = activeDialogs.get(document);
|
||||
if (activeDialog) {
|
||||
activeDialog.input.focus();
|
||||
activeDialog.input.select();
|
||||
return activeDialog.promise;
|
||||
}
|
||||
|
||||
const previousFocusedElement = document.activeElement;
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.dataset.scesFavoriteFolderDialog = "root";
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-modal", "true");
|
||||
root.setAttribute("aria-labelledby", "sces-favorite-folder-dialog-title");
|
||||
applyOverlayStyles(root);
|
||||
|
||||
const panel = document.createElement("section");
|
||||
applyPanelStyles(panel);
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.id = "sces-favorite-folder-dialog-title";
|
||||
title.textContent = options.title;
|
||||
applyTitleStyles(title);
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.value = options.initialValue ?? "";
|
||||
input.maxLength = 60;
|
||||
input.placeholder = "请输入收藏夹名称";
|
||||
input.dataset.scesFavoriteFolderDialog = "input";
|
||||
input.setAttribute("aria-label", "收藏夹名称");
|
||||
applyInputStyles(input);
|
||||
|
||||
const error = document.createElement("p");
|
||||
error.dataset.scesFavoriteFolderDialog = "error";
|
||||
error.setAttribute("aria-live", "polite");
|
||||
applyErrorStyles(error);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
applyActionStyles(actions);
|
||||
|
||||
const cancelButton = createButton(document, "取消");
|
||||
cancelButton.dataset.scesFavoriteFolderDialog = "cancel";
|
||||
applySecondaryButtonStyles(cancelButton);
|
||||
|
||||
const confirmButton = createButton(document, "确认");
|
||||
confirmButton.dataset.scesFavoriteFolderDialog = "confirm";
|
||||
applyPrimaryButtonStyles(confirmButton);
|
||||
|
||||
actions.append(cancelButton, confirmButton);
|
||||
panel.append(title, input, error, actions);
|
||||
root.append(panel);
|
||||
(document.body ?? document.documentElement).append(root);
|
||||
|
||||
let closeDialog: (value: string | null) => void = () => {};
|
||||
const promise = new Promise<string | null>((resolve) => {
|
||||
let closed = false;
|
||||
|
||||
const clearError = () => {
|
||||
if (!input.value.trim()) {
|
||||
return;
|
||||
}
|
||||
error.textContent = "";
|
||||
input.removeAttribute("aria-invalid");
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
error.textContent = "请输入收藏夹名称";
|
||||
input.setAttribute("aria-invalid", "true");
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
closeDialog(value);
|
||||
};
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeDialog(null);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
const focusableElements = Array.from(
|
||||
root.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled)")
|
||||
);
|
||||
const activeIndex = focusableElements.indexOf(
|
||||
document.activeElement as HTMLElement
|
||||
);
|
||||
const nextIndex = event.shiftKey
|
||||
? activeIndex <= 0
|
||||
? focusableElements.length - 1
|
||||
: activeIndex - 1
|
||||
: activeIndex === focusableElements.length - 1
|
||||
? 0
|
||||
: activeIndex + 1;
|
||||
const nextElement = focusableElements[nextIndex];
|
||||
if (nextElement) {
|
||||
event.preventDefault();
|
||||
nextElement.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const onOverlayClick = (event: MouseEvent) => {
|
||||
if (event.target === root) {
|
||||
closeDialog(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onCancel = () => closeDialog(null);
|
||||
const onConfirm = () => submit();
|
||||
|
||||
closeDialog = (value) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
activeDialogs.delete(document);
|
||||
input.removeEventListener("input", clearError);
|
||||
cancelButton.removeEventListener("click", onCancel);
|
||||
confirmButton.removeEventListener("click", onConfirm);
|
||||
root.removeEventListener("click", onOverlayClick);
|
||||
document.removeEventListener("keydown", onKeydown, true);
|
||||
root.remove();
|
||||
if (
|
||||
previousFocusedElement instanceof HTMLElement &&
|
||||
previousFocusedElement.isConnected
|
||||
) {
|
||||
previousFocusedElement.focus();
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
input.addEventListener("input", clearError);
|
||||
cancelButton.addEventListener("click", onCancel);
|
||||
confirmButton.addEventListener("click", onConfirm);
|
||||
root.addEventListener("click", onOverlayClick);
|
||||
document.addEventListener("keydown", onKeydown, true);
|
||||
});
|
||||
|
||||
activeDialogs.set(document, { input, promise });
|
||||
input.focus();
|
||||
return promise;
|
||||
}
|
||||
|
||||
function createButton(document: Document, label: string): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
return button;
|
||||
}
|
||||
|
||||
function applyOverlayStyles(root: HTMLElement): void {
|
||||
root.style.position = "fixed";
|
||||
root.style.inset = "0";
|
||||
root.style.zIndex = "2147483647";
|
||||
root.style.display = "flex";
|
||||
root.style.alignItems = "center";
|
||||
root.style.justifyContent = "center";
|
||||
root.style.padding = "24px";
|
||||
root.style.boxSizing = "border-box";
|
||||
root.style.background = "rgba(15, 23, 42, 0.38)";
|
||||
}
|
||||
|
||||
function applyPanelStyles(panel: HTMLElement): void {
|
||||
panel.style.width = "min(420px, calc(100vw - 32px))";
|
||||
panel.style.padding = "20px";
|
||||
panel.style.boxSizing = "border-box";
|
||||
panel.style.border = "1px solid #d7dde6";
|
||||
panel.style.borderRadius = "8px";
|
||||
panel.style.background = "#ffffff";
|
||||
panel.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)";
|
||||
}
|
||||
|
||||
function applyTitleStyles(title: HTMLElement): void {
|
||||
title.style.margin = "0";
|
||||
title.style.color = "#172033";
|
||||
title.style.fontSize = "18px";
|
||||
title.style.lineHeight = "26px";
|
||||
title.style.fontWeight = "700";
|
||||
}
|
||||
|
||||
function applyInputStyles(input: HTMLInputElement): void {
|
||||
input.style.width = "100%";
|
||||
input.style.height = "40px";
|
||||
input.style.marginTop = "18px";
|
||||
input.style.padding = "0 12px";
|
||||
input.style.boxSizing = "border-box";
|
||||
input.style.border = "1px solid #b9c4d3";
|
||||
input.style.borderRadius = "6px";
|
||||
input.style.color = "#172033";
|
||||
input.style.fontSize = "14px";
|
||||
}
|
||||
|
||||
function applyErrorStyles(error: HTMLElement): void {
|
||||
error.style.minHeight = "18px";
|
||||
error.style.margin = "6px 0 0";
|
||||
error.style.color = "#b42318";
|
||||
error.style.fontSize = "12px";
|
||||
error.style.lineHeight = "18px";
|
||||
}
|
||||
|
||||
function applyActionStyles(actions: HTMLElement): void {
|
||||
actions.style.display = "flex";
|
||||
actions.style.justifyContent = "flex-end";
|
||||
actions.style.gap = "8px";
|
||||
actions.style.marginTop = "16px";
|
||||
}
|
||||
|
||||
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "34px";
|
||||
button.style.padding = "0 14px";
|
||||
button.style.border = "1px solid #b9c4d3";
|
||||
button.style.borderRadius = "6px";
|
||||
button.style.background = "#ffffff";
|
||||
button.style.color = "#334155";
|
||||
button.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "34px";
|
||||
button.style.padding = "0 14px";
|
||||
button.style.border = "1px solid #0f766e";
|
||||
button.style.borderRadius = "6px";
|
||||
button.style.background = "#0f766e";
|
||||
button.style.color = "#ffffff";
|
||||
button.style.cursor = "pointer";
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
import type {
|
||||
FavoriteCreator,
|
||||
FavoritesStateV1
|
||||
} from "./favorites-store";
|
||||
|
||||
export interface FavoritesDrawerHandlers {
|
||||
onCreateFolder(): Promise<void>;
|
||||
onDeleteFolder(folderId: string): Promise<void>;
|
||||
onImportFolder(folderId: string, authorIds: string[]): Promise<void>;
|
||||
onImportSelected(authorIds: string[]): Promise<void>;
|
||||
onRemoveCreatorFromFolder(authorId: string, folderId: string): Promise<void>;
|
||||
onRenameFolder(folderId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FavoritesDrawerController {
|
||||
dispose(): void;
|
||||
render(state: FavoritesStateV1): void;
|
||||
setBusy(isBusy: boolean): void;
|
||||
setError(message: string): void;
|
||||
}
|
||||
|
||||
const DRAWER_ID = "sces-favorites-drawer";
|
||||
const activeDrawers = new WeakMap<Document, FavoritesDrawerController>();
|
||||
|
||||
export function createFavoritesDrawer(
|
||||
document: Document,
|
||||
handlers: FavoritesDrawerHandlers,
|
||||
confirmImport: (message: string) => boolean
|
||||
): FavoritesDrawerController {
|
||||
activeDrawers.get(document)?.dispose();
|
||||
document
|
||||
.querySelectorAll(
|
||||
'[data-sces-favorites-tab="button"], [data-sces-favorites-drawer="root"]'
|
||||
)
|
||||
.forEach((element) => element.remove());
|
||||
|
||||
const tab = createButton(document, "收藏夹");
|
||||
tab.dataset.scesFavoritesTab = "button";
|
||||
tab.setAttribute("aria-controls", DRAWER_ID);
|
||||
tab.setAttribute("aria-expanded", "false");
|
||||
tab.setAttribute("aria-haspopup", "dialog");
|
||||
applyTabStyles(tab);
|
||||
|
||||
const drawer = document.createElement("aside");
|
||||
drawer.id = DRAWER_ID;
|
||||
drawer.dataset.scesFavoritesDrawer = "root";
|
||||
drawer.hidden = true;
|
||||
drawer.tabIndex = -1;
|
||||
drawer.setAttribute("role", "dialog");
|
||||
drawer.setAttribute("aria-label", "收藏夹");
|
||||
drawer.setAttribute("aria-modal", "false");
|
||||
drawer.setAttribute("aria-hidden", "true");
|
||||
applyDrawerStyles(drawer);
|
||||
|
||||
(document.body ?? document.documentElement).append(tab, drawer);
|
||||
|
||||
let busy = false;
|
||||
let disposed = false;
|
||||
let errorMessage = "";
|
||||
let isOpen = false;
|
||||
let searchQuery = "";
|
||||
let state = emptyState();
|
||||
let view: "all" | string = "all";
|
||||
let selectedAuthorIds = new Set<string>();
|
||||
|
||||
const controller: FavoritesDrawerController = {
|
||||
dispose,
|
||||
render(nextState) {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
state = nextState;
|
||||
renderDrawer();
|
||||
},
|
||||
setBusy(isBusy) {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
busy = isBusy;
|
||||
renderDrawer();
|
||||
},
|
||||
setError(message) {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
errorMessage = message;
|
||||
renderDrawer();
|
||||
}
|
||||
};
|
||||
|
||||
const onTabClick = () => {
|
||||
if (isOpen) {
|
||||
closeDrawer();
|
||||
} else {
|
||||
openDrawer();
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || !isOpen) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
closeDrawer();
|
||||
};
|
||||
|
||||
const onDrawerClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = target.closest("button");
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.scesFavoritesDrawer === "close") {
|
||||
closeDrawer();
|
||||
return;
|
||||
}
|
||||
if (button.dataset.scesFavoritesDrawer === "view-all") {
|
||||
view = "all";
|
||||
renderDrawer();
|
||||
return;
|
||||
}
|
||||
|
||||
const folderId = button.dataset.scesFavoritesFolderId;
|
||||
if (folderId) {
|
||||
view = folderId;
|
||||
renderDrawer();
|
||||
return;
|
||||
}
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.scesFavoritesDrawer === "create-folder") {
|
||||
invokeHandler(handlers.onCreateFolder);
|
||||
return;
|
||||
}
|
||||
|
||||
const renameFolderId = button.dataset.scesFavoritesFolderRename;
|
||||
if (renameFolderId) {
|
||||
invokeHandler(() => handlers.onRenameFolder(renameFolderId));
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteFolderId = button.dataset.scesFavoritesFolderDelete;
|
||||
if (deleteFolderId) {
|
||||
invokeHandler(() => handlers.onDeleteFolder(deleteFolderId));
|
||||
return;
|
||||
}
|
||||
|
||||
const removeAuthorId = button.dataset.scesFavoritesRemoveAuthorId;
|
||||
if (removeAuthorId && view !== "all") {
|
||||
invokeHandler(() => handlers.onRemoveCreatorFromFolder(removeAuthorId, view));
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.scesFavoritesDrawer === "import-selected") {
|
||||
const authorIds = selectedCreatorsInCurrentView().map((creator) => creator.authorId);
|
||||
if (authorIds.length > 0) {
|
||||
invokeHandler(() => handlers.onImportSelected(authorIds));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.dataset.scesFavoritesDrawer === "import-folder" && view !== "all") {
|
||||
const authorIds = creatorsInCurrentView().map((creator) => creator.authorId);
|
||||
const message = `将当前收藏夹的 ${authorIds.length} 位达人导入秒探吗?`;
|
||||
if (confirmImport(message)) {
|
||||
invokeHandler(() => handlers.onImportFolder(view, authorIds));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDrawerInput = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLInputElement &&
|
||||
target.dataset.scesFavoritesDrawer === "search"
|
||||
) {
|
||||
searchQuery = target.value;
|
||||
const selectionStart = target.selectionStart;
|
||||
const selectionEnd = target.selectionEnd;
|
||||
renderDrawer();
|
||||
const search = drawer.querySelector(
|
||||
'[data-sces-favorites-drawer="search"]'
|
||||
);
|
||||
if (search instanceof HTMLInputElement) {
|
||||
search.focus();
|
||||
if (selectionStart !== null && selectionEnd !== null) {
|
||||
search.setSelectionRange(selectionStart, selectionEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDrawerChange = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
busy ||
|
||||
!(target instanceof HTMLInputElement) ||
|
||||
target.type !== "checkbox"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const authorId = target.dataset.scesFavoritesSelectAuthorId;
|
||||
if (!authorId) {
|
||||
return;
|
||||
}
|
||||
if (target.checked) {
|
||||
selectedAuthorIds.add(authorId);
|
||||
} else {
|
||||
selectedAuthorIds.delete(authorId);
|
||||
}
|
||||
renderDrawer();
|
||||
};
|
||||
|
||||
tab.addEventListener("click", onTabClick);
|
||||
drawer.addEventListener("click", onDrawerClick);
|
||||
drawer.addEventListener("input", onDrawerInput);
|
||||
drawer.addEventListener("change", onDrawerChange);
|
||||
document.addEventListener("keydown", onDocumentKeydown);
|
||||
renderDrawer();
|
||||
activeDrawers.set(document, controller);
|
||||
|
||||
return controller;
|
||||
|
||||
function openDrawer(): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
isOpen = true;
|
||||
drawer.hidden = false;
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
tab.setAttribute("aria-expanded", "true");
|
||||
renderDrawer();
|
||||
const closeButton = drawer.querySelector(
|
||||
'[data-sces-favorites-drawer="close"]'
|
||||
);
|
||||
if (closeButton instanceof HTMLButtonElement) {
|
||||
closeButton.focus();
|
||||
} else {
|
||||
drawer.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeDrawer(): void {
|
||||
if (disposed || !isOpen) {
|
||||
return;
|
||||
}
|
||||
isOpen = false;
|
||||
drawer.hidden = true;
|
||||
drawer.setAttribute("aria-hidden", "true");
|
||||
tab.setAttribute("aria-expanded", "false");
|
||||
if (tab.isConnected) {
|
||||
tab.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function renderDrawer(): void {
|
||||
if (view !== "all" && !state.folders.some((folder) => folder.id === view)) {
|
||||
view = "all";
|
||||
}
|
||||
|
||||
const currentCreators = creatorsInCurrentView();
|
||||
const currentAuthorIds = new Set(currentCreators.map((creator) => creator.authorId));
|
||||
selectedAuthorIds = new Set(
|
||||
Array.from(selectedAuthorIds).filter((authorId) => currentAuthorIds.has(authorId))
|
||||
);
|
||||
|
||||
const query = searchQuery.trim().toLocaleLowerCase();
|
||||
const visibleCreators = currentCreators.filter((creator) =>
|
||||
creator.authorName.toLocaleLowerCase().includes(query)
|
||||
);
|
||||
const selectedCount = selectedCreatorsInCurrentView().length;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(createHeader());
|
||||
if (errorMessage) {
|
||||
const error = document.createElement("p");
|
||||
error.dataset.scesFavoritesDrawer = "error";
|
||||
error.textContent = errorMessage;
|
||||
applyErrorStyles(error);
|
||||
fragment.append(error);
|
||||
}
|
||||
fragment.append(createFolderNavigation());
|
||||
fragment.append(createSearch());
|
||||
fragment.append(createCreatorList(visibleCreators));
|
||||
fragment.append(createFooter(selectedCount));
|
||||
drawer.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function createHeader(): HTMLElement {
|
||||
const header = document.createElement("header");
|
||||
applyHeaderStyles(header);
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = "收藏夹";
|
||||
applyTitleStyles(title);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
applyHeaderActionsStyles(actions);
|
||||
|
||||
const createFolder = createButton(document, "新建收藏夹");
|
||||
createFolder.dataset.scesFavoritesDrawer = "create-folder";
|
||||
createFolder.disabled = busy;
|
||||
applySecondaryButtonStyles(createFolder);
|
||||
|
||||
const close = createButton(document, "关闭收藏夹");
|
||||
close.dataset.scesFavoritesDrawer = "close";
|
||||
close.setAttribute("aria-label", "关闭收藏夹");
|
||||
applyIconButtonStyles(close);
|
||||
|
||||
actions.append(createFolder, close);
|
||||
header.append(title, actions);
|
||||
return header;
|
||||
}
|
||||
|
||||
function createFolderNavigation(): HTMLElement {
|
||||
const navigation = document.createElement("nav");
|
||||
navigation.setAttribute("aria-label", "收藏夹列表");
|
||||
applyNavigationStyles(navigation);
|
||||
|
||||
const allButton = createButton(document, "全部收藏");
|
||||
allButton.dataset.scesFavoritesDrawer = "view-all";
|
||||
allButton.setAttribute("aria-current", String(view === "all"));
|
||||
applyViewButtonStyles(allButton, view === "all");
|
||||
navigation.append(allButton);
|
||||
|
||||
state.folders.forEach((folder) => {
|
||||
const row = document.createElement("div");
|
||||
applyFolderRowStyles(row);
|
||||
|
||||
const viewButton = createButton(document, folder.name);
|
||||
viewButton.dataset.scesFavoritesFolderId = folder.id;
|
||||
viewButton.setAttribute("aria-current", String(view === folder.id));
|
||||
applyViewButtonStyles(viewButton, view === folder.id);
|
||||
|
||||
const rename = createButton(document, "重命名");
|
||||
rename.dataset.scesFavoritesFolderRename = folder.id;
|
||||
rename.setAttribute("aria-label", `重命名收藏夹 ${folder.name}`);
|
||||
rename.disabled = busy;
|
||||
applyTextActionStyles(rename);
|
||||
|
||||
const remove = createButton(document, "删除");
|
||||
remove.dataset.scesFavoritesFolderDelete = folder.id;
|
||||
remove.setAttribute("aria-label", `删除收藏夹 ${folder.name}`);
|
||||
remove.disabled = busy;
|
||||
applyDangerActionStyles(remove);
|
||||
|
||||
row.append(viewButton, rename, remove);
|
||||
navigation.append(row);
|
||||
});
|
||||
return navigation;
|
||||
}
|
||||
|
||||
function createSearch(): HTMLInputElement {
|
||||
const search = document.createElement("input");
|
||||
search.type = "search";
|
||||
search.value = searchQuery;
|
||||
search.placeholder = "搜索达人名称";
|
||||
search.dataset.scesFavoritesDrawer = "search";
|
||||
search.setAttribute("aria-label", "搜索达人名称");
|
||||
applySearchStyles(search);
|
||||
return search;
|
||||
}
|
||||
|
||||
function createCreatorList(creators: FavoriteCreator[]): HTMLElement {
|
||||
const list = document.createElement("section");
|
||||
list.dataset.scesFavoritesDrawer = "creators";
|
||||
list.setAttribute("aria-label", "收藏达人");
|
||||
applyCreatorListStyles(list);
|
||||
|
||||
if (creators.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.dataset.scesFavoritesDrawer = "empty";
|
||||
empty.textContent = "暂无收藏达人";
|
||||
applyEmptyStyles(empty);
|
||||
list.append(empty);
|
||||
return list;
|
||||
}
|
||||
|
||||
creators.forEach((creator) => {
|
||||
const row = document.createElement("article");
|
||||
row.dataset.scesFavoritesAuthorId = creator.authorId;
|
||||
applyCreatorRowStyles(row);
|
||||
|
||||
const selectLabel = document.createElement("label");
|
||||
applySelectLabelStyles(selectLabel);
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = selectedAuthorIds.has(creator.authorId);
|
||||
checkbox.disabled = busy;
|
||||
checkbox.dataset.scesFavoritesSelectAuthorId = creator.authorId;
|
||||
checkbox.setAttribute("aria-label", `选择达人 ${creator.authorName}`);
|
||||
selectLabel.append(checkbox);
|
||||
|
||||
const details = document.createElement("div");
|
||||
details.style.minWidth = "0";
|
||||
details.style.flex = "1";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = creator.authorName;
|
||||
applyCreatorNameStyles(name);
|
||||
details.append(name);
|
||||
|
||||
if (view === "all") {
|
||||
const affiliation = document.createElement("p");
|
||||
affiliation.textContent = `所属:${folderNamesForCreator(creator.authorId).join("、") || "未分类"}`;
|
||||
applyAffiliationStyles(affiliation);
|
||||
details.append(affiliation);
|
||||
}
|
||||
|
||||
row.append(selectLabel, details);
|
||||
if (view !== "all") {
|
||||
const remove = createButton(document, "移出收藏夹");
|
||||
remove.dataset.scesFavoritesRemoveAuthorId = creator.authorId;
|
||||
remove.setAttribute("aria-label", `将 ${creator.authorName} 移出当前收藏夹`);
|
||||
remove.disabled = busy;
|
||||
applyDangerActionStyles(remove);
|
||||
row.append(remove);
|
||||
}
|
||||
list.append(row);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function createFooter(selectedCount: number): HTMLElement {
|
||||
const footer = document.createElement("footer");
|
||||
applyFooterStyles(footer);
|
||||
|
||||
const selected = document.createElement("span");
|
||||
selected.textContent = `已选择 ${selectedCount} 位达人`;
|
||||
applySelectedCountStyles(selected);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
applyFooterActionsStyles(actions);
|
||||
if (view !== "all") {
|
||||
const importFolder = createButton(document, "导入当前收藏夹全部达人");
|
||||
importFolder.dataset.scesFavoritesDrawer = "import-folder";
|
||||
importFolder.disabled = busy;
|
||||
applySecondaryButtonStyles(importFolder);
|
||||
actions.append(importFolder);
|
||||
}
|
||||
|
||||
const importSelected = createButton(document, `导入已选 ${selectedCount} 位达人`);
|
||||
importSelected.dataset.scesFavoritesDrawer = "import-selected";
|
||||
importSelected.disabled = busy || selectedCount === 0;
|
||||
applyPrimaryButtonStyles(importSelected);
|
||||
actions.append(importSelected);
|
||||
|
||||
footer.append(selected, actions);
|
||||
return footer;
|
||||
}
|
||||
|
||||
function creatorsInCurrentView(): FavoriteCreator[] {
|
||||
const uniqueCreators = new Map<string, FavoriteCreator>();
|
||||
if (view === "all") {
|
||||
state.creators.forEach((creator) => {
|
||||
if (!uniqueCreators.has(creator.authorId)) {
|
||||
uniqueCreators.set(creator.authorId, creator);
|
||||
}
|
||||
});
|
||||
return Array.from(uniqueCreators.values());
|
||||
}
|
||||
|
||||
const creatorsById = new Map<string, FavoriteCreator>();
|
||||
state.creators.forEach((creator) => {
|
||||
if (!creatorsById.has(creator.authorId)) {
|
||||
creatorsById.set(creator.authorId, creator);
|
||||
}
|
||||
});
|
||||
state.memberships.forEach((membership) => {
|
||||
if (membership.folderId !== view || uniqueCreators.has(membership.authorId)) {
|
||||
return;
|
||||
}
|
||||
const creator = creatorsById.get(membership.authorId);
|
||||
if (creator) {
|
||||
uniqueCreators.set(creator.authorId, creator);
|
||||
}
|
||||
});
|
||||
return Array.from(uniqueCreators.values());
|
||||
}
|
||||
|
||||
function selectedCreatorsInCurrentView(): FavoriteCreator[] {
|
||||
return creatorsInCurrentView().filter((creator) => selectedAuthorIds.has(creator.authorId));
|
||||
}
|
||||
|
||||
function folderNamesForCreator(authorId: string): string[] {
|
||||
const folderNamesById = new Map(state.folders.map((folder) => [folder.id, folder.name]));
|
||||
const names = new Set<string>();
|
||||
state.memberships.forEach((membership) => {
|
||||
if (membership.authorId !== authorId) {
|
||||
return;
|
||||
}
|
||||
const name = folderNamesById.get(membership.folderId);
|
||||
if (name) {
|
||||
names.add(name);
|
||||
}
|
||||
});
|
||||
return Array.from(names);
|
||||
}
|
||||
|
||||
function invokeHandler(handler: () => Promise<void>): void {
|
||||
if (busy || disposed) {
|
||||
return;
|
||||
}
|
||||
void handler().catch((error: unknown) => {
|
||||
errorMessage = error instanceof Error ? error.message : "操作失败";
|
||||
renderDrawer();
|
||||
});
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
tab.removeEventListener("click", onTabClick);
|
||||
drawer.removeEventListener("click", onDrawerClick);
|
||||
drawer.removeEventListener("input", onDrawerInput);
|
||||
drawer.removeEventListener("change", onDrawerChange);
|
||||
document.removeEventListener("keydown", onDocumentKeydown);
|
||||
tab.remove();
|
||||
drawer.remove();
|
||||
if (activeDrawers.get(document) === controller) {
|
||||
activeDrawers.delete(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emptyState(): FavoritesStateV1 {
|
||||
return { creators: [], folders: [], memberships: [], version: 1 };
|
||||
}
|
||||
|
||||
function createButton(document: Document, label: string): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
return button;
|
||||
}
|
||||
|
||||
function applyTabStyles(tab: HTMLButtonElement): void {
|
||||
tab.style.position = "fixed";
|
||||
tab.style.top = "50%";
|
||||
tab.style.right = "56px";
|
||||
tab.style.zIndex = "2147483645";
|
||||
tab.style.padding = "12px 8px";
|
||||
tab.style.border = "1px solid #0f766e";
|
||||
tab.style.borderRadius = "6px 0 0 6px";
|
||||
tab.style.background = "#ffffff";
|
||||
tab.style.color = "#0f766e";
|
||||
tab.style.boxShadow = "0 4px 14px rgba(15, 23, 42, 0.14)";
|
||||
tab.style.cursor = "pointer";
|
||||
tab.style.fontWeight = "700";
|
||||
tab.style.writingMode = "vertical-rl";
|
||||
}
|
||||
|
||||
function applyDrawerStyles(drawer: HTMLElement): void {
|
||||
drawer.style.position = "fixed";
|
||||
drawer.style.top = "64px";
|
||||
drawer.style.right = "56px";
|
||||
drawer.style.bottom = "24px";
|
||||
drawer.style.zIndex = "2147483646";
|
||||
drawer.style.display = "flex";
|
||||
drawer.style.flexDirection = "column";
|
||||
drawer.style.width = "380px";
|
||||
drawer.style.maxWidth = "calc(100vw - 72px)";
|
||||
drawer.style.boxSizing = "border-box";
|
||||
drawer.style.overflow = "hidden";
|
||||
drawer.style.border = "1px solid #d7dde6";
|
||||
drawer.style.borderRadius = "8px";
|
||||
drawer.style.background = "#ffffff";
|
||||
drawer.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.2)";
|
||||
drawer.style.color = "#172033";
|
||||
drawer.style.fontFamily = "Arial, sans-serif";
|
||||
}
|
||||
|
||||
function applyHeaderStyles(header: HTMLElement): void {
|
||||
header.style.display = "flex";
|
||||
header.style.alignItems = "center";
|
||||
header.style.justifyContent = "space-between";
|
||||
header.style.gap = "12px";
|
||||
header.style.padding = "16px";
|
||||
header.style.borderBottom = "1px solid #e2e8f0";
|
||||
}
|
||||
|
||||
function applyTitleStyles(title: HTMLElement): void {
|
||||
title.style.margin = "0";
|
||||
title.style.fontSize = "18px";
|
||||
title.style.lineHeight = "24px";
|
||||
title.style.fontWeight = "700";
|
||||
}
|
||||
|
||||
function applyHeaderActionsStyles(actions: HTMLElement): void {
|
||||
actions.style.display = "flex";
|
||||
actions.style.alignItems = "center";
|
||||
actions.style.gap = "8px";
|
||||
}
|
||||
|
||||
function applyNavigationStyles(navigation: HTMLElement): void {
|
||||
navigation.style.display = "flex";
|
||||
navigation.style.flexDirection = "column";
|
||||
navigation.style.gap = "4px";
|
||||
navigation.style.padding = "12px 16px";
|
||||
navigation.style.borderBottom = "1px solid #e2e8f0";
|
||||
}
|
||||
|
||||
function applyFolderRowStyles(row: HTMLElement): void {
|
||||
row.style.display = "flex";
|
||||
row.style.alignItems = "center";
|
||||
row.style.gap = "4px";
|
||||
}
|
||||
|
||||
function applyViewButtonStyles(button: HTMLButtonElement, active: boolean): void {
|
||||
button.style.minWidth = "0";
|
||||
button.style.flex = "1";
|
||||
button.style.height = "30px";
|
||||
button.style.padding = "0 8px";
|
||||
button.style.border = "0";
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.background = active ? "#e0f2f1" : "transparent";
|
||||
button.style.color = active ? "#0f766e" : "#334155";
|
||||
button.style.cursor = "pointer";
|
||||
button.style.fontSize = "13px";
|
||||
button.style.overflow = "hidden";
|
||||
button.style.textAlign = "left";
|
||||
button.style.textOverflow = "ellipsis";
|
||||
button.style.whiteSpace = "nowrap";
|
||||
}
|
||||
|
||||
function applyTextActionStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "26px";
|
||||
button.style.padding = "0 4px";
|
||||
button.style.border = "0";
|
||||
button.style.background = "transparent";
|
||||
button.style.color = "#475569";
|
||||
button.style.cursor = "pointer";
|
||||
button.style.fontSize = "12px";
|
||||
}
|
||||
|
||||
function applyDangerActionStyles(button: HTMLButtonElement): void {
|
||||
applyTextActionStyles(button);
|
||||
button.style.color = "#b42318";
|
||||
}
|
||||
|
||||
function applySearchStyles(search: HTMLInputElement): void {
|
||||
search.style.height = "36px";
|
||||
search.style.margin = "12px 16px";
|
||||
search.style.padding = "0 10px";
|
||||
search.style.border = "1px solid #cbd5e1";
|
||||
search.style.borderRadius = "6px";
|
||||
search.style.fontSize = "13px";
|
||||
}
|
||||
|
||||
function applyCreatorListStyles(list: HTMLElement): void {
|
||||
list.style.display = "flex";
|
||||
list.style.flex = "1";
|
||||
list.style.flexDirection = "column";
|
||||
list.style.gap = "8px";
|
||||
list.style.minHeight = "0";
|
||||
list.style.padding = "0 16px 16px";
|
||||
list.style.overflowY = "auto";
|
||||
}
|
||||
|
||||
function applyCreatorRowStyles(row: HTMLElement): void {
|
||||
row.style.display = "flex";
|
||||
row.style.alignItems = "center";
|
||||
row.style.gap = "10px";
|
||||
row.style.padding = "10px";
|
||||
row.style.border = "1px solid #e2e8f0";
|
||||
row.style.borderRadius = "6px";
|
||||
}
|
||||
|
||||
function applySelectLabelStyles(label: HTMLElement): void {
|
||||
label.style.display = "flex";
|
||||
label.style.alignItems = "center";
|
||||
}
|
||||
|
||||
function applyCreatorNameStyles(name: HTMLElement): void {
|
||||
name.style.display = "block";
|
||||
name.style.overflow = "hidden";
|
||||
name.style.fontSize = "14px";
|
||||
name.style.lineHeight = "20px";
|
||||
name.style.textOverflow = "ellipsis";
|
||||
name.style.whiteSpace = "nowrap";
|
||||
}
|
||||
|
||||
function applyAffiliationStyles(affiliation: HTMLElement): void {
|
||||
affiliation.style.margin = "2px 0 0";
|
||||
affiliation.style.color = "#64748b";
|
||||
affiliation.style.fontSize = "12px";
|
||||
affiliation.style.lineHeight = "18px";
|
||||
}
|
||||
|
||||
function applyEmptyStyles(empty: HTMLElement): void {
|
||||
empty.style.margin = "24px 0";
|
||||
empty.style.color = "#64748b";
|
||||
empty.style.fontSize = "13px";
|
||||
empty.style.textAlign = "center";
|
||||
}
|
||||
|
||||
function applyFooterStyles(footer: HTMLElement): void {
|
||||
footer.style.display = "flex";
|
||||
footer.style.alignItems = "center";
|
||||
footer.style.justifyContent = "space-between";
|
||||
footer.style.gap = "12px";
|
||||
footer.style.padding = "12px 16px";
|
||||
footer.style.borderTop = "1px solid #e2e8f0";
|
||||
}
|
||||
|
||||
function applySelectedCountStyles(selected: HTMLElement): void {
|
||||
selected.style.flex = "0 0 auto";
|
||||
selected.style.color = "#475569";
|
||||
selected.style.fontSize = "12px";
|
||||
}
|
||||
|
||||
function applyFooterActionsStyles(actions: HTMLElement): void {
|
||||
actions.style.display = "flex";
|
||||
actions.style.alignItems = "center";
|
||||
actions.style.justifyContent = "flex-end";
|
||||
actions.style.gap = "8px";
|
||||
actions.style.minWidth = "0";
|
||||
}
|
||||
|
||||
function applyErrorStyles(error: HTMLElement): void {
|
||||
error.style.margin = "0";
|
||||
error.style.padding = "8px 16px";
|
||||
error.style.background = "#fef2f2";
|
||||
error.style.color = "#b42318";
|
||||
error.style.fontSize = "12px";
|
||||
error.style.lineHeight = "18px";
|
||||
}
|
||||
|
||||
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "30px";
|
||||
button.style.padding = "0 8px";
|
||||
button.style.border = "1px solid #b9c4d3";
|
||||
button.style.borderRadius = "5px";
|
||||
button.style.background = "#ffffff";
|
||||
button.style.color = "#334155";
|
||||
button.style.cursor = "pointer";
|
||||
button.style.fontSize = "12px";
|
||||
}
|
||||
|
||||
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.height = "30px";
|
||||
button.style.padding = "0 8px";
|
||||
button.style.border = "1px solid #0f766e";
|
||||
button.style.borderRadius = "5px";
|
||||
button.style.background = "#0f766e";
|
||||
button.style.color = "#ffffff";
|
||||
button.style.cursor = "pointer";
|
||||
button.style.fontSize = "12px";
|
||||
}
|
||||
|
||||
function applyIconButtonStyles(button: HTMLButtonElement): void {
|
||||
button.style.width = "30px";
|
||||
button.style.height = "30px";
|
||||
button.style.padding = "0";
|
||||
button.style.border = "1px solid #b9c4d3";
|
||||
button.style.borderRadius = "5px";
|
||||
button.style.background = "#ffffff";
|
||||
button.style.color = "#334155";
|
||||
button.style.cursor = "pointer";
|
||||
button.style.fontSize = "12px";
|
||||
}
|
||||
Reference in New Issue
Block a user