提交0.1.0版本

- 完成了书签的基本功能和插件
This commit is contained in:
2026-01-21 23:09:33 +08:00
parent 3e2d1456eb
commit 1a3bbac9ff
95 changed files with 12431 additions and 12445 deletions

View File

@@ -1,5 +1,5 @@
# Extension API base (Fastify server)
VITE_SERVER_BASE_URL=http://mark.cloud-xl.top:6667
# Web app base (used by Options -> 跳转 Web)
VITE_WEB_BASE_URL=http://mark.cloud-xl.top:6666
# Extension API base (Fastify server)
VITE_SERVER_BASE_URL=http://mark.cloud-xl.top:6667
# Web app base (used by Options -> 跳转 Web)
VITE_WEB_BASE_URL=http://mark.cloud-xl.top:6666

View File

@@ -1,12 +1,12 @@
export default [
{
files: ["**/*.js", "**/*.vue"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module"
},
rules: {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
];
export default [
{
files: ["**/*.js", "**/*.vue"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module"
},
rules: {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
];

Binary file not shown.

View File

@@ -1,12 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>云书签 选项</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/options/main.js"></script>
</body>
</html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>云书签 选项</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/options/main.js"></script>
</body>
</html>

View File

@@ -11,7 +11,7 @@
"lint": "eslint ."
},
"dependencies": {
"@browser-bookmark/shared": "0.1.0",
"@browser-bookmark/shared": "file:../../packages/shared",
"vue-router": "^4.5.1",
"vue": "^3.5.24"
},

View File

@@ -1,12 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bookmarks</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/popup/main.js"></script>
</body>
</html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bookmarks</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/popup/main.js"></script>
</body>
</html>

View File

@@ -1,12 +1,12 @@
{
"manifest_version": 3,
"name": "BrowserBookmark",
"version": "0.1.0",
"action": {
"default_title": "Bookmarks",
"default_popup": "popup.html"
},
"options_page": "options.html",
"permissions": ["storage", "tabs"],
"host_permissions": ["<all_urls>"]
}
{
"manifest_version": 3,
"name": "BrowserBookmark",
"version": "0.1.0",
"action": {
"default_title": "Bookmarks",
"default_popup": "popup.html"
},
"options_page": "options.html",
"permissions": ["storage", "tabs"],
"host_permissions": ["<all_urls>"]
}

View File

@@ -1,154 +1,154 @@
<script setup>
import { onBeforeUnmount, onMounted } from "vue";
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: "请确认" },
message: { type: String, default: "" },
confirmText: { type: String, default: "确定" },
cancelText: { type: String, default: "取消" },
danger: { type: Boolean, default: false },
maxWidth: { type: String, default: "520px" }
});
const emit = defineEmits(["update:modelValue", "confirm", "cancel"]);
function cancel() {
emit("update:modelValue", false);
emit("cancel");
}
function confirm() {
emit("confirm");
}
function onKeydown(e) {
if (e.key === "Escape") cancel();
}
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="cancel" />
<div class="bb-modalPanel" :style="{ maxWidth }">
<div class="bb-modalHeader">
<div class="bb-modalTitle">{{ title }}</div>
<button type="button" class="bb-modalClose" @click="cancel" aria-label="关闭">×</button>
</div>
<div class="bb-modalBody">
<div v-if="message" class="bb-modalMessage">{{ message }}</div>
<slot />
<div class="bb-modalActions">
<button type="button" class="bb-btn bb-btn--secondary" @click="cancel">{{ cancelText }}</button>
<button type="button" class="bb-btn" :class="danger ? 'bb-btn--danger' : ''" @click="confirm">{{ confirmText }}</button>
</div>
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
.bb-modalOverlay {
position: fixed;
inset: 0;
z-index: 2147483500;
display: grid;
place-items: center;
padding: 16px;
}
.bb-modalBackdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.38);
backdrop-filter: blur(8px);
}
.bb-modalPanel {
position: relative;
width: min(100%, 560px);
max-height: min(84vh, 860px);
overflow: auto;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.22);
color: var(--bb-text);
}
.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.1);
}
.bb-modalTitle {
font-weight: 900;
}
.bb-modalClose {
width: 34px;
height: 34px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 20px;
line-height: 1;
color: rgba(15, 23, 42, 0.75);
}
.bb-modalClose:hover {
background: rgba(255, 255, 255, 0.78);
}
.bb-modalBody {
padding: 14px;
}
.bb-modalMessage {
color: rgba(15, 23, 42, 0.78);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.bb-modalActions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.bb-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;
}
.bb-btn--secondary {
background: rgba(255, 255, 255, 0.55);
color: var(--bb-text);
border-color: var(--bb-border);
}
.bb-btn--danger {
background: linear-gradient(135deg, #ef4444, #f97316);
}
</style>
<script setup>
import { onBeforeUnmount, onMounted } from "vue";
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: "请确认" },
message: { type: String, default: "" },
confirmText: { type: String, default: "确定" },
cancelText: { type: String, default: "取消" },
danger: { type: Boolean, default: false },
maxWidth: { type: String, default: "520px" }
});
const emit = defineEmits(["update:modelValue", "confirm", "cancel"]);
function cancel() {
emit("update:modelValue", false);
emit("cancel");
}
function confirm() {
emit("confirm");
}
function onKeydown(e) {
if (e.key === "Escape") cancel();
}
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="cancel" />
<div class="bb-modalPanel" :style="{ maxWidth }">
<div class="bb-modalHeader">
<div class="bb-modalTitle">{{ title }}</div>
<button type="button" class="bb-modalClose" @click="cancel" aria-label="关闭">×</button>
</div>
<div class="bb-modalBody">
<div v-if="message" class="bb-modalMessage">{{ message }}</div>
<slot />
<div class="bb-modalActions">
<button type="button" class="bb-btn bb-btn--secondary" @click="cancel">{{ cancelText }}</button>
<button type="button" class="bb-btn" :class="danger ? 'bb-btn--danger' : ''" @click="confirm">{{ confirmText }}</button>
</div>
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
.bb-modalOverlay {
position: fixed;
inset: 0;
z-index: 2147483500;
display: grid;
place-items: center;
padding: 16px;
}
.bb-modalBackdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.38);
backdrop-filter: blur(8px);
}
.bb-modalPanel {
position: relative;
width: min(100%, 560px);
max-height: min(84vh, 860px);
overflow: auto;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.22);
color: var(--bb-text);
}
.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.1);
}
.bb-modalTitle {
font-weight: 900;
}
.bb-modalClose {
width: 34px;
height: 34px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 20px;
line-height: 1;
color: rgba(15, 23, 42, 0.75);
}
.bb-modalClose:hover {
background: rgba(255, 255, 255, 0.78);
}
.bb-modalBody {
padding: 14px;
}
.bb-modalMessage {
color: rgba(15, 23, 42, 0.78);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.bb-modalActions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.bb-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;
}
.bb-btn--secondary {
background: rgba(255, 255, 255, 0.55);
color: var(--bb-text);
border-color: var(--bb-border);
}
.bb-btn--danger {
background: linear-gradient(135deg, #ef4444, #f97316);
}
</style>

View File

@@ -1,31 +1,31 @@
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
export async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {});
if (!headers.has("Accept")) headers.set("Accept", "application/json");
if (!(options.body instanceof FormData) && options.body != null) {
headers.set("Content-Type", "application/json");
}
// Lazy import to avoid circular deps
const { getToken } = await import("./extStorage.js");
const token = await getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
const contentType = res.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
if (!res.ok) {
const message = payload?.message || `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
export async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {});
if (!headers.has("Accept")) headers.set("Accept", "application/json");
if (!(options.body instanceof FormData) && options.body != null) {
headers.set("Content-Type", "application/json");
}
// Lazy import to avoid circular deps
const { getToken } = await import("./extStorage.js");
const token = await getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
const contentType = res.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
if (!res.ok) {
const message = payload?.message || `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}

View File

@@ -1,43 +1,43 @@
const TOKEN_KEY = "bb_token";
const LOCAL_STATE_KEY = "bb_local_state_v1";
function hasChromeStorage() {
return typeof chrome !== "undefined" && chrome.storage?.local;
}
export async function getToken() {
if (hasChromeStorage()) {
const res = await chrome.storage.local.get([TOKEN_KEY]);
return res[TOKEN_KEY] || "";
}
return localStorage.getItem(TOKEN_KEY) || "";
}
export async function setToken(token) {
if (hasChromeStorage()) {
await chrome.storage.local.set({ [TOKEN_KEY]: token || "" });
return;
}
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
export async function loadLocalState() {
if (hasChromeStorage()) {
const res = await chrome.storage.local.get([LOCAL_STATE_KEY]);
return res[LOCAL_STATE_KEY] || { folders: [], bookmarks: [] };
}
try {
return JSON.parse(localStorage.getItem(LOCAL_STATE_KEY) || "") || { folders: [], bookmarks: [] };
} catch {
return { folders: [], bookmarks: [] };
}
}
export async function saveLocalState(state) {
if (hasChromeStorage()) {
await chrome.storage.local.set({ [LOCAL_STATE_KEY]: state });
return;
}
localStorage.setItem(LOCAL_STATE_KEY, JSON.stringify(state));
}
const TOKEN_KEY = "bb_token";
const LOCAL_STATE_KEY = "bb_local_state_v1";
function hasChromeStorage() {
return typeof chrome !== "undefined" && chrome.storage?.local;
}
export async function getToken() {
if (hasChromeStorage()) {
const res = await chrome.storage.local.get([TOKEN_KEY]);
return res[TOKEN_KEY] || "";
}
return localStorage.getItem(TOKEN_KEY) || "";
}
export async function setToken(token) {
if (hasChromeStorage()) {
await chrome.storage.local.set({ [TOKEN_KEY]: token || "" });
return;
}
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
export async function loadLocalState() {
if (hasChromeStorage()) {
const res = await chrome.storage.local.get([LOCAL_STATE_KEY]);
return res[LOCAL_STATE_KEY] || { folders: [], bookmarks: [] };
}
try {
return JSON.parse(localStorage.getItem(LOCAL_STATE_KEY) || "") || { folders: [], bookmarks: [] };
} catch {
return { folders: [], bookmarks: [] };
}
}
export async function saveLocalState(state) {
if (hasChromeStorage()) {
await chrome.storage.local.set({ [LOCAL_STATE_KEY]: state });
return;
}
localStorage.setItem(LOCAL_STATE_KEY, JSON.stringify(state));
}

View File

@@ -1,218 +1,218 @@
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
import { loadLocalState, saveLocalState } from "./extStorage";
function nowIso() {
return new Date().toISOString();
}
function ensureBookmarkHashes(bookmark) {
const urlNormalized = bookmark.urlNormalized || normalizeUrl(bookmark.url || "");
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
return { ...bookmark, urlNormalized, urlHash };
}
export async function listLocalBookmarks({ includeDeleted = false } = {}) {
const state = await loadLocalState();
const items = (state.bookmarks || []).map(ensureBookmarkHashes);
const filtered = includeDeleted ? items : items.filter((b) => !b.deletedAt);
// Keep newest first
filtered.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
return filtered;
}
export async function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
const state = await loadLocalState();
const now = nowIso();
const urlNormalized = normalizeUrl(url || "");
const urlHash = computeUrlHash(urlNormalized);
// Dedupe: same urlHash and not deleted -> update LWW
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
if (existing) {
existing.title = title || existing.title;
existing.url = url || existing.url;
existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash;
existing.visibility = visibility;
existing.folderId = folderId;
existing.source = source;
existing.updatedAt = now;
await saveLocalState(state);
return { bookmark: ensureBookmarkHashes(existing), merged: true };
}
const bookmark = {
id: crypto.randomUUID(),
userId: null,
folderId,
title: title || "",
url: url || "",
urlNormalized,
urlHash,
visibility,
source,
updatedAt: now,
deletedAt: null
};
state.bookmarks = state.bookmarks || [];
state.bookmarks.unshift(bookmark);
await saveLocalState(state);
return { bookmark: ensureBookmarkHashes(bookmark), merged: false };
}
export async function markLocalDeleted(id) {
const state = await loadLocalState();
const now = nowIso();
const item = (state.bookmarks || []).find((b) => b.id === id);
if (!item) return false;
item.deletedAt = now;
item.updatedAt = now;
await saveLocalState(state);
return true;
}
export async function clearLocalState() {
await saveLocalState({ folders: [], bookmarks: [] });
}
export async function mergeLocalToUser() {
const state = await loadLocalState();
return {
folders: (state.folders || []).map((f) => ({
id: f.id,
parentId: f.parentId ?? null,
name: f.name || "",
visibility: f.visibility || "private",
updatedAt: f.updatedAt || nowIso()
})),
bookmarks: (state.bookmarks || []).map((b) => {
const fixed = ensureBookmarkHashes(b);
return {
id: fixed.id,
folderId: fixed.folderId ?? null,
title: fixed.title || "",
url: fixed.url || "",
visibility: fixed.visibility || "private",
source: fixed.source || "manual",
updatedAt: fixed.updatedAt || nowIso(),
deletedAt: fixed.deletedAt || null
};
})
};
}
export async function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
const parsed = parseNetscapeBookmarkHtml(html || "");
const state = await loadLocalState();
const now = nowIso();
state.folders = state.folders || [];
state.bookmarks = state.bookmarks || [];
// Folder id remap (avoid collisions with existing UUID ids)
const folderIdMap = new Map();
for (const f of parsed.folders || []) {
folderIdMap.set(f.id, crypto.randomUUID());
}
// Dedupe folders by (parentId,name)
const folderKeyToId = new Map(
state.folders.map((f) => [`${f.parentId ?? ""}::${(f.name || "").toLowerCase()}`, f.id])
);
const oldFolderIdToActual = new Map();
let foldersImported = 0;
for (const f of parsed.folders || []) {
const parentId = f.parentFolderId ? oldFolderIdToActual.get(f.parentFolderId) || null : null;
const name = (f.name || "").trim();
const key = `${parentId ?? ""}::${name.toLowerCase()}`;
let id = folderKeyToId.get(key);
if (!id) {
id = folderIdMap.get(f.id);
state.folders.push({
id,
userId: null,
parentId,
name,
visibility,
updatedAt: now
});
folderKeyToId.set(key, id);
foldersImported++;
} else {
// Keep existing, but ensure it has a recent updatedAt
const existing = state.folders.find((x) => x.id === id);
if (existing && (!existing.updatedAt || existing.updatedAt < now)) existing.updatedAt = now;
}
oldFolderIdToActual.set(f.id, id);
}
// Dedupe bookmarks by urlHash
const existingByHash = new Map();
for (const b of state.bookmarks.map(ensureBookmarkHashes)) {
if (!b.deletedAt && b.urlHash) existingByHash.set(b.urlHash, b);
}
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks || []) {
const title = (b.title || "").trim();
const url = (b.url || "").trim();
if (!url) continue;
const urlNormalized = normalizeUrl(url);
const urlHash = computeUrlHash(urlNormalized);
const folderId = b.parentFolderId ? oldFolderIdToActual.get(b.parentFolderId) || null : null;
const existing = existingByHash.get(urlHash);
if (existing) {
existing.title = title || existing.title;
existing.updatedAt = now;
merged++;
continue;
}
state.bookmarks.unshift({
id: crypto.randomUUID(),
userId: null,
folderId: folderId ?? null,
title,
url,
urlNormalized,
urlHash,
visibility,
source: "import",
updatedAt: now,
deletedAt: null
});
existingByHash.set(urlHash, state.bookmarks[0]);
imported++;
}
await saveLocalState(state);
return { foldersImported, imported, merged };
}
export function exportLocalToNetscapeHtml(bookmarks) {
const safe = (s) => String(s || "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const lines = [];
lines.push("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
lines.push('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">');
lines.push("<TITLE>Bookmarks</TITLE>");
lines.push("<H1>Bookmarks</H1>");
lines.push("<DL><p>");
for (const b of bookmarks || []) {
if (b.deletedAt) continue;
lines.push(` <DT><A HREF="${safe(b.url)}">${safe(b.title || b.url)}</A>`);
}
lines.push("</DL><p>");
return lines.join("\n");
}
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
import { loadLocalState, saveLocalState } from "./extStorage";
function nowIso() {
return new Date().toISOString();
}
function ensureBookmarkHashes(bookmark) {
const urlNormalized = bookmark.urlNormalized || normalizeUrl(bookmark.url || "");
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
return { ...bookmark, urlNormalized, urlHash };
}
export async function listLocalBookmarks({ includeDeleted = false } = {}) {
const state = await loadLocalState();
const items = (state.bookmarks || []).map(ensureBookmarkHashes);
const filtered = includeDeleted ? items : items.filter((b) => !b.deletedAt);
// Keep newest first
filtered.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
return filtered;
}
export async function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
const state = await loadLocalState();
const now = nowIso();
const urlNormalized = normalizeUrl(url || "");
const urlHash = computeUrlHash(urlNormalized);
// Dedupe: same urlHash and not deleted -> update LWW
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
if (existing) {
existing.title = title || existing.title;
existing.url = url || existing.url;
existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash;
existing.visibility = visibility;
existing.folderId = folderId;
existing.source = source;
existing.updatedAt = now;
await saveLocalState(state);
return { bookmark: ensureBookmarkHashes(existing), merged: true };
}
const bookmark = {
id: crypto.randomUUID(),
userId: null,
folderId,
title: title || "",
url: url || "",
urlNormalized,
urlHash,
visibility,
source,
updatedAt: now,
deletedAt: null
};
state.bookmarks = state.bookmarks || [];
state.bookmarks.unshift(bookmark);
await saveLocalState(state);
return { bookmark: ensureBookmarkHashes(bookmark), merged: false };
}
export async function markLocalDeleted(id) {
const state = await loadLocalState();
const now = nowIso();
const item = (state.bookmarks || []).find((b) => b.id === id);
if (!item) return false;
item.deletedAt = now;
item.updatedAt = now;
await saveLocalState(state);
return true;
}
export async function clearLocalState() {
await saveLocalState({ folders: [], bookmarks: [] });
}
export async function mergeLocalToUser() {
const state = await loadLocalState();
return {
folders: (state.folders || []).map((f) => ({
id: f.id,
parentId: f.parentId ?? null,
name: f.name || "",
visibility: f.visibility || "private",
updatedAt: f.updatedAt || nowIso()
})),
bookmarks: (state.bookmarks || []).map((b) => {
const fixed = ensureBookmarkHashes(b);
return {
id: fixed.id,
folderId: fixed.folderId ?? null,
title: fixed.title || "",
url: fixed.url || "",
visibility: fixed.visibility || "private",
source: fixed.source || "manual",
updatedAt: fixed.updatedAt || nowIso(),
deletedAt: fixed.deletedAt || null
};
})
};
}
export async function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
const parsed = parseNetscapeBookmarkHtml(html || "");
const state = await loadLocalState();
const now = nowIso();
state.folders = state.folders || [];
state.bookmarks = state.bookmarks || [];
// Folder id remap (avoid collisions with existing UUID ids)
const folderIdMap = new Map();
for (const f of parsed.folders || []) {
folderIdMap.set(f.id, crypto.randomUUID());
}
// Dedupe folders by (parentId,name)
const folderKeyToId = new Map(
state.folders.map((f) => [`${f.parentId ?? ""}::${(f.name || "").toLowerCase()}`, f.id])
);
const oldFolderIdToActual = new Map();
let foldersImported = 0;
for (const f of parsed.folders || []) {
const parentId = f.parentFolderId ? oldFolderIdToActual.get(f.parentFolderId) || null : null;
const name = (f.name || "").trim();
const key = `${parentId ?? ""}::${name.toLowerCase()}`;
let id = folderKeyToId.get(key);
if (!id) {
id = folderIdMap.get(f.id);
state.folders.push({
id,
userId: null,
parentId,
name,
visibility,
updatedAt: now
});
folderKeyToId.set(key, id);
foldersImported++;
} else {
// Keep existing, but ensure it has a recent updatedAt
const existing = state.folders.find((x) => x.id === id);
if (existing && (!existing.updatedAt || existing.updatedAt < now)) existing.updatedAt = now;
}
oldFolderIdToActual.set(f.id, id);
}
// Dedupe bookmarks by urlHash
const existingByHash = new Map();
for (const b of state.bookmarks.map(ensureBookmarkHashes)) {
if (!b.deletedAt && b.urlHash) existingByHash.set(b.urlHash, b);
}
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks || []) {
const title = (b.title || "").trim();
const url = (b.url || "").trim();
if (!url) continue;
const urlNormalized = normalizeUrl(url);
const urlHash = computeUrlHash(urlNormalized);
const folderId = b.parentFolderId ? oldFolderIdToActual.get(b.parentFolderId) || null : null;
const existing = existingByHash.get(urlHash);
if (existing) {
existing.title = title || existing.title;
existing.updatedAt = now;
merged++;
continue;
}
state.bookmarks.unshift({
id: crypto.randomUUID(),
userId: null,
folderId: folderId ?? null,
title,
url,
urlNormalized,
urlHash,
visibility,
source: "import",
updatedAt: now,
deletedAt: null
});
existingByHash.set(urlHash, state.bookmarks[0]);
imported++;
}
await saveLocalState(state);
return { foldersImported, imported, merged };
}
export function exportLocalToNetscapeHtml(bookmarks) {
const safe = (s) => String(s || "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const lines = [];
lines.push("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
lines.push('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">');
lines.push("<TITLE>Bookmarks</TITLE>");
lines.push("<H1>Bookmarks</H1>");
lines.push("<DL><p>");
for (const b of bookmarks || []) {
if (b.deletedAt) continue;
lines.push(` <DT><A HREF="${safe(b.url)}">${safe(b.title || b.url)}</A>`);
}
lines.push("</DL><p>");
return lines.join("\n");
}

View File

@@ -1,45 +1,45 @@
<script setup>
import { RouterView } from "vue-router";
</script>
<template>
<div class="shell">
<header class="nav">
<div class="brand">云书签 · 更多操作</div>
</header>
<main class="content">
<RouterView />
</main>
</div>
</template>
<style>
body { margin: 0; }
.shell { min-height: 100vh; }
.nav {
position: sticky;
top: 0;
background: rgba(248, 250, 252, 0.82);
border-bottom: 1px solid var(--bb-border);
backdrop-filter: blur(10px);
display: flex;
justify-content: flex-start;
padding: 10px 14px;
gap: 10px;
}
.brand { font-weight: 800; }
.content { max-width: 1100px; margin: 0 auto; padding: 14px; }
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: 2px solid rgba(59, 130, 246, 0.6);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; scroll-behavior: auto !important; }
}
</style>
<script setup>
import { RouterView } from "vue-router";
</script>
<template>
<div class="shell">
<header class="nav">
<div class="brand">云书签 · 更多操作</div>
</header>
<main class="content">
<RouterView />
</main>
</div>
</template>
<style>
body { margin: 0; }
.shell { min-height: 100vh; }
.nav {
position: sticky;
top: 0;
background: rgba(248, 250, 252, 0.82);
border-bottom: 1px solid var(--bb-border);
backdrop-filter: blur(10px);
display: flex;
justify-content: flex-start;
padding: 10px 14px;
gap: 10px;
}
.brand { font-weight: 800; }
.content { max-width: 1100px; margin: 0 auto; padding: 14px; }
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: 2px solid rgba(59, 130, 246, 0.6);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; scroll-behavior: auto !important; }
}
</style>

View File

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

View File

@@ -1,135 +1,135 @@
<script setup>
import { ref } from "vue";
import { apiFetch } from "../../lib/api";
import { getToken } from "../../lib/extStorage";
import { exportLocalToNetscapeHtml, importLocalFromNetscapeHtml, listLocalBookmarks } from "../../lib/localData";
const file = ref(null);
const status = ref("");
const error = ref("");
function onFileChange(e) {
file.value = e?.target?.files?.[0] || null;
}
async function importToLocal() {
status.value = "";
error.value = "";
if (!file.value) return;
try {
const text = await file.value.text();
const res = await importLocalFromNetscapeHtml(text, { visibility: "public" });
status.value = `本地导入完成:文件夹新增 ${res.foldersImported},书签新增 ${res.imported},合并 ${res.merged}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function importFile() {
status.value = "";
error.value = "";
const token = await getToken();
if (!token) {
await importToLocal();
return;
}
if (!file.value) return;
try {
const fd = new FormData();
fd.append("file", file.value);
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function exportCloud() {
status.value = "";
error.value = "";
try {
const html = await apiFetch("/bookmarks/export/html", {
method: "GET",
headers: { Accept: "text/html" }
});
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks-cloud.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = "云端导出完成";
} catch (e) {
error.value = e.message || String(e);
}
}
async function exportLocal() {
status.value = "";
error.value = "";
try {
const bookmarks = await listLocalBookmarks({ includeDeleted: false });
const html = exportLocalToNetscapeHtml(bookmarks);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = `本地导出完成:${bookmarks.length}`;
} catch (e) {
error.value = e.message || String(e);
}
}
</script>
<template>
<section>
<h1>导入 / 导出</h1>
<div class="card">
<h2>导入书签 HTML写入云端</h2>
<input
type="file"
accept="text/html,.html"
@change="onFileChange"
/>
<button class="btn" @click="importFile">开始导入</button>
<p v-if="status" class="ok">{{ status }}</p>
<p v-if="error" class="error">{{ error }}</p>
</div>
<div class="card">
<h2>导出本地</h2>
<button class="btn" @click="exportLocal">导出本地为 HTML</button>
</div>
<div class="card">
<h2>导出云端</h2>
<button class="btn" @click="exportCloud">导出为 HTML</button>
</div>
</section>
</template>
<style scoped>
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; margin: 12px 0; }
.btn { margin-top: 10px; padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.ok { color: #065f46; }
.error { color: #b91c1c; }
</style>
<script setup>
import { ref } from "vue";
import { apiFetch } from "../../lib/api";
import { getToken } from "../../lib/extStorage";
import { exportLocalToNetscapeHtml, importLocalFromNetscapeHtml, listLocalBookmarks } from "../../lib/localData";
const file = ref(null);
const status = ref("");
const error = ref("");
function onFileChange(e) {
file.value = e?.target?.files?.[0] || null;
}
async function importToLocal() {
status.value = "";
error.value = "";
if (!file.value) return;
try {
const text = await file.value.text();
const res = await importLocalFromNetscapeHtml(text, { visibility: "public" });
status.value = `本地导入完成:文件夹新增 ${res.foldersImported},书签新增 ${res.imported},合并 ${res.merged}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function importFile() {
status.value = "";
error.value = "";
const token = await getToken();
if (!token) {
await importToLocal();
return;
}
if (!file.value) return;
try {
const fd = new FormData();
fd.append("file", file.value);
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
} catch (e) {
error.value = e.message || String(e);
}
}
async function exportCloud() {
status.value = "";
error.value = "";
try {
const html = await apiFetch("/bookmarks/export/html", {
method: "GET",
headers: { Accept: "text/html" }
});
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks-cloud.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = "云端导出完成";
} catch (e) {
error.value = e.message || String(e);
}
}
async function exportLocal() {
status.value = "";
error.value = "";
try {
const bookmarks = await listLocalBookmarks({ includeDeleted: false });
const html = exportLocalToNetscapeHtml(bookmarks);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = `本地导出完成:${bookmarks.length}`;
} catch (e) {
error.value = e.message || String(e);
}
}
</script>
<template>
<section>
<h1>导入 / 导出</h1>
<div class="card">
<h2>导入书签 HTML写入云端</h2>
<input
type="file"
accept="text/html,.html"
@change="onFileChange"
/>
<button class="btn" @click="importFile">开始导入</button>
<p v-if="status" class="ok">{{ status }}</p>
<p v-if="error" class="error">{{ error }}</p>
</div>
<div class="card">
<h2>导出本地</h2>
<button class="btn" @click="exportLocal">导出本地为 HTML</button>
</div>
<div class="card">
<h2>导出云端</h2>
<button class="btn" @click="exportCloud">导出为 HTML</button>
</div>
</section>
</template>
<style scoped>
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; margin: 12px 0; }
.btn { margin-top: 10px; padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.ok { color: #065f46; }
.error { color: #b91c1c; }
</style>

View File

@@ -1,77 +1,77 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch } from "../../lib/api";
import { setToken } from "../../lib/extStorage";
import { clearLocalState, mergeLocalToUser } from "../../lib/localData";
const router = useRouter();
const mode = ref("login");
const email = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
async function submit() {
loading.value = true;
error.value = "";
try {
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
const res = await apiFetch(path, {
method: "POST",
body: JSON.stringify({ email: email.value, password: password.value })
});
await setToken(res.token);
// Push local state to server on login
const payload = await mergeLocalToUser();
await apiFetch("/sync/push", {
method: "POST",
body: JSON.stringify(payload)
});
// After merge, keep extension in cloud mode
await clearLocalState();
await router.replace("/");
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<section>
<h1>登录 / 注册</h1>
<div class="row">
<button class="tab" :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button>
<button class="tab" :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button>
</div>
<div class="form">
<input v-model="email" class="input" placeholder="邮箱" autocomplete="email" />
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
<button class="btn" :disabled="loading" @click="submit">提交</button>
<p v-if="error" class="error">{{ error }}</p>
</div>
<p class="muted">扩展内的本地书签存储在 chrome.storage.local</p>
</section>
</template>
<style scoped>
.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
.tab { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; cursor: pointer; }
.tab.active { border-color: #111827; background: #111827; color: white; }
.form { display: grid; gap: 10px; max-width: 560px; }
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.error { color: #b91c1c; }
.muted { color: #475569; font-size: 12px; margin-top: 10px; }
</style>
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch } from "../../lib/api";
import { setToken } from "../../lib/extStorage";
import { clearLocalState, mergeLocalToUser } from "../../lib/localData";
const router = useRouter();
const mode = ref("login");
const email = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
async function submit() {
loading.value = true;
error.value = "";
try {
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
const res = await apiFetch(path, {
method: "POST",
body: JSON.stringify({ email: email.value, password: password.value })
});
await setToken(res.token);
// Push local state to server on login
const payload = await mergeLocalToUser();
await apiFetch("/sync/push", {
method: "POST",
body: JSON.stringify(payload)
});
// After merge, keep extension in cloud mode
await clearLocalState();
await router.replace("/");
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<section>
<h1>登录 / 注册</h1>
<div class="row">
<button class="tab" :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button>
<button class="tab" :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button>
</div>
<div class="form">
<input v-model="email" class="input" placeholder="邮箱" autocomplete="email" />
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
<button class="btn" :disabled="loading" @click="submit">提交</button>
<p v-if="error" class="error">{{ error }}</p>
</div>
<p class="muted">扩展内的本地书签存储在 chrome.storage.local</p>
</section>
</template>
<style scoped>
.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
.tab { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; cursor: pointer; }
.tab.active { border-color: #111827; background: #111827; color: white; }
.form { display: grid; gap: 10px; max-width: 560px; }
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.error { color: #b91c1c; }
.muted { color: #475569; font-size: 12px; margin-top: 10px; }
</style>

View File

@@ -1,104 +1,104 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { getToken, setToken } from "../../lib/extStorage";
import BbConfirmModal from "../../components/BbConfirmModal.vue";
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");
}
const logoutModalOpen = ref(false);
const logoutStep = ref(1);
function startLogout() {
logoutStep.value = 1;
logoutModalOpen.value = true;
}
async function confirmLogout() {
if (logoutStep.value === 1) {
logoutStep.value = 2;
return;
}
await setToken("");
logoutModalOpen.value = false;
await refresh();
await router.replace("/login");
}
function cancelLogout() {
logoutModalOpen.value = false;
logoutStep.value = 1;
}
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="startLogout">退出登录</button>
<p class="hint">Web 地址来自环境变量VITE_WEB_BASE_URL</p>
</div>
<BbConfirmModal
v-model="logoutModalOpen"
title="退出登录"
:message="logoutStep === 1 ? '退出以后无法同步书签。' : '你确定要退出吗?'"
:confirm-text="logoutStep === 1 ? '继续' : '确定退出'"
cancel-text="取消"
:danger="logoutStep === 2"
@confirm="confirmLogout"
@cancel="cancelLogout"
/>
</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>
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { getToken, setToken } from "../../lib/extStorage";
import BbConfirmModal from "../../components/BbConfirmModal.vue";
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");
}
const logoutModalOpen = ref(false);
const logoutStep = ref(1);
function startLogout() {
logoutStep.value = 1;
logoutModalOpen.value = true;
}
async function confirmLogout() {
if (logoutStep.value === 1) {
logoutStep.value = 2;
return;
}
await setToken("");
logoutModalOpen.value = false;
await refresh();
await router.replace("/login");
}
function cancelLogout() {
logoutModalOpen.value = false;
logoutStep.value = 1;
}
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="startLogout">退出登录</button>
<p class="hint">Web 地址来自环境变量VITE_WEB_BASE_URL</p>
</div>
<BbConfirmModal
v-model="logoutModalOpen"
title="退出登录"
:message="logoutStep === 1 ? '退出以后无法同步书签。' : '你确定要退出吗?'"
:confirm-text="logoutStep === 1 ? '继续' : '确定退出'"
cancel-text="取消"
:danger="logoutStep === 2"
@confirm="confirmLogout"
@cancel="cancelLogout"
/>
</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

@@ -1,134 +1,134 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { apiFetch } from "../../lib/api";
import { getToken } from "../../lib/extStorage";
import { listLocalBookmarks, markLocalDeleted, upsertLocalBookmark } from "../../lib/localData";
import BbConfirmModal from "../../components/BbConfirmModal.vue";
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const items = ref([]);
const error = ref("");
const mode = computed(() => (loggedIn.value ? "cloud" : "local"));
const title = ref("");
const url = ref("");
async function load() {
error.value = "";
try {
token.value = await getToken();
if (!token.value) {
items.value = await listLocalBookmarks();
return;
}
items.value = await apiFetch("/bookmarks");
} catch (e) {
error.value = e.message || String(e);
}
}
async function add() {
if (!title.value || !url.value) return;
if (mode.value === "cloud") {
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({ folderId: null, title: title.value, url: url.value, visibility: "public" })
});
} else {
await upsertLocalBookmark({ title: title.value, url: url.value, visibility: "public" });
}
title.value = "";
url.value = "";
await load();
}
async function remove(id) {
if (mode.value !== "local") return;
pendingDeleteId.value = id;
deleteConfirmOpen.value = true;
}
const deleteConfirmOpen = ref(false);
const pendingDeleteId = ref("");
async function confirmDelete() {
const id = pendingDeleteId.value;
if (!id) {
deleteConfirmOpen.value = false;
return;
}
await markLocalDeleted(id);
pendingDeleteId.value = "";
deleteConfirmOpen.value = false;
await load();
}
function cancelDelete() {
pendingDeleteId.value = "";
deleteConfirmOpen.value = false;
}
onMounted(load);
</script>
<template>
<section>
<h1>我的书签{{ mode === 'cloud' ? '云端' : '本地' }}</h1>
<p v-if="!loggedIn" class="muted">未登录时书签保存在扩展本地可在登录后自动合并上云</p>
<div class="form">
<input v-model="title" class="input" placeholder="标题" />
<input v-model="url" class="input" placeholder="链接" />
<button class="btn" @click="add">添加</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<ul class="list">
<li v-for="b in items" :key="b.id" class="card">
<div class="row">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<button v-if="mode === 'local'" class="ghost" @click.prevent="remove(b.id)">删除</button>
</div>
<div class="muted">{{ b.url }}</div>
</li>
</ul>
<BbConfirmModal
v-model="deleteConfirmOpen"
title="删除书签"
message="确定删除该书签?"
confirm-text="删除"
cancel-text="取消"
:danger="true"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</section>
</template>
<style scoped>
.form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
@media (min-width: 900px) { .form { grid-template-columns: 2fr 3fr auto; } }
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.row { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
.ghost { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 10px; padding: 6px 10px; cursor: pointer; }
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
.title {
color: #111827;
font-weight: 700;
text-decoration: none;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; }
.muted { color: #475569; font-size: 12px; }
</style>
<script setup>
import { computed, onMounted, ref } from "vue";
import { apiFetch } from "../../lib/api";
import { getToken } from "../../lib/extStorage";
import { listLocalBookmarks, markLocalDeleted, upsertLocalBookmark } from "../../lib/localData";
import BbConfirmModal from "../../components/BbConfirmModal.vue";
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const items = ref([]);
const error = ref("");
const mode = computed(() => (loggedIn.value ? "cloud" : "local"));
const title = ref("");
const url = ref("");
async function load() {
error.value = "";
try {
token.value = await getToken();
if (!token.value) {
items.value = await listLocalBookmarks();
return;
}
items.value = await apiFetch("/bookmarks");
} catch (e) {
error.value = e.message || String(e);
}
}
async function add() {
if (!title.value || !url.value) return;
if (mode.value === "cloud") {
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({ folderId: null, title: title.value, url: url.value, visibility: "public" })
});
} else {
await upsertLocalBookmark({ title: title.value, url: url.value, visibility: "public" });
}
title.value = "";
url.value = "";
await load();
}
async function remove(id) {
if (mode.value !== "local") return;
pendingDeleteId.value = id;
deleteConfirmOpen.value = true;
}
const deleteConfirmOpen = ref(false);
const pendingDeleteId = ref("");
async function confirmDelete() {
const id = pendingDeleteId.value;
if (!id) {
deleteConfirmOpen.value = false;
return;
}
await markLocalDeleted(id);
pendingDeleteId.value = "";
deleteConfirmOpen.value = false;
await load();
}
function cancelDelete() {
pendingDeleteId.value = "";
deleteConfirmOpen.value = false;
}
onMounted(load);
</script>
<template>
<section>
<h1>我的书签{{ mode === 'cloud' ? '云端' : '本地' }}</h1>
<p v-if="!loggedIn" class="muted">未登录时书签保存在扩展本地可在登录后自动合并上云</p>
<div class="form">
<input v-model="title" class="input" placeholder="标题" />
<input v-model="url" class="input" placeholder="链接" />
<button class="btn" @click="add">添加</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<ul class="list">
<li v-for="b in items" :key="b.id" class="card">
<div class="row">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<button v-if="mode === 'local'" class="ghost" @click.prevent="remove(b.id)">删除</button>
</div>
<div class="muted">{{ b.url }}</div>
</li>
</ul>
<BbConfirmModal
v-model="deleteConfirmOpen"
title="删除书签"
message="确定删除该书签?"
confirm-text="删除"
cancel-text="取消"
:danger="true"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</section>
</template>
<style scoped>
.form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
@media (min-width: 900px) { .form { grid-template-columns: 2fr 3fr auto; } }
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.row { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
.ghost { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 10px; padding: 6px 10px; cursor: pointer; }
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
.title {
color: #111827;
font-weight: 700;
text-decoration: none;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; }
.muted { color: #475569; font-size: 12px; }
</style>

View File

@@ -1,92 +1,92 @@
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { apiFetch } from "../../lib/api";
const items = ref([]);
const q = ref("");
const loading = ref(false);
const error = ref("");
async function load() {
loading.value = true;
error.value = "";
try {
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
let searchTimer = 0;
watch(
() => q.value,
() => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
load();
}, 200);
}
);
onBeforeUnmount(() => {
window.clearTimeout(searchTimer);
});
onMounted(load);
</script>
<template>
<section>
<h1>公开书签</h1>
<div class="row">
<div class="searchWrap">
<input v-model="q" class="input input--withClear" placeholder="搜索" />
<button v-if="q.trim()" class="clearBtn" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<ul class="list">
<li v-for="b in items" :key="b.id" class="card">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<div class="muted">{{ b.url }}</div>
</li>
</ul>
</section>
</template>
<style scoped>
.row { display: flex; gap: 8px; margin: 12px 0; }
.searchWrap { flex: 1; position: relative; }
.input { width: 100%; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.input.input--withClear { padding-right: 40px; }
.clearBtn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: white;
cursor: pointer;
}
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
.title {
color: #111827;
font-weight: 700;
text-decoration: none;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; }
</style>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { apiFetch } from "../../lib/api";
const items = ref([]);
const q = ref("");
const loading = ref(false);
const error = ref("");
async function load() {
loading.value = true;
error.value = "";
try {
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
let searchTimer = 0;
watch(
() => q.value,
() => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
load();
}, 200);
}
);
onBeforeUnmount(() => {
window.clearTimeout(searchTimer);
});
onMounted(load);
</script>
<template>
<section>
<h1>公开书签</h1>
<div class="row">
<div class="searchWrap">
<input v-model="q" class="input input--withClear" placeholder="搜索" />
<button v-if="q.trim()" class="clearBtn" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<ul class="list">
<li v-for="b in items" :key="b.id" class="card">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<div class="muted">{{ b.url }}</div>
</li>
</ul>
</section>
</template>
<style scoped>
.row { display: flex; gap: 8px; margin: 12px 0; }
.searchWrap { flex: 1; position: relative; }
.input { width: 100%; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
.input.input--withClear { padding-right: 40px; }
.clearBtn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: white;
cursor: pointer;
}
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
.title {
color: #111827;
font-weight: 700;
text-decoration: none;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; }
</style>

View File

@@ -1,22 +1,22 @@
import { createRouter, createWebHashHistory } from "vue-router";
import LoginPage from "./pages/LoginPage.vue";
import MorePage from "./pages/MorePage.vue";
import { getToken } from "../lib/extStorage";
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: "/", component: MorePage },
{ path: "/login", component: LoginPage }
]
});
router.beforeEach(async (to) => {
const token = await getToken();
const authed = Boolean(token);
if (!authed && to.path !== "/login") return "/login";
if (authed && to.path === "/login") return "/";
return true;
});
import { createRouter, createWebHashHistory } from "vue-router";
import LoginPage from "./pages/LoginPage.vue";
import MorePage from "./pages/MorePage.vue";
import { getToken } from "../lib/extStorage";
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: "/", component: MorePage },
{ path: "/login", component: LoginPage }
]
});
router.beforeEach(async (to) => {
const token = await getToken();
const authed = Boolean(token);
if (!authed && to.path !== "/login") return "/login";
if (authed && to.path === "/login") return "/";
return true;
});

View File

@@ -1,494 +1,494 @@
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { apiFetch } from "../lib/api";
import { getToken } from "../lib/extStorage";
const view = ref("list"); // add | list
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const loading = ref(false);
const error = ref("");
function openOptions() {
if (typeof chrome !== "undefined" && 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() {
try {
if (typeof chrome !== "undefined" && chrome.tabs?.query) {
const tabs = await new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, (result) => resolve(result || []));
});
const tab = tabs?.[0];
return { title: tab?.title || "", url: tab?.url || "" };
}
} catch {
// ignore
}
return { title: "", url: "" };
}
async function prepareAddCurrent() {
addStatus.value = "";
error.value = "";
addFolderId.value = null;
const page = await getActiveTabPage();
addTitle.value = String(page.title || "").trim() || 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;
try {
addBusy.value = true;
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({
folderId: addFolderId.value ?? null,
title: t,
url: u,
visibility: "private"
})
});
addStatus.value = "已添加";
if (view.value === "list") await loadBookmarks();
} catch (e) {
error.value = e.message || String(e);
} finally {
addBusy.value = false;
}
}
// create folder (cloud only)
const folderName = ref("");
const folderBusy = ref(false);
const folderModalOpen = ref(false);
async function createFolder() {
error.value = "";
const name = folderName.value.trim();
if (!name) return;
if (!loggedIn.value) {
error.value = "请先登录";
return;
}
try {
folderBusy.value = true;
await apiFetch("/folders", {
method: "POST",
body: JSON.stringify({ parentId: null, name, visibility: "private" })
});
folderName.value = "";
folderModalOpen.value = false;
await loadFolders();
} catch (e) {
error.value = e.message || String(e);
} finally {
folderBusy.value = false;
}
}
onMounted(async () => {
await refreshAuth();
await prepareAddCurrent();
if (loggedIn.value) await loadAll();
});
watch(
() => q.value,
async () => {
if (!loggedIn.value) return;
await loadBookmarks();
}
);
</script>
<template>
<div class="wrap">
<header class="top">
<div class="brand">云书签</div>
<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>
<p v-if="!loggedIn" class="hint">未登录请点右上角更多操作先登录</p>
<p v-if="error" class="alert">{{ error }}</p>
<p v-if="addStatus" class="ok">{{ addStatus }}</p>
<section v-if="view === 'add'" class="card">
<div class="cardTitle">一键添加书签</div>
<div class="muted">会自动读取标题和链接你也可以手动修改</div>
<label class="label">标题</label>
<input v-model="addTitle" class="input" placeholder="标题" />
<label class="label">链接</label>
<input v-model="addUrl" class="input" placeholder="https://..." />
<label class="label">文件夹不选则未分组</label>
<select v-model="addFolderId" class="input" :disabled="!loggedIn">
<option :value="null">未分组</option>
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
<div class="row">
<button class="btn btn--secondary" type="button" @click="prepareAddCurrent" :disabled="addBusy">
重新读取
</button>
<button class="btn" type="button" @click="submitAddCurrent" :disabled="addBusy || !addUrl">
{{ addBusy ? '添加中…' : '添加' }}
</button>
</div>
</section>
<section v-else class="card">
<div class="titleRow">
<div class="cardTitle">书签目录</div>
<button class="miniBtn" type="button" :disabled="!loggedIn" @click="folderModalOpen = true">新增文件夹</button>
</div>
<div class="row" style="margin-top: 8px;">
<div class="searchWrap">
<input v-model="q" class="input input--withClear" placeholder="搜索标题/链接" :disabled="!loggedIn" />
<button
v-if="q.trim()"
class="clearBtn"
type="button"
aria-label="清空搜索"
@click="q = ''"
>
×
</button>
</div>
<button class="btn btn--secondary" type="button" @click="loadAll" :disabled="!loggedIn || loading">刷新</button>
</div>
<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>
</template>
<style scoped>
.wrap{
width: 380px;
padding: 12px;
font-family: ui-sans-serif, system-ui;
color: var(--bb-text);
}
.top{ display:flex; justify-content:space-between; align-items:center; gap:10px; }
.brand{ font-weight: 900; letter-spacing: 0.5px; }
.seg{ display:flex; gap:8px; margin-top: 10px; }
.segBtn{
flex:1;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.85);
padding: 8px 10px;
border-radius: 14px;
cursor:pointer;
}
.segBtn.is-active{
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
border-color: rgba(255,255,255,0.35);
}
.btn{
border: 1px solid rgba(15, 23, 42, 0.10);
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);
}
.searchWrap{ position: relative; flex: 1; min-width: 0; }
.input.input--withClear{ padding-right: 40px; }
.clearBtn{
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 26px;
height: 26px;
border-radius: 999px;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.92);
cursor: pointer;
display: grid;
place-items: center;
color: rgba(15, 23, 42, 0.7);
padding: 0;
}
.clearBtn:hover{ background: rgba(255,255,255,1); }
.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>
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { apiFetch } from "../lib/api";
import { getToken } from "../lib/extStorage";
const view = ref("list"); // add | list
const token = ref("");
const loggedIn = computed(() => Boolean(token.value));
const loading = ref(false);
const error = ref("");
function openOptions() {
if (typeof chrome !== "undefined" && 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() {
try {
if (typeof chrome !== "undefined" && chrome.tabs?.query) {
const tabs = await new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, (result) => resolve(result || []));
});
const tab = tabs?.[0];
return { title: tab?.title || "", url: tab?.url || "" };
}
} catch {
// ignore
}
return { title: "", url: "" };
}
async function prepareAddCurrent() {
addStatus.value = "";
error.value = "";
addFolderId.value = null;
const page = await getActiveTabPage();
addTitle.value = String(page.title || "").trim() || 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;
try {
addBusy.value = true;
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({
folderId: addFolderId.value ?? null,
title: t,
url: u,
visibility: "private"
})
});
addStatus.value = "已添加";
if (view.value === "list") await loadBookmarks();
} catch (e) {
error.value = e.message || String(e);
} finally {
addBusy.value = false;
}
}
// create folder (cloud only)
const folderName = ref("");
const folderBusy = ref(false);
const folderModalOpen = ref(false);
async function createFolder() {
error.value = "";
const name = folderName.value.trim();
if (!name) return;
if (!loggedIn.value) {
error.value = "请先登录";
return;
}
try {
folderBusy.value = true;
await apiFetch("/folders", {
method: "POST",
body: JSON.stringify({ parentId: null, name, visibility: "private" })
});
folderName.value = "";
folderModalOpen.value = false;
await loadFolders();
} catch (e) {
error.value = e.message || String(e);
} finally {
folderBusy.value = false;
}
}
onMounted(async () => {
await refreshAuth();
await prepareAddCurrent();
if (loggedIn.value) await loadAll();
});
watch(
() => q.value,
async () => {
if (!loggedIn.value) return;
await loadBookmarks();
}
);
</script>
<template>
<div class="wrap">
<header class="top">
<div class="brand">云书签</div>
<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>
<p v-if="!loggedIn" class="hint">未登录请点右上角更多操作先登录</p>
<p v-if="error" class="alert">{{ error }}</p>
<p v-if="addStatus" class="ok">{{ addStatus }}</p>
<section v-if="view === 'add'" class="card">
<div class="cardTitle">一键添加书签</div>
<div class="muted">会自动读取标题和链接你也可以手动修改</div>
<label class="label">标题</label>
<input v-model="addTitle" class="input" placeholder="标题" />
<label class="label">链接</label>
<input v-model="addUrl" class="input" placeholder="https://..." />
<label class="label">文件夹不选则未分组</label>
<select v-model="addFolderId" class="input" :disabled="!loggedIn">
<option :value="null">未分组</option>
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
<div class="row">
<button class="btn btn--secondary" type="button" @click="prepareAddCurrent" :disabled="addBusy">
重新读取
</button>
<button class="btn" type="button" @click="submitAddCurrent" :disabled="addBusy || !addUrl">
{{ addBusy ? '添加中…' : '添加' }}
</button>
</div>
</section>
<section v-else class="card">
<div class="titleRow">
<div class="cardTitle">书签目录</div>
<button class="miniBtn" type="button" :disabled="!loggedIn" @click="folderModalOpen = true">新增文件夹</button>
</div>
<div class="row" style="margin-top: 8px;">
<div class="searchWrap">
<input v-model="q" class="input input--withClear" placeholder="搜索标题/链接" :disabled="!loggedIn" />
<button
v-if="q.trim()"
class="clearBtn"
type="button"
aria-label="清空搜索"
@click="q = ''"
>
×
</button>
</div>
<button class="btn btn--secondary" type="button" @click="loadAll" :disabled="!loggedIn || loading">刷新</button>
</div>
<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>
</template>
<style scoped>
.wrap{
width: 380px;
padding: 12px;
font-family: ui-sans-serif, system-ui;
color: var(--bb-text);
}
.top{ display:flex; justify-content:space-between; align-items:center; gap:10px; }
.brand{ font-weight: 900; letter-spacing: 0.5px; }
.seg{ display:flex; gap:8px; margin-top: 10px; }
.segBtn{
flex:1;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.85);
padding: 8px 10px;
border-radius: 14px;
cursor:pointer;
}
.segBtn.is-active{
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
border-color: rgba(255,255,255,0.35);
}
.btn{
border: 1px solid rgba(15, 23, 42, 0.10);
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);
}
.searchWrap{ position: relative; flex: 1; min-width: 0; }
.input.input--withClear{ padding-right: 40px; }
.clearBtn{
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 26px;
height: 26px;
border-radius: 999px;
border: 1px solid var(--bb-border);
background: rgba(255,255,255,0.92);
cursor: pointer;
display: grid;
place-items: center;
color: rgba(15, 23, 42, 0.7);
padding: 0;
}
.clearBtn:hover{ background: rgba(255,255,255,1); }
.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>

View File

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

View File

@@ -1,12 +1,12 @@
export default [
{
files: ["**/*.js"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module"
},
rules: {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
];
export default [
{
files: ["**/*.js"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module"
},
rules: {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
];

View File

@@ -1,44 +1,44 @@
create extension if not exists pgcrypto;
create table if not exists users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
role text not null default 'user',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists bookmark_folders (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
parent_id uuid null references bookmark_folders(id) on delete cascade,
name text not null,
visibility text not null default 'private',
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_bookmark_folders_user_parent on bookmark_folders (user_id, parent_id);
create table if not exists bookmarks (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
folder_id uuid null references bookmark_folders(id) on delete set null,
sort_order integer not null default 0,
title text not null,
url text not null,
url_normalized text not null,
url_hash text not null,
visibility text not null default 'private',
source text not null default 'manual',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz null
);
create index if not exists idx_bookmarks_user_updated_at on bookmarks (user_id, updated_at);
create index if not exists idx_bookmarks_user_folder_sort on bookmarks (user_id, folder_id, sort_order);
create index if not exists idx_bookmarks_user_url_hash on bookmarks (user_id, url_hash);
create index if not exists idx_bookmarks_visibility on bookmarks (visibility);
create extension if not exists pgcrypto;
create table if not exists users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
role text not null default 'user',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists bookmark_folders (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
parent_id uuid null references bookmark_folders(id) on delete cascade,
name text not null,
visibility text not null default 'private',
sort_order integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_bookmark_folders_user_parent on bookmark_folders (user_id, parent_id);
create table if not exists bookmarks (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
folder_id uuid null references bookmark_folders(id) on delete set null,
sort_order integer not null default 0,
title text not null,
url text not null,
url_normalized text not null,
url_hash text not null,
visibility text not null default 'private',
source text not null default 'manual',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz null
);
create index if not exists idx_bookmarks_user_updated_at on bookmarks (user_id, updated_at);
create index if not exists idx_bookmarks_user_folder_sort on bookmarks (user_id, folder_id, sort_order);
create index if not exists idx_bookmarks_user_url_hash on bookmarks (user_id, url_hash);
create index if not exists idx_bookmarks_visibility on bookmarks (visibility);

View File

@@ -1,5 +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);
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

@@ -1,5 +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);
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

@@ -1,33 +1,33 @@
{
"name": "@browser-bookmark/server",
"private": true,
"type": "module",
"version": "0.1.0",
"main": "src/index.js",
"scripts": {
"dev": "node --watch src/index.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",
"lint": "eslint .",
"db:migrate": "node src/migrate.js",
"db:reset": "node src/resetDb.js"
},
"dependencies": {
"@browser-bookmark/shared": "0.1.0",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0",
"bcryptjs": "^3.0.3",
"cheerio": "^1.1.2",
"dotenv": "^16.4.7",
"fastify": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.13.1"
},
"devDependencies": {
"eslint": "^9.17.0"
},
"engines": {
"node": ">=22"
}
}
{
"name": "@browser-bookmark/server",
"private": true,
"type": "module",
"version": "0.1.0",
"main": "src/index.js",
"scripts": {
"dev": "node --watch src/index.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",
"lint": "eslint .",
"db:migrate": "node src/migrate.js",
"db:reset": "node src/resetDb.js"
},
"dependencies": {
"@browser-bookmark/shared": "file:../../packages/shared",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0",
"bcryptjs": "^3.0.3",
"cheerio": "^1.1.2",
"dotenv": "^16.4.7",
"fastify": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.13.1"
},
"devDependencies": {
"eslint": "^9.17.0"
},
"engines": {
"node": ">=22"
}
}

View File

@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
test("placeholder", () => {
assert.equal(1 + 1, 2);
});
import test from "node:test";
import assert from "node:assert/strict";
test("placeholder", () => {
assert.equal(1 + 1, 2);
});

View File

@@ -1,44 +1,44 @@
import fs from "node:fs";
import path from "node:path";
import dotenv from "dotenv";
function loadEnv() {
// When running via npm workspaces, cwd is often apps/server.
// Support both apps/server/.env and repo-root/.env.
const candidates = [
path.resolve(process.cwd(), ".env"),
path.resolve(process.cwd(), "..", "..", ".env")
];
for (const envPath of candidates) {
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
return;
}
}
}
loadEnv();
export function getConfig() {
const serverPort = Number(process.env.SERVER_PORT || 3001);
const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
const corsOriginsRaw = String(process.env.CORS_ORIGINS || "").trim();
const corsOrigins = corsOriginsRaw
? corsOriginsRaw.split(",").map((item) => item.trim()).filter(Boolean)
: true;
return {
serverPort,
adminEmail,
corsOrigins,
database: {
host: process.env.DATABASE_HOST || "127.0.0.1",
port: Number(process.env.DATABASE_PORT || 5432),
database: process.env.DATABASE_NAME || "postgres",
user: process.env.DATABASE_USER || "postgres",
password: process.env.DATABASE_PASSWORD || "",
ssl: String(process.env.DATABASE_SSL || "false").toLowerCase() === "true"
}
};
}
import fs from "node:fs";
import path from "node:path";
import dotenv from "dotenv";
function loadEnv() {
// When running via npm workspaces, cwd is often apps/server.
// Support both apps/server/.env and repo-root/.env.
const candidates = [
path.resolve(process.cwd(), ".env"),
path.resolve(process.cwd(), "..", "..", ".env")
];
for (const envPath of candidates) {
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
return;
}
}
}
loadEnv();
export function getConfig() {
const serverPort = Number(process.env.SERVER_PORT || 3001);
const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
const corsOriginsRaw = String(process.env.CORS_ORIGINS || "").trim();
const corsOrigins = corsOriginsRaw
? corsOriginsRaw.split(",").map((item) => item.trim()).filter(Boolean)
: true;
return {
serverPort,
adminEmail,
corsOrigins,
database: {
host: process.env.DATABASE_HOST || "127.0.0.1",
port: Number(process.env.DATABASE_PORT || 5432),
database: process.env.DATABASE_NAME || "postgres",
user: process.env.DATABASE_USER || "postgres",
password: process.env.DATABASE_PASSWORD || "",
ssl: String(process.env.DATABASE_SSL || "false").toLowerCase() === "true"
}
};
}

View File

@@ -1,16 +1,16 @@
import pg from "pg";
import { getConfig } from "./config.js";
const { Pool } = pg;
export function createPool() {
const { database } = getConfig();
return new Pool({
host: database.host,
port: database.port,
database: database.database,
user: database.user,
password: database.password,
ssl: database.ssl ? { rejectUnauthorized: false } : false
});
}
import pg from "pg";
import { getConfig } from "./config.js";
const { Pool } = pg;
export function createPool() {
const { database } = getConfig();
return new Pool({
host: database.host,
port: database.port,
database: database.database,
user: database.user,
password: database.password,
ssl: database.ssl ? { rejectUnauthorized: false } : false
});
}

View File

@@ -1,87 +1,87 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import multipart from "@fastify/multipart";
import jwt from "@fastify/jwt";
import { getConfig } from "./config.js";
import { createPool } from "./db.js";
import { authRoutes } from "./routes/auth.routes.js";
import { adminRoutes } from "./routes/admin.routes.js";
import { foldersRoutes } from "./routes/folders.routes.js";
import { bookmarksRoutes } from "./routes/bookmarks.routes.js";
import { importExportRoutes } from "./routes/importExport.routes.js";
import { syncRoutes } from "./routes/sync.routes.js";
const app = Fastify({ logger: true });
// Plugins
const config = getConfig();
await app.register(cors, {
origin: config.corsOrigins,
credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "Accept"]
});
await app.register(multipart);
const jwtSecret = process.env.AUTH_JWT_SECRET;
if (!jwtSecret) {
throw new Error("AUTH_JWT_SECRET is required");
}
await app.register(jwt, { secret: jwtSecret });
const pool = createPool();
app.decorate("pg", pool);
// Detect optional DB features (for backwards compatibility when migrations haven't run yet).
async function hasColumn(tableName, columnName) {
try {
const r = await app.pg.query(
"select 1 from information_schema.columns where table_schema=current_schema() and table_name=$1 and column_name=$2 limit 1",
[tableName, columnName]
);
return r.rowCount > 0;
} catch {
return false;
}
}
const folderSortOrderSupported = await hasColumn("bookmark_folders", "sort_order");
const bookmarkSortOrderSupported = await hasColumn("bookmarks", "sort_order");
app.decorate("features", {
folderSortOrder: folderSortOrderSupported,
bookmarkSortOrder: bookmarkSortOrderSupported
});
app.decorate("authenticate", async (req, reply) => {
try {
await req.jwtVerify();
} catch (err) {
reply.code(401);
throw err;
}
});
app.setErrorHandler((err, _req, reply) => {
const statusCode = err.statusCode || 500;
reply.code(statusCode).send({ message: err.message || "server error" });
});
app.get("/health", async () => ({ ok: true }));
// Routes
app.decorate("config", config);
await authRoutes(app);
await adminRoutes(app);
await foldersRoutes(app);
await bookmarksRoutes(app);
await importExportRoutes(app);
await syncRoutes(app);
app.addHook("onClose", async (instance) => {
await instance.pg.end();
});
const { serverPort } = config;
await app.listen({ port: serverPort, host: "0.0.0.0" });
import Fastify from "fastify";
import cors from "@fastify/cors";
import multipart from "@fastify/multipart";
import jwt from "@fastify/jwt";
import { getConfig } from "./config.js";
import { createPool } from "./db.js";
import { authRoutes } from "./routes/auth.routes.js";
import { adminRoutes } from "./routes/admin.routes.js";
import { foldersRoutes } from "./routes/folders.routes.js";
import { bookmarksRoutes } from "./routes/bookmarks.routes.js";
import { importExportRoutes } from "./routes/importExport.routes.js";
import { syncRoutes } from "./routes/sync.routes.js";
const app = Fastify({ logger: true });
// Plugins
const config = getConfig();
await app.register(cors, {
origin: config.corsOrigins,
credentials: true,
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "Accept"]
});
await app.register(multipart);
const jwtSecret = process.env.AUTH_JWT_SECRET;
if (!jwtSecret) {
throw new Error("AUTH_JWT_SECRET is required");
}
await app.register(jwt, { secret: jwtSecret });
const pool = createPool();
app.decorate("pg", pool);
// Detect optional DB features (for backwards compatibility when migrations haven't run yet).
async function hasColumn(tableName, columnName) {
try {
const r = await app.pg.query(
"select 1 from information_schema.columns where table_schema=current_schema() and table_name=$1 and column_name=$2 limit 1",
[tableName, columnName]
);
return r.rowCount > 0;
} catch {
return false;
}
}
const folderSortOrderSupported = await hasColumn("bookmark_folders", "sort_order");
const bookmarkSortOrderSupported = await hasColumn("bookmarks", "sort_order");
app.decorate("features", {
folderSortOrder: folderSortOrderSupported,
bookmarkSortOrder: bookmarkSortOrderSupported
});
app.decorate("authenticate", async (req, reply) => {
try {
await req.jwtVerify();
} catch (err) {
reply.code(401);
throw err;
}
});
app.setErrorHandler((err, _req, reply) => {
const statusCode = err.statusCode || 500;
reply.code(statusCode).send({ message: err.message || "server error" });
});
app.get("/health", async () => ({ ok: true }));
// Routes
app.decorate("config", config);
await authRoutes(app);
await adminRoutes(app);
await foldersRoutes(app);
await bookmarksRoutes(app);
await importExportRoutes(app);
await syncRoutes(app);
app.addHook("onClose", async (instance) => {
await instance.pg.end();
});
const { serverPort } = config;
await app.listen({ port: serverPort, host: "0.0.0.0" });

View File

@@ -1,29 +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;
}
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

@@ -1,10 +1,10 @@
import bcrypt from "bcryptjs";
export async function hashPassword(password) {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
}
export async function verifyPassword(password, passwordHash) {
return bcrypt.compare(password, passwordHash);
}
import bcrypt from "bcryptjs";
export async function hashPassword(password) {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
}
export async function verifyPassword(password, passwordHash) {
return bcrypt.compare(password, passwordHash);
}

View File

@@ -1,125 +1,125 @@
import * as cheerio from "cheerio";
export function parseNetscapeBookmarkHtmlNode(html) {
const $ = cheerio.load(html, { decodeEntities: false });
const rootDl = $("dl").first();
if (!rootDl.length) return { folders: [], bookmarks: [] };
const folders = [];
const bookmarks = [];
function normText(s) {
return String(s || "").replace(/\s+/g, " ").trim();
}
function collectLevelDt(node) {
const out = [];
const children = $(node).contents().toArray();
for (const child of children) {
if (!child || child.type !== "tag") continue;
const tag = child.tagName?.toLowerCase();
if (tag === "dt") {
out.push(child);
continue;
}
if (tag === "dl") {
// nested list belongs to the previous <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 $h3 = $dt.children("h3").first().length ? $dt.children("h3").first() : $dt.find("h3").first();
const $a = $dt.children("a").first().length ? $dt.children("a").first() : $dt.find("a").first();
const $nestedDl = $dt.children("dl").first();
const $nextDl = $nestedDl.length ? $nestedDl : findNextDlForDt(node, $dl[0]);
if ($h3.length) {
const tempId = `${folders.length + 1}`;
const name = normText($h3.text() || "");
folders.push({ tempId, parentTempId: parentTempId ?? null, name });
if ($nextDl?.length) walkDl($nextDl, tempId);
} else if ($a.length) {
const title = normText($a.text() || "");
const url = $a.attr("href") || "";
bookmarks.push({ parentTempId: parentTempId ?? null, title, url });
}
}
}
walkDl(rootDl, null);
return { folders, bookmarks };
}
export function buildNetscapeBookmarkHtml({ folders, bookmarks }) {
// folders: [{id, parentId, name}]
// bookmarks: [{folderId, title, url}]
const folderChildren = new Map();
const bookmarkChildren = new Map();
for (const f of folders) {
const key = f.parentId ?? "root";
if (!folderChildren.has(key)) folderChildren.set(key, []);
folderChildren.get(key).push(f);
}
for (const b of bookmarks) {
const key = b.folderId ?? "root";
if (!bookmarkChildren.has(key)) bookmarkChildren.set(key, []);
bookmarkChildren.get(key).push(b);
}
function esc(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function renderFolder(parentId) {
const key = parentId ?? "root";
const subFolders = (folderChildren.get(key) || []).slice().sort((a, b) => a.name.localeCompare(b.name));
const subBookmarks = (bookmarkChildren.get(key) || []).slice().sort((a, b) => a.title.localeCompare(b.title));
let out = "<DL><p>\n";
for (const f of subFolders) {
out += ` <DT><H3>${esc(f.name)}</H3>\n`;
out += renderFolder(f.id)
.split("\n")
.map((line) => (line ? ` ${line}` : line))
.join("\n");
out += "\n";
}
for (const b of subBookmarks) {
out += ` <DT><A HREF=\"${esc(b.url)}\">${esc(b.title)}</A>\n`;
}
out += "</DL><p>";
return out;
}
const header = `<!DOCTYPE NETSCAPE-Bookmark-file-1>\n<!-- This is an automatically generated file. -->\n<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n<TITLE>Bookmarks</TITLE>\n<H1>Bookmarks</H1>\n`;
const body = renderFolder(null);
return header + body + "\n";
}
import * as cheerio from "cheerio";
export function parseNetscapeBookmarkHtmlNode(html) {
const $ = cheerio.load(html, { decodeEntities: false });
const rootDl = $("dl").first();
if (!rootDl.length) return { folders: [], bookmarks: [] };
const folders = [];
const bookmarks = [];
function normText(s) {
return String(s || "").replace(/\s+/g, " ").trim();
}
function collectLevelDt(node) {
const out = [];
const children = $(node).contents().toArray();
for (const child of children) {
if (!child || child.type !== "tag") continue;
const tag = child.tagName?.toLowerCase();
if (tag === "dt") {
out.push(child);
continue;
}
if (tag === "dl") {
// nested list belongs to the previous <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 $h3 = $dt.children("h3").first().length ? $dt.children("h3").first() : $dt.find("h3").first();
const $a = $dt.children("a").first().length ? $dt.children("a").first() : $dt.find("a").first();
const $nestedDl = $dt.children("dl").first();
const $nextDl = $nestedDl.length ? $nestedDl : findNextDlForDt(node, $dl[0]);
if ($h3.length) {
const tempId = `${folders.length + 1}`;
const name = normText($h3.text() || "");
folders.push({ tempId, parentTempId: parentTempId ?? null, name });
if ($nextDl?.length) walkDl($nextDl, tempId);
} else if ($a.length) {
const title = normText($a.text() || "");
const url = $a.attr("href") || "";
bookmarks.push({ parentTempId: parentTempId ?? null, title, url });
}
}
}
walkDl(rootDl, null);
return { folders, bookmarks };
}
export function buildNetscapeBookmarkHtml({ folders, bookmarks }) {
// folders: [{id, parentId, name}]
// bookmarks: [{folderId, title, url}]
const folderChildren = new Map();
const bookmarkChildren = new Map();
for (const f of folders) {
const key = f.parentId ?? "root";
if (!folderChildren.has(key)) folderChildren.set(key, []);
folderChildren.get(key).push(f);
}
for (const b of bookmarks) {
const key = b.folderId ?? "root";
if (!bookmarkChildren.has(key)) bookmarkChildren.set(key, []);
bookmarkChildren.get(key).push(b);
}
function esc(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function renderFolder(parentId) {
const key = parentId ?? "root";
const subFolders = (folderChildren.get(key) || []).slice().sort((a, b) => a.name.localeCompare(b.name));
const subBookmarks = (bookmarkChildren.get(key) || []).slice().sort((a, b) => a.title.localeCompare(b.title));
let out = "<DL><p>\n";
for (const f of subFolders) {
out += ` <DT><H3>${esc(f.name)}</H3>\n`;
out += renderFolder(f.id)
.split("\n")
.map((line) => (line ? ` ${line}` : line))
.join("\n");
out += "\n";
}
for (const b of subBookmarks) {
out += ` <DT><A HREF=\"${esc(b.url)}\">${esc(b.title)}</A>\n`;
}
out += "</DL><p>";
return out;
}
const header = `<!DOCTYPE NETSCAPE-Bookmark-file-1>\n<!-- This is an automatically generated file. -->\n<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n<TITLE>Bookmarks</TITLE>\n<H1>Bookmarks</H1>\n`;
const body = renderFolder(null);
return header + body + "\n";
}

View File

@@ -1,5 +1,5 @@
export function httpError(statusCode, message) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
export function httpError(statusCode, message) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}

View File

@@ -1,39 +1,39 @@
export function userRowToDto(row) {
return {
id: row.id,
email: row.email,
role: row.role,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function folderRowToDto(row) {
return {
id: row.id,
userId: row.user_id,
parentId: row.parent_id,
name: row.name,
visibility: row.visibility,
sortOrder: row.sort_order ?? 0,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function bookmarkRowToDto(row) {
return {
id: row.id,
userId: row.user_id,
folderId: row.folder_id,
sortOrder: row.sort_order ?? 0,
title: row.title,
url: row.url,
urlNormalized: row.url_normalized,
urlHash: row.url_hash,
visibility: row.visibility,
source: row.source,
updatedAt: row.updated_at,
deletedAt: row.deleted_at
};
}
export function userRowToDto(row) {
return {
id: row.id,
email: row.email,
role: row.role,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function folderRowToDto(row) {
return {
id: row.id,
userId: row.user_id,
parentId: row.parent_id,
name: row.name,
visibility: row.visibility,
sortOrder: row.sort_order ?? 0,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function bookmarkRowToDto(row) {
return {
id: row.id,
userId: row.user_id,
folderId: row.folder_id,
sortOrder: row.sort_order ?? 0,
title: row.title,
url: row.url,
urlNormalized: row.url_normalized,
urlHash: row.url_hash,
visibility: row.visibility,
source: row.source,
updatedAt: row.updated_at,
deletedAt: row.deleted_at
};
}

View File

@@ -1,61 +1,61 @@
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createPool } from "./db.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function ensureMigrationsTable(pool) {
await pool.query(`
create table if not exists schema_migrations (
id text primary key,
applied_at timestamptz not null default now()
);
`);
}
async function getApplied(pool) {
const res = await pool.query("select id from schema_migrations order by id");
return new Set(res.rows.map((r) => r.id));
}
async function applyMigration(pool, id, sql) {
await pool.query("begin");
try {
await pool.query(sql);
await pool.query("insert into schema_migrations (id) values ($1)", [id]);
await pool.query("commit");
// eslint-disable-next-line no-console
console.log(`[migrate] applied ${id}`);
} catch (err) {
await pool.query("rollback");
throw err;
}
}
async function main() {
const pool = createPool();
try {
await ensureMigrationsTable(pool);
const applied = await getApplied(pool);
const migrationsDir = path.resolve(__dirname, "..", "migrations");
const files = (await readdir(migrationsDir))
.filter((f) => f.endsWith(".sql"))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = await readFile(path.join(migrationsDir, file), "utf8");
await applyMigration(pool, file, sql);
}
// eslint-disable-next-line no-console
console.log("[migrate] done");
} finally {
await pool.end();
}
}
main();
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createPool } from "./db.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function ensureMigrationsTable(pool) {
await pool.query(`
create table if not exists schema_migrations (
id text primary key,
applied_at timestamptz not null default now()
);
`);
}
async function getApplied(pool) {
const res = await pool.query("select id from schema_migrations order by id");
return new Set(res.rows.map((r) => r.id));
}
async function applyMigration(pool, id, sql) {
await pool.query("begin");
try {
await pool.query(sql);
await pool.query("insert into schema_migrations (id) values ($1)", [id]);
await pool.query("commit");
// eslint-disable-next-line no-console
console.log(`[migrate] applied ${id}`);
} catch (err) {
await pool.query("rollback");
throw err;
}
}
async function main() {
const pool = createPool();
try {
await ensureMigrationsTable(pool);
const applied = await getApplied(pool);
const migrationsDir = path.resolve(__dirname, "..", "migrations");
const files = (await readdir(migrationsDir))
.filter((f) => f.endsWith(".sql"))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = await readFile(path.join(migrationsDir, file), "utf8");
await applyMigration(pool, file, sql);
}
// eslint-disable-next-line no-console
console.log("[migrate] done");
} finally {
await pool.end();
}
}
main();

View File

@@ -1,26 +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();
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

@@ -1,147 +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]);
}
);
}
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

@@ -1,74 +1,74 @@
import { hashPassword, verifyPassword } from "../lib/auth.js";
import { httpError } from "../lib/httpErrors.js";
import { userRowToDto } from "../lib/rows.js";
function normalizeEmail(email) {
return String(email || "").trim().toLowerCase();
}
function toUserDtoWithAdminOverride(app, row) {
const dto = userRowToDto(row);
const adminEmail = normalizeEmail(app.config?.adminEmail);
if (adminEmail && normalizeEmail(dto.email) === adminEmail) {
dto.role = "admin";
}
return dto;
}
export async function authRoutes(app) {
app.post("/auth/register", async (req) => {
const { email, password } = req.body || {};
if (!email || !password) throw httpError(400, "email and password required");
if (String(password).length < 8) throw httpError(400, "password too short");
const passwordHash = await hashPassword(password);
try {
const res = await app.pg.query(
"insert into users (email, password_hash) values ($1, $2) returning id, email, role, created_at, updated_at",
[email, passwordHash]
);
const user = toUserDtoWithAdminOverride(app, res.rows[0]);
const token = await app.jwt.sign({ sub: user.id, role: user.role });
return { token, user };
} catch (err) {
if (String(err?.code) === "23505") throw httpError(409, "email already exists");
throw err;
}
});
app.post("/auth/login", async (req) => {
const { email, password } = req.body || {};
if (!email || !password) throw httpError(400, "email and password required");
const res = await app.pg.query(
"select id, email, role, password_hash, created_at, updated_at from users where email=$1",
[email]
);
const row = res.rows[0];
if (!row) throw httpError(401, "invalid credentials");
const ok = await verifyPassword(password, row.password_hash);
if (!ok) throw httpError(401, "invalid credentials");
const user = userRowToDto(row);
const userWithRole = toUserDtoWithAdminOverride(app, row);
const token = await app.jwt.sign({ sub: userWithRole.id, role: userWithRole.role });
return { token, user: userWithRole };
});
app.get(
"/auth/me",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
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(404, "user not found");
return toUserDtoWithAdminOverride(app, row);
}
);
}
import { hashPassword, verifyPassword } from "../lib/auth.js";
import { httpError } from "../lib/httpErrors.js";
import { userRowToDto } from "../lib/rows.js";
function normalizeEmail(email) {
return String(email || "").trim().toLowerCase();
}
function toUserDtoWithAdminOverride(app, row) {
const dto = userRowToDto(row);
const adminEmail = normalizeEmail(app.config?.adminEmail);
if (adminEmail && normalizeEmail(dto.email) === adminEmail) {
dto.role = "admin";
}
return dto;
}
export async function authRoutes(app) {
app.post("/auth/register", async (req) => {
const { email, password } = req.body || {};
if (!email || !password) throw httpError(400, "email and password required");
if (String(password).length < 8) throw httpError(400, "password too short");
const passwordHash = await hashPassword(password);
try {
const res = await app.pg.query(
"insert into users (email, password_hash) values ($1, $2) returning id, email, role, created_at, updated_at",
[email, passwordHash]
);
const user = toUserDtoWithAdminOverride(app, res.rows[0]);
const token = await app.jwt.sign({ sub: user.id, role: user.role });
return { token, user };
} catch (err) {
if (String(err?.code) === "23505") throw httpError(409, "email already exists");
throw err;
}
});
app.post("/auth/login", async (req) => {
const { email, password } = req.body || {};
if (!email || !password) throw httpError(400, "email and password required");
const res = await app.pg.query(
"select id, email, role, password_hash, created_at, updated_at from users where email=$1",
[email]
);
const row = res.rows[0];
if (!row) throw httpError(401, "invalid credentials");
const ok = await verifyPassword(password, row.password_hash);
if (!ok) throw httpError(401, "invalid credentials");
const user = userRowToDto(row);
const userWithRole = toUserDtoWithAdminOverride(app, row);
const token = await app.jwt.sign({ sub: userWithRole.id, role: userWithRole.role });
return { token, user: userWithRole };
});
app.get(
"/auth/me",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
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(404, "user not found");
return toUserDtoWithAdminOverride(app, row);
}
);
}

View File

@@ -1,305 +1,305 @@
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
import { httpError } from "../lib/httpErrors.js";
import { bookmarkRowToDto } from "../lib/rows.js";
export async function bookmarksRoutes(app) {
app.get("/bookmarks/public", async (req) => {
const q = (req.query?.q || "").trim();
const params = [];
let where = "where visibility='public' and deleted_at is null";
if (q) {
params.push(`%${q}%`);
where += ` and (title ilike $${params.length} or url ilike $${params.length})`;
}
const res = await app.pg.query(
`select * from bookmarks ${where} order by updated_at desc limit 200`,
params
);
return res.rows.map(bookmarkRowToDto);
});
app.get(
"/bookmarks",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
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.post(
"/bookmarks",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const { folderId, title, url, visibility } = req.body || {};
if (!title) throw httpError(400, "title required");
if (!url) throw httpError(400, "url required");
if (!visibility) throw httpError(400, "visibility required");
const urlNormalized = normalizeUrl(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",
[userId, urlHash]
);
if (existing.rows[0]) {
// auto-merge
const targetFolderId = folderId ?? null;
const merged = app.features?.bookmarkSortOrder
? await app.pg.query(
`update bookmarks
set title=$1,
url=$2,
url_normalized=$3,
visibility=$4,
folder_id=$5,
sort_order = case
when folder_id is distinct from $5 then (
select coalesce(max(sort_order), -1) + 1
from bookmarks
where user_id=$7 and folder_id is not distinct from $5 and deleted_at is null
)
else sort_order
end,
source='manual',
updated_at=now()
where id=$6
returning *`,
[title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id, userId]
)
: await app.pg.query(
`update bookmarks
set title=$1, url=$2, url_normalized=$3, visibility=$4, folder_id=$5, source='manual', updated_at=now()
where id=$6
returning *`,
[title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id]
);
return bookmarkRowToDto(merged.rows[0]);
}
const targetFolderId = folderId ?? null;
const res = app.features?.bookmarkSortOrder
? await app.pg.query(
`insert into bookmarks (user_id, folder_id, sort_order, title, url, url_normalized, url_hash, visibility, source)
values (
$1,
$2,
(select coalesce(max(sort_order), -1) + 1 from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null),
$3,
$4,
$5,
$6,
$7,
'manual'
)
returning *`,
[userId, targetFolderId, title, url, urlNormalized, urlHash, visibility]
)
: await app.pg.query(
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
values ($1, $2, $3, $4, $5, $6, $7, 'manual')
returning *`,
[userId, targetFolderId, title, url, urlNormalized, urlHash, visibility]
);
return bookmarkRowToDto(res.rows[0]);
}
);
app.post(
"/bookmarks/reorder",
{ preHandler: [app.authenticate] },
async (req) => {
if (!app.features?.bookmarkSortOrder) {
throw httpError(
409,
"bookmark sort order is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const userId = req.user.sub;
const { folderId, orderedIds } = req.body || {};
const folder = folderId ?? null;
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
throw httpError(400, "orderedIds required");
}
const siblings = await app.pg.query(
"select id from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null",
[userId, folder]
);
const siblingIds = siblings.rows.map((r) => r.id);
const want = new Set(orderedIds);
if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique");
if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all bookmarks in the folder");
for (const id of siblingIds) {
if (!want.has(id)) throw httpError(400, "orderedIds must include all bookmarks in the folder");
}
await app.pg.query("begin");
try {
for (let i = 0; i < orderedIds.length; i++) {
await app.pg.query(
"update bookmarks set sort_order=$1, updated_at=now() where id=$2 and user_id=$3 and deleted_at is null",
[i, orderedIds[i], userId]
);
}
await app.pg.query("commit");
} catch (e) {
await app.pg.query("rollback");
throw e;
}
return { ok: true };
}
);
app.patch(
"/bookmarks/:id",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const id = req.params?.id;
const body = req.body || {};
const existingRes = await app.pg.query(
"select * from bookmarks where id=$1 and user_id=$2 and deleted_at is null",
[id, userId]
);
const existing = existingRes.rows[0];
if (!existing) throw httpError(404, "bookmark not found");
const sets = [];
const params = [];
let i = 1;
// url update implies url_normalized + url_hash update
let nextUrl = existing.url;
if (Object.prototype.hasOwnProperty.call(body, "url")) {
nextUrl = String(body.url || "").trim();
if (!nextUrl) throw httpError(400, "url required");
}
let urlNormalized = existing.url_normalized;
let urlHash = existing.url_hash;
const urlChanged = nextUrl !== existing.url;
if (urlChanged) {
urlNormalized = normalizeUrl(nextUrl);
urlHash = computeUrlHash(urlNormalized);
}
if (Object.prototype.hasOwnProperty.call(body, "title")) {
const title = String(body.title || "").trim();
if (!title) throw httpError(400, "title required");
sets.push(`title=$${i++}`);
params.push(title);
}
if (Object.prototype.hasOwnProperty.call(body, "folderId")) {
sets.push(`folder_id=$${i++}`);
params.push(body.folderId ?? null);
}
if (Object.prototype.hasOwnProperty.call(body, "visibility")) {
if (!body.visibility) throw httpError(400, "visibility required");
sets.push(`visibility=$${i++}`);
params.push(body.visibility);
}
if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) {
if (!app.features?.bookmarkSortOrder) {
throw httpError(
409,
"sortOrder is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const n = Number(body.sortOrder);
if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number");
sets.push(`sort_order=$${i++}`);
params.push(Math.trunc(n));
}
if (Object.prototype.hasOwnProperty.call(body, "url")) {
sets.push(`url=$${i++}`);
params.push(nextUrl);
sets.push(`url_normalized=$${i++}`);
params.push(urlNormalized);
sets.push(`url_hash=$${i++}`);
params.push(urlHash);
}
if (sets.length === 0) throw httpError(400, "no fields to update");
// If URL changed and collides with another bookmark, auto-merge by keeping the existing row.
if (urlChanged) {
const dup = await app.pg.query(
"select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null and id<>$3 limit 1",
[userId, urlHash, id]
);
if (dup.rows[0]) {
const targetId = dup.rows[0].id;
const merged = await app.pg.query(
`update bookmarks
set ${sets.join(", ")}, source='manual', updated_at=now()
where id=$${i++} and user_id=$${i}
returning *`,
[...params, targetId, userId]
);
await app.pg.query(
"update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2",
[id, userId]
);
return bookmarkRowToDto(merged.rows[0]);
}
}
params.push(id, userId);
const res = await app.pg.query(
`update bookmarks
set ${sets.join(", ")}, source='manual', updated_at=now()
where id=$${i++} and user_id=$${i}
returning *`,
params
);
return bookmarkRowToDto(res.rows[0]);
}
);
app.delete(
"/bookmarks/:id",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const id = req.params?.id;
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 *",
[id, userId]
);
if (!res.rows[0]) throw httpError(404, "bookmark not found");
return res.rows.map(bookmarkRowToDto)[0];
}
);
}
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
import { httpError } from "../lib/httpErrors.js";
import { bookmarkRowToDto } from "../lib/rows.js";
export async function bookmarksRoutes(app) {
app.get("/bookmarks/public", async (req) => {
const q = (req.query?.q || "").trim();
const params = [];
let where = "where visibility='public' and deleted_at is null";
if (q) {
params.push(`%${q}%`);
where += ` and (title ilike $${params.length} or url ilike $${params.length})`;
}
const res = await app.pg.query(
`select * from bookmarks ${where} order by updated_at desc limit 200`,
params
);
return res.rows.map(bookmarkRowToDto);
});
app.get(
"/bookmarks",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
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.post(
"/bookmarks",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const { folderId, title, url, visibility } = req.body || {};
if (!title) throw httpError(400, "title required");
if (!url) throw httpError(400, "url required");
if (!visibility) throw httpError(400, "visibility required");
const urlNormalized = normalizeUrl(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",
[userId, urlHash]
);
if (existing.rows[0]) {
// auto-merge
const targetFolderId = folderId ?? null;
const merged = app.features?.bookmarkSortOrder
? await app.pg.query(
`update bookmarks
set title=$1,
url=$2,
url_normalized=$3,
visibility=$4,
folder_id=$5,
sort_order = case
when folder_id is distinct from $5 then (
select coalesce(max(sort_order), -1) + 1
from bookmarks
where user_id=$7 and folder_id is not distinct from $5 and deleted_at is null
)
else sort_order
end,
source='manual',
updated_at=now()
where id=$6
returning *`,
[title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id, userId]
)
: await app.pg.query(
`update bookmarks
set title=$1, url=$2, url_normalized=$3, visibility=$4, folder_id=$5, source='manual', updated_at=now()
where id=$6
returning *`,
[title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id]
);
return bookmarkRowToDto(merged.rows[0]);
}
const targetFolderId = folderId ?? null;
const res = app.features?.bookmarkSortOrder
? await app.pg.query(
`insert into bookmarks (user_id, folder_id, sort_order, title, url, url_normalized, url_hash, visibility, source)
values (
$1,
$2,
(select coalesce(max(sort_order), -1) + 1 from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null),
$3,
$4,
$5,
$6,
$7,
'manual'
)
returning *`,
[userId, targetFolderId, title, url, urlNormalized, urlHash, visibility]
)
: await app.pg.query(
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
values ($1, $2, $3, $4, $5, $6, $7, 'manual')
returning *`,
[userId, targetFolderId, title, url, urlNormalized, urlHash, visibility]
);
return bookmarkRowToDto(res.rows[0]);
}
);
app.post(
"/bookmarks/reorder",
{ preHandler: [app.authenticate] },
async (req) => {
if (!app.features?.bookmarkSortOrder) {
throw httpError(
409,
"bookmark sort order is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const userId = req.user.sub;
const { folderId, orderedIds } = req.body || {};
const folder = folderId ?? null;
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
throw httpError(400, "orderedIds required");
}
const siblings = await app.pg.query(
"select id from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null",
[userId, folder]
);
const siblingIds = siblings.rows.map((r) => r.id);
const want = new Set(orderedIds);
if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique");
if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all bookmarks in the folder");
for (const id of siblingIds) {
if (!want.has(id)) throw httpError(400, "orderedIds must include all bookmarks in the folder");
}
await app.pg.query("begin");
try {
for (let i = 0; i < orderedIds.length; i++) {
await app.pg.query(
"update bookmarks set sort_order=$1, updated_at=now() where id=$2 and user_id=$3 and deleted_at is null",
[i, orderedIds[i], userId]
);
}
await app.pg.query("commit");
} catch (e) {
await app.pg.query("rollback");
throw e;
}
return { ok: true };
}
);
app.patch(
"/bookmarks/:id",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const id = req.params?.id;
const body = req.body || {};
const existingRes = await app.pg.query(
"select * from bookmarks where id=$1 and user_id=$2 and deleted_at is null",
[id, userId]
);
const existing = existingRes.rows[0];
if (!existing) throw httpError(404, "bookmark not found");
const sets = [];
const params = [];
let i = 1;
// url update implies url_normalized + url_hash update
let nextUrl = existing.url;
if (Object.prototype.hasOwnProperty.call(body, "url")) {
nextUrl = String(body.url || "").trim();
if (!nextUrl) throw httpError(400, "url required");
}
let urlNormalized = existing.url_normalized;
let urlHash = existing.url_hash;
const urlChanged = nextUrl !== existing.url;
if (urlChanged) {
urlNormalized = normalizeUrl(nextUrl);
urlHash = computeUrlHash(urlNormalized);
}
if (Object.prototype.hasOwnProperty.call(body, "title")) {
const title = String(body.title || "").trim();
if (!title) throw httpError(400, "title required");
sets.push(`title=$${i++}`);
params.push(title);
}
if (Object.prototype.hasOwnProperty.call(body, "folderId")) {
sets.push(`folder_id=$${i++}`);
params.push(body.folderId ?? null);
}
if (Object.prototype.hasOwnProperty.call(body, "visibility")) {
if (!body.visibility) throw httpError(400, "visibility required");
sets.push(`visibility=$${i++}`);
params.push(body.visibility);
}
if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) {
if (!app.features?.bookmarkSortOrder) {
throw httpError(
409,
"sortOrder is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const n = Number(body.sortOrder);
if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number");
sets.push(`sort_order=$${i++}`);
params.push(Math.trunc(n));
}
if (Object.prototype.hasOwnProperty.call(body, "url")) {
sets.push(`url=$${i++}`);
params.push(nextUrl);
sets.push(`url_normalized=$${i++}`);
params.push(urlNormalized);
sets.push(`url_hash=$${i++}`);
params.push(urlHash);
}
if (sets.length === 0) throw httpError(400, "no fields to update");
// If URL changed and collides with another bookmark, auto-merge by keeping the existing row.
if (urlChanged) {
const dup = await app.pg.query(
"select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null and id<>$3 limit 1",
[userId, urlHash, id]
);
if (dup.rows[0]) {
const targetId = dup.rows[0].id;
const merged = await app.pg.query(
`update bookmarks
set ${sets.join(", ")}, source='manual', updated_at=now()
where id=$${i++} and user_id=$${i}
returning *`,
[...params, targetId, userId]
);
await app.pg.query(
"update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2",
[id, userId]
);
return bookmarkRowToDto(merged.rows[0]);
}
}
params.push(id, userId);
const res = await app.pg.query(
`update bookmarks
set ${sets.join(", ")}, source='manual', updated_at=now()
where id=$${i++} and user_id=$${i}
returning *`,
params
);
return bookmarkRowToDto(res.rows[0]);
}
);
app.delete(
"/bookmarks/:id",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const id = req.params?.id;
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 *",
[id, userId]
);
if (!res.rows[0]) throw httpError(404, "bookmark not found");
return res.rows.map(bookmarkRowToDto)[0];
}
);
}

View File

@@ -1,199 +1,199 @@
import { httpError } from "../lib/httpErrors.js";
import { folderRowToDto } from "../lib/rows.js";
export async function foldersRoutes(app) {
app.get(
"/folders",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const orderBy = app.features?.folderSortOrder
? "parent_id nulls first, sort_order asc, name asc"
: "parent_id nulls first, name asc";
const res = await app.pg.query(
`select * from bookmark_folders where user_id=$1 order by ${orderBy}`,
[userId]
);
return res.rows.map(folderRowToDto);
}
);
app.post(
"/folders",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const { parentId, name, visibility } = req.body || {};
await app.pg.query("begin");
try {
// Move bookmarks in this folder back to root (so they remain visible).
await app.pg.query(
"update bookmarks set folder_id=null, updated_at=now() where user_id=$1 and folder_id=$2 and deleted_at is null",
[userId, id]
);
// Lift child folders to root.
await app.pg.query(
"update bookmark_folders set parent_id=null, updated_at=now() where user_id=$1 and parent_id=$2",
[userId, id]
);
const res = await app.pg.query(
"delete from bookmark_folders where id=$1 and user_id=$2 returning id",
[id, userId]
);
if (!res.rows[0]) throw httpError(404, "folder not found");
await app.pg.query("commit");
} catch (e) {
await app.pg.query("rollback");
throw e;
}
return { ok: true };
const res = app.features?.folderSortOrder
? await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order)
values (
$1,
$2,
$3,
$4,
(select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is not distinct from $2)
)
returning *`,
[userId, parent, name, visibility]
)
: await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, $2, $3, $4)
returning *`,
[userId, parent, name, visibility]
);
return folderRowToDto(res.rows[0]);
}
);
app.post(
"/folders/reorder",
{ preHandler: [app.authenticate] },
async (req) => {
if (!app.features?.folderSortOrder) {
throw httpError(
409,
"folder sort order is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const userId = req.user.sub;
const { parentId, orderedIds } = req.body || {};
const parent = parentId ?? null;
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
throw httpError(400, "orderedIds required");
}
const siblings = await app.pg.query(
"select id from bookmark_folders where user_id=$1 and parent_id is not distinct from $2",
[userId, parent]
);
const siblingIds = siblings.rows.map((r) => r.id);
// ensure same set
const want = new Set(orderedIds);
if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique");
if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all sibling folders");
for (const id of siblingIds) {
if (!want.has(id)) throw httpError(400, "orderedIds must include all sibling folders");
}
await app.pg.query("begin");
try {
for (let i = 0; i < orderedIds.length; i++) {
await app.pg.query(
"update bookmark_folders set sort_order=$1, updated_at=now() where id=$2 and user_id=$3",
[i, orderedIds[i], userId]
);
}
await app.pg.query("commit");
} catch (e) {
await app.pg.query("rollback");
throw e;
}
return { ok: true };
}
);
app.patch(
"/folders/:id",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const id = req.params?.id;
const body = req.body || {};
const existing = await app.pg.query(
"select * from bookmark_folders where id=$1 and user_id=$2",
[id, userId]
);
if (!existing.rows[0]) throw httpError(404, "folder not found");
const sets = [];
const params = [];
let i = 1;
if (Object.prototype.hasOwnProperty.call(body, "parentId")) {
sets.push(`parent_id=$${i++}`);
params.push(body.parentId ?? null);
}
if (Object.prototype.hasOwnProperty.call(body, "name")) {
const name = String(body.name || "").trim();
if (!name) throw httpError(400, "name required");
sets.push(`name=$${i++}`);
params.push(name);
}
if (Object.prototype.hasOwnProperty.call(body, "visibility")) {
if (!body.visibility) throw httpError(400, "visibility required");
sets.push(`visibility=$${i++}`);
params.push(body.visibility);
}
if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) {
if (!app.features?.folderSortOrder) {
throw httpError(
409,
"sortOrder is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const n = Number(body.sortOrder);
if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number");
sets.push(`sort_order=$${i++}`);
params.push(Math.trunc(n));
}
if (sets.length === 0) throw httpError(400, "no fields to update");
params.push(id, userId);
const res = await app.pg.query(
`update bookmark_folders set ${sets.join(", ")}, updated_at=now() where id=$${i++} and user_id=$${i} returning *`,
params
);
return folderRowToDto(res.rows[0]);
}
);
app.delete(
"/folders/:id",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const id = req.params?.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");
return { ok: true };
}
);
}
import { httpError } from "../lib/httpErrors.js";
import { folderRowToDto } from "../lib/rows.js";
export async function foldersRoutes(app) {
app.get(
"/folders",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const orderBy = app.features?.folderSortOrder
? "parent_id nulls first, sort_order asc, name asc"
: "parent_id nulls first, name asc";
const res = await app.pg.query(
`select * from bookmark_folders where user_id=$1 order by ${orderBy}`,
[userId]
);
return res.rows.map(folderRowToDto);
}
);
app.post(
"/folders",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const { parentId, name, visibility } = req.body || {};
await app.pg.query("begin");
try {
// Move bookmarks in this folder back to root (so they remain visible).
await app.pg.query(
"update bookmarks set folder_id=null, updated_at=now() where user_id=$1 and folder_id=$2 and deleted_at is null",
[userId, id]
);
// Lift child folders to root.
await app.pg.query(
"update bookmark_folders set parent_id=null, updated_at=now() where user_id=$1 and parent_id=$2",
[userId, id]
);
const res = await app.pg.query(
"delete from bookmark_folders where id=$1 and user_id=$2 returning id",
[id, userId]
);
if (!res.rows[0]) throw httpError(404, "folder not found");
await app.pg.query("commit");
} catch (e) {
await app.pg.query("rollback");
throw e;
}
return { ok: true };
const res = app.features?.folderSortOrder
? await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order)
values (
$1,
$2,
$3,
$4,
(select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is not distinct from $2)
)
returning *`,
[userId, parent, name, visibility]
)
: await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, $2, $3, $4)
returning *`,
[userId, parent, name, visibility]
);
return folderRowToDto(res.rows[0]);
}
);
app.post(
"/folders/reorder",
{ preHandler: [app.authenticate] },
async (req) => {
if (!app.features?.folderSortOrder) {
throw httpError(
409,
"folder sort order is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const userId = req.user.sub;
const { parentId, orderedIds } = req.body || {};
const parent = parentId ?? null;
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
throw httpError(400, "orderedIds required");
}
const siblings = await app.pg.query(
"select id from bookmark_folders where user_id=$1 and parent_id is not distinct from $2",
[userId, parent]
);
const siblingIds = siblings.rows.map((r) => r.id);
// ensure same set
const want = new Set(orderedIds);
if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique");
if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all sibling folders");
for (const id of siblingIds) {
if (!want.has(id)) throw httpError(400, "orderedIds must include all sibling folders");
}
await app.pg.query("begin");
try {
for (let i = 0; i < orderedIds.length; i++) {
await app.pg.query(
"update bookmark_folders set sort_order=$1, updated_at=now() where id=$2 and user_id=$3",
[i, orderedIds[i], userId]
);
}
await app.pg.query("commit");
} catch (e) {
await app.pg.query("rollback");
throw e;
}
return { ok: true };
}
);
app.patch(
"/folders/:id",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const id = req.params?.id;
const body = req.body || {};
const existing = await app.pg.query(
"select * from bookmark_folders where id=$1 and user_id=$2",
[id, userId]
);
if (!existing.rows[0]) throw httpError(404, "folder not found");
const sets = [];
const params = [];
let i = 1;
if (Object.prototype.hasOwnProperty.call(body, "parentId")) {
sets.push(`parent_id=$${i++}`);
params.push(body.parentId ?? null);
}
if (Object.prototype.hasOwnProperty.call(body, "name")) {
const name = String(body.name || "").trim();
if (!name) throw httpError(400, "name required");
sets.push(`name=$${i++}`);
params.push(name);
}
if (Object.prototype.hasOwnProperty.call(body, "visibility")) {
if (!body.visibility) throw httpError(400, "visibility required");
sets.push(`visibility=$${i++}`);
params.push(body.visibility);
}
if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) {
if (!app.features?.folderSortOrder) {
throw httpError(
409,
"sortOrder is not supported by current database schema. Please run server migrations (db:migrate)."
);
}
const n = Number(body.sortOrder);
if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number");
sets.push(`sort_order=$${i++}`);
params.push(Math.trunc(n));
}
if (sets.length === 0) throw httpError(400, "no fields to update");
params.push(id, userId);
const res = await app.pg.query(
`update bookmark_folders set ${sets.join(", ")}, updated_at=now() where id=$${i++} and user_id=$${i} returning *`,
params
);
return folderRowToDto(res.rows[0]);
}
);
app.delete(
"/folders/:id",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const id = req.params?.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");
return { ok: true };
}
);
}

View File

@@ -1,131 +1,131 @@
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
import { parseNetscapeBookmarkHtmlNode, buildNetscapeBookmarkHtml } from "../lib/bookmarkHtmlNode.js";
export async function importExportRoutes(app) {
app.post(
"/bookmarks/import/html",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const file = await req.file();
if (!file) return { imported: 0, merged: 0 };
const chunks = [];
for await (const c of file.file) chunks.push(c);
const html = Buffer.concat(chunks).toString("utf8");
const parsed = parseNetscapeBookmarkHtmlNode(html);
// Flatten folders (no nesting): dedupe/merge by folder name for this user.
const normName = (s) => String(s || "").replace(/\s+/g, " ").trim().toLowerCase();
const existingFolders = await app.pg.query(
"select id, name from bookmark_folders where user_id=$1",
[userId]
);
const folderIdByName = new Map(
existingFolders.rows.map((r) => [normName(r.name), r.id])
);
const tempIdToFolderName = new Map(
(parsed.folders || []).map((f) => [f.tempId, f.name])
);
const tempToDbId = new Map();
for (const f of parsed.folders || []) {
const key = normName(f.name);
if (!key) continue;
let id = folderIdByName.get(key);
if (!id) {
const res = app.features?.folderSortOrder
? await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order)
values (
$1,
null,
$2,
'private',
(select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is null)
)
returning id`,
[userId, f.name]
)
: await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, null, $2, 'private')
returning id`,
[userId, f.name]
);
id = res.rows[0].id;
folderIdByName.set(key, id);
}
tempToDbId.set(f.tempId, id);
}
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks) {
// Map bookmark's folder via folder name (flattened).
let folderId = null;
if (b.parentTempId) {
const fname = tempIdToFolderName.get(b.parentTempId);
const key = normName(fname);
folderId = key ? (folderIdByName.get(key) || tempToDbId.get(b.parentTempId) || null) : null;
}
const urlNormalized = normalizeUrl(b.url);
const urlHash = computeUrlHash(urlNormalized);
const existing = await app.pg.query(
"select id from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1",
[userId, urlHash]
);
if (existing.rows[0]) {
await app.pg.query(
`update bookmarks
set title=$1, url=$2, url_normalized=$3, folder_id=$4, source='import', updated_at=now()
where id=$5`,
[b.title || "", b.url || "", urlNormalized, folderId, existing.rows[0].id]
);
merged++;
} else {
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, 'private', 'import')`,
[userId, folderId, b.title || "", b.url || "", urlNormalized, urlHash]
);
imported++;
}
}
return { imported, merged };
}
);
app.get(
"/bookmarks/export/html",
{ preHandler: [app.authenticate] },
async (req, reply) => {
const userId = req.user.sub;
const folders = await app.pg.query(
"select id, parent_id, name from bookmark_folders where user_id=$1 order by name",
[userId]
);
const bookmarks = await app.pg.query(
"select folder_id, title, url from bookmarks where user_id=$1 and deleted_at is null order by title",
[userId]
);
const html = buildNetscapeBookmarkHtml({
folders: folders.rows.map((r) => ({ id: r.id, parentId: r.parent_id, name: r.name })),
bookmarks: bookmarks.rows.map((r) => ({ folderId: r.folder_id, title: r.title, url: r.url }))
});
reply.type("text/html; charset=utf-8");
return html;
}
);
}
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
import { parseNetscapeBookmarkHtmlNode, buildNetscapeBookmarkHtml } from "../lib/bookmarkHtmlNode.js";
export async function importExportRoutes(app) {
app.post(
"/bookmarks/import/html",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const file = await req.file();
if (!file) return { imported: 0, merged: 0 };
const chunks = [];
for await (const c of file.file) chunks.push(c);
const html = Buffer.concat(chunks).toString("utf8");
const parsed = parseNetscapeBookmarkHtmlNode(html);
// Flatten folders (no nesting): dedupe/merge by folder name for this user.
const normName = (s) => String(s || "").replace(/\s+/g, " ").trim().toLowerCase();
const existingFolders = await app.pg.query(
"select id, name from bookmark_folders where user_id=$1",
[userId]
);
const folderIdByName = new Map(
existingFolders.rows.map((r) => [normName(r.name), r.id])
);
const tempIdToFolderName = new Map(
(parsed.folders || []).map((f) => [f.tempId, f.name])
);
const tempToDbId = new Map();
for (const f of parsed.folders || []) {
const key = normName(f.name);
if (!key) continue;
let id = folderIdByName.get(key);
if (!id) {
const res = app.features?.folderSortOrder
? await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order)
values (
$1,
null,
$2,
'private',
(select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is null)
)
returning id`,
[userId, f.name]
)
: await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, null, $2, 'private')
returning id`,
[userId, f.name]
);
id = res.rows[0].id;
folderIdByName.set(key, id);
}
tempToDbId.set(f.tempId, id);
}
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks) {
// Map bookmark's folder via folder name (flattened).
let folderId = null;
if (b.parentTempId) {
const fname = tempIdToFolderName.get(b.parentTempId);
const key = normName(fname);
folderId = key ? (folderIdByName.get(key) || tempToDbId.get(b.parentTempId) || null) : null;
}
const urlNormalized = normalizeUrl(b.url);
const urlHash = computeUrlHash(urlNormalized);
const existing = await app.pg.query(
"select id from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1",
[userId, urlHash]
);
if (existing.rows[0]) {
await app.pg.query(
`update bookmarks
set title=$1, url=$2, url_normalized=$3, folder_id=$4, source='import', updated_at=now()
where id=$5`,
[b.title || "", b.url || "", urlNormalized, folderId, existing.rows[0].id]
);
merged++;
} else {
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, 'private', 'import')`,
[userId, folderId, b.title || "", b.url || "", urlNormalized, urlHash]
);
imported++;
}
}
return { imported, merged };
}
);
app.get(
"/bookmarks/export/html",
{ preHandler: [app.authenticate] },
async (req, reply) => {
const userId = req.user.sub;
const folders = await app.pg.query(
"select id, parent_id, name from bookmark_folders where user_id=$1 order by name",
[userId]
);
const bookmarks = await app.pg.query(
"select folder_id, title, url from bookmarks where user_id=$1 and deleted_at is null order by title",
[userId]
);
const html = buildNetscapeBookmarkHtml({
folders: folders.rows.map((r) => ({ id: r.id, parentId: r.parent_id, name: r.name })),
bookmarks: bookmarks.rows.map((r) => ({ folderId: r.folder_id, title: r.title, url: r.url }))
});
reply.type("text/html; charset=utf-8");
return html;
}
);
}

View File

@@ -1,162 +1,162 @@
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
function toDate(v) {
if (!v) return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
export async function syncRoutes(app) {
app.post(
"/sync/push",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const { bookmarks = [], folders = [] } = req.body || {};
// folders: upsert by id with LWW
for (const f of folders) {
const incomingUpdatedAt = toDate(f.updatedAt) || new Date();
const existing = await app.pg.query(
"select id, updated_at from bookmark_folders where id=$1 and user_id=$2",
[f.id, userId]
);
if (!existing.rows[0]) {
await app.pg.query(
`insert into bookmark_folders (id, user_id, parent_id, name, visibility, updated_at)
values ($1, $2, $3, $4, $5, $6)`,
[f.id, userId, f.parentId ?? null, f.name || "", f.visibility || "private", incomingUpdatedAt]
);
} else {
const serverUpdatedAt = new Date(existing.rows[0].updated_at);
if (incomingUpdatedAt > serverUpdatedAt) {
await app.pg.query(
`update bookmark_folders
set parent_id=$1, name=$2, visibility=$3, updated_at=$4
where id=$5 and user_id=$6`,
[f.parentId ?? null, f.name || "", f.visibility || "private", incomingUpdatedAt, f.id, userId]
);
}
}
}
// bookmarks: upsert by id with LWW; keep urlHash normalized
for (const b of bookmarks) {
const incomingUpdatedAt = toDate(b.updatedAt) || new Date();
const incomingDeletedAt = toDate(b.deletedAt);
const urlNormalized = normalizeUrl(b.url || "");
const urlHash = computeUrlHash(urlNormalized);
const existing = await app.pg.query(
"select id, updated_at from bookmarks where id=$1 and user_id=$2",
[b.id, userId]
);
if (!existing.rows[0]) {
await app.pg.query(
`insert into bookmarks (
id, user_id, folder_id, title, url, url_normalized, url_hash, visibility, source, updated_at, deleted_at
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
[
b.id,
userId,
b.folderId ?? null,
b.title || "",
b.url || "",
urlNormalized,
urlHash,
b.visibility || "private",
b.source || "manual",
incomingUpdatedAt,
incomingDeletedAt
]
);
} else {
const serverUpdatedAt = new Date(existing.rows[0].updated_at);
if (incomingUpdatedAt > serverUpdatedAt) {
await app.pg.query(
`update bookmarks
set folder_id=$1, title=$2, url=$3, url_normalized=$4, url_hash=$5, visibility=$6, source=$7, updated_at=$8, deleted_at=$9
where id=$10 and user_id=$11`,
[
b.folderId ?? null,
b.title || "",
b.url || "",
urlNormalized,
urlHash,
b.visibility || "private",
b.source || "manual",
incomingUpdatedAt,
incomingDeletedAt,
b.id,
userId
]
);
}
}
}
return { ok: true };
}
);
app.get(
"/sync/pull",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const since = toDate(req.query?.since);
const paramsFolders = [userId];
let whereFolders = "where user_id=$1";
if (since) {
paramsFolders.push(since);
whereFolders += ` and updated_at > $${paramsFolders.length}`;
}
const paramsBookmarks = [userId];
let whereBookmarks = "where user_id=$1";
if (since) {
paramsBookmarks.push(since);
whereBookmarks += ` and updated_at > $${paramsBookmarks.length}`;
}
const foldersRes = await app.pg.query(
`select id, user_id, parent_id, name, visibility, created_at, updated_at from bookmark_folders ${whereFolders}`,
paramsFolders
);
const bookmarksRes = await app.pg.query(
`select id, user_id, folder_id, title, url, url_normalized, url_hash, visibility, source, updated_at, deleted_at from bookmarks ${whereBookmarks}`,
paramsBookmarks
);
return {
folders: foldersRes.rows.map((r) => ({
id: r.id,
userId: r.user_id,
parentId: r.parent_id,
name: r.name,
visibility: r.visibility,
createdAt: r.created_at,
updatedAt: r.updated_at
})),
bookmarks: bookmarksRes.rows.map((r) => ({
id: r.id,
userId: r.user_id,
folderId: r.folder_id,
title: r.title,
url: r.url,
urlNormalized: r.url_normalized,
urlHash: r.url_hash,
visibility: r.visibility,
source: r.source,
updatedAt: r.updated_at,
deletedAt: r.deleted_at
})),
serverTime: new Date().toISOString()
};
}
);
}
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
function toDate(v) {
if (!v) return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
export async function syncRoutes(app) {
app.post(
"/sync/push",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const { bookmarks = [], folders = [] } = req.body || {};
// folders: upsert by id with LWW
for (const f of folders) {
const incomingUpdatedAt = toDate(f.updatedAt) || new Date();
const existing = await app.pg.query(
"select id, updated_at from bookmark_folders where id=$1 and user_id=$2",
[f.id, userId]
);
if (!existing.rows[0]) {
await app.pg.query(
`insert into bookmark_folders (id, user_id, parent_id, name, visibility, updated_at)
values ($1, $2, $3, $4, $5, $6)`,
[f.id, userId, f.parentId ?? null, f.name || "", f.visibility || "private", incomingUpdatedAt]
);
} else {
const serverUpdatedAt = new Date(existing.rows[0].updated_at);
if (incomingUpdatedAt > serverUpdatedAt) {
await app.pg.query(
`update bookmark_folders
set parent_id=$1, name=$2, visibility=$3, updated_at=$4
where id=$5 and user_id=$6`,
[f.parentId ?? null, f.name || "", f.visibility || "private", incomingUpdatedAt, f.id, userId]
);
}
}
}
// bookmarks: upsert by id with LWW; keep urlHash normalized
for (const b of bookmarks) {
const incomingUpdatedAt = toDate(b.updatedAt) || new Date();
const incomingDeletedAt = toDate(b.deletedAt);
const urlNormalized = normalizeUrl(b.url || "");
const urlHash = computeUrlHash(urlNormalized);
const existing = await app.pg.query(
"select id, updated_at from bookmarks where id=$1 and user_id=$2",
[b.id, userId]
);
if (!existing.rows[0]) {
await app.pg.query(
`insert into bookmarks (
id, user_id, folder_id, title, url, url_normalized, url_hash, visibility, source, updated_at, deleted_at
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
[
b.id,
userId,
b.folderId ?? null,
b.title || "",
b.url || "",
urlNormalized,
urlHash,
b.visibility || "private",
b.source || "manual",
incomingUpdatedAt,
incomingDeletedAt
]
);
} else {
const serverUpdatedAt = new Date(existing.rows[0].updated_at);
if (incomingUpdatedAt > serverUpdatedAt) {
await app.pg.query(
`update bookmarks
set folder_id=$1, title=$2, url=$3, url_normalized=$4, url_hash=$5, visibility=$6, source=$7, updated_at=$8, deleted_at=$9
where id=$10 and user_id=$11`,
[
b.folderId ?? null,
b.title || "",
b.url || "",
urlNormalized,
urlHash,
b.visibility || "private",
b.source || "manual",
incomingUpdatedAt,
incomingDeletedAt,
b.id,
userId
]
);
}
}
}
return { ok: true };
}
);
app.get(
"/sync/pull",
{ preHandler: [app.authenticate] },
async (req) => {
const userId = req.user.sub;
const since = toDate(req.query?.since);
const paramsFolders = [userId];
let whereFolders = "where user_id=$1";
if (since) {
paramsFolders.push(since);
whereFolders += ` and updated_at > $${paramsFolders.length}`;
}
const paramsBookmarks = [userId];
let whereBookmarks = "where user_id=$1";
if (since) {
paramsBookmarks.push(since);
whereBookmarks += ` and updated_at > $${paramsBookmarks.length}`;
}
const foldersRes = await app.pg.query(
`select id, user_id, parent_id, name, visibility, created_at, updated_at from bookmark_folders ${whereFolders}`,
paramsFolders
);
const bookmarksRes = await app.pg.query(
`select id, user_id, folder_id, title, url, url_normalized, url_hash, visibility, source, updated_at, deleted_at from bookmarks ${whereBookmarks}`,
paramsBookmarks
);
return {
folders: foldersRes.rows.map((r) => ({
id: r.id,
userId: r.user_id,
parentId: r.parent_id,
name: r.name,
visibility: r.visibility,
createdAt: r.created_at,
updatedAt: r.updated_at
})),
bookmarks: bookmarksRes.rows.map((r) => ({
id: r.id,
userId: r.user_id,
folderId: r.folder_id,
title: r.title,
url: r.url,
urlNormalized: r.url_normalized,
urlHash: r.url_hash,
visibility: r.visibility,
source: r.source,
updatedAt: r.updated_at,
deletedAt: r.deleted_at
})),
serverTime: new Date().toISOString()
};
}
);
}

View File

@@ -1,12 +1,12 @@
export default [
{
files: ["**/*.js", "**/*.vue"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module"
},
rules: {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
];
export default [
{
files: ["**/*.js", "**/*.vue"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module"
},
rules: {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
];

View File

@@ -11,7 +11,7 @@
"lint": "eslint ."
},
"dependencies": {
"@browser-bookmark/shared": "0.1.0",
"@browser-bookmark/shared": "file:../../packages/shared",
"sortablejs": "^1.15.6",
"vue": "^3.5.24",
"vue-router": "^4.5.1"

View File

@@ -1,108 +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>
<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,153 +1,153 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({
modelValue: { type: [String, Number, Boolean, Object, null], default: null },
options: { type: Array, required: true },
disabled: { type: Boolean, default: false },
placeholder: { type: String, default: "请选择" },
size: { type: String, default: "md" } // md | sm
});
const emit = defineEmits(["update:modelValue"]);
const rootEl = ref(null);
const triggerEl = ref(null);
const menuEl = ref(null);
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 label = computed(() => selected.value?.label ?? "");
function close() {
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() {
if (props.disabled) return;
if (open.value) close();
else openMenu();
}
function choose(value, isDisabled) {
if (props.disabled || isDisabled) return;
emit("update:modelValue", value);
close();
}
function onKeydownTrigger(e) {
if (props.disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
if (e.key === "Escape") {
e.preventDefault();
close();
}
}
function onDocPointerDown(e) {
const el = rootEl.value;
const menu = menuEl.value;
if (!el) return;
if (el.contains(e.target)) return;
if (menu && menu.contains(e.target)) return;
close();
}
function onViewportChange() {
if (!open.value) return;
updateMenuPosition();
}
onMounted(() => {
document.addEventListener("pointerdown", onDocPointerDown);
window.addEventListener("resize", onViewportChange);
// capture scroll events from any scroll container
window.addEventListener("scroll", onViewportChange, true);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDocPointerDown);
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
});
</script>
<template>
<div
ref="rootEl"
class="bb-selectWrap"
:class="[
size === 'sm' ? 'bb-selectWrap--sm' : '',
open ? 'is-open' : ''
]"
>
<button
type="button"
class="bb-selectTrigger"
:disabled="disabled"
:aria-expanded="open ? 'true' : 'false'"
@click="toggle"
@keydown="onKeydownTrigger"
ref="triggerEl"
>
<span class="bb-selectValue" :class="!label ? 'bb-selectPlaceholder' : ''">
{{ label || placeholder }}
</span>
<span class="bb-selectChevron" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</button>
<teleport to="body">
<div
v-if="open"
ref="menuEl"
class="bb-selectMenu bb-selectMenu--portal"
role="listbox"
:style="menuStyle"
>
<button
v-for="(o, idx) in options"
: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>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({
modelValue: { type: [String, Number, Boolean, Object, null], default: null },
options: { type: Array, required: true },
disabled: { type: Boolean, default: false },
placeholder: { type: String, default: "请选择" },
size: { type: String, default: "md" } // md | sm
});
const emit = defineEmits(["update:modelValue"]);
const rootEl = ref(null);
const triggerEl = ref(null);
const menuEl = ref(null);
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 label = computed(() => selected.value?.label ?? "");
function close() {
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() {
if (props.disabled) return;
if (open.value) close();
else openMenu();
}
function choose(value, isDisabled) {
if (props.disabled || isDisabled) return;
emit("update:modelValue", value);
close();
}
function onKeydownTrigger(e) {
if (props.disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
if (e.key === "Escape") {
e.preventDefault();
close();
}
}
function onDocPointerDown(e) {
const el = rootEl.value;
const menu = menuEl.value;
if (!el) return;
if (el.contains(e.target)) return;
if (menu && menu.contains(e.target)) return;
close();
}
function onViewportChange() {
if (!open.value) return;
updateMenuPosition();
}
onMounted(() => {
document.addEventListener("pointerdown", onDocPointerDown);
window.addEventListener("resize", onViewportChange);
// capture scroll events from any scroll container
window.addEventListener("scroll", onViewportChange, true);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDocPointerDown);
window.removeEventListener("resize", onViewportChange);
window.removeEventListener("scroll", onViewportChange, true);
});
</script>
<template>
<div
ref="rootEl"
class="bb-selectWrap"
:class="[
size === 'sm' ? 'bb-selectWrap--sm' : '',
open ? 'is-open' : ''
]"
>
<button
type="button"
class="bb-selectTrigger"
:disabled="disabled"
:aria-expanded="open ? 'true' : 'false'"
@click="toggle"
@keydown="onKeydownTrigger"
ref="triggerEl"
>
<span class="bb-selectValue" :class="!label ? 'bb-selectPlaceholder' : ''">
{{ label || placeholder }}
</span>
<span class="bb-selectChevron" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</button>
<teleport to="body">
<div
v-if="open"
ref="menuEl"
class="bb-selectMenu bb-selectMenu--portal"
role="listbox"
:style="menuStyle"
>
<button
v-for="(o, idx) in options"
: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>
</template>

View File

@@ -1,90 +1,90 @@
import { ref } from "vue";
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
export const tokenRef = ref(localStorage.getItem("bb_token") || "");
export const userRef = ref(null);
let mePromise = null;
export function getToken() {
return tokenRef.value || "";
}
export function setToken(token) {
const next = token || "";
tokenRef.value = next;
if (next) localStorage.setItem("bb_token", next);
else localStorage.removeItem("bb_token");
// reset cached user when auth changes
userRef.value = null;
mePromise = null;
}
// Keep auth state in sync across tabs.
if (typeof window !== "undefined") {
window.addEventListener("storage", (e) => {
if (e.key === "bb_token") {
tokenRef.value = e.newValue || "";
userRef.value = null;
mePromise = null;
}
});
}
export async function ensureMe() {
const token = getToken();
if (!token) {
userRef.value = null;
mePromise = null;
return null;
}
if (userRef.value) return userRef.value;
if (mePromise) return mePromise;
mePromise = (async () => {
try {
const me = await apiFetch("/auth/me", { method: "GET" });
userRef.value = me;
return me;
} catch {
// token may be invalid/expired
userRef.value = null;
return null;
} finally {
mePromise = null;
}
})();
return mePromise;
}
export async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {});
if (!headers.has("Accept")) headers.set("Accept", "application/json");
if (!(options.body instanceof FormData) && options.body != null) {
headers.set("Content-Type", "application/json");
}
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
const contentType = res.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
if (!res.ok) {
const message = payload?.message || `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}
import { ref } from "vue";
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
export const tokenRef = ref(localStorage.getItem("bb_token") || "");
export const userRef = ref(null);
let mePromise = null;
export function getToken() {
return tokenRef.value || "";
}
export function setToken(token) {
const next = token || "";
tokenRef.value = next;
if (next) localStorage.setItem("bb_token", next);
else localStorage.removeItem("bb_token");
// reset cached user when auth changes
userRef.value = null;
mePromise = null;
}
// Keep auth state in sync across tabs.
if (typeof window !== "undefined") {
window.addEventListener("storage", (e) => {
if (e.key === "bb_token") {
tokenRef.value = e.newValue || "";
userRef.value = null;
mePromise = null;
}
});
}
export async function ensureMe() {
const token = getToken();
if (!token) {
userRef.value = null;
mePromise = null;
return null;
}
if (userRef.value) return userRef.value;
if (mePromise) return mePromise;
mePromise = (async () => {
try {
const me = await apiFetch("/auth/me", { method: "GET" });
userRef.value = me;
return me;
} catch {
// token may be invalid/expired
userRef.value = null;
return null;
} finally {
mePromise = null;
}
})();
return mePromise;
}
export async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {});
if (!headers.has("Accept")) headers.set("Accept", "application/json");
if (!(options.body instanceof FormData) && options.body != null) {
headers.set("Content-Type", "application/json");
}
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
const contentType = res.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
if (!res.ok) {
const message = payload?.message || `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.payload = payload;
throw err;
}
return payload;
}

View File

@@ -1,290 +1,290 @@
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
const KEY = "bb_local_state_v1";
function nowIso() {
return new Date().toISOString();
}
function ensureBookmarkHashes(bookmark) {
if (!bookmark) return bookmark;
const url = bookmark.url || "";
const urlNormalized = bookmark.urlNormalized || normalizeUrl(url);
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
bookmark.urlNormalized = urlNormalized;
bookmark.urlHash = urlHash;
return bookmark;
}
export function loadLocalState() {
try {
const parsed = JSON.parse(localStorage.getItem(KEY) || "") || { folders: [], bookmarks: [] };
parsed.folders = parsed.folders || [];
parsed.bookmarks = (parsed.bookmarks || []).map((b) => ensureBookmarkHashes(b));
return parsed;
} catch {
return { folders: [], bookmarks: [] };
}
}
export function saveLocalState(state) {
localStorage.setItem(KEY, JSON.stringify(state));
}
export function listLocalBookmarks({ includeDeleted = false } = {}) {
const state = loadLocalState();
const all = state.bookmarks || [];
return includeDeleted ? all : all.filter((b) => !b.deletedAt);
}
export function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
const state = loadLocalState();
const now = nowIso();
const urlNormalized = normalizeUrl(url || "");
const urlHash = computeUrlHash(urlNormalized);
// Dedupe: same urlHash and not deleted -> update LWW
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
if (existing) {
ensureBookmarkHashes(existing);
existing.title = title || existing.title;
existing.url = url || existing.url;
existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash;
existing.visibility = visibility;
existing.folderId = folderId;
existing.source = source;
existing.updatedAt = now;
saveLocalState(state);
return existing;
}
const bookmark = {
id: crypto.randomUUID(),
userId: null,
folderId,
title,
url,
urlNormalized,
urlHash,
visibility,
source,
updatedAt: now,
deletedAt: null
};
state.bookmarks.unshift(bookmark);
saveLocalState(state);
return bookmark;
}
export function markLocalDeleted(id) {
const state = loadLocalState();
const now = nowIso();
const item = state.bookmarks.find((b) => b.id === id);
if (item) {
item.deletedAt = now;
item.updatedAt = now;
saveLocalState(state);
}
}
export function patchLocalBookmark(id, patch) {
const state = loadLocalState();
const now = nowIso();
const item = state.bookmarks.find((b) => b.id === id);
if (!item) return null;
if (patch.url !== undefined) {
const nextUrl = patch.url || "";
const nextNormalized = normalizeUrl(nextUrl);
const nextHash = computeUrlHash(nextNormalized);
const target = (state.bookmarks || []).find((b) => !b.deletedAt && b.id !== id && (b.urlHash || "") === nextHash);
if (target) {
// Merge: write changes to target and soft-delete current
ensureBookmarkHashes(target);
target.title = patch.title !== undefined ? patch.title : item.title;
target.url = nextUrl;
target.urlNormalized = nextNormalized;
target.urlHash = nextHash;
if (patch.folderId !== undefined) target.folderId = patch.folderId;
if (patch.visibility !== undefined) target.visibility = patch.visibility;
target.updatedAt = now;
item.deletedAt = now;
item.updatedAt = now;
saveLocalState(state);
return target;
}
item.url = nextUrl;
item.urlNormalized = nextNormalized;
item.urlHash = nextHash;
}
if (patch.title !== undefined) item.title = patch.title;
if (patch.folderId !== undefined) item.folderId = patch.folderId;
if (patch.visibility !== undefined) item.visibility = patch.visibility;
item.updatedAt = now;
saveLocalState(state);
return item;
}
export function deleteLocalFolder(folderId) {
const state = loadLocalState();
const now = nowIso();
state.folders = (state.folders || []).filter((f) => f.id !== folderId);
state.bookmarks = (state.bookmarks || []).map((b) => {
if (b.deletedAt) return b;
if ((b.folderId ?? null) !== (folderId ?? null)) return b;
return { ...ensureBookmarkHashes(b), folderId: null, updatedAt: now };
});
saveLocalState(state);
}
export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
const parsed = parseNetscapeBookmarkHtml(html || "");
const state = loadLocalState();
const now = nowIso();
state.folders = state.folders || [];
state.bookmarks = state.bookmarks || [];
// Flatten folders (no nesting): dedupe local folders by name.
const normName = (s) => String(s || "").replace(/\s+/g, " ").trim();
const normKey = (s) => normName(s).toLowerCase();
const existingFolderByName = new Map(
(state.folders || [])
.filter((f) => !f.deletedAt)
.map((f) => [normKey(f.name), f])
);
const tempIdToFolderName = new Map(
(parsed.folders || []).map((f) => [String(f.id), f.name])
);
const folderIdByName = new Map(
(state.folders || [])
.filter((f) => !f.deletedAt)
.map((f) => [normKey(f.name), f.id])
);
for (const f of parsed.folders || []) {
const name = normName(f.name);
const key = normKey(name);
if (!key) continue;
let id = folderIdByName.get(key);
if (!id) {
const created = {
id: crypto.randomUUID(),
userId: null,
parentId: null,
name,
visibility: "private",
sortOrder: (state.folders || []).filter((x) => !x.deletedAt && (x.parentId ?? null) === null).length,
updatedAt: now,
deletedAt: null
};
state.folders.push(created);
existingFolderByName.set(key, created);
folderIdByName.set(key, created.id);
id = created.id;
}
}
const existingByHash = new Map(
(state.bookmarks || [])
.filter((b) => !b.deletedAt)
.map((b) => {
const fixed = ensureBookmarkHashes(b);
return [fixed.urlHash, fixed];
})
);
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks || []) {
const url = (b.url || "").trim();
if (!url) continue;
const title = (b.title || "").trim() || url;
const folderTempId = b.parentFolderId ?? null;
const folderName = folderTempId ? tempIdToFolderName.get(String(folderTempId)) : null;
const folderId = folderName ? (folderIdByName.get(normKey(folderName)) ?? null) : null;
const urlNormalized = normalizeUrl(url);
const urlHash = computeUrlHash(urlNormalized);
const existing = existingByHash.get(urlHash);
if (existing) {
existing.title = title || existing.title;
existing.url = url;
existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash;
existing.visibility = visibility;
existing.folderId = folderId;
existing.source = "import";
existing.updatedAt = now;
merged++;
continue;
}
const created = {
id: crypto.randomUUID(),
userId: null,
folderId,
title,
url,
urlNormalized,
urlHash,
visibility,
source: "import",
updatedAt: now,
deletedAt: null
};
state.bookmarks.unshift(created);
existingByHash.set(urlHash, created);
imported++;
}
saveLocalState(state);
return { imported, merged };
}
export function exportLocalToNetscapeHtml(bookmarks) {
const safe = (s) => String(s || "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const lines = [];
lines.push("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
lines.push('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">');
lines.push("<TITLE>Bookmarks</TITLE>");
lines.push("<H1>Bookmarks</H1>");
lines.push("<DL><p>");
for (const b of bookmarks || []) {
if (b.deletedAt) continue;
lines.push(` <DT><A HREF="${safe(b.url)}">${safe(b.title || b.url)}</A>`);
}
lines.push("</DL><p>");
return lines.join("\n");
}
export function mergeLocalToUser(userId) {
const state = loadLocalState();
return {
folders: state.folders.map((f) => ({ ...f, userId })),
bookmarks: state.bookmarks.map((b) => ({ ...ensureBookmarkHashes(b), userId }))
};
}
export function clearLocalState() {
saveLocalState({ folders: [], bookmarks: [] });
}
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
const KEY = "bb_local_state_v1";
function nowIso() {
return new Date().toISOString();
}
function ensureBookmarkHashes(bookmark) {
if (!bookmark) return bookmark;
const url = bookmark.url || "";
const urlNormalized = bookmark.urlNormalized || normalizeUrl(url);
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
bookmark.urlNormalized = urlNormalized;
bookmark.urlHash = urlHash;
return bookmark;
}
export function loadLocalState() {
try {
const parsed = JSON.parse(localStorage.getItem(KEY) || "") || { folders: [], bookmarks: [] };
parsed.folders = parsed.folders || [];
parsed.bookmarks = (parsed.bookmarks || []).map((b) => ensureBookmarkHashes(b));
return parsed;
} catch {
return { folders: [], bookmarks: [] };
}
}
export function saveLocalState(state) {
localStorage.setItem(KEY, JSON.stringify(state));
}
export function listLocalBookmarks({ includeDeleted = false } = {}) {
const state = loadLocalState();
const all = state.bookmarks || [];
return includeDeleted ? all : all.filter((b) => !b.deletedAt);
}
export function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
const state = loadLocalState();
const now = nowIso();
const urlNormalized = normalizeUrl(url || "");
const urlHash = computeUrlHash(urlNormalized);
// Dedupe: same urlHash and not deleted -> update LWW
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
if (existing) {
ensureBookmarkHashes(existing);
existing.title = title || existing.title;
existing.url = url || existing.url;
existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash;
existing.visibility = visibility;
existing.folderId = folderId;
existing.source = source;
existing.updatedAt = now;
saveLocalState(state);
return existing;
}
const bookmark = {
id: crypto.randomUUID(),
userId: null,
folderId,
title,
url,
urlNormalized,
urlHash,
visibility,
source,
updatedAt: now,
deletedAt: null
};
state.bookmarks.unshift(bookmark);
saveLocalState(state);
return bookmark;
}
export function markLocalDeleted(id) {
const state = loadLocalState();
const now = nowIso();
const item = state.bookmarks.find((b) => b.id === id);
if (item) {
item.deletedAt = now;
item.updatedAt = now;
saveLocalState(state);
}
}
export function patchLocalBookmark(id, patch) {
const state = loadLocalState();
const now = nowIso();
const item = state.bookmarks.find((b) => b.id === id);
if (!item) return null;
if (patch.url !== undefined) {
const nextUrl = patch.url || "";
const nextNormalized = normalizeUrl(nextUrl);
const nextHash = computeUrlHash(nextNormalized);
const target = (state.bookmarks || []).find((b) => !b.deletedAt && b.id !== id && (b.urlHash || "") === nextHash);
if (target) {
// Merge: write changes to target and soft-delete current
ensureBookmarkHashes(target);
target.title = patch.title !== undefined ? patch.title : item.title;
target.url = nextUrl;
target.urlNormalized = nextNormalized;
target.urlHash = nextHash;
if (patch.folderId !== undefined) target.folderId = patch.folderId;
if (patch.visibility !== undefined) target.visibility = patch.visibility;
target.updatedAt = now;
item.deletedAt = now;
item.updatedAt = now;
saveLocalState(state);
return target;
}
item.url = nextUrl;
item.urlNormalized = nextNormalized;
item.urlHash = nextHash;
}
if (patch.title !== undefined) item.title = patch.title;
if (patch.folderId !== undefined) item.folderId = patch.folderId;
if (patch.visibility !== undefined) item.visibility = patch.visibility;
item.updatedAt = now;
saveLocalState(state);
return item;
}
export function deleteLocalFolder(folderId) {
const state = loadLocalState();
const now = nowIso();
state.folders = (state.folders || []).filter((f) => f.id !== folderId);
state.bookmarks = (state.bookmarks || []).map((b) => {
if (b.deletedAt) return b;
if ((b.folderId ?? null) !== (folderId ?? null)) return b;
return { ...ensureBookmarkHashes(b), folderId: null, updatedAt: now };
});
saveLocalState(state);
}
export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
const parsed = parseNetscapeBookmarkHtml(html || "");
const state = loadLocalState();
const now = nowIso();
state.folders = state.folders || [];
state.bookmarks = state.bookmarks || [];
// Flatten folders (no nesting): dedupe local folders by name.
const normName = (s) => String(s || "").replace(/\s+/g, " ").trim();
const normKey = (s) => normName(s).toLowerCase();
const existingFolderByName = new Map(
(state.folders || [])
.filter((f) => !f.deletedAt)
.map((f) => [normKey(f.name), f])
);
const tempIdToFolderName = new Map(
(parsed.folders || []).map((f) => [String(f.id), f.name])
);
const folderIdByName = new Map(
(state.folders || [])
.filter((f) => !f.deletedAt)
.map((f) => [normKey(f.name), f.id])
);
for (const f of parsed.folders || []) {
const name = normName(f.name);
const key = normKey(name);
if (!key) continue;
let id = folderIdByName.get(key);
if (!id) {
const created = {
id: crypto.randomUUID(),
userId: null,
parentId: null,
name,
visibility: "private",
sortOrder: (state.folders || []).filter((x) => !x.deletedAt && (x.parentId ?? null) === null).length,
updatedAt: now,
deletedAt: null
};
state.folders.push(created);
existingFolderByName.set(key, created);
folderIdByName.set(key, created.id);
id = created.id;
}
}
const existingByHash = new Map(
(state.bookmarks || [])
.filter((b) => !b.deletedAt)
.map((b) => {
const fixed = ensureBookmarkHashes(b);
return [fixed.urlHash, fixed];
})
);
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks || []) {
const url = (b.url || "").trim();
if (!url) continue;
const title = (b.title || "").trim() || url;
const folderTempId = b.parentFolderId ?? null;
const folderName = folderTempId ? tempIdToFolderName.get(String(folderTempId)) : null;
const folderId = folderName ? (folderIdByName.get(normKey(folderName)) ?? null) : null;
const urlNormalized = normalizeUrl(url);
const urlHash = computeUrlHash(urlNormalized);
const existing = existingByHash.get(urlHash);
if (existing) {
existing.title = title || existing.title;
existing.url = url;
existing.urlNormalized = urlNormalized;
existing.urlHash = urlHash;
existing.visibility = visibility;
existing.folderId = folderId;
existing.source = "import";
existing.updatedAt = now;
merged++;
continue;
}
const created = {
id: crypto.randomUUID(),
userId: null,
folderId,
title,
url,
urlNormalized,
urlHash,
visibility,
source: "import",
updatedAt: now,
deletedAt: null
};
state.bookmarks.unshift(created);
existingByHash.set(urlHash, created);
imported++;
}
saveLocalState(state);
return { imported, merged };
}
export function exportLocalToNetscapeHtml(bookmarks) {
const safe = (s) => String(s || "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const lines = [];
lines.push("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
lines.push('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">');
lines.push("<TITLE>Bookmarks</TITLE>");
lines.push("<H1>Bookmarks</H1>");
lines.push("<DL><p>");
for (const b of bookmarks || []) {
if (b.deletedAt) continue;
lines.push(` <DT><A HREF="${safe(b.url)}">${safe(b.title || b.url)}</A>`);
}
lines.push("</DL><p>");
return lines.join("\n");
}
export function mergeLocalToUser(userId) {
const state = loadLocalState();
return {
folders: state.folders.map((f) => ({ ...f, userId })),
bookmarks: state.bookmarks.map((b) => ({ ...ensureBookmarkHashes(b), userId }))
};
}
export function clearLocalState() {
saveLocalState({ folders: [], bookmarks: [] });
}

View File

@@ -1,435 +1,435 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { apiFetch, ensureMe, userRef } from "../lib/api";
import BbModal from "../components/BbModal.vue";
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");
const confirmOpen = ref(false);
const confirmTitle = ref("请确认");
const confirmMessage = ref("");
const confirmOkText = ref("确定");
const confirmDanger = ref(false);
let confirmResolve = null;
function askConfirm(message, { title = "请确认", okText = "确定", danger = false } = {}) {
confirmTitle.value = title;
confirmMessage.value = message;
confirmOkText.value = okText;
confirmDanger.value = danger;
confirmOpen.value = true;
return new Promise((resolve) => {
confirmResolve = resolve;
});
}
function resolveConfirm(result) {
const resolve = confirmResolve;
confirmResolve = null;
confirmOpen.value = false;
if (resolve) resolve(Boolean(result));
}
function onConfirmModalUpdate(v) {
if (!v) resolveConfirm(false);
else confirmOpen.value = true;
}
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;
}
}
let searchTimer = 0;
watch(
() => q.value,
() => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
loadBookmarks();
}, 200);
}
);
onBeforeUnmount(() => {
window.clearTimeout(searchTimer);
});
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 (!(await askConfirm("确定删除该书签?", { title: "删除书签", okText: "删除", danger: true }))) 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 (!(await askConfirm("确定删除该文件夹?子文件夹会一起删除,文件夹内书签会变成未分组。", { title: "删除文件夹", okText: "删除", danger: true }))) 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;">
<div class="bb-searchWrap" style="min-width: 260px;">
<input v-model="q" class="bb-input bb-input--sm bb-input--withClear" placeholder="搜索标题/链接" />
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</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>
<BbModal :model-value="confirmOpen" :title="confirmTitle" max-width="520px" @update:model-value="onConfirmModalUpdate">
<div class="bb-muted" style="white-space: pre-wrap; line-height: 1.6;">{{ confirmMessage }}</div>
<div class="bb-row" style="justify-content: flex-end; gap: 10px; margin-top: 14px;">
<button class="bb-btn bb-btn--secondary" type="button" @click="resolveConfirm(false)">取消</button>
<button class="bb-btn" :class="confirmDanger ? 'bb-btn--danger' : ''" type="button" @click="resolveConfirm(true)">{{ confirmOkText }}</button>
</div>
</BbModal>
</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>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { apiFetch, ensureMe, userRef } from "../lib/api";
import BbModal from "../components/BbModal.vue";
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");
const confirmOpen = ref(false);
const confirmTitle = ref("请确认");
const confirmMessage = ref("");
const confirmOkText = ref("确定");
const confirmDanger = ref(false);
let confirmResolve = null;
function askConfirm(message, { title = "请确认", okText = "确定", danger = false } = {}) {
confirmTitle.value = title;
confirmMessage.value = message;
confirmOkText.value = okText;
confirmDanger.value = danger;
confirmOpen.value = true;
return new Promise((resolve) => {
confirmResolve = resolve;
});
}
function resolveConfirm(result) {
const resolve = confirmResolve;
confirmResolve = null;
confirmOpen.value = false;
if (resolve) resolve(Boolean(result));
}
function onConfirmModalUpdate(v) {
if (!v) resolveConfirm(false);
else confirmOpen.value = true;
}
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;
}
}
let searchTimer = 0;
watch(
() => q.value,
() => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
loadBookmarks();
}, 200);
}
);
onBeforeUnmount(() => {
window.clearTimeout(searchTimer);
});
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 (!(await askConfirm("确定删除该书签?", { title: "删除书签", okText: "删除", danger: true }))) 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 (!(await askConfirm("确定删除该文件夹?子文件夹会一起删除,文件夹内书签会变成未分组。", { title: "删除文件夹", okText: "删除", danger: true }))) 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;">
<div class="bb-searchWrap" style="min-width: 260px;">
<input v-model="q" class="bb-input bb-input--sm bb-input--withClear" placeholder="搜索标题/链接" />
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</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>
<BbModal :model-value="confirmOpen" :title="confirmTitle" max-width="520px" @update:model-value="onConfirmModalUpdate">
<div class="bb-muted" style="white-space: pre-wrap; line-height: 1.6;">{{ confirmMessage }}</div>
<div class="bb-row" style="justify-content: flex-end; gap: 10px; margin-top: 14px;">
<button class="bb-btn bb-btn--secondary" type="button" @click="resolveConfirm(false)">取消</button>
<button class="bb-btn" :class="confirmDanger ? 'bb-btn--danger' : ''" type="button" @click="resolveConfirm(true)">{{ confirmOkText }}</button>
</div>
</BbModal>
</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,168 +1,168 @@
<script setup>
import { computed, ref } from "vue";
import { apiFetch, tokenRef } from "../lib/api";
import {
exportLocalToNetscapeHtml,
importLocalFromNetscapeHtml,
listLocalBookmarks
} from "../lib/localData";
const loggedIn = computed(() => Boolean(tokenRef.value));
const file = ref(null);
const status = ref("");
const error = ref("");
const busy = ref(false);
const fileInputEl = ref(null);
function onFileChange(e) {
file.value = e?.target?.files?.[0] || null;
}
function openFilePicker() {
fileInputEl.value?.click?.();
}
async function importFile() {
status.value = "";
error.value = "";
if (!file.value) return;
try {
busy.value = true;
if (loggedIn.value) {
const fd = new FormData();
fd.append("file", file.value);
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
} else {
const text = await file.value.text();
const res = importLocalFromNetscapeHtml(text, { visibility: "public" });
status.value = `本地导入完成:新增 ${res.imported},合并 ${res.merged}`;
}
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
async function exportCloud() {
status.value = "";
error.value = "";
try {
busy.value = true;
const html = await apiFetch("/bookmarks/export/html", {
method: "GET",
headers: { Accept: "text/html" }
});
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks-cloud.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = "云端导出完成";
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
async function exportLocal() {
status.value = "";
error.value = "";
try {
busy.value = true;
const bookmarks = listLocalBookmarks({ includeDeleted: false });
const html = exportLocalToNetscapeHtml(bookmarks);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = `本地导出完成:${bookmarks.length}`;
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
</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 class="bb-clayCard" style="margin-top: 12px;">
<div class="bb-cardTitle">导入 Chrome/Edge 书签 HTML</div>
<div class="bb-cardSub">支持自动去重与合并登录后会写入云端数据库</div>
<div class="bb-row" style="margin-top: 12px;">
<input
ref="fileInputEl"
class="bb-fileInput"
type="file"
accept="text/html,.html"
@change="onFileChange"
/>
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="openFilePicker">选择 HTML 文件</button>
<div class="bb-fileName" :class="!file ? 'is-empty' : ''">
{{ file ? file.name : "未选择文件" }}
</div>
<button class="bb-btn" :disabled="busy || !file" @click="importFile">开始导入</button>
</div>
<p v-if="status" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ status }}</p>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
<p class="bb-muted" style="margin-top: 10px;">
未登录导入写入本机 localStorage并自动去重登录导入写入数据库并自动去重合并
</p>
</div>
<div v-if="loggedIn" class="bb-grid2" style="margin-top: 12px;">
<div class="bb-card bb-card--interactive">
<div class="bb-cardTitle">导出云端</div>
<div class="bb-cardSub">保留文件夹层级已携带登录态导出</div>
<div style="margin-top: 12px;">
<button class="bb-btn" :disabled="busy" @click="exportCloud">导出为 HTML</button>
</div>
</div>
<div class="bb-card bb-card--interactive">
<div class="bb-cardTitle">导出本地</div>
<div class="bb-cardSub">导出当前浏览器 localStorage 中的书签平铺导出</div>
<div style="margin-top: 12px;">
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="exportLocal">导出为 HTML</button>
</div>
</div>
</div>
<div v-else class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 900;">登录后可导出云端书签</div>
<div class="bb-muted" style="margin-top: 6px;">你仍可在未登录状态下导入到本机并导出本机数据</div>
</div>
</section>
</template>
<style scoped>
/* page-level tweaks only */
</style>
<script setup>
import { computed, ref } from "vue";
import { apiFetch, tokenRef } from "../lib/api";
import {
exportLocalToNetscapeHtml,
importLocalFromNetscapeHtml,
listLocalBookmarks
} from "../lib/localData";
const loggedIn = computed(() => Boolean(tokenRef.value));
const file = ref(null);
const status = ref("");
const error = ref("");
const busy = ref(false);
const fileInputEl = ref(null);
function onFileChange(e) {
file.value = e?.target?.files?.[0] || null;
}
function openFilePicker() {
fileInputEl.value?.click?.();
}
async function importFile() {
status.value = "";
error.value = "";
if (!file.value) return;
try {
busy.value = true;
if (loggedIn.value) {
const fd = new FormData();
fd.append("file", file.value);
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
} else {
const text = await file.value.text();
const res = importLocalFromNetscapeHtml(text, { visibility: "public" });
status.value = `本地导入完成:新增 ${res.imported},合并 ${res.merged}`;
}
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
async function exportCloud() {
status.value = "";
error.value = "";
try {
busy.value = true;
const html = await apiFetch("/bookmarks/export/html", {
method: "GET",
headers: { Accept: "text/html" }
});
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks-cloud.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = "云端导出完成";
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
async function exportLocal() {
status.value = "";
error.value = "";
try {
busy.value = true;
const bookmarks = listLocalBookmarks({ includeDeleted: false });
const html = exportLocalToNetscapeHtml(bookmarks);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks.html";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
status.value = `本地导出完成:${bookmarks.length}`;
} catch (e) {
error.value = e.message || String(e);
} finally {
busy.value = false;
}
}
</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 class="bb-clayCard" style="margin-top: 12px;">
<div class="bb-cardTitle">导入 Chrome/Edge 书签 HTML</div>
<div class="bb-cardSub">支持自动去重与合并登录后会写入云端数据库</div>
<div class="bb-row" style="margin-top: 12px;">
<input
ref="fileInputEl"
class="bb-fileInput"
type="file"
accept="text/html,.html"
@change="onFileChange"
/>
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="openFilePicker">选择 HTML 文件</button>
<div class="bb-fileName" :class="!file ? 'is-empty' : ''">
{{ file ? file.name : "未选择文件" }}
</div>
<button class="bb-btn" :disabled="busy || !file" @click="importFile">开始导入</button>
</div>
<p v-if="status" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ status }}</p>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
<p class="bb-muted" style="margin-top: 10px;">
未登录导入写入本机 localStorage并自动去重登录导入写入数据库并自动去重合并
</p>
</div>
<div v-if="loggedIn" class="bb-grid2" style="margin-top: 12px;">
<div class="bb-card bb-card--interactive">
<div class="bb-cardTitle">导出云端</div>
<div class="bb-cardSub">保留文件夹层级已携带登录态导出</div>
<div style="margin-top: 12px;">
<button class="bb-btn" :disabled="busy" @click="exportCloud">导出为 HTML</button>
</div>
</div>
<div class="bb-card bb-card--interactive">
<div class="bb-cardTitle">导出本地</div>
<div class="bb-cardSub">导出当前浏览器 localStorage 中的书签平铺导出</div>
<div style="margin-top: 12px;">
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="exportLocal">导出为 HTML</button>
</div>
</div>
</div>
<div v-else class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 900;">登录后可导出云端书签</div>
<div class="bb-muted" style="margin-top: 6px;">你仍可在未登录状态下导入到本机并导出本机数据</div>
</div>
</section>
</template>
<style scoped>
/* page-level tweaks only */
</style>

View File

@@ -1,133 +1,133 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch, setToken } from "../lib/api";
import { clearLocalState, mergeLocalToUser } from "../lib/localData";
const router = useRouter();
const mode = ref("login");
const email = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
async function submit() {
loading.value = true;
error.value = "";
try {
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
const res = await apiFetch(path, {
method: "POST",
body: JSON.stringify({ email: email.value, password: password.value })
});
setToken(res.token);
// Bootstrap: push local data to server (server enforces userId from token)
const payload = mergeLocalToUser(res.user.id);
await apiFetch("/sync/push", {
method: "POST",
body: JSON.stringify(payload)
});
clearLocalState();
const next = String(router.currentRoute.value.query?.next || "").trim();
await router.push(next || "/my");
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<section class="bb-page bb-auth">
<div class="bb-authCard bb-clayCard">
<div class="bb-authHeader">
<div class="bb-pill">云端同步 · 本地不丢</div>
<h1 class="bb-authTitle">{{ mode === 'register' ? '注册账号' : '登录账号' }}</h1>
<p class="bb-heroSub">登录后自动同步到云端不登录也能本地管理localStorage</p>
</div>
<div class="bb-seg" role="tablist" aria-label="登录模式">
<button
class="bb-segBtn"
:class="{ active: mode === 'login' }"
role="tab"
:aria-selected="mode === 'login'"
@click="mode = 'login'"
>
登录
</button>
<button
class="bb-segBtn"
:class="{ active: mode === 'register' }"
role="tab"
:aria-selected="mode === 'register'"
@click="mode = 'register'"
>
注册
</button>
</div>
<div class="bb-authForm">
<label class="bb-field">
<span class="bb-label">邮箱</span>
<input v-model="email" class="bb-input" placeholder="name@example.com" autocomplete="email" />
</label>
<label class="bb-field">
<span class="bb-label">密码</span>
<input
v-model="password"
class="bb-input"
placeholder="至少 8 位"
type="password"
autocomplete="current-password"
/>
</label>
<button class="bb-btn" :disabled="loading" @click="submit">
{{ loading ? '处理中' : (mode === 'register' ? '创建账号并登录' : '登录') }}
</button>
<p v-if="error" class="bb-alert bb-alert--error" style="margin: 0;">{{ error }}</p>
</div>
<div class="bb-authFoot bb-muted">
Token 会持久化保存仅当你手动退出才会清除
</div>
</div>
<aside class="bb-authAside bb-card">
<div class="bb-cardTitle">你将获得</div>
<div class="bb-cardSub">更像课程平台的学习型收藏体验</div>
<div class="bb-authBadges">
<span class="bb-tag">同步</span>
<span class="bb-tag bb-tag2">去重</span>
<span class="bb-tag bb-tag3">文件夹层级</span>
</div>
<ul class="bb-bullets" style="margin-top: 10px;">
<li>导入 Chrome/Edge 书签 HTML 一键合并</li>
<li>公开/私有可切换公开页可分享</li>
<li>跨标签页登录态自动同步</li>
</ul>
<div class="bb-miniQuote" style="margin-top: 12px;">
<div class="bb-quote">终于把书签变成了可复习的知识库</div>
<div class="bb-quoteBy"> 早八人 · 收藏课学员</div>
</div>
</aside>
</section>
</template>
<style scoped>
.bb-authTitle { margin: 10px 0 6px; }
.bb-authForm { display: grid; gap: 10px; margin-top: 12px; }
.bb-authFoot { margin-top: 10px; }
</style>
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch, setToken } from "../lib/api";
import { clearLocalState, mergeLocalToUser } from "../lib/localData";
const router = useRouter();
const mode = ref("login");
const email = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
async function submit() {
loading.value = true;
error.value = "";
try {
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
const res = await apiFetch(path, {
method: "POST",
body: JSON.stringify({ email: email.value, password: password.value })
});
setToken(res.token);
// Bootstrap: push local data to server (server enforces userId from token)
const payload = mergeLocalToUser(res.user.id);
await apiFetch("/sync/push", {
method: "POST",
body: JSON.stringify(payload)
});
clearLocalState();
const next = String(router.currentRoute.value.query?.next || "").trim();
await router.push(next || "/my");
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<section class="bb-page bb-auth">
<div class="bb-authCard bb-clayCard">
<div class="bb-authHeader">
<div class="bb-pill">云端同步 · 本地不丢</div>
<h1 class="bb-authTitle">{{ mode === 'register' ? '注册账号' : '登录账号' }}</h1>
<p class="bb-heroSub">登录后自动同步到云端不登录也能本地管理localStorage</p>
</div>
<div class="bb-seg" role="tablist" aria-label="登录模式">
<button
class="bb-segBtn"
:class="{ active: mode === 'login' }"
role="tab"
:aria-selected="mode === 'login'"
@click="mode = 'login'"
>
登录
</button>
<button
class="bb-segBtn"
:class="{ active: mode === 'register' }"
role="tab"
:aria-selected="mode === 'register'"
@click="mode = 'register'"
>
注册
</button>
</div>
<div class="bb-authForm">
<label class="bb-field">
<span class="bb-label">邮箱</span>
<input v-model="email" class="bb-input" placeholder="name@example.com" autocomplete="email" />
</label>
<label class="bb-field">
<span class="bb-label">密码</span>
<input
v-model="password"
class="bb-input"
placeholder="至少 8 位"
type="password"
autocomplete="current-password"
/>
</label>
<button class="bb-btn" :disabled="loading" @click="submit">
{{ loading ? '处理中' : (mode === 'register' ? '创建账号并登录' : '登录') }}
</button>
<p v-if="error" class="bb-alert bb-alert--error" style="margin: 0;">{{ error }}</p>
</div>
<div class="bb-authFoot bb-muted">
Token 会持久化保存仅当你手动退出才会清除
</div>
</div>
<aside class="bb-authAside bb-card">
<div class="bb-cardTitle">你将获得</div>
<div class="bb-cardSub">更像课程平台的学习型收藏体验</div>
<div class="bb-authBadges">
<span class="bb-tag">同步</span>
<span class="bb-tag bb-tag2">去重</span>
<span class="bb-tag bb-tag3">文件夹层级</span>
</div>
<ul class="bb-bullets" style="margin-top: 10px;">
<li>导入 Chrome/Edge 书签 HTML 一键合并</li>
<li>公开/私有可切换公开页可分享</li>
<li>跨标签页登录态自动同步</li>
</ul>
<div class="bb-miniQuote" style="margin-top: 12px;">
<div class="bb-quote">终于把书签变成了可复习的知识库</div>
<div class="bb-quoteBy"> 早八人 · 收藏课学员</div>
</div>
</aside>
</section>
</template>
<style scoped>
.bb-authTitle { margin: 10px 0 6px; }
.bb-authForm { display: grid; gap: 10px; margin-top: 12px; }
.bb-authFoot { margin-top: 10px; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,180 +1,180 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import BbSelect from "../components/BbSelect.vue";
import BbModal from "../components/BbModal.vue";
import { apiFetch, tokenRef } from "../lib/api";
const q = ref("");
const loading = ref(false);
const error = 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() {
loading.value = true;
error.value = "";
try {
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
let searchTimer = 0;
watch(
() => q.value,
() => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
load();
}, 200);
}
);
onBeforeUnmount(() => {
window.clearTimeout(searchTimer);
});
onMounted(load);
onMounted(loadFolders);
</script>
<template>
<section class="bb-page">
<div class="bb-pageHeader">
<div class="bb-row" style="justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap;">
<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 class="bb-card" style="margin-top: 12px;">
<div class="bb-row">
<div class="bb-searchWrap">
<input v-model="q" class="bb-input bb-input--withClear" placeholder="搜索标题或链接" />
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</div>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
<p v-else-if="loading" class="bb-muted" style="margin-top: 12px;">加载中</p>
<div v-else-if="!items.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>
<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">
<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="bb-cardLink">
<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>
</ul>
</section>
</template>
<style scoped>
.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; } }
.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>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import BbSelect from "../components/BbSelect.vue";
import BbModal from "../components/BbModal.vue";
import { apiFetch, tokenRef } from "../lib/api";
const q = ref("");
const loading = ref(false);
const error = 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() {
loading.value = true;
error.value = "";
try {
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
let searchTimer = 0;
watch(
() => q.value,
() => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
load();
}, 200);
}
);
onBeforeUnmount(() => {
window.clearTimeout(searchTimer);
});
onMounted(load);
onMounted(loadFolders);
</script>
<template>
<section class="bb-page">
<div class="bb-pageHeader">
<div class="bb-row" style="justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap;">
<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 class="bb-card" style="margin-top: 12px;">
<div class="bb-row">
<div class="bb-searchWrap">
<input v-model="q" class="bb-input bb-input--withClear" placeholder="搜索标题或链接" />
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
</div>
</div>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
<p v-else-if="loading" class="bb-muted" style="margin-top: 12px;">加载中</p>
<div v-else-if="!items.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>
<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">
<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="bb-cardLink">
<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>
</ul>
</section>
</template>
<style scoped>
.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; } }
.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>

View File

@@ -1,41 +1,41 @@
import { createRouter, createWebHistory } from "vue-router";
import PublicPage from "./pages/PublicPage.vue";
import LoginPage from "./pages/LoginPage.vue";
import MyPage from "./pages/MyPage.vue";
import ImportExportPage from "./pages/ImportExportPage.vue";
import AdminPage from "./pages/AdminPage.vue";
import { ensureMe, tokenRef } from "./lib/api";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: PublicPage },
{ path: "/login", component: LoginPage },
{ path: "/my", component: MyPage },
{ path: "/import", component: ImportExportPage },
{ path: "/admin", component: AdminPage }
]
});
router.beforeEach(async (to) => {
const loggedIn = Boolean(tokenRef.value);
// 主页(/)永远是公共首页;不因登录态自动跳转
// 已登录访问登录页:直接去“我的”
if (to.path === "/login" && loggedIn) return { path: "/my" };
// 导入导出:登录后才可见/可用
if (to.path === "/import" && !loggedIn) {
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;
});
import { createRouter, createWebHistory } from "vue-router";
import PublicPage from "./pages/PublicPage.vue";
import LoginPage from "./pages/LoginPage.vue";
import MyPage from "./pages/MyPage.vue";
import ImportExportPage from "./pages/ImportExportPage.vue";
import AdminPage from "./pages/AdminPage.vue";
import { ensureMe, tokenRef } from "./lib/api";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: PublicPage },
{ path: "/login", component: LoginPage },
{ path: "/my", component: MyPage },
{ path: "/import", component: ImportExportPage },
{ path: "/admin", component: AdminPage }
]
});
router.beforeEach(async (to) => {
const loggedIn = Boolean(tokenRef.value);
// 主页(/)永远是公共首页;不因登录态自动跳转
// 已登录访问登录页:直接去“我的”
if (to.path === "/login" && loggedIn) return { path: "/my" };
// 导入导出:登录后才可见/可用
if (to.path === "/import" && !loggedIn) {
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;
});