frontend: rebuild bookmark page with drag-and-drop, search, and theme system

- bookmark management with dnd-kit reordering, bulk edit, search,
  category filter/rename, and meta auto-fetch
- migrate /bookmarks → /dashboard/bookmarks under (main) layout
- homepage redesign with category grid, /-key search, dock tooltips
- theme toggle + use-theme, sonner toasts, alert-dialog/skeleton,
  visual refresh of auth pages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-05-02 22:53:17 +00:00
parent 832512469a
commit 694b02e848
26 changed files with 2377 additions and 561 deletions

143
frontend/lib/bookmarks.ts Normal file
View File

@@ -0,0 +1,143 @@
export interface BookmarkLite {
title: string;
url: string;
description?: string;
icon?: string;
category?: string;
sortOrder?: number;
}
export function hostnameOf(raw: string): string {
try {
const u = new URL(raw.includes("://") ? raw : `https://${raw}`);
return u.hostname.replace(/^www\./, "");
} catch {
return "";
}
}
export function faviconFallback(url: string, size = 64): string {
const host = hostnameOf(url);
if (!host) return "";
return `https://www.google.com/s2/favicons?domain=${host}&sz=${size}`;
}
export function normalizeURL(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function exportToJSON(bookmarks: BookmarkLite[]): string {
return JSON.stringify(
{
exportedAt: new Date().toISOString(),
bookmarks: bookmarks.map((b) => ({
title: b.title,
url: b.url,
description: b.description ?? "",
icon: b.icon ?? "",
category: b.category ?? "默认",
sortOrder: b.sortOrder ?? 0,
})),
},
null,
2,
);
}
export function downloadFile(filename: string, content: string, mime = "application/json") {
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function parseImport(text: string): BookmarkLite[] {
const trimmed = text.trim();
if (!trimmed) return [];
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return parseJSON(trimmed);
}
if (trimmed.toLowerCase().includes("<!doctype netscape-bookmark") || trimmed.toLowerCase().includes("<dl")) {
return parseNetscapeHTML(trimmed);
}
return parseJSON(trimmed);
}
function parseJSON(raw: string): BookmarkLite[] {
try {
const parsed = JSON.parse(raw);
const list: unknown[] = Array.isArray(parsed)
? parsed
: Array.isArray((parsed as { bookmarks?: unknown[] })?.bookmarks)
? (parsed as { bookmarks: unknown[] }).bookmarks
: [];
return list
.map((item) => {
const i = item as Record<string, unknown>;
return {
title: String(i.title ?? i.name ?? "").trim(),
url: String(i.url ?? i.href ?? "").trim(),
description: i.description ? String(i.description) : "",
icon: i.icon ? String(i.icon) : "",
category: i.category ? String(i.category) : "默认",
sortOrder: typeof i.sortOrder === "number" ? i.sortOrder : 0,
};
})
.filter((b) => b.title && b.url);
} catch {
return [];
}
}
function parseNetscapeHTML(raw: string): BookmarkLite[] {
if (typeof DOMParser === "undefined") return [];
const doc = new DOMParser().parseFromString(raw, "text/html");
const out: BookmarkLite[] = [];
const walk = (node: Element, currentCategory: string) => {
const children = Array.from(node.children);
for (let i = 0; i < children.length; i++) {
const el = children[i];
const tag = el.tagName.toLowerCase();
if (tag === "dt") {
const inner = el.firstElementChild;
if (!inner) continue;
const innerTag = inner.tagName.toLowerCase();
if (innerTag === "a") {
const a = inner as HTMLAnchorElement;
const href = a.getAttribute("href") || "";
const title = (a.textContent || "").trim() || href;
const icon = a.getAttribute("icon") || "";
if (href && /^https?:\/\//i.test(href)) {
out.push({
title,
url: href,
description: "",
icon,
category: currentCategory || "默认",
sortOrder: 0,
});
}
} else if (innerTag === "h3") {
const folderName = (inner.textContent || "").trim() || currentCategory;
const dl = el.querySelector("dl");
if (dl) walk(dl, folderName);
}
} else if (tag === "dl") {
walk(el, currentCategory);
}
}
};
const root = doc.querySelector("dl");
if (root) walk(root, "默认");
return out;
}