From dbeb181e5d9897de2f79bf54a1e66ef722f1b06b Mon Sep 17 00:00:00 2001 From: Xujiacheng Date: Sun, 18 Jan 2026 23:33:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=B9=E5=92=8C=E4=B9=A6=E7=AD=BE=E7=9A=84=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E4=B8=8E=E6=8B=96=E6=8B=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + apps/extension/.env.example | 5 + apps/extension/README.md | 14 +- apps/extension/public/manifest.json | 2 +- apps/extension/src/options/OptionsApp.vue | 37 +- apps/extension/src/options/main.js | 2 + .../extension/src/options/pages/LoginPage.vue | 12 +- apps/extension/src/options/pages/MorePage.vue | 74 ++ apps/extension/src/options/pages/MyPage.vue | 12 +- .../src/options/pages/PublicPage.vue | 10 +- apps/extension/src/options/router.js | 21 +- apps/extension/src/popup/PopupApp.vue | 581 ++++++++++------ apps/extension/src/popup/main.js | 2 + apps/extension/src/style.css | 96 ++- apps/server/migrations/0001_init.sql | 3 + .../migrations/0002_folder_sort_order.sql | 5 + .../migrations/0003_bookmark_sort_order.sql | 5 + apps/server/package.json | 3 +- apps/server/src/config.js | 2 + apps/server/src/index.js | 35 +- apps/server/src/lib/admin.js | 29 + apps/server/src/lib/bookmarkHtmlNode.js | 58 +- apps/server/src/lib/rows.js | 2 + apps/server/src/resetDb.js | 26 + apps/server/src/routes/admin.routes.js | 147 ++++ apps/server/src/routes/auth.routes.js | 22 +- apps/server/src/routes/bookmarks.routes.js | 136 +++- apps/server/src/routes/folders.routes.js | 121 +++- apps/server/src/routes/importExport.routes.js | 63 +- apps/web/package.json | 5 +- apps/web/src/App.vue | 20 +- apps/web/src/components/BbModal.vue | 108 +++ apps/web/src/components/BbSelect.vue | 90 ++- apps/web/src/lib/api.js | 41 +- apps/web/src/lib/localData.js | 66 +- apps/web/src/pages/AdminPage.vue | 379 +++++++++++ apps/web/src/pages/MyPage.vue | 636 +++++++++++++++--- apps/web/src/pages/PublicPage.vue | 120 +++- apps/web/src/router.js | 15 +- apps/web/src/style.css | 95 ++- docs/验收清单-持久排序与拖拽.md | 131 ++++ openspec/changes/add-dnd-sorting/design.md | 24 + openspec/changes/add-dnd-sorting/proposal.md | 18 + .../changes/add-dnd-sorting/specs/api/spec.md | 35 + openspec/changes/add-dnd-sorting/tasks.md | 12 + openspec/specs/api/spec.md | 18 + package-lock.json | 7 + packages/shared/src/bookmarkHtml.js | 54 +- spec/openapi.yaml | 246 ++++++- 49 files changed, 3141 insertions(+), 507 deletions(-) create mode 100644 apps/extension/.env.example create mode 100644 apps/extension/src/options/pages/MorePage.vue create mode 100644 apps/server/migrations/0002_folder_sort_order.sql create mode 100644 apps/server/migrations/0003_bookmark_sort_order.sql create mode 100644 apps/server/src/lib/admin.js create mode 100644 apps/server/src/resetDb.js create mode 100644 apps/server/src/routes/admin.routes.js create mode 100644 apps/web/src/components/BbModal.vue create mode 100644 apps/web/src/pages/AdminPage.vue create mode 100644 docs/验收清单-持久排序与拖拽.md create mode 100644 openspec/changes/add-dnd-sorting/design.md create mode 100644 openspec/changes/add-dnd-sorting/proposal.md create mode 100644 openspec/changes/add-dnd-sorting/specs/api/spec.md create mode 100644 openspec/changes/add-dnd-sorting/tasks.md diff --git a/.env.example b/.env.example index 54e23ac..7753735 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,6 @@ DATABASE_SSL=false # Auth AUTH_JWT_SECRET=change_me_long_random + +# Admin (only this email is treated as admin) +ADMIN_EMAIL=admin@example.com diff --git a/apps/extension/.env.example b/apps/extension/.env.example new file mode 100644 index 0000000..879d175 --- /dev/null +++ b/apps/extension/.env.example @@ -0,0 +1,5 @@ +# Extension API base (Fastify server) +VITE_SERVER_BASE_URL=http://localhost:3001 + +# Web app base (used by Options -> 跳转 Web) +VITE_WEB_BASE_URL=http://localhost:5173 diff --git a/apps/extension/README.md b/apps/extension/README.md index 1511959..35e4097 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -1,5 +1,13 @@ -# Vue 3 + Vite +# BrowserBookmark Extension -This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` diff --git a/apps/extension/src/options/pages/MyPage.vue b/apps/extension/src/options/pages/MyPage.vue index 1a61451..23a088f 100644 --- a/apps/extension/src/options/pages/MyPage.vue +++ b/apps/extension/src/options/pages/MyPage.vue @@ -44,6 +44,7 @@ async function add() { async function remove(id) { if (mode.value !== "local") return; + if (!confirm("确定删除该书签?")) return; await markLocalDeleted(id); await load(); } @@ -86,7 +87,16 @@ onMounted(load); .list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; } @media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } } .card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; } -.title { color: #111827; font-weight: 700; text-decoration: none; } +.title { + color: #111827; + font-weight: 700; + text-decoration: none; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} .muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; } .error { color: #b91c1c; } .muted { color: #475569; font-size: 12px; } diff --git a/apps/extension/src/options/pages/PublicPage.vue b/apps/extension/src/options/pages/PublicPage.vue index 09abc73..d9b20ab 100644 --- a/apps/extension/src/options/pages/PublicPage.vue +++ b/apps/extension/src/options/pages/PublicPage.vue @@ -47,7 +47,15 @@ onMounted(load); .list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; } @media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } } .card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; } -.title { color: #111827; font-weight: 700; text-decoration: none; } +.title { + color: #111827; + font-weight: 700; + text-decoration: none; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} .muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; } .error { color: #b91c1c; } diff --git a/apps/extension/src/options/router.js b/apps/extension/src/options/router.js index b043bbc..3b190a9 100644 --- a/apps/extension/src/options/router.js +++ b/apps/extension/src/options/router.js @@ -1,15 +1,22 @@ import { createRouter, createWebHashHistory } from "vue-router"; -import PublicPage from "./pages/PublicPage.vue"; import LoginPage from "./pages/LoginPage.vue"; -import MyPage from "./pages/MyPage.vue"; -import ImportExportPage from "./pages/ImportExportPage.vue"; +import MorePage from "./pages/MorePage.vue"; +import { getToken } from "../lib/extStorage"; export const router = createRouter({ history: createWebHashHistory(), routes: [ - { path: "/", component: PublicPage }, - { path: "/login", component: LoginPage }, - { path: "/my", component: MyPage }, - { path: "/import", component: ImportExportPage } + { path: "/", component: MorePage }, + { path: "/login", component: LoginPage } ] }); + +router.beforeEach(async (to) => { + const token = await getToken(); + const authed = Boolean(token); + + if (!authed && to.path !== "/login") return "/login"; + if (authed && to.path === "/login") return "/"; + + return true; +}); diff --git a/apps/extension/src/popup/PopupApp.vue b/apps/extension/src/popup/PopupApp.vue index c281572..42b9430 100644 --- a/apps/extension/src/popup/PopupApp.vue +++ b/apps/extension/src/popup/PopupApp.vue @@ -1,51 +1,98 @@ diff --git a/apps/extension/src/popup/main.js b/apps/extension/src/popup/main.js index 67cf0aa..8a151ce 100644 --- a/apps/extension/src/popup/main.js +++ b/apps/extension/src/popup/main.js @@ -1,4 +1,6 @@ import { createApp } from "vue"; import PopupApp from "./PopupApp.vue"; +import "../style.css"; + createApp(PopupApp).mount("#app"); diff --git a/apps/extension/src/style.css b/apps/extension/src/style.css index f691315..cf8e857 100644 --- a/apps/extension/src/style.css +++ b/apps/extension/src/style.css @@ -1,79 +1,63 @@ :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + --bb-bg: #f8fafc; + --bb-text: #0f172a; + --bb-muted: rgba(15, 23, 42, 0.70); + --bb-primary: #2563eb; + --bb-primary-weak: rgba(37, 99, 235, 0.12); + --bb-cta: #f97316; + --bb-border: rgba(15, 23, 42, 0.14); + --bb-card: rgba(255, 255, 255, 0.88); + --bb-card-solid: #ffffff; + + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial; line-height: 1.5; font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; +* { box-sizing: border-box; } + +html, body { + width: 100%; + height: 100%; + margin: 0; } body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; + background: + radial-gradient(900px 520px at 10% 0%, rgba(37, 99, 235, 0.12), rgba(0,0,0,0) 60%), + radial-gradient(900px 520px at 90% 0%, rgba(249, 115, 22, 0.12), rgba(0,0,0,0) 60%), + var(--bb-bg); + color: var(--bb-text); } -h1 { - font-size: 3.2em; - line-height: 1.1; +a { color: var(--bb-primary); text-decoration: none; } +a:hover { color: #1d4ed8; } + +button, input, select, textarea { + font: inherit; + color: inherit; } -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; +select, input, textarea { + background: rgba(255,255,255,0.92); } -.card { - padding: 2em; +::placeholder { + color: rgba(15, 23, 42, 0.45); } -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.55); + outline-offset: 2px; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; scroll-behavior: auto !important; } } diff --git a/apps/server/migrations/0001_init.sql b/apps/server/migrations/0001_init.sql index fc90042..3087425 100644 --- a/apps/server/migrations/0001_init.sql +++ b/apps/server/migrations/0001_init.sql @@ -15,6 +15,7 @@ create table if not exists bookmark_folders ( parent_id uuid null references bookmark_folders(id) on delete cascade, name text not null, visibility text not null default 'private', + sort_order integer not null default 0, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); @@ -25,6 +26,7 @@ create table if not exists bookmarks ( id uuid primary key default gen_random_uuid(), user_id uuid not null references users(id) on delete cascade, folder_id uuid null references bookmark_folders(id) on delete set null, + sort_order integer not null default 0, title text not null, url text not null, url_normalized text not null, @@ -37,5 +39,6 @@ create table if not exists bookmarks ( ); create index if not exists idx_bookmarks_user_updated_at on bookmarks (user_id, updated_at); +create index if not exists idx_bookmarks_user_folder_sort on bookmarks (user_id, folder_id, sort_order); create index if not exists idx_bookmarks_user_url_hash on bookmarks (user_id, url_hash); create index if not exists idx_bookmarks_visibility on bookmarks (visibility); diff --git a/apps/server/migrations/0002_folder_sort_order.sql b/apps/server/migrations/0002_folder_sort_order.sql new file mode 100644 index 0000000..00f713e --- /dev/null +++ b/apps/server/migrations/0002_folder_sort_order.sql @@ -0,0 +1,5 @@ +alter table if exists bookmark_folders + add column if not exists sort_order integer not null default 0; + +create index if not exists idx_bookmark_folders_user_parent_sort + on bookmark_folders (user_id, parent_id, sort_order); diff --git a/apps/server/migrations/0003_bookmark_sort_order.sql b/apps/server/migrations/0003_bookmark_sort_order.sql new file mode 100644 index 0000000..17c2755 --- /dev/null +++ b/apps/server/migrations/0003_bookmark_sort_order.sql @@ -0,0 +1,5 @@ +alter table if exists bookmarks + add column if not exists sort_order integer not null default 0; + +create index if not exists idx_bookmarks_user_folder_sort + on bookmarks (user_id, folder_id, sort_order); diff --git a/apps/server/package.json b/apps/server/package.json index d26816c..1c070f1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -9,7 +9,8 @@ "build": "node -c src/index.js && node -c src/routes/auth.routes.js && node -c src/routes/bookmarks.routes.js && node -c src/routes/folders.routes.js && node -c src/routes/importExport.routes.js && node -c src/routes/sync.routes.js", "test": "node --test", "lint": "eslint .", - "db:migrate": "node src/migrate.js" + "db:migrate": "node src/migrate.js", + "db:reset": "node src/resetDb.js" }, "dependencies": { "@browser-bookmark/shared": "0.1.0", diff --git a/apps/server/src/config.js b/apps/server/src/config.js index 2aa2e3f..98bcc11 100644 --- a/apps/server/src/config.js +++ b/apps/server/src/config.js @@ -22,9 +22,11 @@ loadEnv(); export function getConfig() { const serverPort = Number(process.env.SERVER_PORT || 3001); + const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase(); return { serverPort, + adminEmail, database: { host: process.env.DATABASE_HOST || "127.0.0.1", port: Number(process.env.DATABASE_PORT || 5432), diff --git a/apps/server/src/index.js b/apps/server/src/index.js index ec863e4..3849e17 100644 --- a/apps/server/src/index.js +++ b/apps/server/src/index.js @@ -5,6 +5,7 @@ import jwt from "@fastify/jwt"; import { getConfig } from "./config.js"; import { createPool } from "./db.js"; import { authRoutes } from "./routes/auth.routes.js"; +import { adminRoutes } from "./routes/admin.routes.js"; import { foldersRoutes } from "./routes/folders.routes.js"; import { bookmarksRoutes } from "./routes/bookmarks.routes.js"; import { importExportRoutes } from "./routes/importExport.routes.js"; @@ -15,7 +16,9 @@ const app = Fastify({ logger: true }); // Plugins await app.register(cors, { origin: true, - credentials: true + credentials: true, + methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "Accept"] }); await app.register(multipart); @@ -25,7 +28,29 @@ if (!jwtSecret) { } await app.register(jwt, { secret: jwtSecret }); -app.decorate("pg", createPool()); +const pool = createPool(); +app.decorate("pg", pool); + +// Detect optional DB features (for backwards compatibility when migrations haven't run yet). +async function hasColumn(tableName, columnName) { + try { + const r = await app.pg.query( + "select 1 from information_schema.columns where table_schema=current_schema() and table_name=$1 and column_name=$2 limit 1", + [tableName, columnName] + ); + return r.rowCount > 0; + } catch { + return false; + } +} + +const folderSortOrderSupported = await hasColumn("bookmark_folders", "sort_order"); +const bookmarkSortOrderSupported = await hasColumn("bookmarks", "sort_order"); + +app.decorate("features", { + folderSortOrder: folderSortOrderSupported, + bookmarkSortOrder: bookmarkSortOrderSupported +}); app.decorate("authenticate", async (req, reply) => { try { @@ -44,7 +69,11 @@ app.setErrorHandler((err, _req, reply) => { app.get("/health", async () => ({ ok: true })); // Routes +const config = getConfig(); +app.decorate("config", config); + await authRoutes(app); +await adminRoutes(app); await foldersRoutes(app); await bookmarksRoutes(app); await importExportRoutes(app); @@ -54,5 +83,5 @@ app.addHook("onClose", async (instance) => { await instance.pg.end(); }); -const { serverPort } = getConfig(); +const { serverPort } = config; await app.listen({ port: serverPort, host: "0.0.0.0" }); diff --git a/apps/server/src/lib/admin.js b/apps/server/src/lib/admin.js new file mode 100644 index 0000000..806b7fe --- /dev/null +++ b/apps/server/src/lib/admin.js @@ -0,0 +1,29 @@ +import { httpError } from "./httpErrors.js"; + +function normalizeEmail(email) { + return String(email || "").trim().toLowerCase(); +} + +export async function requireAdmin(app, req) { + await app.authenticate(req); + const userId = req.user?.sub; + if (!userId) throw httpError(401, "unauthorized"); + + const res = await app.pg.query( + "select id, email, role, created_at, updated_at from users where id=$1", + [userId] + ); + const row = res.rows[0]; + if (!row) throw httpError(401, "unauthorized"); + + const adminEmail = normalizeEmail(app.config?.adminEmail); + const isAdmin = Boolean(adminEmail) && normalizeEmail(row.email) === adminEmail; + if (!isAdmin) throw httpError(403, "admin only"); + + req.adminUser = row; +} + +export function isAdminEmail(app, email) { + const adminEmail = normalizeEmail(app.config?.adminEmail); + return Boolean(adminEmail) && normalizeEmail(email) === adminEmail; +} diff --git a/apps/server/src/lib/bookmarkHtmlNode.js b/apps/server/src/lib/bookmarkHtmlNode.js index 5b8b890..9d492f2 100644 --- a/apps/server/src/lib/bookmarkHtmlNode.js +++ b/apps/server/src/lib/bookmarkHtmlNode.js @@ -8,26 +8,58 @@ export function parseNetscapeBookmarkHtmlNode(html) { const folders = []; const bookmarks = []; - function walkDl($dl, parentTempId) { - // Netscape format:

contains repeating

items and nested
- const children = $dl.children().toArray(); - for (let i = 0; i < children.length; i++) { - const node = children[i]; - if (!node || node.tagName?.toLowerCase() !== "dt") continue; + function normText(s) { + return String(s || "").replace(/\s+/g, " ").trim(); + } + function collectLevelDt(node) { + const out = []; + const children = $(node).contents().toArray(); + for (const child of children) { + if (!child || child.type !== "tag") continue; + const tag = child.tagName?.toLowerCase(); + if (tag === "dt") { + out.push(child); + continue; + } + if (tag === "dl") { + // nested list belongs to the previous
+ continue; + } + out.push(...collectLevelDt(child)); + } + return out; + } + + function findNextDlForDt(dtNode, stopDlNode) { + let cur = dtNode; + while (cur && cur !== stopDlNode) { + let next = cur.nextSibling; + while (next && next.type !== "tag") next = next.nextSibling; + if (next && next.type === "tag" && next.tagName?.toLowerCase() === "dl") return $(next); + cur = cur.parent; + } + return null; + } + + function walkDl($dl, parentTempId) { + // Netscape format:

contains repeating

items and nested
. + // When parsed,
may be wrapped (e.g. inside

), so we must be robust. + const dts = collectLevelDt($dl[0]); + for (const node of dts) { const $dt = $(node); - const $h3 = $dt.children("h3").first(); - const $a = $dt.children("a").first(); - const $next = $(children[i + 1] || null); - const nextIsDl = $next && $next[0]?.tagName?.toLowerCase() === "dl"; + const $h3 = $dt.children("h3").first().length ? $dt.children("h3").first() : $dt.find("h3").first(); + const $a = $dt.children("a").first().length ? $dt.children("a").first() : $dt.find("a").first(); + const $nestedDl = $dt.children("dl").first(); + const $nextDl = $nestedDl.length ? $nestedDl : findNextDlForDt(node, $dl[0]); if ($h3.length) { const tempId = `${folders.length + 1}`; - const name = ($h3.text() || "").trim(); + const name = normText($h3.text() || ""); folders.push({ tempId, parentTempId: parentTempId ?? null, name }); - if (nextIsDl) walkDl($next, tempId); + if ($nextDl?.length) walkDl($nextDl, tempId); } else if ($a.length) { - const title = ($a.text() || "").trim(); + const title = normText($a.text() || ""); const url = $a.attr("href") || ""; bookmarks.push({ parentTempId: parentTempId ?? null, title, url }); } diff --git a/apps/server/src/lib/rows.js b/apps/server/src/lib/rows.js index a32675b..2b22357 100644 --- a/apps/server/src/lib/rows.js +++ b/apps/server/src/lib/rows.js @@ -15,6 +15,7 @@ export function folderRowToDto(row) { parentId: row.parent_id, name: row.name, visibility: row.visibility, + sortOrder: row.sort_order ?? 0, createdAt: row.created_at, updatedAt: row.updated_at }; @@ -25,6 +26,7 @@ export function bookmarkRowToDto(row) { id: row.id, userId: row.user_id, folderId: row.folder_id, + sortOrder: row.sort_order ?? 0, title: row.title, url: row.url, urlNormalized: row.url_normalized, diff --git a/apps/server/src/resetDb.js b/apps/server/src/resetDb.js new file mode 100644 index 0000000..90edf77 --- /dev/null +++ b/apps/server/src/resetDb.js @@ -0,0 +1,26 @@ +import { createPool } from "./db.js"; + +async function main() { + const pool = createPool(); + try { + // Destructive: development convenience only. + await pool.query("begin"); + try { + await pool.query("drop table if exists bookmarks cascade"); + await pool.query("drop table if exists bookmark_folders cascade"); + await pool.query("drop table if exists users cascade"); + await pool.query("drop table if exists schema_migrations cascade"); + await pool.query("commit"); + } catch (e) { + await pool.query("rollback"); + throw e; + } + } finally { + await pool.end(); + } + + // Re-apply migrations. + await import("./migrate.js"); +} + +main(); diff --git a/apps/server/src/routes/admin.routes.js b/apps/server/src/routes/admin.routes.js new file mode 100644 index 0000000..26ddb73 --- /dev/null +++ b/apps/server/src/routes/admin.routes.js @@ -0,0 +1,147 @@ +import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared"; +import { httpError } from "../lib/httpErrors.js"; +import { requireAdmin, isAdminEmail } from "../lib/admin.js"; +import { bookmarkRowToDto, folderRowToDto, userRowToDto } from "../lib/rows.js"; + +function toUserDtoWithAdminOverride(app, row) { + const dto = userRowToDto(row); + if (isAdminEmail(app, dto.email)) dto.role = "admin"; + return dto; +} + +export async function adminRoutes(app) { + app.get( + "/admin/users", + { preHandler: [async (req) => requireAdmin(app, req)] }, + async () => { + const res = await app.pg.query( + "select id, email, role, created_at, updated_at from users order by created_at desc limit 500" + ); + return res.rows.map((r) => toUserDtoWithAdminOverride(app, r)); + } + ); + + app.get( + "/admin/users/:id/folders", + { preHandler: [async (req) => requireAdmin(app, req)] }, + async (req) => { + const userId = req.params?.id; + if (!userId) throw httpError(400, "user id required"); + const orderBy = app.features?.folderSortOrder + ? "parent_id nulls first, sort_order asc, name asc" + : "parent_id nulls first, name asc"; + const res = await app.pg.query( + `select * from bookmark_folders where user_id=$1 order by ${orderBy} limit 1000`, + [userId] + ); + return res.rows.map(folderRowToDto); + } + ); + + app.get( + "/admin/users/:id/bookmarks", + { preHandler: [async (req) => requireAdmin(app, req)] }, + async (req) => { + const userId = req.params?.id; + if (!userId) throw httpError(400, "user id required"); + + const q = (req.query?.q || "").trim(); + const params = [userId]; + let where = "where user_id=$1 and deleted_at is null"; + if (q) { + params.push(`%${q}%`); + where += ` and (title ilike $${params.length} or url ilike $${params.length})`; + } + + const orderBy = app.features?.bookmarkSortOrder + ? "folder_id nulls first, sort_order asc, updated_at desc" + : "updated_at desc"; + + const res = await app.pg.query( + `select * from bookmarks ${where} order by ${orderBy} limit 500`, + params + ); + return res.rows.map(bookmarkRowToDto); + } + ); + + app.delete( + "/admin/users/:userId/bookmarks/:bookmarkId", + { preHandler: [async (req) => requireAdmin(app, req)] }, + async (req) => { + const userId = req.params?.userId; + const bookmarkId = req.params?.bookmarkId; + if (!userId || !bookmarkId) throw httpError(400, "userId and bookmarkId required"); + + const res = await app.pg.query( + "update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2 and deleted_at is null returning *", + [bookmarkId, userId] + ); + if (!res.rows[0]) throw httpError(404, "bookmark not found"); + return bookmarkRowToDto(res.rows[0]); + } + ); + + app.delete( + "/admin/users/:userId/folders/:folderId", + { preHandler: [async (req) => requireAdmin(app, req)] }, + async (req) => { + const userId = req.params?.userId; + const folderId = req.params?.folderId; + if (!userId || !folderId) throw httpError(400, "userId and folderId required"); + + const res = await app.pg.query( + "delete from bookmark_folders where id=$1 and user_id=$2 returning *", + [folderId, userId] + ); + if (!res.rows[0]) throw httpError(404, "folder not found"); + return folderRowToDto(res.rows[0]); + } + ); + + app.post( + "/admin/users/:userId/bookmarks/:bookmarkId/copy-to-me", + { preHandler: [async (req) => requireAdmin(app, req)] }, + async (req) => { + const sourceUserId = req.params?.userId; + const bookmarkId = req.params?.bookmarkId; + const adminUserId = req.adminUser?.id; + if (!sourceUserId || !bookmarkId) throw httpError(400, "userId and bookmarkId required"); + if (!adminUserId) throw httpError(401, "unauthorized"); + + const srcRes = await app.pg.query( + "select * from bookmarks where id=$1 and user_id=$2 and deleted_at is null", + [bookmarkId, sourceUserId] + ); + const src = srcRes.rows[0]; + if (!src) throw httpError(404, "bookmark not found"); + + const urlNormalized = normalizeUrl(src.url); + const urlHash = computeUrlHash(urlNormalized); + + const existing = await app.pg.query( + "select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1", + [adminUserId, urlHash] + ); + + if (existing.rows[0]) { + const merged = await app.pg.query( + `update bookmarks + set title=$1, url=$2, url_normalized=$3, visibility='private', folder_id=null, source='manual', updated_at=now() + where id=$4 + returning *`, + [src.title, src.url, urlNormalized, existing.rows[0].id] + ); + return bookmarkRowToDto(merged.rows[0]); + } + + const res = await app.pg.query( + `insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source) + values ($1, null, $2, $3, $4, $5, 'private', 'manual') + returning *`, + [adminUserId, src.title, src.url, urlNormalized, urlHash] + ); + return bookmarkRowToDto(res.rows[0]); + } + ); +} diff --git a/apps/server/src/routes/auth.routes.js b/apps/server/src/routes/auth.routes.js index f49c1e7..0f9b1b9 100644 --- a/apps/server/src/routes/auth.routes.js +++ b/apps/server/src/routes/auth.routes.js @@ -2,6 +2,19 @@ import { hashPassword, verifyPassword } from "../lib/auth.js"; import { httpError } from "../lib/httpErrors.js"; import { userRowToDto } from "../lib/rows.js"; +function normalizeEmail(email) { + return String(email || "").trim().toLowerCase(); +} + +function toUserDtoWithAdminOverride(app, row) { + const dto = userRowToDto(row); + const adminEmail = normalizeEmail(app.config?.adminEmail); + if (adminEmail && normalizeEmail(dto.email) === adminEmail) { + dto.role = "admin"; + } + return dto; +} + export async function authRoutes(app) { app.post("/auth/register", async (req) => { const { email, password } = req.body || {}; @@ -15,7 +28,7 @@ export async function authRoutes(app) { "insert into users (email, password_hash) values ($1, $2) returning id, email, role, created_at, updated_at", [email, passwordHash] ); - const user = userRowToDto(res.rows[0]); + const user = toUserDtoWithAdminOverride(app, res.rows[0]); const token = await app.jwt.sign({ sub: user.id, role: user.role }); return { token, user }; } catch (err) { @@ -39,8 +52,9 @@ export async function authRoutes(app) { if (!ok) throw httpError(401, "invalid credentials"); const user = userRowToDto(row); - const token = await app.jwt.sign({ sub: user.id, role: user.role }); - return { token, user }; + const userWithRole = toUserDtoWithAdminOverride(app, row); + const token = await app.jwt.sign({ sub: userWithRole.id, role: userWithRole.role }); + return { token, user: userWithRole }; }); app.get( @@ -54,7 +68,7 @@ export async function authRoutes(app) { ); const row = res.rows[0]; if (!row) throw httpError(404, "user not found"); - return userRowToDto(row); + return toUserDtoWithAdminOverride(app, row); } ); } diff --git a/apps/server/src/routes/bookmarks.routes.js b/apps/server/src/routes/bookmarks.routes.js index 7bb3e60..004ba3e 100644 --- a/apps/server/src/routes/bookmarks.routes.js +++ b/apps/server/src/routes/bookmarks.routes.js @@ -34,8 +34,12 @@ export async function bookmarksRoutes(app) { where += ` and (title ilike $${params.length} or url ilike $${params.length})`; } + const orderBy = app.features?.bookmarkSortOrder + ? "folder_id nulls first, sort_order asc, updated_at desc" + : "updated_at desc"; + const res = await app.pg.query( - `select * from bookmarks ${where} order by updated_at desc limit 500`, + `select * from bookmarks ${where} order by ${orderBy} limit 500`, params ); return res.rows.map(bookmarkRowToDto); @@ -62,26 +66,117 @@ export async function bookmarksRoutes(app) { if (existing.rows[0]) { // auto-merge - const merged = await app.pg.query( - `update bookmarks - set title=$1, url=$2, url_normalized=$3, visibility=$4, folder_id=$5, source='manual', updated_at=now() - where id=$6 - returning *`, - [title, url, urlNormalized, visibility, folderId ?? null, existing.rows[0].id] - ); + const targetFolderId = folderId ?? null; + const merged = app.features?.bookmarkSortOrder + ? await app.pg.query( + `update bookmarks + set title=$1, + url=$2, + url_normalized=$3, + visibility=$4, + folder_id=$5, + sort_order = case + when folder_id is distinct from $5 then ( + select coalesce(max(sort_order), -1) + 1 + from bookmarks + where user_id=$7 and folder_id is not distinct from $5 and deleted_at is null + ) + else sort_order + end, + source='manual', + updated_at=now() + where id=$6 + returning *`, + [title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id, userId] + ) + : await app.pg.query( + `update bookmarks + set title=$1, url=$2, url_normalized=$3, visibility=$4, folder_id=$5, source='manual', updated_at=now() + where id=$6 + returning *`, + [title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id] + ); return bookmarkRowToDto(merged.rows[0]); } - const res = await app.pg.query( - `insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source) - values ($1, $2, $3, $4, $5, $6, $7, 'manual') - returning *`, - [userId, folderId ?? null, title, url, urlNormalized, urlHash, visibility] - ); + const targetFolderId = folderId ?? null; + const res = app.features?.bookmarkSortOrder + ? await app.pg.query( + `insert into bookmarks (user_id, folder_id, sort_order, title, url, url_normalized, url_hash, visibility, source) + values ( + $1, + $2, + (select coalesce(max(sort_order), -1) + 1 from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null), + $3, + $4, + $5, + $6, + $7, + 'manual' + ) + returning *`, + [userId, targetFolderId, title, url, urlNormalized, urlHash, visibility] + ) + : await app.pg.query( + `insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source) + values ($1, $2, $3, $4, $5, $6, $7, 'manual') + returning *`, + [userId, targetFolderId, title, url, urlNormalized, urlHash, visibility] + ); return bookmarkRowToDto(res.rows[0]); } ); + app.post( + "/bookmarks/reorder", + { preHandler: [app.authenticate] }, + async (req) => { + if (!app.features?.bookmarkSortOrder) { + throw httpError( + 409, + "bookmark sort order is not supported by current database schema. Please run server migrations (db:migrate)." + ); + } + + const userId = req.user.sub; + const { folderId, orderedIds } = req.body || {}; + const folder = folderId ?? null; + + if (!Array.isArray(orderedIds) || orderedIds.length === 0) { + throw httpError(400, "orderedIds required"); + } + + const siblings = await app.pg.query( + "select id from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null", + [userId, folder] + ); + const siblingIds = siblings.rows.map((r) => r.id); + + const want = new Set(orderedIds); + if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique"); + if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all bookmarks in the folder"); + for (const id of siblingIds) { + if (!want.has(id)) throw httpError(400, "orderedIds must include all bookmarks in the folder"); + } + + await app.pg.query("begin"); + try { + for (let i = 0; i < orderedIds.length; i++) { + await app.pg.query( + "update bookmarks set sort_order=$1, updated_at=now() where id=$2 and user_id=$3 and deleted_at is null", + [i, orderedIds[i], userId] + ); + } + await app.pg.query("commit"); + } catch (e) { + await app.pg.query("rollback"); + throw e; + } + + return { ok: true }; + } + ); + app.patch( "/bookmarks/:id", { preHandler: [app.authenticate] }, @@ -134,6 +229,19 @@ export async function bookmarksRoutes(app) { params.push(body.visibility); } + if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) { + if (!app.features?.bookmarkSortOrder) { + throw httpError( + 409, + "sortOrder is not supported by current database schema. Please run server migrations (db:migrate)." + ); + } + const n = Number(body.sortOrder); + if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number"); + sets.push(`sort_order=$${i++}`); + params.push(Math.trunc(n)); + } + if (Object.prototype.hasOwnProperty.call(body, "url")) { sets.push(`url=$${i++}`); params.push(nextUrl); diff --git a/apps/server/src/routes/folders.routes.js b/apps/server/src/routes/folders.routes.js index 276ca16..48d34a5 100644 --- a/apps/server/src/routes/folders.routes.js +++ b/apps/server/src/routes/folders.routes.js @@ -7,8 +7,11 @@ export async function foldersRoutes(app) { { preHandler: [app.authenticate] }, async (req) => { const userId = req.user.sub; + const orderBy = app.features?.folderSortOrder + ? "parent_id nulls first, sort_order asc, name asc" + : "parent_id nulls first, name asc"; const res = await app.pg.query( - "select * from bookmark_folders where user_id=$1 order by name", + `select * from bookmark_folders where user_id=$1 order by ${orderBy}`, [userId] ); return res.rows.map(folderRowToDto); @@ -21,19 +24,106 @@ export async function foldersRoutes(app) { async (req) => { const userId = req.user.sub; const { parentId, name, visibility } = req.body || {}; - if (!name) throw httpError(400, "name required"); - if (!visibility) throw httpError(400, "visibility required"); - const res = await app.pg.query( - `insert into bookmark_folders (user_id, parent_id, name, visibility) - values ($1, $2, $3, $4) - returning *`, - [userId, parentId ?? null, name, visibility] - ); + await app.pg.query("begin"); + try { + // Move bookmarks in this folder back to root (so they remain visible). + await app.pg.query( + "update bookmarks set folder_id=null, updated_at=now() where user_id=$1 and folder_id=$2 and deleted_at is null", + [userId, id] + ); + + // Lift child folders to root. + await app.pg.query( + "update bookmark_folders set parent_id=null, updated_at=now() where user_id=$1 and parent_id=$2", + [userId, id] + ); + + const res = await app.pg.query( + "delete from bookmark_folders where id=$1 and user_id=$2 returning id", + [id, userId] + ); + if (!res.rows[0]) throw httpError(404, "folder not found"); + await app.pg.query("commit"); + } catch (e) { + await app.pg.query("rollback"); + throw e; + } + + return { ok: true }; + const res = app.features?.folderSortOrder + ? await app.pg.query( + `insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order) + values ( + $1, + $2, + $3, + $4, + (select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is not distinct from $2) + ) + returning *`, + [userId, parent, name, visibility] + ) + : await app.pg.query( + `insert into bookmark_folders (user_id, parent_id, name, visibility) + values ($1, $2, $3, $4) + returning *`, + [userId, parent, name, visibility] + ); return folderRowToDto(res.rows[0]); } ); + app.post( + "/folders/reorder", + { preHandler: [app.authenticate] }, + async (req) => { + if (!app.features?.folderSortOrder) { + throw httpError( + 409, + "folder sort order is not supported by current database schema. Please run server migrations (db:migrate)." + ); + } + + const userId = req.user.sub; + const { parentId, orderedIds } = req.body || {}; + const parent = parentId ?? null; + + if (!Array.isArray(orderedIds) || orderedIds.length === 0) { + throw httpError(400, "orderedIds required"); + } + + const siblings = await app.pg.query( + "select id from bookmark_folders where user_id=$1 and parent_id is not distinct from $2", + [userId, parent] + ); + const siblingIds = siblings.rows.map((r) => r.id); + + // ensure same set + const want = new Set(orderedIds); + if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique"); + if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all sibling folders"); + for (const id of siblingIds) { + if (!want.has(id)) throw httpError(400, "orderedIds must include all sibling folders"); + } + + await app.pg.query("begin"); + try { + for (let i = 0; i < orderedIds.length; i++) { + await app.pg.query( + "update bookmark_folders set sort_order=$1, updated_at=now() where id=$2 and user_id=$3", + [i, orderedIds[i], userId] + ); + } + await app.pg.query("commit"); + } catch (e) { + await app.pg.query("rollback"); + throw e; + } + return { ok: true }; + } + ); + app.patch( "/folders/:id", { preHandler: [app.authenticate] }, @@ -68,6 +158,19 @@ export async function foldersRoutes(app) { params.push(body.visibility); } + if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) { + if (!app.features?.folderSortOrder) { + throw httpError( + 409, + "sortOrder is not supported by current database schema. Please run server migrations (db:migrate)." + ); + } + const n = Number(body.sortOrder); + if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number"); + sets.push(`sort_order=$${i++}`); + params.push(Math.trunc(n)); + } + if (sets.length === 0) throw httpError(400, "no fields to update"); params.push(id, userId); diff --git a/apps/server/src/routes/importExport.routes.js b/apps/server/src/routes/importExport.routes.js index b9a8a5c..9cf66e8 100644 --- a/apps/server/src/routes/importExport.routes.js +++ b/apps/server/src/routes/importExport.routes.js @@ -16,24 +16,65 @@ export async function importExportRoutes(app) { const parsed = parseNetscapeBookmarkHtmlNode(html); - // Create folders preserving structure + // Flatten folders (no nesting): dedupe/merge by folder name for this user. + const normName = (s) => String(s || "").replace(/\s+/g, " ").trim().toLowerCase(); + + const existingFolders = await app.pg.query( + "select id, name from bookmark_folders where user_id=$1", + [userId] + ); + const folderIdByName = new Map( + existingFolders.rows.map((r) => [normName(r.name), r.id]) + ); + + const tempIdToFolderName = new Map( + (parsed.folders || []).map((f) => [f.tempId, f.name]) + ); + const tempToDbId = new Map(); - for (const f of parsed.folders) { - const parentId = f.parentTempId ? tempToDbId.get(f.parentTempId) : null; - const res = await app.pg.query( - `insert into bookmark_folders (user_id, parent_id, name, visibility) - values ($1, $2, $3, 'private') - returning id`, - [userId, parentId, f.name] - ); - tempToDbId.set(f.tempId, res.rows[0].id); + for (const f of parsed.folders || []) { + const key = normName(f.name); + if (!key) continue; + + let id = folderIdByName.get(key); + if (!id) { + const res = app.features?.folderSortOrder + ? await app.pg.query( + `insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order) + values ( + $1, + null, + $2, + 'private', + (select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is null) + ) + returning id`, + [userId, f.name] + ) + : await app.pg.query( + `insert into bookmark_folders (user_id, parent_id, name, visibility) + values ($1, null, $2, 'private') + returning id`, + [userId, f.name] + ); + id = res.rows[0].id; + folderIdByName.set(key, id); + } + + tempToDbId.set(f.tempId, id); } let imported = 0; let merged = 0; for (const b of parsed.bookmarks) { - const folderId = b.parentTempId ? tempToDbId.get(b.parentTempId) : null; + // Map bookmark's folder via folder name (flattened). + let folderId = null; + if (b.parentTempId) { + const fname = tempIdToFolderName.get(b.parentTempId); + const key = normName(fname); + folderId = key ? (folderIdByName.get(key) || tempToDbId.get(b.parentTempId) || null) : null; + } const urlNormalized = normalizeUrl(b.url); const urlHash = computeUrlHash(urlNormalized); diff --git a/apps/web/package.json b/apps/web/package.json index cf63655..d862e4a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,8 +12,9 @@ }, "dependencies": { "@browser-bookmark/shared": "0.1.0", - "vue-router": "^4.5.1", - "vue": "^3.5.24" + "sortablejs": "^1.15.6", + "vue": "^3.5.24", + "vue-router": "^4.5.1" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.1", diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 1f35a25..f82dbec 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -1,10 +1,11 @@ + + + + diff --git a/apps/web/src/components/BbSelect.vue b/apps/web/src/components/BbSelect.vue index 671a091..8ab8937 100644 --- a/apps/web/src/components/BbSelect.vue +++ b/apps/web/src/components/BbSelect.vue @@ -1,5 +1,5 @@ diff --git a/apps/web/src/lib/api.js b/apps/web/src/lib/api.js index 001140f..81a7c3d 100644 --- a/apps/web/src/lib/api.js +++ b/apps/web/src/lib/api.js @@ -4,6 +4,9 @@ const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001" export const tokenRef = ref(localStorage.getItem("bb_token") || ""); +export const userRef = ref(null); +let mePromise = null; + export function getToken() { return tokenRef.value || ""; } @@ -13,15 +16,51 @@ export function setToken(token) { tokenRef.value = next; if (next) localStorage.setItem("bb_token", next); else localStorage.removeItem("bb_token"); + + // reset cached user when auth changes + userRef.value = null; + mePromise = null; } // Keep auth state in sync across tabs. if (typeof window !== "undefined") { window.addEventListener("storage", (e) => { - if (e.key === "bb_token") tokenRef.value = e.newValue || ""; + if (e.key === "bb_token") { + tokenRef.value = e.newValue || ""; + userRef.value = null; + mePromise = null; + } }); } +export async function ensureMe() { + const token = getToken(); + if (!token) { + userRef.value = null; + mePromise = null; + return null; + } + + if (userRef.value) return userRef.value; + if (mePromise) return mePromise; + + mePromise = (async () => { + try { + const me = await apiFetch("/auth/me", { method: "GET" }); + userRef.value = me; + return me; + } catch { + // token may be invalid/expired + userRef.value = null; + return null; + } finally { + mePromise = null; + } + })(); + + return mePromise; +} + export async function apiFetch(path, options = {}) { const headers = new Headers(options.headers || {}); if (!headers.has("Accept")) headers.set("Accept", "application/json"); diff --git a/apps/web/src/lib/localData.js b/apps/web/src/lib/localData.js index 7ae7ada..61b7712 100644 --- a/apps/web/src/lib/localData.js +++ b/apps/web/src/lib/localData.js @@ -132,13 +132,72 @@ export function patchLocalBookmark(id, patch) { return item; } +export function deleteLocalFolder(folderId) { + const state = loadLocalState(); + const now = nowIso(); + + state.folders = (state.folders || []).filter((f) => f.id !== folderId); + state.bookmarks = (state.bookmarks || []).map((b) => { + if (b.deletedAt) return b; + if ((b.folderId ?? null) !== (folderId ?? null)) return b; + return { ...ensureBookmarkHashes(b), folderId: null, updatedAt: now }; + }); + + saveLocalState(state); +} + export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) { const parsed = parseNetscapeBookmarkHtml(html || ""); const state = loadLocalState(); const now = nowIso(); + state.folders = state.folders || []; state.bookmarks = state.bookmarks || []; + // Flatten folders (no nesting): dedupe local folders by name. + const normName = (s) => String(s || "").replace(/\s+/g, " ").trim(); + const normKey = (s) => normName(s).toLowerCase(); + + const existingFolderByName = new Map( + (state.folders || []) + .filter((f) => !f.deletedAt) + .map((f) => [normKey(f.name), f]) + ); + + const tempIdToFolderName = new Map( + (parsed.folders || []).map((f) => [String(f.id), f.name]) + ); + + const folderIdByName = new Map( + (state.folders || []) + .filter((f) => !f.deletedAt) + .map((f) => [normKey(f.name), f.id]) + ); + + for (const f of parsed.folders || []) { + const name = normName(f.name); + const key = normKey(name); + if (!key) continue; + + let id = folderIdByName.get(key); + if (!id) { + const created = { + id: crypto.randomUUID(), + userId: null, + parentId: null, + name, + visibility: "private", + sortOrder: (state.folders || []).filter((x) => !x.deletedAt && (x.parentId ?? null) === null).length, + updatedAt: now, + deletedAt: null + }; + state.folders.push(created); + existingFolderByName.set(key, created); + folderIdByName.set(key, created.id); + id = created.id; + } + } + const existingByHash = new Map( (state.bookmarks || []) .filter((b) => !b.deletedAt) @@ -156,6 +215,10 @@ export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {} if (!url) continue; const title = (b.title || "").trim() || url; + const folderTempId = b.parentFolderId ?? null; + const folderName = folderTempId ? tempIdToFolderName.get(String(folderTempId)) : null; + const folderId = folderName ? (folderIdByName.get(normKey(folderName)) ?? null) : null; + const urlNormalized = normalizeUrl(url); const urlHash = computeUrlHash(urlNormalized); @@ -166,6 +229,7 @@ export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {} existing.urlNormalized = urlNormalized; existing.urlHash = urlHash; existing.visibility = visibility; + existing.folderId = folderId; existing.source = "import"; existing.updatedAt = now; merged++; @@ -175,7 +239,7 @@ export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {} const created = { id: crypto.randomUUID(), userId: null, - folderId: null, + folderId, title, url, urlNormalized, diff --git a/apps/web/src/pages/AdminPage.vue b/apps/web/src/pages/AdminPage.vue new file mode 100644 index 0000000..385c73c --- /dev/null +++ b/apps/web/src/pages/AdminPage.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/apps/web/src/pages/MyPage.vue b/apps/web/src/pages/MyPage.vue index fedd5f1..48ada6d 100644 --- a/apps/web/src/pages/MyPage.vue +++ b/apps/web/src/pages/MyPage.vue @@ -1,8 +1,10 @@ @@ -405,10 +784,15 @@ onMounted(() => { .form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; } @media (min-width: 768px) { .form { grid-template-columns: 2fr 3fr 2fr 1fr auto; align-items: center; } } .searchRow { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; } -@media (min-width: 768px) { .searchRow { grid-template-columns: 1fr auto auto; align-items: center; } } -.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 12px; } -@media (min-width: 768px) { .list { grid-template-columns: 1fr 1fr; } } +.searchActions { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; } +@media (min-width: 768px) { + .searchRow { grid-template-columns: 1fr auto; align-items: center; } + .searchActions { display: flex; gap: 10px; } +} +.list { list-style: none; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; margin-top: 12px; } +@media (min-width: 768px) { .list { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .title { color: var(--bb-text); font-weight: 700; text-decoration: none; } +.bb-clickCard { cursor: pointer; } .actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; } .edit { display: grid; gap: 10px; } .sectionTitle { font-weight: 800; } @@ -417,4 +801,64 @@ onMounted(() => { @media (min-width: 768px) { .folderRow { grid-template-columns: 2fr 1fr 2fr; align-items: center; } } .folderName { font-weight: 700; } .folderMeta { font-size: 12px; color: #475569; } + +.bb-myFolder { margin-top: 10px; } +.bb-myFolderHeaderRow { display: flex; gap: 8px; align-items: center; } +.bb-myFolderHeader { + flex: 1; + width: 100%; + text-align: left; + padding: 8px 10px; + border-radius: 16px; + border: 1px solid rgba(255,255,255,0.45); + background: rgba(255,255,255,0.35); + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.bb-myFolderHeader:hover { background: rgba(255,255,255,0.6); } +.bb-myFolderHeader .name { font-weight: 900; color: var(--bb-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bb-myFolderHeader .meta { font-size: 12px; color: rgba(19, 78, 74, 0.72); } +.bb-myFolderBody { margin-top: 10px; } + +/* Sticky open folder header (keeps the row visible while scrolling long lists) */ +.bb-myFolder.is-open > .bb-myFolderHeaderRow { + position: sticky; + top: 10px; + z-index: 30; + padding: 4px; + border-radius: 18px; + background: rgba(255,255,255,0.72); + backdrop-filter: blur(12px); + border: 1px solid rgba(255,255,255,0.55); +} + +.bb-folderDelete{ + padding: 8px 10px; + border-radius: 14px; +} + +.bb-backTop{ + position: fixed; + right: 16px; + bottom: 16px; + z-index: 9999; + box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14); +} + +.bb-dragHint { + user-select: none; + touch-action: none; + cursor: grab; + padding: 6px 8px; + border-radius: 12px; + border: 1px solid rgba(255,255,255,0.45); + background: rgba(255,255,255,0.25); + color: rgba(15, 23, 42, 0.58); +} +.bb-dragHint:active { cursor: grabbing; } + +.bb-modalForm { display: grid; gap: 10px; } diff --git a/apps/web/src/pages/PublicPage.vue b/apps/web/src/pages/PublicPage.vue index b4054df..a304db5 100644 --- a/apps/web/src/pages/PublicPage.vue +++ b/apps/web/src/pages/PublicPage.vue @@ -1,12 +1,79 @@ diff --git a/apps/web/src/router.js b/apps/web/src/router.js index ecdf5fa..973c11f 100644 --- a/apps/web/src/router.js +++ b/apps/web/src/router.js @@ -3,7 +3,8 @@ import PublicPage from "./pages/PublicPage.vue"; import LoginPage from "./pages/LoginPage.vue"; import MyPage from "./pages/MyPage.vue"; import ImportExportPage from "./pages/ImportExportPage.vue"; -import { tokenRef } from "./lib/api"; +import AdminPage from "./pages/AdminPage.vue"; +import { ensureMe, tokenRef } from "./lib/api"; export const router = createRouter({ history: createWebHistory(), @@ -11,11 +12,12 @@ export const router = createRouter({ { path: "/", component: PublicPage }, { path: "/login", component: LoginPage }, { path: "/my", component: MyPage }, - { path: "/import", component: ImportExportPage } + { path: "/import", component: ImportExportPage }, + { path: "/admin", component: AdminPage } ] }); -router.beforeEach((to) => { +router.beforeEach(async (to) => { const loggedIn = Boolean(tokenRef.value); // 主页(/)永远是公共首页;不因登录态自动跳转 @@ -28,5 +30,12 @@ router.beforeEach((to) => { return { path: "/login", query: { next: to.fullPath } }; } + // 管理界面:仅管理员可见 + if (to.path.startsWith("/admin")) { + if (!loggedIn) return { path: "/login", query: { next: to.fullPath } }; + const me = await ensureMe(); + if (!me || me.role !== "admin") return { path: "/my" }; + } + return true; }); diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 35a13a1..3c6f739 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -31,6 +31,23 @@ * { box-sizing: border-box; } +html, body { + width: 100%; + max-width: 100%; + overflow-x: hidden; +} + +/* Hide scrollbars but keep scrolling behavior */ +body { + -ms-overflow-style: none; /* IE/Edge legacy */ + scrollbar-width: none; /* Firefox */ +} + +body::-webkit-scrollbar { + width: 0; + height: 0; +} + body { margin: 0; min-width: 320px; @@ -41,6 +58,9 @@ body { #app { min-height: 100vh; + width: 100%; + max-width: 100%; + overflow-x: hidden; } a { @@ -137,6 +157,10 @@ input:focus-visible { width: 100%; } +.bb-selectWrap.is-open { + z-index: 9999; +} + .bb-selectWrap--sm .bb-selectTrigger { padding: 8px 10px; } @@ -182,7 +206,7 @@ input:focus-visible { top: calc(100% + 8px); left: 0; right: 0; - z-index: 50; + z-index: 10000; border-radius: 18px; padding: 6px; border: 1px solid rgba(255,255,255,0.65); @@ -193,6 +217,14 @@ input:focus-visible { overflow: auto; } +.bb-selectMenu--portal { + position: fixed; + left: 0; + top: 0; + right: auto; + z-index: 2147483000; +} + .bb-selectOption { width: 100%; border: 1px solid transparent; @@ -226,7 +258,7 @@ input:focus-visible { } .bb-btn { - padding: 10px 12px; + padding: 8px 12px; border: 1px solid rgba(255,255,255,0.25); border-radius: 16px; background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta)); @@ -235,6 +267,14 @@ input:focus-visible { transition: transform 120ms ease, filter 120ms ease, background 120ms ease; } +.bb-oneLineEllipsis{ + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + .bb-btn:hover { filter: brightness(1.03); } @@ -249,6 +289,50 @@ input:focus-visible { color: var(--bb-text); } +/* Slightly stronger secondary background (better contrast on light cards) */ +.bb-btn--secondary.bb-btn--soft { + background: rgba(19, 78, 74, 0.10); + border-color: rgba(19, 78, 74, 0.16); +} + +/* Bookmark title: single line with ellipsis */ +.bb-bookmarkTitle { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +/* When title sits in a flex row, allow it to shrink */ +.bb-bookmarkTitleRow { + flex: 1; + min-width: 0; +} + +/* SortableJS: nicer drag visuals */ +.sortable-ghost { + opacity: 0.55; +} + +.sortable-drag { + opacity: 0.98; + transform: rotate(0.4deg) scale(1.01); + box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18); +} + +/* "Pushed" neighbor feedback when swapping */ +.bb-sortPush { + transform: translateX(28px) translateY(-2px) scale(0.985); + transition: transform 220ms cubic-bezier(0.2, 0.9, 0.2, 1); +} + +@media (max-width: 520px) { + .bb-sortPush { + transform: translateX(18px) translateY(-1px) scale(0.99); + } +} + .bb-btn--danger { border-color: #fecaca; background: #fee2e2; @@ -267,6 +351,13 @@ input:focus-visible { padding: 12px; background: rgba(255,255,255,0.55); backdrop-filter: blur(12px); + max-width: 100%; + min-width: 0; +} + +/* Avoid long content (e.g. URLs) forcing horizontal overflow */ +.bb-card > * { + min-width: 0; } .bb-card--interactive { diff --git a/docs/验收清单-持久排序与拖拽.md b/docs/验收清单-持久排序与拖拽.md new file mode 100644 index 0000000..bc73958 --- /dev/null +++ b/docs/验收清单-持久排序与拖拽.md @@ -0,0 +1,131 @@ +# 验收清单:持久排序(folders + bookmarks)+ 触屏拖拽 + +> 目标:文件夹与书签都能在 PC/手机端拖动排序,刷新后顺序保持;同时无滚动条但仍可滚动;根目录(未分组)视为一个“虚拟文件夹组”。 + +## 0. 前置条件 + +- 已启动 Postgres,且 `.env` 配置正确(支持放在 repo 根目录 `.env` 或 `apps/server/.env`)。 +- 推荐先重建数据库(开发阶段方便确保 schema 一致)。 + +### 0.1(可选)确认 `.env` + +参考根目录 `.env.example`。 + +关键项: +- `DATABASE_HOST/DATABASE_PORT/DATABASE_NAME/DATABASE_USER/DATABASE_PASSWORD` +- `AUTH_JWT_SECRET` +- `ADMIN_EMAIL`(可选,用于验收管理端) + +## 1. 重建数据库(强烈建议) + +> 注意:此操作会 DROP 表并清空数据,仅用于开发环境。 + +在仓库根目录执行: + +- `npm -w apps/server run db:reset` + +预期:命令成功结束;下次启动服务不会再出现缺列(例如 `sort_order`)相关报错。 + +### 1.1(可选)用 SQL 验证列存在 + +- `\d bookmarks` 应包含 `sort_order` +- `\d bookmark_folders` 应包含 `sort_order` + +或执行: + +```sql +select column_name +from information_schema.columns +where table_schema=current_schema() + and table_name='bookmarks' +order by column_name; +``` + +## 2. 启动(server + web) + +在仓库根目录各开一个终端: + +- Server:`npm -w apps/server run dev` +- Web:`npm -w apps/web run dev` + +预期: +- Server 健康检查:`GET http://localhost:3001/health` 返回 `{ ok: true }` +- Web 能正常访问并登录。 + +## 3. UI/交互验收(PC) + +### 3.1 “无滚动条但可滚动” + +- 进入 Web 页面(任意长列表页) +- 鼠标滚轮/触控板滚动 + +预期:页面可以滚动,但看不到滚动条(侧边/底部不出现条)。 + +### 3.2 “我的书签”展开/折叠 + +- 进入 `/my` +- 点击任意文件夹头部 + +预期:能展开/收起;不会出现“点击没反应”。 + +### 3.3 文件夹拖拽排序(同父级) + +- 保证至少有 2 个同级文件夹(同一个 parent 下) +- 在 `/my` 使用文件夹右侧的拖拽柄(⋮⋮)拖动排序 + +预期: +- 能拖动、松手后顺序变化 +- 刷新页面后顺序保持 + +约束预期: +- 不允许跨父级拖动(不同 parent 的文件夹不能混排) + +### 3.4 书签拖拽排序(根目录 + 文件夹内) + +- 在“未分组(根目录)”组内拖动书签排序 +- 展开某个文件夹,在该文件夹内拖动书签排序 + +预期: +- 两处都能拖动排序 +- 刷新页面后顺序保持 +- 拖拽柄拖动不会误触打开链接 + +### 3.5 搜索模式禁用排序 + +- 在 `/my` 的搜索框输入关键字(进入过滤状态) +- 尝试拖动(文件夹/书签) + +预期:拖拽排序不生效(避免搜索时误操作导致重排)。 + +## 4. 触屏验收(手机/模拟器) + +- 打开 `/my` +- 长按拖拽柄(⋮⋮)并移动 + +预期: +- 文件夹可拖动排序(同父级) +- 书签可拖动排序(根目录/文件夹内) +- 刷新后顺序保持 + +## 5. 管理端验收(可选,需要 ADMIN_EMAIL) + +### 5.1 设置管理员 + +- `.env` 设置 `ADMIN_EMAIL=你用来登录的邮箱` +- 重新启动 server + +### 5.2 访问管理端 + +- 用该邮箱登录 +- 打开 `/admin` + +预期: +- 能看到用户列表 +- 选择用户后,能看到该用户的文件夹与书签(按 sortOrder 展示) +- 删除书签/删除文件夹/复制书签到管理员账号能正常工作 + +## 6. 常见失败点与定位 + +- 拖拽接口返回 409:数据库 schema 未包含 `sort_order`,请先跑 `npm -w apps/server run db:migrate` 或直接 `db:reset`。 +- 拖拽后刷新不保存:检查 server 日志是否收到 `/folders/reorder`、`/bookmarks/reorder`;以及 web 是否使用同一个 `VITE_SERVER_BASE_URL`。 +- “点击文件夹没反应”:优先查看浏览器控制台是否有运行时错误(应已修复模板误用 `.value` 的问题)。 diff --git a/openspec/changes/add-dnd-sorting/design.md b/openspec/changes/add-dnd-sorting/design.md new file mode 100644 index 0000000..bdf4284 --- /dev/null +++ b/openspec/changes/add-dnd-sorting/design.md @@ -0,0 +1,24 @@ +# Design: Persistent ordering + touch-friendly DnD + +## Database +- Add `sort_order integer not null default 0` to `bookmarks`. +- Add indexes to support ordered listing: + - `(user_id, folder_id, sort_order)` + +## API +- Extend `Bookmark` DTO/schema with `sortOrder`. +- Add `POST /bookmarks/reorder` similar to existing `/folders/reorder`: + - Input: `{ folderId: uuid|null, orderedIds: uuid[] }` + - Validates `orderedIds` is a permutation of all bookmarks for that user+folder (excluding deleted). + - Transactionally updates `sort_order` for each id. + +## Web UI +- Replace native HTML5 drag/drop with a touch-capable approach. + - Implementation choice: `sortablejs` (small, proven, touch-friendly). + - Bind Sortable to: + - Folder header list (per parent group) for folder ordering. + - Each open folder’s bookmark list for bookmark ordering. +- Root group is rendered as a first-class group and can also be reordered. + +## Compatibility +- If the DB schema lacks ordering columns (fresh/old DB), endpoints should return a clear 409 prompting `db:migrate`. diff --git a/openspec/changes/add-dnd-sorting/proposal.md b/openspec/changes/add-dnd-sorting/proposal.md new file mode 100644 index 0000000..de16a62 --- /dev/null +++ b/openspec/changes/add-dnd-sorting/proposal.md @@ -0,0 +1,18 @@ +# Change: Add persistent drag-and-drop sorting (folders + bookmarks) + +## Why +Users need to reorder folders and bookmarks via drag-and-drop (including mobile/touch) and have that order persist across reloads. Current HTML5 drag/drop is unreliable on mobile and ordering is not stored for bookmarks. + +## What Changes +- Add persistent ordering for bookmarks (new DB column and API endpoint to reorder within a folder). +- Use a touch-friendly drag-and-drop implementation in the web UI for: + - Reordering folders within the same parent. + - Reordering bookmarks within the same folder. +- Keep the root group (no folder) as a first-class group in the UI. + +## Impact +- Affected specs: API (OpenAPI-backed) +- Affected code: + - Server: migrations, bookmarks routes, admin routes, row DTO mapping + - Web: MyPage and AdminPage UI ordering and drag/drop + - OpenAPI: Bookmark schema and reorder endpoint diff --git a/openspec/changes/add-dnd-sorting/specs/api/spec.md b/openspec/changes/add-dnd-sorting/specs/api/spec.md new file mode 100644 index 0000000..893928a --- /dev/null +++ b/openspec/changes/add-dnd-sorting/specs/api/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Folder ordering persistence +The system SHALL persist folder ordering per user per parent folder. + +#### Scenario: List folders returns stable ordered result +- **GIVEN** an authenticated user +- **WHEN** the user calls `GET /folders` +- **THEN** the server returns folders ordered by `(parentId, sortOrder, name)` + +#### Scenario: Reorder folders within the same parent +- **GIVEN** an authenticated user +- **WHEN** the user calls `POST /folders/reorder` with `parentId` and `orderedIds` +- **THEN** the server persists the new order and returns `{ ok: true }` + +### Requirement: Bookmark ordering persistence +The system SHALL persist bookmark ordering per user per folder. + +#### Scenario: List my bookmarks returns stable ordered result +- **GIVEN** an authenticated user +- **WHEN** the user calls `GET /bookmarks` +- **THEN** the server returns bookmarks ordered by `(folderId, sortOrder, updatedAt desc)` + +#### Scenario: Reorder bookmarks within the same folder +- **GIVEN** an authenticated user +- **WHEN** the user calls `POST /bookmarks/reorder` with `folderId` and `orderedIds` +- **THEN** the server persists the new order and returns `{ ok: true }` + +### Requirement: Root group treated consistently +The system SHALL treat `folderId=null` bookmarks as belonging to the root group. + +#### Scenario: Reorder root-group bookmarks +- **GIVEN** an authenticated user +- **WHEN** the user calls `POST /bookmarks/reorder` with `folderId=null` +- **THEN** the server reorders root-group bookmarks and returns `{ ok: true }` diff --git a/openspec/changes/add-dnd-sorting/tasks.md b/openspec/changes/add-dnd-sorting/tasks.md new file mode 100644 index 0000000..52afcac --- /dev/null +++ b/openspec/changes/add-dnd-sorting/tasks.md @@ -0,0 +1,12 @@ +## 1. Implementation +- [ ] Add DB support for bookmark ordering (migration + init schema) +- [ ] Expose bookmark ordering in DTOs and OpenAPI schema +- [ ] Add API endpoint to reorder bookmarks within the same folder +- [ ] Ensure list endpoints return folders/bookmarks in stable order (parent+sortOrder, etc.) +- [ ] Implement touch-friendly drag sorting in Web UI for folders and bookmarks +- [ ] Treat root group (folderId null) as a first-class group for display and bookmark reorder +- [ ] Add basic verification steps (build + manual smoke checklist) + +## 2. Spec Updates +- [ ] Update OpenAPI contract for bookmark sortOrder and reorder endpoint +- [ ] Update OpenSpec API capability delta requirements diff --git a/openspec/specs/api/spec.md b/openspec/specs/api/spec.md index aa7d427..6270f4f 100644 --- a/openspec/specs/api/spec.md +++ b/openspec/specs/api/spec.md @@ -45,3 +45,21 @@ The system SHALL support last-write-wins (LWW) synchronization for folders and b - **THEN** the server stores the items using LWW semantics - **WHEN** the client calls `GET /sync/pull` - **THEN** the server returns folders/bookmarks and `serverTime` + +### Requirement: Admin user management (email-based) +The system SHALL treat exactly one configured email as an administrator and allow that user to manage/view users. + +#### Scenario: Non-admin cannot access admin APIs +- **GIVEN** an authenticated user whose email is not equal to `ADMIN_EMAIL` +- **WHEN** the user calls `GET /admin/users` +- **THEN** the server returns a 403 error + +#### Scenario: Admin can list users +- **GIVEN** an authenticated user whose email equals `ADMIN_EMAIL` +- **WHEN** the user calls `GET /admin/users` +- **THEN** the server returns `200` and a list of users + +#### Scenario: Admin can view a user's bookmarks +- **GIVEN** an authenticated admin user +- **WHEN** the admin calls `GET /admin/users/{id}/bookmarks` +- **THEN** the server returns `200` and that user's bookmarks diff --git a/package-lock.json b/package-lock.json index 134e4f0..d7902e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "version": "0.0.0", "dependencies": { "@browser-bookmark/shared": "0.1.0", + "sortablejs": "^1.15.6", "vue": "^3.5.24", "vue-router": "^4.5.1" }, @@ -3719,6 +3720,12 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/sortablejs": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/packages/shared/src/bookmarkHtml.js b/packages/shared/src/bookmarkHtml.js index e3af62b..93c40ba 100644 --- a/packages/shared/src/bookmarkHtml.js +++ b/packages/shared/src/bookmarkHtml.js @@ -14,22 +14,58 @@ export function parseNetscapeBookmarkHtml(html) { const bookmarks = []; let folderIdSeq = 1; - function walkDl(dl, parentFolderId) { - const children = Array.from(dl.children); - for (const node of children) { - if (node.tagName?.toLowerCase() !== "dt") continue; + function normText(s) { + return String(s || "").replace(/\s+/g, " ").trim(); + } - const h3 = node.querySelector(":scope > h3"); - const a = node.querySelector(":scope > a"); - const nextDl = node.nextElementSibling?.tagName?.toLowerCase() === "dl" ? node.nextElementSibling : null; + // Collect

nodes that belong to the current
level. + // Chrome/Edge exported HTML often uses `

` and browsers may wrap + // subsequent nodes under

or other wrapper elements. + function collectLevelDt(container) { + const out = []; + const els = Array.from(container.children || []); + for (const el of els) { + const tag = el.tagName?.toLowerCase(); + if (!tag) continue; + if (tag === "dt") { + out.push(el); + continue; + } + if (tag === "dl") { + // nested list belongs to the previous

+ continue; + } + out.push(...collectLevelDt(el)); + } + return out; + } + + // Find the nested
that belongs to a
, even if
is wrapped (e.g. inside

). + function findNextDlForDt(dt, stopDl) { + let cur = dt; + while (cur && cur !== stopDl) { + const next = cur.nextElementSibling; + if (next && next.tagName?.toLowerCase() === "dl") return next; + cur = cur.parentElement; + } + return null; + } + + function walkDl(dl, parentFolderId) { + const dts = collectLevelDt(dl); + for (const node of dts) { + const h3 = node.querySelector("h3"); + const a = node.querySelector("a"); + const nestedDl = node.querySelector("dl"); + const nextDl = nestedDl || findNextDlForDt(node, dl); if (h3) { const id = String(folderIdSeq++); - const name = (h3.textContent || "").trim(); + const name = normText(h3.textContent || ""); folders.push({ id, parentFolderId: parentFolderId ?? null, name }); if (nextDl) walkDl(nextDl, id); } else if (a) { - const title = (a.textContent || "").trim(); + const title = normText(a.textContent || ""); const url = a.getAttribute("href") || ""; bookmarks.push({ parentFolderId: parentFolderId ?? null, title, url }); } diff --git a/spec/openapi.yaml b/spec/openapi.yaml index bb8df41..abe54ce 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -64,13 +64,15 @@ components: visibility: type: string enum: [public, private] + sortOrder: + type: integer createdAt: type: string format: date-time updatedAt: type: string format: date-time - required: [id, userId, parentId, name, visibility, createdAt, updatedAt] + required: [id, userId, parentId, name, visibility, sortOrder, createdAt, updatedAt] Bookmark: type: object properties: @@ -85,6 +87,8 @@ components: - type: string format: uuid - type: 'null' + sortOrder: + type: integer title: type: string url: @@ -107,7 +111,7 @@ components: - type: string format: date-time - type: 'null' - required: [id, userId, folderId, title, url, urlNormalized, urlHash, visibility, source, updatedAt, deletedAt] + required: [id, userId, folderId, sortOrder, title, url, urlNormalized, urlHash, visibility, source, updatedAt, deletedAt] FolderPatch: type: object @@ -122,6 +126,23 @@ components: visibility: type: string enum: [public, private] + sortOrder: + type: integer + + FolderReorderRequest: + type: object + properties: + parentId: + anyOf: + - type: string + format: uuid + - type: 'null' + orderedIds: + type: array + items: + type: string + format: uuid + required: [parentId, orderedIds] BookmarkPatch: type: object @@ -138,6 +159,23 @@ components: visibility: type: string enum: [public, private] + sortOrder: + type: integer + + BookmarkReorderRequest: + type: object + properties: + folderId: + anyOf: + - type: string + format: uuid + - type: 'null' + orderedIds: + type: array + items: + type: string + format: uuid + required: [folderId, orderedIds] security: [] paths: /health: @@ -337,6 +375,31 @@ paths: type: boolean required: [ok] + /folders/reorder: + post: + tags: [Folders] + summary: Reorder folders within the same parent + operationId: reorderFolders + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FolderReorderRequest' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + required: [ok] + /bookmarks/public: get: tags: [Bookmarks] @@ -414,6 +477,31 @@ paths: schema: $ref: '#/components/schemas/Bookmark' + /bookmarks/reorder: + post: + tags: [Bookmarks] + summary: Reorder bookmarks within the same folder + operationId: reorderBookmarks + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BookmarkReorderRequest' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + required: [ok] + /bookmarks/{id}: patch: tags: [Bookmarks] @@ -544,6 +632,160 @@ paths: type: boolean required: [ok] + /admin/users: + get: + tags: [Admin] + summary: List users (admin only) + operationId: adminListUsers + security: + - bearerAuth: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + + /admin/users/{id}/bookmarks: + get: + tags: [Admin] + summary: List a user's bookmarks (admin only) + operationId: adminListUserBookmarks + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: query + name: q + schema: + type: string + required: false + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Bookmark' + + /admin/users/{id}/folders: + get: + tags: [Admin] + summary: List a user's folders (admin only) + operationId: adminListUserFolders + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Folder' + + /admin/users/{userId}/bookmarks/{bookmarkId}: + delete: + tags: [Admin] + summary: Delete a user's bookmark (admin only) + operationId: adminDeleteUserBookmark + security: + - bearerAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + - in: path + name: bookmarkId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Bookmark' + + /admin/users/{userId}/folders/{folderId}: + delete: + tags: [Admin] + summary: Delete a user's folder (admin only) + operationId: adminDeleteUserFolder + security: + - bearerAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + - in: path + name: folderId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Folder' + + /admin/users/{userId}/bookmarks/{bookmarkId}/copy-to-me: + post: + tags: [Admin] + summary: Copy a user's bookmark to admin account (admin only) + operationId: adminCopyUserBookmarkToMe + security: + - bearerAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + - in: path + name: bookmarkId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Bookmark' + /sync/pull: get: tags: [Sync]