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:
143
frontend/lib/bookmarks.ts
Normal file
143
frontend/lib/bookmarks.ts
Normal 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;
|
||||
}
|
||||
56
frontend/lib/use-theme.ts
Normal file
56
frontend/lib/use-theme.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
function readTheme(): Theme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") root.classList.add("dark");
|
||||
else root.classList.remove("dark");
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>("light");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setThemeState(readTheme());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === "theme" && (e.newValue === "light" || e.newValue === "dark")) {
|
||||
setThemeState(e.newValue);
|
||||
applyTheme(e.newValue);
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
function setTheme(next: Theme) {
|
||||
setThemeState(next);
|
||||
try {
|
||||
localStorage.setItem("theme", next);
|
||||
} catch {}
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}
|
||||
|
||||
return { theme, setTheme, toggle, mounted };
|
||||
}
|
||||
Reference in New Issue
Block a user