feat: 实现文件夹和书签的持久排序与拖拽功能

This commit is contained in:
2026-01-18 23:33:31 +08:00
parent 6eb3c730bb
commit dbeb181e5d
49 changed files with 3141 additions and 507 deletions

View File

@@ -12,3 +12,6 @@ DATABASE_SSL=false
# Auth # Auth
AUTH_JWT_SECRET=change_me_long_random AUTH_JWT_SECRET=change_me_long_random
# Admin (only this email is treated as admin)
ADMIN_EMAIL=admin@example.com

View File

@@ -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

View File

@@ -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 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. ## 配置
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support). 复制 [apps/extension/.env.example](apps/extension/.env.example) 为 `.env`,按需修改:
- `VITE_SERVER_BASE_URL`:后端 API 地址(默认 `http://localhost:3001`
- `VITE_WEB_BASE_URL`Web 站点地址(用于 options 页“跳转 Web”
## 页面
- **popup**:添加当前页 + 书签目录(折叠文件夹 + 搜索),只允许新增(书签/文件夹),不提供删除/排序/管理。
- **options**:更多操作;未登录只显示登录页;登录后主页仅两项(跳转 Web / 退出登录)。

View File

@@ -8,5 +8,5 @@
}, },
"options_page": "options.html", "options_page": "options.html",
"permissions": ["storage", "tabs"], "permissions": ["storage", "tabs"],
"host_permissions": ["http://localhost:3001/*"] "host_permissions": ["<all_urls>"]
} }

View File

@@ -1,17 +1,11 @@
<script setup> <script setup>
import { RouterLink, RouterView } from "vue-router"; import { RouterView } from "vue-router";
</script> </script>
<template> <template>
<div class="shell"> <div class="shell">
<header class="nav"> <header class="nav">
<div class="brand">云书签</div> <div class="brand">云书签 · 更多操作</div>
<nav class="links">
<RouterLink to="/" class="link">公开</RouterLink>
<RouterLink to="/my" class="link">我的</RouterLink>
<RouterLink to="/import" class="link">导入导出</RouterLink>
<RouterLink to="/login" class="link primary">登录</RouterLink>
</nav>
</header> </header>
<main class="content"> <main class="content">
<RouterView /> <RouterView />
@@ -20,43 +14,22 @@ import { RouterLink, RouterView } from "vue-router";
</template> </template>
<style> <style>
:root {
--bb-bg: #f8fafc;
--bb-text: #1e293b;
--bb-primary: #3b82f6;
--bb-primary-weak: #60a5fa;
--bb-cta: #f97316;
--bb-border: #e5e7eb;
}
body { body { margin: 0; }
margin: 0;
background: var(--bb-bg);
color: var(--bb-text);
font-family: ui-sans-serif, system-ui;
}
* { box-sizing: border-box; }
.shell { min-height: 100vh; } .shell { min-height: 100vh; }
.nav { .nav {
position: sticky; position: sticky;
top: 0; top: 0;
background: rgba(248, 250, 252, 0.9); background: rgba(248, 250, 252, 0.82);
border-bottom: 1px solid var(--bb-border); border-bottom: 1px solid var(--bb-border);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start;
padding: 10px 14px; padding: 10px 14px;
gap: 10px; gap: 10px;
} }
.brand { font-weight: 800; } .brand { font-weight: 800; }
.links { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.link { text-decoration: none; color: var(--bb-text); padding: 8px 10px; border-radius: 10px; transition: background 150ms, color 150ms, border-color 150ms; }
.link:hover { background: rgba(59, 130, 246, 0.08); }
.link.router-link-active { background: rgba(59, 130, 246, 0.12); }
.primary { background: var(--bb-primary); color: white; }
.primary:hover { background: var(--bb-primary-weak); }
.content { max-width: 1100px; margin: 0 auto; padding: 14px; } .content { max-width: 1100px; margin: 0 auto; padding: 14px; }
a:focus-visible, a:focus-visible,

View File

@@ -2,4 +2,6 @@ import { createApp } from "vue";
import OptionsApp from "./OptionsApp.vue"; import OptionsApp from "./OptionsApp.vue";
import { router } from "./router"; import { router } from "./router";
import "../style.css";
createApp(OptionsApp).use(router).mount("#app"); createApp(OptionsApp).use(router).mount("#app");

View File

@@ -2,7 +2,7 @@
import { ref } from "vue"; import { ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { apiFetch } from "../../lib/api"; import { apiFetch } from "../../lib/api";
import { getToken, setToken } from "../../lib/extStorage"; import { setToken } from "../../lib/extStorage";
import { clearLocalState, mergeLocalToUser } from "../../lib/localData"; import { clearLocalState, mergeLocalToUser } from "../../lib/localData";
const router = useRouter(); const router = useRouter();
@@ -35,20 +35,13 @@ async function submit() {
// After merge, keep extension in cloud mode // After merge, keep extension in cloud mode
await clearLocalState(); await clearLocalState();
await router.push("/my"); await router.replace("/");
} catch (e) { } catch (e) {
error.value = e.message || String(e); error.value = e.message || String(e);
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
async function logout() {
const token = await getToken();
if (!token) return;
await setToken("");
await router.push("/");
}
</script> </script>
<template> <template>
@@ -58,7 +51,6 @@ async function logout() {
<div class="row"> <div class="row">
<button class="tab" :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button> <button class="tab" :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button>
<button class="tab" :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button> <button class="tab" :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button>
<button class="tab" @click="logout">退出</button>
</div> </div>
<div class="form"> <div class="form">

View File

@@ -0,0 +1,74 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { getToken, setToken } from "../../lib/extStorage";
const router = useRouter();
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const webBaseUrl = import.meta.env.VITE_WEB_BASE_URL || "http://localhost:5173";
async function refresh() {
token.value = await getToken();
}
function openWeb() {
const url = String(webBaseUrl || "").trim();
if (!url) return;
if (typeof chrome !== "undefined" && chrome.tabs?.create) chrome.tabs.create({ url });
else window.open(url, "_blank", "noopener,noreferrer");
}
async function logout() {
await setToken("");
await refresh();
await router.replace("/login");
}
onMounted(refresh);
</script>
<template>
<section class="page">
<h1 class="h1">更多操作</h1>
<p v-if="!loggedIn" class="muted">当前未登录将跳转到登录页</p>
<div class="card">
<button class="btn" type="button" @click="openWeb">跳转 Web</button>
<button class="btn btn--secondary" type="button" @click="logout">退出登录</button>
<p class="hint">Web 地址来自环境变量VITE_WEB_BASE_URL</p>
</div>
</section>
</template>
<style scoped>
.page { padding: 14px; }
.h1 { margin: 0 0 10px; font-size: 18px; }
.card {
max-width: 560px;
border: 1px solid rgba(255,255,255,0.65);
background: var(--bb-card);
border-radius: 18px;
padding: 12px;
display: grid;
gap: 10px;
}
.btn {
border: 1px solid rgba(255,255,255,0.25);
border-radius: 14px;
padding: 10px 12px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
cursor: pointer;
}
.btn--secondary {
background: rgba(255,255,255,0.55);
color: var(--bb-text);
border-color: var(--bb-border);
}
.muted { color: rgba(19, 78, 74, 0.72); font-size: 12px; }
.hint { color: rgba(19, 78, 74, 0.72); font-size: 12px; margin: 0; }
</style>

View File

@@ -44,6 +44,7 @@ async function add() {
async function remove(id) { async function remove(id) {
if (mode.value !== "local") return; if (mode.value !== "local") return;
if (!confirm("确定删除该书签?")) return;
await markLocalDeleted(id); await markLocalDeleted(id);
await load(); await load();
} }
@@ -86,7 +87,16 @@ onMounted(load);
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; } .list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } } @media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; } .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; } .muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; } .error { color: #b91c1c; }
.muted { color: #475569; font-size: 12px; } .muted { color: #475569; font-size: 12px; }

View File

@@ -47,7 +47,15 @@ onMounted(load);
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; } .list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } } @media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; } .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; } .muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; } .error { color: #b91c1c; }
</style> </style>

View File

@@ -1,15 +1,22 @@
import { createRouter, createWebHashHistory } from "vue-router"; import { createRouter, createWebHashHistory } from "vue-router";
import PublicPage from "./pages/PublicPage.vue";
import LoginPage from "./pages/LoginPage.vue"; import LoginPage from "./pages/LoginPage.vue";
import MyPage from "./pages/MyPage.vue"; import MorePage from "./pages/MorePage.vue";
import ImportExportPage from "./pages/ImportExportPage.vue"; import { getToken } from "../lib/extStorage";
export const router = createRouter({ export const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes: [ routes: [
{ path: "/", component: PublicPage }, { path: "/", component: MorePage },
{ path: "/login", component: LoginPage }, { path: "/login", component: LoginPage }
{ path: "/my", component: MyPage },
{ path: "/import", component: ImportExportPage }
] ]
}); });
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;
});

View File

@@ -1,51 +1,98 @@
<script setup> <script setup>
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { apiFetch } from "../lib/api"; import { apiFetch } from "../lib/api";
import { getToken } from "../lib/extStorage"; import { getToken } from "../lib/extStorage";
import { listLocalBookmarks, upsertLocalBookmark } from "../lib/localData";
const items = ref([]); const view = ref("list"); // add | list
const q = ref("");
const title = ref(""); const token = ref("");
const url = ref(""); const loggedIn = computed(() => Boolean(token.value));
const mode = ref("local");
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
const cloudAvailable = ref(false);
const addCurrentOpen = ref(false);
const foldersLoading = ref(false);
const folders = ref([]);
const selectedFolderId = ref(null);
const addCurrentBusy = ref(false);
const addCurrentStatus = ref("");
const activePage = ref({ title: "", url: "" });
const filtered = computed(() => {
const query = q.value.trim().toLowerCase();
if (!query) return items.value;
return items.value.filter((b) => {
const t = String(b.title || "").toLowerCase();
const u = String(b.url || "").toLowerCase();
return t.includes(query) || u.includes(query);
});
});
function openUrl(url) {
if (typeof chrome !== "undefined" && chrome.tabs?.create) {
chrome.tabs.create({ url });
} else {
window.open(url, "_blank", "noopener");
}
}
function openOptions() { function openOptions() {
if (typeof chrome !== "undefined" && chrome.runtime?.openOptionsPage) { if (typeof chrome !== "undefined" && chrome.runtime?.openOptionsPage) {
chrome.runtime.openOptionsPage(); chrome.runtime.openOptionsPage();
} }
} }
function openUrl(url) {
if (!url) return;
if (typeof chrome !== "undefined" && chrome.tabs?.create) {
chrome.tabs.create({ url });
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
}
async function refreshAuth() {
token.value = await getToken();
}
// folders + bookmarks
const q = ref("");
const folders = ref([]);
const items = ref([]);
const openFolderIds = ref(new Set());
const bookmarksByFolderId = computed(() => {
const map = new Map();
for (const b of items.value || []) {
const key = b.folderId ?? null;
if (!map.has(key)) map.set(key, []);
map.get(key).push(b);
}
return map;
});
function folderCount(folderId) {
return (bookmarksByFolderId.value.get(folderId ?? null) || []).length;
}
function toggleFolder(folderId) {
const next = new Set(openFolderIds.value);
if (next.has(folderId)) next.delete(folderId);
else next.add(folderId);
openFolderIds.value = next;
}
function isFolderOpen(folderId) {
if (q.value.trim()) return true;
return openFolderIds.value.has(folderId);
}
async function loadFolders() {
const list = await apiFetch("/folders");
folders.value = Array.isArray(list) ? list : [];
}
async function loadBookmarks() {
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
const list = await apiFetch(`/bookmarks${qs}`);
items.value = Array.isArray(list) ? list : [];
}
async function loadAll() {
if (!loggedIn.value) return;
loading.value = true;
error.value = "";
try {
await Promise.all([loadFolders(), loadBookmarks()]);
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
// add current page
const addBusy = ref(false);
const addStatus = ref("");
const addFolderId = ref(null);
const addTitle = ref("");
const addUrl = ref("");
async function getActiveTabPage() { async function getActiveTabPage() {
try { try {
if (typeof chrome !== "undefined" && chrome.tabs?.query) { if (typeof chrome !== "undefined" && chrome.tabs?.query) {
@@ -58,215 +105,359 @@ async function getActiveTabPage() {
} catch { } catch {
// ignore // ignore
} }
return { title: document.title || "", url: window.location?.href || "" }; return { title: "", url: "" };
} }
async function loadFolders() { async function prepareAddCurrent() {
foldersLoading.value = true; addStatus.value = "";
try {
const list = await apiFetch("/folders");
folders.value = Array.isArray(list) ? list : [];
} finally {
foldersLoading.value = false;
}
}
async function openAddCurrent() {
addCurrentStatus.value = "";
error.value = ""; error.value = "";
selectedFolderId.value = null; addFolderId.value = null;
activePage.value = await getActiveTabPage();
const token = await getToken();
cloudAvailable.value = Boolean(token);
if (!token) {
addCurrentOpen.value = true;
return;
}
addCurrentOpen.value = true;
await loadFolders();
}
async function addCurrentToCloud() {
addCurrentStatus.value = "";
error.value = "";
const token = await getToken();
if (!token) {
error.value = "请先登录后再添加到云书签";
return;
}
const page = await getActiveTabPage(); const page = await getActiveTabPage();
const t = String(page.title || "").trim() || String(page.url || "").trim(); addTitle.value = String(page.title || "").trim() || String(page.url || "").trim();
const u = String(page.url || "").trim(); addUrl.value = String(page.url || "").trim();
if (loggedIn.value) {
await loadFolders().catch(() => {});
}
}
async function submitAddCurrent() {
addStatus.value = "";
error.value = "";
if (!loggedIn.value) {
error.value = "请先在『更多操作』里登录";
return;
}
const t = addTitle.value.trim() || addUrl.value.trim();
const u = addUrl.value.trim();
if (!u) return; if (!u) return;
try { try {
addCurrentBusy.value = true; addBusy.value = true;
await apiFetch("/bookmarks", { await apiFetch("/bookmarks", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
folderId: selectedFolderId.value ?? null, folderId: addFolderId.value ?? null,
title: t, title: t,
url: u, url: u,
visibility: "private" visibility: "private"
}) })
}); });
addCurrentStatus.value = "已添加到云书签"; addStatus.value = "已添加";
await load(); if (view.value === "list") await loadBookmarks();
} catch (e) { } catch (e) {
error.value = e.message || String(e); error.value = e.message || String(e);
} finally { } finally {
addCurrentBusy.value = false; addBusy.value = false;
} }
} }
async function load() { // create folder (cloud only)
loading.value = true; const folderName = ref("");
const folderBusy = ref(false);
const folderModalOpen = ref(false);
async function createFolder() {
error.value = ""; error.value = "";
try { const name = folderName.value.trim();
const token = await getToken(); if (!name) return;
cloudAvailable.value = Boolean(token); if (!loggedIn.value) {
mode.value = token ? "cloud" : "local"; error.value = "请先登录";
return;
}
if (mode.value === "cloud") { try {
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : ""; folderBusy.value = true;
items.value = await apiFetch(`/bookmarks${qs}`); await apiFetch("/folders", {
items.value = items.value.slice(0, 20); method: "POST",
} else { body: JSON.stringify({ parentId: null, name, visibility: "private" })
items.value = await listLocalBookmarks(); });
items.value = items.value.slice(0, 50); folderName.value = "";
} folderModalOpen.value = false;
await loadFolders();
} catch (e) { } catch (e) {
error.value = e.message || String(e); error.value = e.message || String(e);
} finally { } finally {
loading.value = false; folderBusy.value = false;
} }
} }
async function quickAdd() { onMounted(async () => {
error.value = ""; await refreshAuth();
const t = title.value.trim(); await prepareAddCurrent();
const u = url.value.trim(); if (loggedIn.value) await loadAll();
if (!t || !u) return; });
try { watch(
const token = await getToken(); () => q.value,
if (token) { async () => {
await apiFetch("/bookmarks", { if (!loggedIn.value) return;
method: "POST", await loadBookmarks();
body: JSON.stringify({ folderId: null, title: t, url: u, visibility: "public" })
});
} else {
await upsertLocalBookmark({ title: t, url: u, visibility: "public" });
}
title.value = "";
url.value = "";
await load();
} catch (e) {
error.value = e.message || String(e);
} }
} );
onMounted(load);
</script> </script>
<template> <template>
<div class="wrap"> <div class="wrap">
<div class="top"> <header class="top">
<div class="title">书签</div> <div class="brand">书签</div>
<button class="btn" @click="openOptions">设置/登录</button> <button class="btn btn--secondary" type="button" @click="openOptions">更多操作</button>
</header>
<div class="seg">
<button class="segBtn" :class="view === 'add' ? 'is-active' : ''" type="button" @click="view = 'add'">
一键添加书签
</button>
<button class="segBtn" :class="view === 'list' ? 'is-active' : ''" type="button" @click="view = 'list'">
书签目录
</button>
</div> </div>
<div class="meta"> <p v-if="!loggedIn" class="hint">未登录请点右上角更多操作先登录</p>
<span class="pill">{{ mode === 'cloud' ? '云端' : '本地' }}</span>
<button class="linkBtn" :disabled="loading" @click="load">刷新</button>
</div>
<div class="row"> <p v-if="error" class="alert">{{ error }}</p>
<input v-model="q" class="input" placeholder="搜索" @keyup.enter="load" /> <p v-if="addStatus" class="ok">{{ addStatus }}</p>
<button class="btn primary" :disabled="loading" @click="load"></button>
</div>
<div class="card"> <section v-if="view === 'add'" class="card">
<div class="cardTitle">快速添加</div> <div class="cardTitle">一键添加书签</div>
<input v-model="title" class="input" placeholder="标题" /> <div class="muted">会自动读取标题和链接你也可以手动修改</div>
<input v-model="url" class="input" placeholder="链接" @keyup.enter="quickAdd" />
<button class="btn primary" @click="quickAdd">添加</button>
</div>
<div class="card"> <label class="label">标题</label>
<div class="cardTitle">添加当前页面到云书签</div> <input v-model="addTitle" class="input" placeholder="标题" />
<div class="muted">只需选一个文件夹不选默认根目录</div>
<button class="btn primary" :disabled="addCurrentBusy" @click="openAddCurrent">添加当前页面</button>
<div v-if="addCurrentOpen" class="subCard"> <label class="label">链接</label>
<div class="subTitle">当前页面</div> <input v-model="addUrl" class="input" placeholder="https://..." />
<div class="subText">{{ activePage.title || '(无标题)' }}</div>
<div class="subUrl">{{ activePage.url || '(无法获取链接)' }}</div>
<template v-if="cloudAvailable"> <label class="label">文件夹不选则未分组</label>
<div class="subTitle" style="margin-top: 10px;">选择文件夹</div> <select v-model="addFolderId" class="input" :disabled="!loggedIn">
<select v-model="selectedFolderId" class="input" :disabled="foldersLoading || addCurrentBusy"> <option :value="null">未分组</option>
<option :value="null">根目录</option> <option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option> </select>
</select>
<button class="btn primary" :disabled="addCurrentBusy || !activePage.url" @click="addCurrentToCloud">
{{ addCurrentBusy ? '添加中' : '确认添加到云端' }}
</button>
<div v-if="foldersLoading" class="muted">加载文件夹中</div>
</template>
<template v-else> <div class="row">
<div class="muted" style="margin-top: 10px;">当前未登录无法添加到云端</div> <button class="btn btn--secondary" type="button" @click="prepareAddCurrent" :disabled="addBusy">
<button class="btn" @click="openOptions">去登录</button> 重新读取
</template> </button>
<button class="btn" type="button" @click="submitAddCurrent" :disabled="addBusy || !addUrl">
<div v-if="addCurrentStatus" class="ok">{{ addCurrentStatus }}</div> {{ addBusy ? '添加中…' : '添加' }}
</button>
</div> </div>
</div> </section>
<div v-if="error" class="error">{{ error }}</div> <section v-else class="card">
<div v-if="loading" class="muted">加载中</div> <div class="titleRow">
<div class="cardTitle">书签目录</div>
<button class="miniBtn" type="button" :disabled="!loggedIn" @click="folderModalOpen = true">新增文件夹</button>
</div>
<ul class="list"> <div class="row" style="margin-top: 8px;">
<li v-for="b in filtered" :key="b.id" class="item" @click="openUrl(b.url)"> <input v-model="q" class="input" placeholder="搜索标题/链接" :disabled="!loggedIn" />
<div class="name">{{ b.title }}</div> <button class="btn btn--secondary" type="button" @click="loadAll" :disabled="!loggedIn || loading">刷新</button>
<div class="url">{{ b.url }}</div> </div>
</li>
</ul> <div v-if="folderModalOpen" class="modal" @click.self="folderModalOpen = false">
<div class="dialog" role="dialog" aria-modal="true" aria-label="新增文件夹">
<div class="dialogTitle">新增文件夹</div>
<input
v-model="folderName"
class="input"
placeholder="文件夹名称"
:disabled="!loggedIn || folderBusy"
@keyup.enter="createFolder"
/>
<div class="dialogActions">
<button class="btn btn--secondary" type="button" @click="folderModalOpen = false" :disabled="folderBusy">取消</button>
<button class="btn" type="button" @click="createFolder" :disabled="!loggedIn || folderBusy">
{{ folderBusy ? '创建中…' : '创建' }}
</button>
</div>
</div>
</div>
<div v-if="loading" class="muted" style="margin-top: 10px;">加载中</div>
<div v-if="loggedIn" class="tree">
<div class="folder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
<button class="folderHeader" type="button" @click="toggleFolder('ROOT')">
<span class="folderName">未分组</span>
<span class="folderMeta">{{ folderCount(null) }} </span>
</button>
<div v-if="isFolderOpen('ROOT')" class="folderBody">
<button
v-for="b in bookmarksByFolderId.get(null) || []"
:key="b.id"
type="button"
class="bm"
@click="openUrl(b.url)"
>
<div class="bmTitle">{{ b.title || b.url }}</div>
<div class="bmUrl">{{ b.url }}</div>
</button>
</div>
</div>
<div v-for="f in folders" :key="f.id" class="folder" :class="isFolderOpen(f.id) ? 'is-open' : ''">
<button class="folderHeader" type="button" @click="toggleFolder(f.id)">
<span class="folderName">{{ f.name }}</span>
<span class="folderMeta">{{ folderCount(f.id) }} </span>
</button>
<div v-if="isFolderOpen(f.id)" class="folderBody">
<button
v-for="b in bookmarksByFolderId.get(f.id) || []"
:key="b.id"
type="button"
class="bm"
@click="openUrl(b.url)"
>
<div class="bmTitle">{{ b.title || b.url }}</div>
<div class="bmUrl">{{ b.url }}</div>
</button>
</div>
</div>
</div>
</section>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.wrap { width: 360px; padding: 12px; font-family: ui-sans-serif, system-ui; background: #f8fafc; color: #1e293b; } .wrap{
.top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } width: 380px;
.title { font-weight: 800; } padding: 12px;
.btn { padding: 6px 10px; border: 1px solid #e5e7eb; background: white; border-radius: 10px; cursor: pointer; transition: background 150ms, border-color 150ms; } font-family: ui-sans-serif, system-ui;
.btn:hover { background: rgba(59, 130, 246, 0.08); border-color: rgba(59, 130, 246, 0.35); } color: var(--bb-text);
.btn:disabled { opacity: 0.6; cursor: not-allowed; } }
.primary { border-color: rgba(59, 130, 246, 0.6); background: rgba(59, 130, 246, 0.12); }
.primary:hover { background: rgba(59, 130, 246, 0.18); } .top{ display:flex; justify-content:space-between; align-items:center; gap:10px; }
.linkBtn { border: none; background: transparent; color: #3b82f6; cursor: pointer; padding: 0; } .brand{ font-weight: 900; letter-spacing: 0.5px; }
.linkBtn:disabled { opacity: 0.6; cursor: not-allowed; }
.meta { display: flex; align-items: center; justify-content: space-between; margin: 6px 0 10px; } .seg{ display:flex; gap:8px; margin-top: 10px; }
.pill { font-size: 12px; padding: 2px 8px; border: 1px solid #e5e7eb; border-radius: 999px; background: white; color: #334155; } .segBtn{
.row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin: 10px 0; } flex:1;
.input { padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: white; } border: 1px solid var(--bb-border);
.card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 10px; background: white; display: grid; gap: 8px; margin-bottom: 10px; } background: rgba(255,255,255,0.85);
.cardTitle { font-weight: 700; font-size: 12px; color: #334155; } padding: 8px 10px;
.subCard { border: 1px dashed #e5e7eb; border-radius: 12px; padding: 10px; background: #f8fafc; display: grid; gap: 8px; } border-radius: 14px;
.subTitle { font-weight: 700; font-size: 12px; color: #334155; } cursor:pointer;
.subText { font-weight: 700; color: #0f172a; overflow-wrap: anywhere; } }
.subUrl { font-size: 12px; color: #475569; overflow-wrap: anywhere; } .segBtn.is-active{
.ok { font-size: 12px; color: #065f46; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 10px; padding: 8px 10px; } background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
.list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; } color: white;
.item { border: 1px solid #e5e7eb; border-radius: 12px; padding: 10px; cursor: pointer; background: white; transition: background 150ms, border-color 150ms; } border-color: rgba(255,255,255,0.35);
.item:hover { background: rgba(59, 130, 246, 0.06); border-color: rgba(59, 130, 246, 0.35); } }
.name { font-weight: 700; color: #111827; }
.url { font-size: 12px; color: #475569; overflow-wrap: anywhere; margin-top: 4px; } .btn{
.muted { font-size: 12px; color: #475569; } border: 1px solid rgba(15, 23, 42, 0.10);
.error { color: #b91c1c; font-size: 12px; margin-bottom: 8px; } border-radius: 14px;
padding: 8px 12px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
cursor: pointer;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
}
.btn--secondary{
background: rgba(255,255,255,0.92);
color: var(--bb-text);
border-color: var(--bb-border);
box-shadow: none;
}
button:disabled{ opacity: 0.6; cursor: not-allowed; }
.hint{ margin: 10px 0 0; color: var(--bb-muted); font-size: 12px; }
.alert{ margin: 10px 0 0; padding: 8px 10px; border-radius: 12px; border: 1px solid rgba(248,113,113,0.35); background: rgba(248,113,113,0.08); color: #7f1d1d; font-size: 12px; }
.ok{ margin: 10px 0 0; padding: 8px 10px; border-radius: 12px; border: 1px solid rgba(34,197,94,0.35); background: rgba(34,197,94,0.10); color: #14532d; font-size: 12px; }
.card{
margin-top: 10px;
border: 1px solid rgba(255,255,255,0.65);
background: var(--bb-card);
border-radius: 18px;
padding: 12px;
backdrop-filter: blur(10px);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.10);
}
.cardTitle{ font-weight: 900; }
.muted{ color: var(--bb-muted); font-size: 12px; margin-top: 4px; }
.label{ display:block; margin-top: 10px; font-size: 12px; color: rgba(19, 78, 74, 0.72); }
.input{
width: 100%;
margin-top: 6px;
padding: 8px 10px;
border-radius: 14px;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.92);
}
.row{ display:flex; gap: 8px; align-items:center; margin-top: 10px; }
.row .input{ margin-top: 0; }
.subCard{ margin-top: 10px; padding: 10px; border-radius: 16px; border: 1px dashed rgba(19,78,74,0.22); background: rgba(255,255,255,0.55); }
.subTitle{ font-weight: 800; }
.titleRow{ display:flex; align-items:center; justify-content:space-between; gap:10px; }
.miniBtn{
padding: 6px 10px;
border-radius: 12px;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.92);
cursor: pointer;
font-size: 12px;
font-weight: 700;
box-shadow: none;
}
.miniBtn:disabled{ opacity: 0.6; cursor: not-allowed; }
.modal{
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 14px;
z-index: 50;
}
.dialog{
width: 100%;
max-width: 340px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.65);
background: var(--bb-card-solid);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.25);
padding: 12px;
}
.dialogTitle{ font-weight: 900; margin-bottom: 8px; }
.dialogActions{ display:flex; justify-content:flex-end; gap: 8px; margin-top: 10px; }
.tree{ margin-top: 10px; display: grid; gap: 10px; }
.folder{ border: 1px solid rgba(255,255,255,0.55); border-radius: 16px; background: rgba(255,255,255,0.55); }
.folderHeader{
width: 100%;
text-align: left;
padding: 8px 10px;
border: 0;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.folderName{ font-weight: 900; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.folderMeta{ font-size: 12px; color: rgba(19, 78, 74, 0.72); }
.folderBody{ padding: 8px 10px 10px; display: grid; gap: 8px; }
.bm{
border: 1px solid rgba(255,255,255,0.65);
background: rgba(255,255,255,0.92);
border-radius: 14px;
padding: 8px 10px;
cursor: pointer;
text-align: left;
}
.bmTitle{ font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bmUrl{ font-size: 12px; color: rgba(19, 78, 74, 0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; }
</style> </style>

View File

@@ -1,4 +1,6 @@
import { createApp } from "vue"; import { createApp } from "vue";
import PopupApp from "./PopupApp.vue"; import PopupApp from "./PopupApp.vue";
import "../style.css";
createApp(PopupApp).mount("#app"); createApp(PopupApp).mount("#app");

View File

@@ -1,79 +1,63 @@
:root { :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; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
a { * { box-sizing: border-box; }
font-weight: 500;
color: #646cff; html, body {
text-decoration: inherit; width: 100%;
} height: 100%;
a:hover { margin: 0;
color: #535bf2;
} }
body { body {
margin: 0; background:
display: flex; radial-gradient(900px 520px at 10% 0%, rgba(37, 99, 235, 0.12), rgba(0,0,0,0) 60%),
place-items: center; radial-gradient(900px 520px at 90% 0%, rgba(249, 115, 22, 0.12), rgba(0,0,0,0) 60%),
min-width: 320px; var(--bb-bg);
min-height: 100vh; color: var(--bb-text);
} }
h1 { a { color: var(--bb-primary); text-decoration: none; }
font-size: 3.2em; a:hover { color: #1d4ed8; }
line-height: 1.1;
button, input, select, textarea {
font: inherit;
color: inherit;
} }
button { select, input, textarea {
border-radius: 8px; background: rgba(255,255,255,0.92);
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;
} }
.card { ::placeholder {
padding: 2em; color: rgba(15, 23, 42, 0.45);
} }
#app { a:focus-visible,
max-width: 1280px; button:focus-visible,
margin: 0 auto; input:focus-visible,
padding: 2rem; select:focus-visible {
text-align: center; outline: 2px solid rgba(37, 99, 235, 0.55);
outline-offset: 2px;
} }
@media (prefers-color-scheme: light) { @media (prefers-reduced-motion: reduce) {
:root { * { transition: none !important; scroll-behavior: auto !important; }
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
} }

View File

@@ -15,6 +15,7 @@ create table if not exists bookmark_folders (
parent_id uuid null references bookmark_folders(id) on delete cascade, parent_id uuid null references bookmark_folders(id) on delete cascade,
name text not null, name text not null,
visibility text not null default 'private', visibility text not null default 'private',
sort_order integer not null default 0,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
updated_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(), id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade, user_id uuid not null references users(id) on delete cascade,
folder_id uuid null references bookmark_folders(id) on delete set null, folder_id uuid null references bookmark_folders(id) on delete set null,
sort_order integer not null default 0,
title text not null, title text not null,
url text not null, url text not null,
url_normalized 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_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_user_url_hash on bookmarks (user_id, url_hash);
create index if not exists idx_bookmarks_visibility on bookmarks (visibility); create index if not exists idx_bookmarks_visibility on bookmarks (visibility);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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", "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", "test": "node --test",
"lint": "eslint .", "lint": "eslint .",
"db:migrate": "node src/migrate.js" "db:migrate": "node src/migrate.js",
"db:reset": "node src/resetDb.js"
}, },
"dependencies": { "dependencies": {
"@browser-bookmark/shared": "0.1.0", "@browser-bookmark/shared": "0.1.0",

View File

@@ -22,9 +22,11 @@ loadEnv();
export function getConfig() { export function getConfig() {
const serverPort = Number(process.env.SERVER_PORT || 3001); const serverPort = Number(process.env.SERVER_PORT || 3001);
const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
return { return {
serverPort, serverPort,
adminEmail,
database: { database: {
host: process.env.DATABASE_HOST || "127.0.0.1", host: process.env.DATABASE_HOST || "127.0.0.1",
port: Number(process.env.DATABASE_PORT || 5432), port: Number(process.env.DATABASE_PORT || 5432),

View File

@@ -5,6 +5,7 @@ import jwt from "@fastify/jwt";
import { getConfig } from "./config.js"; import { getConfig } from "./config.js";
import { createPool } from "./db.js"; import { createPool } from "./db.js";
import { authRoutes } from "./routes/auth.routes.js"; import { authRoutes } from "./routes/auth.routes.js";
import { adminRoutes } from "./routes/admin.routes.js";
import { foldersRoutes } from "./routes/folders.routes.js"; import { foldersRoutes } from "./routes/folders.routes.js";
import { bookmarksRoutes } from "./routes/bookmarks.routes.js"; import { bookmarksRoutes } from "./routes/bookmarks.routes.js";
import { importExportRoutes } from "./routes/importExport.routes.js"; import { importExportRoutes } from "./routes/importExport.routes.js";
@@ -15,7 +16,9 @@ const app = Fastify({ logger: true });
// Plugins // Plugins
await app.register(cors, { await app.register(cors, {
origin: true, origin: true,
credentials: true credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "Accept"]
}); });
await app.register(multipart); await app.register(multipart);
@@ -25,7 +28,29 @@ if (!jwtSecret) {
} }
await app.register(jwt, { secret: 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) => { app.decorate("authenticate", async (req, reply) => {
try { try {
@@ -44,7 +69,11 @@ app.setErrorHandler((err, _req, reply) => {
app.get("/health", async () => ({ ok: true })); app.get("/health", async () => ({ ok: true }));
// Routes // Routes
const config = getConfig();
app.decorate("config", config);
await authRoutes(app); await authRoutes(app);
await adminRoutes(app);
await foldersRoutes(app); await foldersRoutes(app);
await bookmarksRoutes(app); await bookmarksRoutes(app);
await importExportRoutes(app); await importExportRoutes(app);
@@ -54,5 +83,5 @@ app.addHook("onClose", async (instance) => {
await instance.pg.end(); await instance.pg.end();
}); });
const { serverPort } = getConfig(); const { serverPort } = config;
await app.listen({ port: serverPort, host: "0.0.0.0" }); await app.listen({ port: serverPort, host: "0.0.0.0" });

View File

@@ -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;
}

View File

@@ -8,26 +8,58 @@ export function parseNetscapeBookmarkHtmlNode(html) {
const folders = []; const folders = [];
const bookmarks = []; const bookmarks = [];
function walkDl($dl, parentTempId) { function normText(s) {
// Netscape format: <DL><p> contains repeating <DT> items and nested <DL> return String(s || "").replace(/\s+/g, " ").trim();
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 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 <DT>
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: <DL><p> contains repeating <DT> items and nested <DL>.
// When parsed, <DT> may be wrapped (e.g. inside <p>), so we must be robust.
const dts = collectLevelDt($dl[0]);
for (const node of dts) {
const $dt = $(node); const $dt = $(node);
const $h3 = $dt.children("h3").first(); const $h3 = $dt.children("h3").first().length ? $dt.children("h3").first() : $dt.find("h3").first();
const $a = $dt.children("a").first(); const $a = $dt.children("a").first().length ? $dt.children("a").first() : $dt.find("a").first();
const $next = $(children[i + 1] || null); const $nestedDl = $dt.children("dl").first();
const nextIsDl = $next && $next[0]?.tagName?.toLowerCase() === "dl"; const $nextDl = $nestedDl.length ? $nestedDl : findNextDlForDt(node, $dl[0]);
if ($h3.length) { if ($h3.length) {
const tempId = `${folders.length + 1}`; const tempId = `${folders.length + 1}`;
const name = ($h3.text() || "").trim(); const name = normText($h3.text() || "");
folders.push({ tempId, parentTempId: parentTempId ?? null, name }); folders.push({ tempId, parentTempId: parentTempId ?? null, name });
if (nextIsDl) walkDl($next, tempId); if ($nextDl?.length) walkDl($nextDl, tempId);
} else if ($a.length) { } else if ($a.length) {
const title = ($a.text() || "").trim(); const title = normText($a.text() || "");
const url = $a.attr("href") || ""; const url = $a.attr("href") || "";
bookmarks.push({ parentTempId: parentTempId ?? null, title, url }); bookmarks.push({ parentTempId: parentTempId ?? null, title, url });
} }

View File

@@ -15,6 +15,7 @@ export function folderRowToDto(row) {
parentId: row.parent_id, parentId: row.parent_id,
name: row.name, name: row.name,
visibility: row.visibility, visibility: row.visibility,
sortOrder: row.sort_order ?? 0,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at updatedAt: row.updated_at
}; };
@@ -25,6 +26,7 @@ export function bookmarkRowToDto(row) {
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
folderId: row.folder_id, folderId: row.folder_id,
sortOrder: row.sort_order ?? 0,
title: row.title, title: row.title,
url: row.url, url: row.url,
urlNormalized: row.url_normalized, urlNormalized: row.url_normalized,

View File

@@ -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();

View File

@@ -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]);
}
);
}

View File

@@ -2,6 +2,19 @@ import { hashPassword, verifyPassword } from "../lib/auth.js";
import { httpError } from "../lib/httpErrors.js"; import { httpError } from "../lib/httpErrors.js";
import { userRowToDto } from "../lib/rows.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) { export async function authRoutes(app) {
app.post("/auth/register", async (req) => { app.post("/auth/register", async (req) => {
const { email, password } = req.body || {}; 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", "insert into users (email, password_hash) values ($1, $2) returning id, email, role, created_at, updated_at",
[email, passwordHash] [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 }); const token = await app.jwt.sign({ sub: user.id, role: user.role });
return { token, user }; return { token, user };
} catch (err) { } catch (err) {
@@ -39,8 +52,9 @@ export async function authRoutes(app) {
if (!ok) throw httpError(401, "invalid credentials"); if (!ok) throw httpError(401, "invalid credentials");
const user = userRowToDto(row); const user = userRowToDto(row);
const token = await app.jwt.sign({ sub: user.id, role: user.role }); const userWithRole = toUserDtoWithAdminOverride(app, row);
return { token, user }; const token = await app.jwt.sign({ sub: userWithRole.id, role: userWithRole.role });
return { token, user: userWithRole };
}); });
app.get( app.get(
@@ -54,7 +68,7 @@ export async function authRoutes(app) {
); );
const row = res.rows[0]; const row = res.rows[0];
if (!row) throw httpError(404, "user not found"); if (!row) throw httpError(404, "user not found");
return userRowToDto(row); return toUserDtoWithAdminOverride(app, row);
} }
); );
} }

View File

@@ -34,8 +34,12 @@ export async function bookmarksRoutes(app) {
where += ` and (title ilike $${params.length} or url ilike $${params.length})`; 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( 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 params
); );
return res.rows.map(bookmarkRowToDto); return res.rows.map(bookmarkRowToDto);
@@ -62,26 +66,117 @@ export async function bookmarksRoutes(app) {
if (existing.rows[0]) { if (existing.rows[0]) {
// auto-merge // auto-merge
const merged = await app.pg.query( const targetFolderId = folderId ?? null;
`update bookmarks const merged = app.features?.bookmarkSortOrder
set title=$1, url=$2, url_normalized=$3, visibility=$4, folder_id=$5, source='manual', updated_at=now() ? await app.pg.query(
where id=$6 `update bookmarks
returning *`, set title=$1,
[title, url, urlNormalized, visibility, folderId ?? null, existing.rows[0].id] 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]); return bookmarkRowToDto(merged.rows[0]);
} }
const res = await app.pg.query( const targetFolderId = folderId ?? null;
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source) const res = app.features?.bookmarkSortOrder
values ($1, $2, $3, $4, $5, $6, $7, 'manual') ? await app.pg.query(
returning *`, `insert into bookmarks (user_id, folder_id, sort_order, title, url, url_normalized, url_hash, visibility, source)
[userId, folderId ?? null, title, url, urlNormalized, urlHash, visibility] 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]); 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( app.patch(
"/bookmarks/:id", "/bookmarks/:id",
{ preHandler: [app.authenticate] }, { preHandler: [app.authenticate] },
@@ -134,6 +229,19 @@ export async function bookmarksRoutes(app) {
params.push(body.visibility); 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")) { if (Object.prototype.hasOwnProperty.call(body, "url")) {
sets.push(`url=$${i++}`); sets.push(`url=$${i++}`);
params.push(nextUrl); params.push(nextUrl);

View File

@@ -7,8 +7,11 @@ export async function foldersRoutes(app) {
{ preHandler: [app.authenticate] }, { preHandler: [app.authenticate] },
async (req) => { async (req) => {
const userId = req.user.sub; 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( 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] [userId]
); );
return res.rows.map(folderRowToDto); return res.rows.map(folderRowToDto);
@@ -21,19 +24,106 @@ export async function foldersRoutes(app) {
async (req) => { async (req) => {
const userId = req.user.sub; const userId = req.user.sub;
const { parentId, name, visibility } = req.body || {}; 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( await app.pg.query("begin");
`insert into bookmark_folders (user_id, parent_id, name, visibility) try {
values ($1, $2, $3, $4) // Move bookmarks in this folder back to root (so they remain visible).
returning *`, await app.pg.query(
[userId, parentId ?? null, name, visibility] "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]); 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( app.patch(
"/folders/:id", "/folders/:id",
{ preHandler: [app.authenticate] }, { preHandler: [app.authenticate] },
@@ -68,6 +158,19 @@ export async function foldersRoutes(app) {
params.push(body.visibility); 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"); if (sets.length === 0) throw httpError(400, "no fields to update");
params.push(id, userId); params.push(id, userId);

View File

@@ -16,24 +16,65 @@ export async function importExportRoutes(app) {
const parsed = parseNetscapeBookmarkHtmlNode(html); 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(); const tempToDbId = new Map();
for (const f of parsed.folders) { for (const f of parsed.folders || []) {
const parentId = f.parentTempId ? tempToDbId.get(f.parentTempId) : null; const key = normName(f.name);
const res = await app.pg.query( if (!key) continue;
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, $2, $3, 'private') let id = folderIdByName.get(key);
returning id`, if (!id) {
[userId, parentId, f.name] const res = app.features?.folderSortOrder
); ? await app.pg.query(
tempToDbId.set(f.tempId, res.rows[0].id); `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 imported = 0;
let merged = 0; let merged = 0;
for (const b of parsed.bookmarks) { 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 urlNormalized = normalizeUrl(b.url);
const urlHash = computeUrlHash(urlNormalized); const urlHash = computeUrlHash(urlNormalized);

View File

@@ -12,8 +12,9 @@
}, },
"dependencies": { "dependencies": {
"@browser-bookmark/shared": "0.1.0", "@browser-bookmark/shared": "0.1.0",
"vue-router": "^4.5.1", "sortablejs": "^1.15.6",
"vue": "^3.5.24" "vue": "^3.5.24",
"vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",

View File

@@ -1,10 +1,11 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink, RouterView, useRouter } from "vue-router"; import { RouterLink, RouterView, useRouter } from "vue-router";
import { setToken, tokenRef } from "./lib/api"; import { ensureMe, setToken, tokenRef, userRef } from "./lib/api";
const router = useRouter(); const router = useRouter();
const loggedIn = computed(() => Boolean(tokenRef.value)); const loggedIn = computed(() => Boolean(tokenRef.value));
const isAdmin = computed(() => userRef.value?.role === "admin");
const menuOpen = ref(false); const menuOpen = ref(false);
@@ -18,6 +19,7 @@ function closeMenu() {
async function logout() { async function logout() {
setToken(""); setToken("");
userRef.value = null;
closeMenu(); closeMenu();
await router.push("/"); await router.push("/");
} }
@@ -33,6 +35,19 @@ onMounted(() => {
document.addEventListener("pointerdown", onDocPointerDown); document.addEventListener("pointerdown", onDocPointerDown);
}); });
watch(
() => tokenRef.value,
(next) => {
if (next) ensureMe();
else userRef.value = null;
},
{ immediate: true }
);
onMounted(() => {
if (loggedIn.value) ensureMe();
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDocPointerDown); document.removeEventListener("pointerdown", onDocPointerDown);
}); });
@@ -64,6 +79,7 @@ router.afterEach(() => {
<div v-if="menuOpen" class="menu" role="menu"> <div v-if="menuOpen" class="menu" role="menu">
<RouterLink class="menuItem" to="/import" role="menuitem">导入 / 导出</RouterLink> <RouterLink class="menuItem" to="/import" role="menuitem">导入 / 导出</RouterLink>
<RouterLink v-if="isAdmin" class="menuItem" to="/admin" role="menuitem">管理用户</RouterLink>
<button class="menuItem danger" type="button" role="menuitem" @click="logout">退出登录</button> <button class="menuItem danger" type="button" role="menuitem" @click="logout">退出登录</button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,108 @@
<script setup>
import { onBeforeUnmount, onMounted } from "vue";
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: "" },
maxWidth: { type: String, default: "720px" }
});
const emit = defineEmits(["update:modelValue"]);
function close() {
emit("update:modelValue", false);
}
function onKeydown(e) {
if (e.key === "Escape") close();
}
onMounted(() => {
window.addEventListener("keydown", onKeydown);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKeydown);
});
</script>
<template>
<teleport to="body">
<div v-if="modelValue" class="bb-modalOverlay" role="dialog" aria-modal="true">
<div class="bb-modalBackdrop" @click="close" />
<div class="bb-modalPanel" :style="{ maxWidth }">
<div class="bb-modalHeader">
<div class="bb-modalTitle">{{ title }}</div>
<button type="button" class="bb-modalClose" @click="close" aria-label="关闭">×</button>
</div>
<div class="bb-modalBody">
<slot />
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
.bb-modalOverlay {
position: fixed;
inset: 0;
z-index: 2147483500;
display: grid;
place-items: center;
padding: 18px;
}
.bb-modalBackdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.35);
backdrop-filter: blur(6px);
}
.bb-modalPanel {
position: relative;
width: min(100%, var(--bb-modal-max, 720px));
max-height: min(84vh, 860px);
overflow: auto;
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.65);
background: rgba(255,255,255,0.82);
backdrop-filter: blur(14px);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.18);
}
.bb-modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.bb-modalTitle {
font-weight: 900;
color: var(--bb-text);
}
.bb-modalClose {
width: 34px;
height: 34px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.55);
background: rgba(255,255,255,0.45);
cursor: pointer;
font-size: 20px;
line-height: 1;
color: rgba(15, 23, 42, 0.72);
}
.bb-modalClose:hover {
background: rgba(255,255,255,0.75);
}
.bb-modalBody {
padding: 14px;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({ const props = defineProps({
modelValue: { type: [String, Number, Boolean, Object, null], default: null }, modelValue: { type: [String, Number, Boolean, Object, null], default: null },
@@ -12,7 +12,10 @@ const props = defineProps({
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue"]);
const rootEl = ref(null); const rootEl = ref(null);
const triggerEl = ref(null);
const menuEl = ref(null);
const open = ref(false); const open = ref(false);
const menuStyle = ref({ left: "0px", top: "0px", width: "0px" });
const selected = computed(() => props.options.find((o) => Object.is(o.value, props.modelValue)) || null); const selected = computed(() => props.options.find((o) => Object.is(o.value, props.modelValue)) || null);
const label = computed(() => selected.value?.label ?? ""); const label = computed(() => selected.value?.label ?? "");
@@ -21,9 +24,30 @@ function close() {
open.value = false; open.value = false;
} }
async function updateMenuPosition() {
const el = triggerEl.value;
if (!el) return;
const rect = el.getBoundingClientRect();
const gap = 8;
menuStyle.value = {
left: `${Math.round(rect.left)}px`,
top: `${Math.round(rect.bottom + gap)}px`,
width: `${Math.round(rect.width)}px`
};
}
async function openMenu() {
if (props.disabled) return;
open.value = true;
await nextTick();
await updateMenuPosition();
}
function toggle() { function toggle() {
if (props.disabled) return; if (props.disabled) return;
open.value = !open.value; if (open.value) close();
else openMenu();
} }
function choose(value, isDisabled) { function choose(value, isDisabled) {
@@ -46,22 +70,41 @@ function onKeydownTrigger(e) {
function onDocPointerDown(e) { function onDocPointerDown(e) {
const el = rootEl.value; const el = rootEl.value;
const menu = menuEl.value;
if (!el) return; if (!el) return;
if (el.contains(e.target)) return; if (el.contains(e.target)) return;
if (menu && menu.contains(e.target)) return;
close(); close();
} }
function onViewportChange() {
if (!open.value) return;
updateMenuPosition();
}
onMounted(() => { onMounted(() => {
document.addEventListener("pointerdown", onDocPointerDown); document.addEventListener("pointerdown", onDocPointerDown);
window.addEventListener("resize", onViewportChange);
// capture scroll events from any scroll container
window.addEventListener("scroll", onViewportChange, true);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDocPointerDown); document.removeEventListener("pointerdown", onDocPointerDown);
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
}); });
</script> </script>
<template> <template>
<div ref="rootEl" class="bb-selectWrap" :class="size === 'sm' ? 'bb-selectWrap--sm' : ''"> <div
ref="rootEl"
class="bb-selectWrap"
:class="[
size === 'sm' ? 'bb-selectWrap--sm' : '',
open ? 'is-open' : ''
]"
>
<button <button
type="button" type="button"
class="bb-selectTrigger" class="bb-selectTrigger"
@@ -69,6 +112,7 @@ onBeforeUnmount(() => {
:aria-expanded="open ? 'true' : 'false'" :aria-expanded="open ? 'true' : 'false'"
@click="toggle" @click="toggle"
@keydown="onKeydownTrigger" @keydown="onKeydownTrigger"
ref="triggerEl"
> >
<span class="bb-selectValue" :class="!label ? 'bb-selectPlaceholder' : ''"> <span class="bb-selectValue" :class="!label ? 'bb-selectPlaceholder' : ''">
{{ label || placeholder }} {{ label || placeholder }}
@@ -80,22 +124,30 @@ onBeforeUnmount(() => {
</span> </span>
</button> </button>
<div v-if="open" class="bb-selectMenu" role="listbox"> <teleport to="body">
<button <div
v-for="(o, idx) in options" v-if="open"
:key="idx" ref="menuEl"
type="button" class="bb-selectMenu bb-selectMenu--portal"
class="bb-selectOption" role="listbox"
:class="[ :style="menuStyle"
Object.is(o.value, modelValue) ? 'is-selected' : '',
o.disabled ? 'is-disabled' : ''
]"
role="option"
:aria-selected="Object.is(o.value, modelValue) ? 'true' : 'false'"
@click="choose(o.value, o.disabled)"
> >
<span class="bb-selectOptionLabel">{{ o.label }}</span> <button
</button> v-for="(o, idx) in options"
</div> :key="idx"
type="button"
class="bb-selectOption"
:class="[
Object.is(o.value, modelValue) ? 'is-selected' : '',
o.disabled ? 'is-disabled' : ''
]"
role="option"
:aria-selected="Object.is(o.value, modelValue) ? 'true' : 'false'"
@click="choose(o.value, o.disabled)"
>
<span class="bb-selectOptionLabel">{{ o.label }}</span>
</button>
</div>
</teleport>
</div> </div>
</template> </template>

View File

@@ -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 tokenRef = ref(localStorage.getItem("bb_token") || "");
export const userRef = ref(null);
let mePromise = null;
export function getToken() { export function getToken() {
return tokenRef.value || ""; return tokenRef.value || "";
} }
@@ -13,15 +16,51 @@ export function setToken(token) {
tokenRef.value = next; tokenRef.value = next;
if (next) localStorage.setItem("bb_token", next); if (next) localStorage.setItem("bb_token", next);
else localStorage.removeItem("bb_token"); else localStorage.removeItem("bb_token");
// reset cached user when auth changes
userRef.value = null;
mePromise = null;
} }
// Keep auth state in sync across tabs. // Keep auth state in sync across tabs.
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.addEventListener("storage", (e) => { 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 = {}) { export async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {}); const headers = new Headers(options.headers || {});
if (!headers.has("Accept")) headers.set("Accept", "application/json"); if (!headers.has("Accept")) headers.set("Accept", "application/json");

View File

@@ -132,13 +132,72 @@ export function patchLocalBookmark(id, patch) {
return item; 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" } = {}) { export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
const parsed = parseNetscapeBookmarkHtml(html || ""); const parsed = parseNetscapeBookmarkHtml(html || "");
const state = loadLocalState(); const state = loadLocalState();
const now = nowIso(); const now = nowIso();
state.folders = state.folders || [];
state.bookmarks = state.bookmarks || []; 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( const existingByHash = new Map(
(state.bookmarks || []) (state.bookmarks || [])
.filter((b) => !b.deletedAt) .filter((b) => !b.deletedAt)
@@ -156,6 +215,10 @@ export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}
if (!url) continue; if (!url) continue;
const title = (b.title || "").trim() || url; 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 urlNormalized = normalizeUrl(url);
const urlHash = computeUrlHash(urlNormalized); const urlHash = computeUrlHash(urlNormalized);
@@ -166,6 +229,7 @@ export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}
existing.urlNormalized = urlNormalized; existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash; existing.urlHash = urlHash;
existing.visibility = visibility; existing.visibility = visibility;
existing.folderId = folderId;
existing.source = "import"; existing.source = "import";
existing.updatedAt = now; existing.updatedAt = now;
merged++; merged++;
@@ -175,7 +239,7 @@ export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}
const created = { const created = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
userId: null, userId: null,
folderId: null, folderId,
title, title,
url, url,
urlNormalized, urlNormalized,

View File

@@ -0,0 +1,379 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { apiFetch, ensureMe, userRef } from "../lib/api";
const loadingUsers = ref(false);
const usersError = ref("");
const users = ref([]);
const selectedUserId = ref("");
const selectedUser = computed(() => users.value.find((u) => u.id === selectedUserId.value) || null);
const q = ref("");
const loadingBookmarks = ref(false);
const bookmarksError = ref("");
const bookmarks = ref([]);
const loadingFolders = ref(false);
const foldersError = ref("");
const folders = ref([]);
const openFolderIds = ref(new Set());
const isAdmin = computed(() => userRef.value?.role === "admin");
async function loadUsers() {
loadingUsers.value = true;
usersError.value = "";
try {
users.value = await apiFetch("/admin/users");
if (!selectedUserId.value && users.value.length) selectedUserId.value = users.value[0].id;
} catch (e) {
usersError.value = e.message || String(e);
} finally {
loadingUsers.value = false;
}
}
async function loadFolders() {
if (!selectedUserId.value) return;
loadingFolders.value = true;
foldersError.value = "";
try {
folders.value = await apiFetch(`/admin/users/${selectedUserId.value}/folders`);
} catch (e) {
foldersError.value = e.message || String(e);
} finally {
loadingFolders.value = false;
}
}
async function loadBookmarks() {
if (!selectedUserId.value) return;
loadingBookmarks.value = true;
bookmarksError.value = "";
try {
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
bookmarks.value = await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks${qs}`);
} catch (e) {
bookmarksError.value = e.message || String(e);
} finally {
loadingBookmarks.value = false;
}
}
function selectUser(id) {
selectedUserId.value = id;
q.value = "";
folders.value = [];
bookmarks.value = [];
openFolderIds.value = new Set(["ROOT"]);
loadFolders();
loadBookmarks();
}
function openUrl(url) {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
}
function buildFolderFlat(list) {
const byId = new Map((list || []).map((f) => [f.id, f]));
const children = new Map();
for (const f of list || []) {
const key = f.parentId ?? null;
if (!children.has(key)) children.set(key, []);
children.get(key).push(f);
}
for (const arr of children.values()) {
arr.sort((a, b) => {
const ao = Number.isFinite(a.sortOrder) ? a.sortOrder : 0;
const bo = Number.isFinite(b.sortOrder) ? b.sortOrder : 0;
if (ao !== bo) return ao - bo;
return String(a.name || "").localeCompare(String(b.name || ""));
});
}
const out = [];
const visited = new Set();
function walk(parentId, depth) {
const arr = children.get(parentId ?? null) || [];
for (const f of arr) {
if (!f?.id || visited.has(f.id)) continue;
visited.add(f.id);
out.push({ folder: f, depth });
walk(f.id, depth + 1);
}
}
walk(null, 0);
for (const f of list || []) {
if (f?.id && !visited.has(f.id) && byId.has(f.id)) {
out.push({ folder: f, depth: 0 });
visited.add(f.id);
}
}
return out;
}
const folderFlat = computed(() => buildFolderFlat(folders.value));
const bookmarksByFolderId = computed(() => {
const map = new Map();
for (const b of bookmarks.value || []) {
const key = b.folderId ?? null;
if (!map.has(key)) map.set(key, []);
map.get(key).push(b);
}
for (const arr of map.values()) {
arr.sort((a, b) => {
const ao = Number.isFinite(a.sortOrder) ? a.sortOrder : 0;
const bo = Number.isFinite(b.sortOrder) ? b.sortOrder : 0;
if (ao !== bo) return ao - bo;
return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
});
}
return map;
});
function folderCount(folderId) {
return (bookmarksByFolderId.value.get(folderId ?? null) || []).length;
}
function toggleFolder(folderId) {
const set = new Set(openFolderIds.value);
if (set.has(folderId)) set.delete(folderId);
else set.add(folderId);
openFolderIds.value = set;
}
function isFolderOpen(folderId) {
return openFolderIds.value.has(folderId);
}
async function deleteBookmark(bookmarkId) {
if (!selectedUserId.value) return;
if (!confirm("确定删除该书签?")) return;
try {
await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks/${bookmarkId}`, { method: "DELETE" });
await loadBookmarks();
} catch (e) {
bookmarksError.value = e.message || String(e);
}
}
async function deleteFolder(folderId) {
if (!selectedUserId.value) return;
if (!confirm("确定删除该文件夹?子文件夹会一起删除,文件夹内书签会变成未分组。")) return;
try {
await apiFetch(`/admin/users/${selectedUserId.value}/folders/${folderId}`, { method: "DELETE" });
await loadFolders();
await loadBookmarks();
} catch (e) {
foldersError.value = e.message || String(e);
}
}
async function copyToMe(bookmarkId) {
if (!selectedUserId.value) return;
try {
await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks/${bookmarkId}/copy-to-me`, { method: "POST" });
} catch (e) {
bookmarksError.value = e.message || String(e);
}
}
onMounted(async () => {
await ensureMe();
if (!isAdmin.value) return;
await loadUsers();
if (selectedUserId.value) openFolderIds.value = new Set(["ROOT"]);
await loadFolders();
await loadBookmarks();
});
</script>
<template>
<section class="bb-page">
<div class="bb-pageHeader">
<div>
<h1 style="margin: 0;">管理用户</h1>
<div class="bb-muted" style="margin-top: 4px;">仅管理员可见查看用户列表与其书签</div>
</div>
</div>
<div v-if="!isAdmin" class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 900;">无权限</div>
<div class="bb-muted" style="margin-top: 6px;">当前账号不是管理员</div>
</div>
<div v-else class="bb-adminGrid" style="margin-top: 12px;">
<aside class="bb-card">
<div class="bb-cardTitle">用户</div>
<div class="bb-muted" style="margin-top: 4px;">点击查看该用户书签</div>
<p v-if="usersError" class="bb-alert bb-alert--error" style="margin-top: 10px;">{{ usersError }}</p>
<p v-else-if="loadingUsers" class="bb-muted" style="margin-top: 10px;">加载中</p>
<div v-else class="bb-adminUserList" style="margin-top: 10px;">
<button
v-for="u in users"
:key="u.id"
type="button"
class="bb-adminUser"
:class="u.id === selectedUserId ? 'is-active' : ''"
@click="selectUser(u.id)"
>
<div class="email">{{ u.email }}</div>
<div class="meta">{{ u.role === 'admin' ? '管理员' : '用户' }}</div>
</button>
</div>
</aside>
<section class="bb-card">
<div class="bb-row" style="justify-content: space-between; align-items: flex-end;">
<div>
<div class="bb-cardTitle">书签</div>
<div class="bb-muted" style="margin-top: 4px;">
<span v-if="selectedUser">当前{{ selectedUser.email }}</span>
<span v-else>请选择一个用户</span>
</div>
</div>
<div class="bb-row" style="gap: 8px;">
<input v-model="q" class="bb-input bb-input--sm" placeholder="搜索标题/链接" @keyup.enter="loadBookmarks" />
<button class="bb-btn bb-btn--secondary" :disabled="loadingBookmarks" @click="loadBookmarks">搜索</button>
</div>
</div>
<p v-if="bookmarksError" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ bookmarksError }}</p>
<p v-else-if="loadingBookmarks" class="bb-muted" style="margin-top: 12px;">加载中</p>
<p v-if="foldersError" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ foldersError }}</p>
<div v-else-if="(!folders.length && !bookmarks.length)" class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 800;">暂无数据</div>
<div class="bb-muted" style="margin-top: 6px;">该用户还没有书签或文件夹</div>
</div>
<div v-else class="bb-adminTree" style="margin-top: 12px;">
<!-- Root group -->
<div class="bb-adminFolder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
<button type="button" class="bb-adminFolderHeader" @click="toggleFolder('ROOT')">
<span class="name">未分组</span>
<span class="meta">{{ folderCount(null) }} </span>
</button>
<div v-if="isFolderOpen('ROOT')" class="bb-adminFolderBody">
<ul class="bb-adminBookmarks">
<li
v-for="b in (bookmarksByFolderId.get(null) || [])"
:key="b.id"
class="bb-card bb-card--interactive bb-clickCard"
role="link"
tabindex="0"
@click="openUrl(b.url)"
@keydown.enter.prevent="openUrl(b.url)"
@keydown.space.prevent="openUrl(b.url)"
>
<div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
<div class="bb-adminActions" style="margin-top: 10px;">
<button class="bb-btn bb-btn--secondary" type="button" @click.stop="copyToMe(b.id)">复制到我</button>
<button class="bb-btn bb-btn--danger" type="button" @click.stop="deleteBookmark(b.id)">删除</button>
</div>
</li>
</ul>
</div>
</div>
<!-- Folder tree (flat with indent) -->
<div
v-for="x in folderFlat"
:key="x.folder.id"
class="bb-adminFolder"
:class="isFolderOpen(x.folder.id) ? 'is-open' : ''"
:style="{ paddingLeft: `${x.depth * 14}px` }"
>
<div class="bb-adminFolderHeaderRow">
<button type="button" class="bb-adminFolderHeader" @click="toggleFolder(x.folder.id)">
<span class="name">{{ x.folder.name }}</span>
<span class="meta">{{ folderCount(x.folder.id) }} </span>
</button>
<button class="bb-btn bb-btn--danger bb-adminFolderDel" type="button" @click.stop="deleteFolder(x.folder.id)">删除文件夹</button>
</div>
<div v-if="isFolderOpen(x.folder.id)" class="bb-adminFolderBody">
<ul class="bb-adminBookmarks">
<li
v-for="b in (bookmarksByFolderId.get(x.folder.id) || [])"
:key="b.id"
class="bb-card bb-card--interactive bb-clickCard"
role="link"
tabindex="0"
@click="openUrl(b.url)"
@keydown.enter.prevent="openUrl(b.url)"
@keydown.space.prevent="openUrl(b.url)"
>
<div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
<div class="bb-adminActions" style="margin-top: 10px;">
<button class="bb-btn bb-btn--secondary" type="button" @click.stop="copyToMe(b.id)">复制到我</button>
<button class="bb-btn bb-btn--danger" type="button" @click.stop="deleteBookmark(b.id)">删除</button>
</div>
</li>
</ul>
</div>
</div>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.bb-adminGrid { display: grid; grid-template-columns: 1fr; gap: 12px; }
@media (min-width: 960px) { .bb-adminGrid { grid-template-columns: 1.1fr 2fr; } }
.bb-adminUserList { display: grid; gap: 8px; }
.bb-adminUser {
width: 100%;
text-align: left;
padding: 10px 12px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.45);
background: rgba(255,255,255,0.35);
cursor: pointer;
}
.bb-adminUser:hover { background: rgba(255,255,255,0.6); }
.bb-adminUser.is-active { background: rgba(13, 148, 136, 0.12); border-color: rgba(13, 148, 136, 0.22); }
.bb-adminUser .email { font-weight: 800; color: var(--bb-text); }
.bb-adminUser .meta { font-size: 12px; color: rgba(19, 78, 74, 0.72); margin-top: 2px; }
.bb-adminBookmarks { list-style: none; padding: 0; margin: 12px 0 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 768px) { .bb-adminBookmarks { grid-template-columns: 1fr 1fr; } }
.bb-clickCard { cursor: pointer; }
.title { font-weight: 900; color: var(--bb-text); }
.bb-adminFolder { margin-top: 10px; }
.bb-adminFolderHeaderRow { display: flex; gap: 10px; align-items: center; }
.bb-adminFolderHeader {
flex: 1;
width: 100%;
text-align: left;
padding: 10px 12px;
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-adminFolderHeader:hover { background: rgba(255,255,255,0.6); }
.bb-adminFolderHeader .name { font-weight: 900; color: var(--bb-text); }
.bb-adminFolderHeader .meta { font-size: 12px; color: rgba(19, 78, 74, 0.72); }
.bb-adminFolderBody { margin-top: 10px; }
.bb-adminFolderDel { white-space: nowrap; }
.bb-adminActions { display: flex; gap: 8px; flex-wrap: wrap; }
</style>

View File

@@ -1,8 +1,10 @@
<script setup> <script setup>
import { computed, onMounted, ref } from "vue"; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import BbSelect from "../components/BbSelect.vue"; import BbSelect from "../components/BbSelect.vue";
import BbModal from "../components/BbModal.vue";
import Sortable from "sortablejs";
import { apiFetch, tokenRef } from "../lib/api"; import { apiFetch, tokenRef } from "../lib/api";
import { loadLocalState, markLocalDeleted, patchLocalBookmark, upsertLocalBookmark } from "../lib/localData"; import { deleteLocalFolder, loadLocalState, markLocalDeleted, patchLocalBookmark, upsertLocalBookmark } from "../lib/localData";
const loggedIn = computed(() => Boolean(tokenRef.value)); const loggedIn = computed(() => Boolean(tokenRef.value));
const error = ref(""); const error = ref("");
@@ -24,6 +26,15 @@ const visibility = ref("public");
const q = ref(""); const q = ref("");
const openFolderIds = ref(new Set());
const addModalOpen = ref(false);
const folderModalOpen = ref(false);
const treeEl = ref(null);
let folderSortable = null;
const bookmarkSortables = new Map();
const items = ref([]); const items = ref([]);
const editingId = ref(""); const editingId = ref("");
@@ -32,6 +43,35 @@ const editUrl = ref("");
const editFolderId = ref(null); const editFolderId = ref(null);
const editVisibility = ref("public"); const editVisibility = ref("public");
const dragging = ref(false);
let dragResetTimer = null;
function setDragging(next) {
dragging.value = Boolean(next);
if (dragResetTimer) {
clearTimeout(dragResetTimer);
dragResetTimer = null;
}
if (!next) return;
// stop clicks right after drop (some browsers still emit a click)
dragResetTimer = setTimeout(() => {
dragging.value = false;
dragResetTimer = null;
}, 180);
}
function openUrl(url) {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
}
function onBookmarkActivate(b) {
if (!b) return;
if (dragging.value) return;
if (editingId.value === b.id) return;
openUrl(b.url);
}
function buildFolderFlat(list) { function buildFolderFlat(list) {
const byId = new Map((list || []).map((f) => [f.id, f])); const byId = new Map((list || []).map((f) => [f.id, f]));
const children = new Map(); const children = new Map();
@@ -41,7 +81,12 @@ function buildFolderFlat(list) {
children.get(key).push(f); children.get(key).push(f);
} }
for (const arr of children.values()) { for (const arr of children.values()) {
arr.sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))); arr.sort((a, b) => {
const ao = Number.isFinite(a.sortOrder) ? a.sortOrder : 0;
const bo = Number.isFinite(b.sortOrder) ? b.sortOrder : 0;
if (ao !== bo) return ao - bo;
return String(a.name || "").localeCompare(String(b.name || ""));
});
} }
const out = []; const out = [];
@@ -70,13 +115,48 @@ function buildFolderFlat(list) {
const folderFlat = computed(() => buildFolderFlat(folders.value)); const folderFlat = computed(() => buildFolderFlat(folders.value));
const bookmarksByFolderId = computed(() => {
const map = new Map();
for (const b of items.value || []) {
const key = b.folderId ?? null;
if (!map.has(key)) map.set(key, []);
map.get(key).push(b);
}
for (const arr of map.values()) {
arr.sort((a, b) => {
const ao = Number.isFinite(a.sortOrder) ? a.sortOrder : 0;
const bo = Number.isFinite(b.sortOrder) ? b.sortOrder : 0;
if (ao !== bo) return ao - bo;
return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
});
}
return map;
});
function folderCount(folderId) {
return (bookmarksByFolderId.value.get(folderId ?? null) || []).length;
}
function toggleFolder(folderId) {
const next = new Set(openFolderIds.value);
if (next.has(folderId)) next.delete(folderId);
else next.add(folderId);
openFolderIds.value = next;
}
function isFolderOpen(folderId) {
// 搜索时强制展开(否则用户会误以为“没结果”)
if (q.value.trim()) return true;
return openFolderIds.value.has(folderId);
}
function folderLabel(f, depth) { function folderLabel(f, depth) {
const pad = depth > 0 ? `${"—".repeat(Math.min(depth, 6))} ` : ""; const pad = depth > 0 ? `${"—".repeat(Math.min(depth, 6))} ` : "";
return `${pad}${f.name}`; return `${pad}${f.name}`;
} }
const folderOptions = computed(() => [ const folderOptions = computed(() => [
{ value: null, label: "(无文件夹" }, { value: null, label: "未分组(根目录" },
...folderFlat.value.map((x) => ({ value: x.folder.id, label: folderLabel(x.folder, x.depth) })) ...folderFlat.value.map((x) => ({ value: x.folder.id, label: folderLabel(x.folder, x.depth) }))
]); ]);
@@ -119,6 +199,9 @@ async function loadRemote() {
} finally { } finally {
loading.value = false; loading.value = false;
} }
// Ensure the tree is rendered before binding Sortable.
await syncSortables();
} }
function loadLocal() { function loadLocal() {
@@ -129,6 +212,7 @@ function loadLocal() {
items.value = query items.value = query
? base.filter((b) => String(b.title || "").toLowerCase().includes(query) || String(b.url || "").toLowerCase().includes(query)) ? base.filter((b) => String(b.title || "").toLowerCase().includes(query) || String(b.url || "").toLowerCase().includes(query))
: base; : base;
syncSortables();
} }
async function createFolder() { async function createFolder() {
@@ -139,15 +223,19 @@ async function createFolder() {
if (loggedIn.value) { if (loggedIn.value) {
await apiFetch("/folders", { await apiFetch("/folders", {
method: "POST", method: "POST",
body: JSON.stringify({ parentId: folderParentId.value ?? null, name, visibility: folderVisibility.value }) body: JSON.stringify({ parentId: null, name, visibility: folderVisibility.value })
}); });
folderName.value = ""; folderName.value = "";
folderParentId.value = null; folderParentId.value = null;
folderVisibility.value = "private";
await loadRemote(); await loadRemote();
folderModalOpen.value = false;
} else { } else {
// Local folders are MVP-only for now // Local folders are MVP-only for now
folderName.value = ""; folderName.value = "";
folderParentId.value = null; folderParentId.value = null;
folderVisibility.value = "private";
folderModalOpen.value = false;
} }
} catch (e) { } catch (e) {
error.value = e.message || String(e); error.value = e.message || String(e);
@@ -158,7 +246,7 @@ function startEditFolder(f) {
folderEditingId.value = f.id; folderEditingId.value = f.id;
editFolderName.value = f.name || ""; editFolderName.value = f.name || "";
editFolderVisibility.value = f.visibility || "private"; editFolderVisibility.value = f.visibility || "private";
editFolderParentId.value = f.parentId ?? null; editFolderParentId.value = null;
} }
function cancelEditFolder() { function cancelEditFolder() {
@@ -174,7 +262,7 @@ async function saveFolder(id) {
body: JSON.stringify({ body: JSON.stringify({
name, name,
visibility: editFolderVisibility.value, visibility: editFolderVisibility.value,
parentId: editFolderParentId.value === id ? null : editFolderParentId.value ?? null parentId: null
}) })
}); });
folderEditingId.value = ""; folderEditingId.value = "";
@@ -186,37 +274,209 @@ async function saveFolder(id) {
async function removeFolder(id) { async function removeFolder(id) {
try { try {
await apiFetch(`/folders/${id}`, { method: "DELETE" }); if (loggedIn.value) {
// If current selection removed, reset if (!confirm("确定删除该文件夹?文件夹内书签会移到『未分组』。")) return;
await apiFetch(`/folders/${id}`, { method: "DELETE" });
if (folderId.value === id) folderId.value = null;
await loadRemote();
return;
}
if (!confirm("确定删除该文件夹?文件夹内书签会移到『未分组』(本地)。")) return;
deleteLocalFolder(id);
if (folderId.value === id) folderId.value = null; if (folderId.value === id) folderId.value = null;
await loadRemote(); loadLocal();
} catch (e) { } catch (e) {
error.value = e.message || String(e); error.value = e.message || String(e);
} }
} }
const showBackToTop = ref(false);
function onWindowScroll() {
showBackToTop.value = (window.scrollY || 0) > 400;
}
function scrollToTop() {
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
window.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" });
}
async function add() { async function add() {
if (!title.value || !url.value) return; if (!title.value || !url.value) return;
if (loggedIn.value) { try {
await apiFetch("/bookmarks", { if (loggedIn.value) {
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({ folderId: folderId.value ?? null, title: title.value, url: url.value, visibility: visibility.value })
});
title.value = "";
url.value = "";
folderId.value = null;
visibility.value = "public";
await loadRemote();
addModalOpen.value = false;
} else {
upsertLocalBookmark({ title: title.value, url: url.value, visibility: visibility.value, folderId: folderId.value ?? null });
title.value = "";
url.value = "";
folderId.value = null;
visibility.value = "public";
loadLocal();
addModalOpen.value = false;
}
} catch (e) {
error.value = e.message || String(e);
}
}
function parentKeyFromDataset(v) {
const s = String(v ?? "");
return s ? s : null;
}
async function persistFolderOrder(parentId, orderedIds) {
// Optimistic update
const nextOrder = new Map(orderedIds.map((id, idx) => [id, idx]));
folders.value = (folders.value || []).map((f) => {
if ((f.parentId ?? null) !== (parentId ?? null)) return f;
const so = nextOrder.get(f.id);
if (so === undefined) return f;
return { ...f, sortOrder: so };
});
try {
await apiFetch("/folders/reorder", {
method: "POST", method: "POST",
body: JSON.stringify({ folderId: folderId.value ?? null, title: title.value, url: url.value, visibility: visibility.value }) body: JSON.stringify({ parentId: parentId ?? null, orderedIds })
}); });
title.value = "";
url.value = "";
folderId.value = null;
await loadRemote(); await loadRemote();
} else { } catch (e) {
upsertLocalBookmark({ title: title.value, url: url.value, visibility: visibility.value, folderId: folderId.value ?? null }); error.value = e.message || String(e);
title.value = ""; await loadRemote();
url.value = ""; }
folderId.value = null; }
loadLocal();
async function persistBookmarkOrder(folderId, orderedIds) {
// Optimistic update
const nextOrder = new Map(orderedIds.map((id, idx) => [id, idx]));
items.value = (items.value || []).map((b) => {
if ((b.folderId ?? null) !== (folderId ?? null)) return b;
const so = nextOrder.get(b.id);
if (so === undefined) return b;
return { ...b, sortOrder: so };
});
try {
await apiFetch("/bookmarks/reorder", {
method: "POST",
body: JSON.stringify({ folderId: folderId ?? null, orderedIds })
});
await loadRemote();
} catch (e) {
error.value = e.message || String(e);
await loadRemote();
}
}
function destroySortables() {
if (folderSortable) {
folderSortable.destroy();
folderSortable = null;
}
for (const s of bookmarkSortables.values()) s.destroy();
bookmarkSortables.clear();
}
async function syncSortables() {
destroySortables();
if (!loggedIn.value) return;
if (q.value.trim()) return; // search mode: avoid accidental reorder
await nextTick();
const root = treeEl.value;
if (!root) return;
// Folder sorting (same parent only)
folderSortable = new Sortable(root, {
animation: 150,
handle: ".bb-dragHint--folder",
draggable: ".bb-myFolder[data-folder-id]",
forceFallback: true,
fallbackOnBody: true,
delay: 150,
delayOnTouchOnly: true,
touchStartThreshold: 3,
onStart: () => setDragging(true),
onMove: (evt) => {
const dragged = evt.dragged;
const related = evt.related;
if (!dragged?.dataset?.folderId || !related?.dataset?.folderId) return false;
// Only allow sorting among siblings with the same parent
return (dragged.dataset.parentId || "") === (related.dataset.parentId || "");
},
onEnd: async (evt) => {
const item = evt.item;
const parentId = parentKeyFromDataset(item?.dataset?.parentId);
const allEls = Array.from(root.querySelectorAll(`.bb-myFolder[data-folder-id]`));
const els = allEls.filter((el) => parentKeyFromDataset(el?.dataset?.parentId) === (parentId ?? null));
const orderedIds = els.map((el) => el.dataset.folderId).filter(Boolean);
if (orderedIds.length) await persistFolderOrder(parentId, orderedIds);
setDragging(false);
}
});
// Bookmark sorting per visible folder list
const lists = Array.from(root.querySelectorAll("ul[data-bookmark-list='1']"));
for (const ul of lists) {
const folderKey = ul.getAttribute("data-folder-id") || "";
const folderId = folderKey && folderKey !== "__ROOT__" ? folderKey : null;
// local push animation helper (gives a "squeezed/pushed" feel)
let pushedEl = null;
let pushTimer = null;
function push(el) {
if (!el) return;
if (pushedEl && pushedEl !== el) pushedEl.classList.remove("bb-sortPush");
pushedEl = el;
pushedEl.classList.add("bb-sortPush");
if (pushTimer) clearTimeout(pushTimer);
pushTimer = setTimeout(() => {
if (pushedEl) pushedEl.classList.remove("bb-sortPush");
pushedEl = null;
pushTimer = null;
}, 180);
}
const s = new Sortable(ul, {
animation: 220,
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
handle: ".bb-dragHint--bookmark",
draggable: "li[data-bookmark-id]",
forceFallback: true,
fallbackOnBody: true,
delay: 150,
delayOnTouchOnly: true,
touchStartThreshold: 3,
invertSwap: true,
swapThreshold: 0.75,
onStart: () => setDragging(true),
onMove: (evt) => {
// Provide a subtle "pushed" feedback on the neighbor being swapped.
push(evt.related);
return true;
},
onEnd: async () => {
const orderedIds = Array.from(ul.querySelectorAll("li[data-bookmark-id]")).map((li) => li.dataset.bookmarkId).filter(Boolean);
if (orderedIds.length) await persistBookmarkOrder(folderId, orderedIds);
setDragging(false);
}
});
bookmarkSortables.set(folderKey, s);
} }
} }
async function remove(id) { async function remove(id) {
if (!confirm("确定删除该书签?")) return;
if (loggedIn.value) { if (loggedIn.value) {
await apiFetch(`/bookmarks/${id}`, { method: "DELETE" }); await apiFetch(`/bookmarks/${id}`, { method: "DELETE" });
await loadRemote(); await loadRemote();
@@ -284,15 +544,35 @@ async function clearSearch() {
onMounted(() => { onMounted(() => {
if (loggedIn.value) loadRemote(); if (loggedIn.value) loadRemote();
else loadLocal(); else loadLocal();
onWindowScroll();
window.addEventListener("scroll", onWindowScroll, { passive: true });
});
watch([() => loggedIn.value, () => q.value, () => openFolderIds.value], () => {
// keep Sortable instances in sync with current DOM (open folders)
syncSortables();
}, { flush: "post" });
onBeforeUnmount(() => {
destroySortables();
window.removeEventListener("scroll", onWindowScroll);
}); });
</script> </script>
<template> <template>
<section class="bb-page"> <section class="bb-page">
<div class="bb-pageHeader"> <div class="bb-pageHeader">
<div> <div class="bb-row" style="justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap;">
<h1 style="margin: 0;">我的书签</h1> <div>
<div class="bb-muted" style="margin-top: 4px;">像课程进度一样管理你的链接分组搜索可公开</div> <h1 style="margin: 0;">我的书签</h1>
<div class="bb-muted" style="margin-top: 4px;">像课程进度一样管理你的链接分组搜索可公开</div>
</div>
<div class="bb-row" style="gap: 10px; flex-wrap: wrap;">
<button class="bb-btn" type="button" @click="addModalOpen = true">添加书签</button>
<button v-if="loggedIn" class="bb-btn bb-btn--secondary" type="button" @click="folderModalOpen = true">新建文件夹</button>
</div>
</div> </div>
</div> </div>
@@ -304,67 +584,37 @@ onMounted(() => {
<div class="sectionTitle">快速搜索</div> <div class="sectionTitle">快速搜索</div>
<div class="searchRow" style="margin-top: 10px;"> <div class="searchRow" style="margin-top: 10px;">
<input v-model="q" class="bb-input" placeholder="搜索标题/链接" @keyup.enter="reload" /> <input v-model="q" class="bb-input" placeholder="搜索标题/链接" @keyup.enter="reload" />
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="reload">搜索</button> <div class="searchActions">
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="clearSearch">清除</button> <button class="bb-btn bb-btn--secondary bb-btn--soft" :disabled="loading" @click="reload">搜索</button>
<button class="bb-btn bb-btn--secondary bb-btn--soft" :disabled="loading" @click="clearSearch">清除</button>
</div>
</div> </div>
</div> </div>
<div class="bb-card" style="margin-top: 12px;"> <BbModal v-model="addModalOpen" title="添加书签">
<div class="sectionTitle">添加书签</div> <div class="bb-modalForm">
<div class="form" style="margin-top: 10px;">
<input v-model="title" class="bb-input" placeholder="标题" /> <input v-model="title" class="bb-input" placeholder="标题" />
<input v-model="url" class="bb-input" placeholder="链接https://..." /> <input v-model="url" class="bb-input" placeholder="链接https://..." />
<BbSelect v-model="folderId" :options="folderOptions" /> <BbSelect v-model="folderId" :options="folderOptions" />
<BbSelect v-model="visibility" :options="visibilityOptions" /> <BbSelect v-model="visibility" :options="visibilityOptions" />
<button class="bb-btn" @click="add">添加</button> <div class="bb-row" style="justify-content: flex-end; gap: 10px;">
<button class="bb-btn bb-btn--secondary" type="button" @click="addModalOpen = false">取消</button>
<button class="bb-btn" type="button" @click="add">添加</button>
</div>
<div class="bb-muted" style="margin-top: 8px;">小贴士标题可写为什么收藏/怎么用以后复习更快</div>
</div> </div>
<div class="bb-muted" style="margin-top: 10px;"> </BbModal>
小贴士标题可写为什么收藏/怎么用以后复习更快
</div>
</div>
<div v-if="loggedIn" class="bb-card" style="margin: 12px 0;"> <BbModal v-if="loggedIn" v-model="folderModalOpen" title="新建文件夹">
<div class="bb-row"> <div class="bb-modalForm">
<div class="sectionTitle">文件夹云端</div>
</div>
<div class="bb-row" style="margin-top: 10px;">
<input v-model="folderName" class="bb-input" placeholder="新文件夹名称" /> <input v-model="folderName" class="bb-input" placeholder="新文件夹名称" />
<BbSelect v-model="folderParentId" :options="folderRootOptions" />
<BbSelect v-model="folderVisibility" :options="folderVisibilityOptions" /> <BbSelect v-model="folderVisibility" :options="folderVisibilityOptions" />
<button class="bb-btn" @click="createFolder">创建文件夹</button> <div class="bb-row" style="justify-content: flex-end; gap: 10px;">
</div> <button class="bb-btn bb-btn--secondary" type="button" @click="folderModalOpen = false">取消</button>
<button class="bb-btn" type="button" @click="createFolder">创建</button>
<div v-if="folders.length" class="folderList">
<div v-for="x in folderFlat" :key="x.folder.id" class="folderRow">
<template v-if="folderEditingId !== x.folder.id">
<div class="folderName" :style="{ paddingLeft: `${x.depth * 14}px` }">{{ x.folder.name }}</div>
<div class="folderMeta">
{{ x.folder.visibility === 'public' ? '公开' : '私有' }} · {{ x.folder.parentId ? '子文件夹' : '根文件夹' }}
</div>
<div class="actions">
<button class="bb-btn bb-btn--secondary" @click="startEditFolder(x.folder)">编辑</button>
<button class="bb-btn bb-btn--danger" @click="removeFolder(x.folder.id)">删除</button>
</div>
</template>
<template v-else>
<input v-model="editFolderName" class="bb-input" placeholder="文件夹名称" />
<BbSelect
v-model="editFolderParentId"
:options="folderRootOptionsForEdit(x.folder.id)"
/>
<BbSelect v-model="editFolderVisibility" :options="folderVisibilityOptions" />
<div class="actions">
<button class="bb-btn" @click="saveFolder(x.folder.id)">保存</button>
<button class="bb-btn bb-btn--secondary" @click="cancelEditFolder">取消</button>
</div>
</template>
</div> </div>
</div> </div>
<div v-else class="bb-empty" style="margin-top: 12px;"> </BbModal>
<div style="font-weight: 700;">还没有文件夹</div>
<div class="bb-muted" style="margin-top: 6px;">可以先创建一个根文件夹或为它选择父级形成层级结构</div>
</div>
</div>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p> <p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
<p v-if="loading" class="bb-muted">加载中</p> <p v-if="loading" class="bb-muted">加载中</p>
@@ -374,29 +624,158 @@ onMounted(() => {
<div class="bb-muted" style="margin-top: 6px;">用上面的输入框快速添加一个吧</div> <div class="bb-muted" style="margin-top: 6px;">用上面的输入框快速添加一个吧</div>
</div> </div>
<ul v-if="!loading && !error && items.length" class="list"> <div ref="treeEl" v-if="!loading && !error && items.length" class="bb-myTree" style="margin-top: 12px;">
<li v-for="b in items" :key="b.id" class="bb-card bb-card--interactive"> <!-- Root group -->
<div v-if="editingId !== b.id"> <div class="bb-myFolder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a> <div class="bb-myFolderHeaderRow">
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div> <button
<div class="actions"> type="button"
<button class="bb-btn bb-btn--secondary" @click="startEdit(b)">编辑</button> class="bb-myFolderHeader"
<button class="bb-btn bb-btn--danger" @click="remove(b.id)">删除</button> :aria-expanded="isFolderOpen('ROOT') ? 'true' : 'false'"
</div> @click="toggleFolder('ROOT')"
>
<span class="name">未分组</span>
<span class="meta">{{ folderCount(null) }} </span>
</button>
</div>
<div v-if="isFolderOpen('ROOT')" class="bb-myFolderBody">
<ul class="list" data-bookmark-list="1" data-folder-id="__ROOT__">
<li
v-for="b in (bookmarksByFolderId.get(null) || [])"
:key="b.id"
:data-bookmark-id="b.id"
class="bb-card bb-card--interactive"
:class="editingId !== b.id ? 'bb-clickCard' : ''"
role="link"
:tabindex="editingId !== b.id ? 0 : -1"
@click="onBookmarkActivate(b)"
@keydown.enter.prevent="onBookmarkActivate(b)"
@keydown.space.prevent="onBookmarkActivate(b)"
>
<div v-if="editingId !== b.id">
<div class="bb-row" style="justify-content: space-between; gap: 10px;">
<div class="bb-bookmarkTitle bb-bookmarkTitleRow" style="font-weight: 700; color: var(--bb-text);">{{ b.title }}</div>
<span
v-if="loggedIn"
class="bb-dragHint bb-dragHint--bookmark"
title="拖动排序"
@click.stop.prevent
></span>
</div>
<div class="bb-muted bb-oneLineEllipsis" style="margin-top: 6px;">{{ b.url }}</div>
<div class="actions">
<button class="bb-btn bb-btn--secondary" @click.stop="startEdit(b)">编辑</button>
<button class="bb-btn bb-btn--danger" @click.stop="remove(b.id)">删除</button>
</div>
</div>
<div v-else class="edit">
<input v-model="editTitle" class="bb-input" placeholder="标题" />
<input v-model="editUrl" class="bb-input" placeholder="链接" />
<BbSelect v-model="editFolderId" :options="folderOptions" />
<BbSelect v-model="editVisibility" :options="visibilityOptions" />
<div class="actions">
<button class="bb-btn" @click="saveEdit(b.id)">保存</button>
<button class="bb-btn bb-btn--secondary" @click="cancelEdit">取消</button>
</div>
</div>
</li>
</ul>
</div>
</div>
<!-- Folder groups -->
<div
v-for="x in folderFlat"
:key="x.folder.id"
class="bb-myFolder"
:class="isFolderOpen(x.folder.id) ? 'is-open' : ''"
:style="{ paddingLeft: '0px' }"
:data-folder-id="x.folder.id"
:data-parent-id="x.folder.parentId ?? ''"
>
<div
class="bb-myFolderHeaderRow"
>
<button
type="button"
class="bb-myFolderHeader"
:aria-expanded="isFolderOpen(x.folder.id) ? 'true' : 'false'"
@click="toggleFolder(x.folder.id)"
>
<span class="name">{{ x.folder.name }}</span>
<span class="meta">{{ folderCount(x.folder.id) }} </span>
</button>
<button
class="bb-btn bb-btn--danger bb-folderDelete"
type="button"
title="删除文件夹(书签会移到未分组)"
@click.stop="removeFolder(x.folder.id)"
>删除</button>
<span
v-if="loggedIn"
class="bb-dragHint bb-dragHint--folder"
title="拖动排序"
>
</span>
</div> </div>
<div v-else class="edit"> <div v-if="isFolderOpen(x.folder.id)" class="bb-myFolderBody">
<input v-model="editTitle" class="bb-input" placeholder="标题" /> <ul class="list" data-bookmark-list="1" :data-folder-id="x.folder.id">
<input v-model="editUrl" class="bb-input" placeholder="链接" /> <li
<BbSelect v-model="editFolderId" :options="folderOptions" /> v-for="b in (bookmarksByFolderId.get(x.folder.id) || [])"
<BbSelect v-model="editVisibility" :options="visibilityOptions" /> :key="b.id"
<div class="actions"> :data-bookmark-id="b.id"
<button class="bb-btn" @click="saveEdit(b.id)">保存</button> class="bb-card bb-card--interactive"
<button class="bb-btn bb-btn--secondary" @click="cancelEdit">取消</button> :class="editingId !== b.id ? 'bb-clickCard' : ''"
</div> role="link"
:tabindex="editingId !== b.id ? 0 : -1"
@click="onBookmarkActivate(b)"
@keydown.enter.prevent="onBookmarkActivate(b)"
@keydown.space.prevent="onBookmarkActivate(b)"
>
<div v-if="editingId !== b.id">
<div class="bb-row" style="justify-content: space-between; gap: 10px;">
<div class="bb-bookmarkTitle bb-bookmarkTitleRow" style="font-weight: 700; color: var(--bb-text);">{{ b.title }}</div>
<span
v-if="loggedIn"
class="bb-dragHint bb-dragHint--bookmark"
title="拖动排序"
@click.stop.prevent
></span>
</div>
<div class="bb-muted bb-oneLineEllipsis" style="margin-top: 6px;">{{ b.url }}</div>
<div class="actions">
<button class="bb-btn bb-btn--secondary" @click.stop="startEdit(b)">编辑</button>
<button class="bb-btn bb-btn--danger" @click.stop="remove(b.id)">删除</button>
</div>
</div>
<div v-else class="edit">
<input v-model="editTitle" class="bb-input" placeholder="标题" />
<input v-model="editUrl" class="bb-input" placeholder="链接" />
<BbSelect v-model="editFolderId" :options="folderOptions" />
<BbSelect v-model="editVisibility" :options="visibilityOptions" />
<div class="actions">
<button class="bb-btn" @click="saveEdit(b.id)">保存</button>
<button class="bb-btn bb-btn--secondary" @click="cancelEdit">取消</button>
</div>
</div>
</li>
</ul>
</div> </div>
</li> </div>
</ul> </div>
<button
v-if="showBackToTop"
type="button"
class="bb-btn bb-btn--secondary bb-backTop"
@click="scrollToTop"
>回到顶部</button>
</section> </section>
</template> </template>
@@ -405,10 +784,15 @@ onMounted(() => {
.form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; } .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; } } @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; } .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; } } .searchActions { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; }
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 12px; } @media (min-width: 768px) {
@media (min-width: 768px) { .list { grid-template-columns: 1fr 1fr; } } .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; } .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; } .actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.edit { display: grid; gap: 10px; } .edit { display: grid; gap: 10px; }
.sectionTitle { font-weight: 800; } .sectionTitle { font-weight: 800; }
@@ -417,4 +801,64 @@ onMounted(() => {
@media (min-width: 768px) { .folderRow { grid-template-columns: 2fr 1fr 2fr; align-items: center; } } @media (min-width: 768px) { .folderRow { grid-template-columns: 2fr 1fr 2fr; align-items: center; } }
.folderName { font-weight: 700; } .folderName { font-weight: 700; }
.folderMeta { font-size: 12px; color: #475569; } .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; }
</style> </style>

View File

@@ -1,12 +1,79 @@
<script setup> <script setup>
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { apiFetch } from "../lib/api"; import BbSelect from "../components/BbSelect.vue";
import BbModal from "../components/BbModal.vue";
import { apiFetch, tokenRef } from "../lib/api";
const q = ref(""); const q = ref("");
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
const items = ref([]); const items = ref([]);
const loggedIn = computed(() => Boolean(tokenRef.value));
const addTitle = ref("");
const addUrl = ref("");
const addVisibility = ref("public");
const addFolderId = ref(null);
const addBusy = ref(false);
const addStatus = ref("");
const addModalOpen = ref(false);
const folders = ref([]);
const foldersLoading = ref(false);
const folderOptions = computed(() => [
{ value: null, label: "(无文件夹)" },
...folders.value.map((f) => ({ value: f.id, label: f.name }))
]);
const visibilityOptions = [
{ value: "public", label: "公开" },
{ value: "private", label: "私有" }
];
async function loadFolders() {
if (!loggedIn.value) return;
foldersLoading.value = true;
try {
folders.value = await apiFetch("/folders");
} finally {
foldersLoading.value = false;
}
}
async function addBookmark() {
const title = addTitle.value.trim();
const url = addUrl.value.trim();
if (!title || !url) return;
addBusy.value = true;
addStatus.value = "";
error.value = "";
try {
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({
folderId: addFolderId.value ?? null,
title,
url,
visibility: addVisibility.value
})
});
addTitle.value = "";
addUrl.value = "";
addFolderId.value = null;
addVisibility.value = "public";
addStatus.value = "已添加";
addModalOpen.value = false;
await load();
} catch (e) {
error.value = e.message || String(e);
} finally {
addBusy.value = false;
}
}
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
@@ -20,14 +87,21 @@ async function load() {
} }
onMounted(load); onMounted(load);
onMounted(loadFolders);
</script> </script>
<template> <template>
<section class="bb-page"> <section class="bb-page">
<div class="bb-pageHeader"> <div class="bb-pageHeader">
<div> <div class="bb-row" style="justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap;">
<h1 style="margin: 0;">公开书签</h1> <div>
<div class="bb-muted" style="margin-top: 4px;">无需登录即可浏览来自用户设置为公开的书签</div> <h1 style="margin: 0;">公开书签</h1>
<div class="bb-muted" style="margin-top: 4px;">无需登录即可浏览来自用户设置为公开的书签</div>
</div>
<div v-if="loggedIn" class="bb-row" style="gap: 10px; flex-wrap: wrap;">
<button class="bb-btn" type="button" @click="addModalOpen = true">添加书签</button>
</div>
</div> </div>
</div> </div>
@@ -45,17 +119,45 @@ onMounted(load);
</div> </div>
</div> </div>
<BbModal v-if="loggedIn" v-model="addModalOpen" title="添加书签">
<div class="bb-publicAdd" style="margin-top: 2px;">
<input v-model="addTitle" class="bb-input" placeholder="标题" />
<input v-model="addUrl" class="bb-input" placeholder="链接https://..." />
<BbSelect
v-model="addFolderId"
:options="folderOptions"
:disabled="foldersLoading"
placeholder="选择文件夹"
/>
<BbSelect v-model="addVisibility" :options="visibilityOptions" />
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
<button class="bb-btn bb-btn--secondary" type="button" @click="addModalOpen = false">取消</button>
<button class="bb-btn" type="button" :disabled="addBusy" @click="addBookmark">添加</button>
</div>
</div>
</BbModal>
<p v-if="addStatus" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ addStatus }}</p>
<ul v-if="!loading && !error && items.length" class="bb-publicList"> <ul v-if="!loading && !error && items.length" class="bb-publicList">
<li v-for="b in items" :key="b.id" class="bb-card bb-card--interactive"> <li v-for="b in items" :key="b.id" class="bb-card bb-card--interactive bb-clickCard">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a> <a :href="b.url" target="_blank" rel="noopener" class="bb-cardLink">
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div> <div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
</a>
</li> </li>
</ul> </ul>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.title { color: var(--bb-text); font-weight: 900; text-decoration: none; }
.bb-publicList { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 12px; } .bb-publicList { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 12px; }
@media (min-width: 768px) { .bb-publicList { grid-template-columns: 1fr 1fr; } } @media (min-width: 768px) { .bb-publicList { grid-template-columns: 1fr 1fr; } }
.bb-publicAdd { display: grid; gap: 10px; grid-template-columns: 1fr; }
.bb-clickCard { padding: 0; }
.bb-cardLink { display: block; padding: 12px; text-decoration: none; color: inherit; }
.bb-cardLink:hover .bb-bookmarkTitle { color: var(--bb-cta); }
</style> </style>

View File

@@ -3,7 +3,8 @@ import PublicPage from "./pages/PublicPage.vue";
import LoginPage from "./pages/LoginPage.vue"; import LoginPage from "./pages/LoginPage.vue";
import MyPage from "./pages/MyPage.vue"; import MyPage from "./pages/MyPage.vue";
import ImportExportPage from "./pages/ImportExportPage.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({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -11,11 +12,12 @@ export const router = createRouter({
{ path: "/", component: PublicPage }, { path: "/", component: PublicPage },
{ path: "/login", component: LoginPage }, { path: "/login", component: LoginPage },
{ path: "/my", component: MyPage }, { 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); const loggedIn = Boolean(tokenRef.value);
// 主页(/)永远是公共首页;不因登录态自动跳转 // 主页(/)永远是公共首页;不因登录态自动跳转
@@ -28,5 +30,12 @@ router.beforeEach((to) => {
return { path: "/login", query: { next: to.fullPath } }; 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; return true;
}); });

View File

@@ -31,6 +31,23 @@
* { box-sizing: border-box; } * { 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 { body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
@@ -41,6 +58,9 @@ body {
#app { #app {
min-height: 100vh; min-height: 100vh;
width: 100%;
max-width: 100%;
overflow-x: hidden;
} }
a { a {
@@ -137,6 +157,10 @@ input:focus-visible {
width: 100%; width: 100%;
} }
.bb-selectWrap.is-open {
z-index: 9999;
}
.bb-selectWrap--sm .bb-selectTrigger { .bb-selectWrap--sm .bb-selectTrigger {
padding: 8px 10px; padding: 8px 10px;
} }
@@ -182,7 +206,7 @@ input:focus-visible {
top: calc(100% + 8px); top: calc(100% + 8px);
left: 0; left: 0;
right: 0; right: 0;
z-index: 50; z-index: 10000;
border-radius: 18px; border-radius: 18px;
padding: 6px; padding: 6px;
border: 1px solid rgba(255,255,255,0.65); border: 1px solid rgba(255,255,255,0.65);
@@ -193,6 +217,14 @@ input:focus-visible {
overflow: auto; overflow: auto;
} }
.bb-selectMenu--portal {
position: fixed;
left: 0;
top: 0;
right: auto;
z-index: 2147483000;
}
.bb-selectOption { .bb-selectOption {
width: 100%; width: 100%;
border: 1px solid transparent; border: 1px solid transparent;
@@ -226,7 +258,7 @@ input:focus-visible {
} }
.bb-btn { .bb-btn {
padding: 10px 12px; padding: 8px 12px;
border: 1px solid rgba(255,255,255,0.25); border: 1px solid rgba(255,255,255,0.25);
border-radius: 16px; border-radius: 16px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta)); 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; 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 { .bb-btn:hover {
filter: brightness(1.03); filter: brightness(1.03);
} }
@@ -249,6 +289,50 @@ input:focus-visible {
color: var(--bb-text); 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 { .bb-btn--danger {
border-color: #fecaca; border-color: #fecaca;
background: #fee2e2; background: #fee2e2;
@@ -267,6 +351,13 @@ input:focus-visible {
padding: 12px; padding: 12px;
background: rgba(255,255,255,0.55); background: rgba(255,255,255,0.55);
backdrop-filter: blur(12px); 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 { .bb-card--interactive {

View File

@@ -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` 的问题)。

View File

@@ -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 folders 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`.

View File

@@ -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

View File

@@ -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 }`

View File

@@ -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

View File

@@ -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 - **THEN** the server stores the items using LWW semantics
- **WHEN** the client calls `GET /sync/pull` - **WHEN** the client calls `GET /sync/pull`
- **THEN** the server returns folders/bookmarks and `serverTime` - **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

7
package-lock.json generated
View File

@@ -55,6 +55,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@browser-bookmark/shared": "0.1.0", "@browser-bookmark/shared": "0.1.0",
"sortablejs": "^1.15.6",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
@@ -3719,6 +3720,12 @@
"atomic-sleep": "^1.0.0" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -14,22 +14,58 @@ export function parseNetscapeBookmarkHtml(html) {
const bookmarks = []; const bookmarks = [];
let folderIdSeq = 1; let folderIdSeq = 1;
function walkDl(dl, parentFolderId) { function normText(s) {
const children = Array.from(dl.children); return String(s || "").replace(/\s+/g, " ").trim();
for (const node of children) { }
if (node.tagName?.toLowerCase() !== "dt") continue;
const h3 = node.querySelector(":scope > h3"); // Collect <DT> nodes that belong to the current <DL> level.
const a = node.querySelector(":scope > a"); // Chrome/Edge exported HTML often uses `<DL><p>` and browsers may wrap
const nextDl = node.nextElementSibling?.tagName?.toLowerCase() === "dl" ? node.nextElementSibling : null; // subsequent nodes under <p> 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 <DT>
continue;
}
out.push(...collectLevelDt(el));
}
return out;
}
// Find the nested <DL> that belongs to a <DT>, even if <DT> is wrapped (e.g. inside <p>).
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) { if (h3) {
const id = String(folderIdSeq++); const id = String(folderIdSeq++);
const name = (h3.textContent || "").trim(); const name = normText(h3.textContent || "");
folders.push({ id, parentFolderId: parentFolderId ?? null, name }); folders.push({ id, parentFolderId: parentFolderId ?? null, name });
if (nextDl) walkDl(nextDl, id); if (nextDl) walkDl(nextDl, id);
} else if (a) { } else if (a) {
const title = (a.textContent || "").trim(); const title = normText(a.textContent || "");
const url = a.getAttribute("href") || ""; const url = a.getAttribute("href") || "";
bookmarks.push({ parentFolderId: parentFolderId ?? null, title, url }); bookmarks.push({ parentFolderId: parentFolderId ?? null, title, url });
} }

View File

@@ -64,13 +64,15 @@ components:
visibility: visibility:
type: string type: string
enum: [public, private] enum: [public, private]
sortOrder:
type: integer
createdAt: createdAt:
type: string type: string
format: date-time format: date-time
updatedAt: updatedAt:
type: string type: string
format: date-time format: date-time
required: [id, userId, parentId, name, visibility, createdAt, updatedAt] required: [id, userId, parentId, name, visibility, sortOrder, createdAt, updatedAt]
Bookmark: Bookmark:
type: object type: object
properties: properties:
@@ -85,6 +87,8 @@ components:
- type: string - type: string
format: uuid format: uuid
- type: 'null' - type: 'null'
sortOrder:
type: integer
title: title:
type: string type: string
url: url:
@@ -107,7 +111,7 @@ components:
- type: string - type: string
format: date-time format: date-time
- type: 'null' - 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: FolderPatch:
type: object type: object
@@ -122,6 +126,23 @@ components:
visibility: visibility:
type: string type: string
enum: [public, private] 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: BookmarkPatch:
type: object type: object
@@ -138,6 +159,23 @@ components:
visibility: visibility:
type: string type: string
enum: [public, private] 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: [] security: []
paths: paths:
/health: /health:
@@ -337,6 +375,31 @@ paths:
type: boolean type: boolean
required: [ok] 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: /bookmarks/public:
get: get:
tags: [Bookmarks] tags: [Bookmarks]
@@ -414,6 +477,31 @@ paths:
schema: schema:
$ref: '#/components/schemas/Bookmark' $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}: /bookmarks/{id}:
patch: patch:
tags: [Bookmarks] tags: [Bookmarks]
@@ -544,6 +632,160 @@ paths:
type: boolean type: boolean
required: [ok] 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: /sync/pull:
get: get:
tags: [Sync] tags: [Sync]