初始化项目

This commit is contained in:
2026-01-18 10:35:27 +08:00
parent 85042841ae
commit 00ca4c1b0d
116 changed files with 11569 additions and 2 deletions

24
apps/extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
apps/extension/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

View File

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

13
apps/extension/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>extension</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +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>BrowserBookmark Options</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/options/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"name": "extension",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node -e \"console.log('extension: no tests yet')\"",
"lint": "eslint ."
},
"dependencies": {
"@browser-bookmark/shared": "0.1.0",
"vue-router": "^4.5.1",
"vue": "^3.5.24"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"eslint": "^9.17.0",
"vite": "^7.2.4"
}
}

12
apps/extension/popup.html Normal file
View File

@@ -0,0 +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>

View File

View File

View File

View File

@@ -0,0 +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": ["http://localhost:3001/*"]
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,30 @@
<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
<HelloWorld msg="Vite + Vue" />
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +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 || {});
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

@@ -0,0 +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));
}

View File

@@ -0,0 +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");
}

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

View File

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

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

View File

@@ -0,0 +1,109 @@
<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("");
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);
}
}
function exportCloud() {
window.open("http://localhost:3001/bookmarks/export/html", "_blank");
}
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="(e) => (file.value = e.target.files?.[0] || null)"
/>
<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

@@ -0,0 +1,85 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch } from "../../lib/api";
import { getToken, 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.push("/my");
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
async function logout() {
const token = await getToken();
if (!token) return;
await setToken("");
await router.push("/");
}
</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>
<button class="tab" @click="logout">退出</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

@@ -0,0 +1,93 @@
<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";
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;
await markLocalDeleted(id);
await load();
}
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>
</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; }
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; }
.muted { color: #475569; font-size: 12px; }
</style>

View File

@@ -0,0 +1,53 @@
<script setup>
import { onMounted, ref } 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;
}
}
onMounted(load);
</script>
<template>
<section>
<h1>公开书签</h1>
<div class="row">
<input v-model="q" class="input" placeholder="搜索" @keyup.enter="load" />
<button class="btn" :disabled="loading" @click="load">搜索</button>
</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; }
.input { flex: 1; 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; }
.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; }
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
.error { color: #b91c1c; }
</style>

View File

@@ -0,0 +1,15 @@
import { createRouter, createWebHashHistory } from "vue-router";
import PublicPage from "./pages/PublicPage.vue";
import LoginPage from "./pages/LoginPage.vue";
import MyPage from "./pages/MyPage.vue";
import ImportExportPage from "./pages/ImportExportPage.vue";
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: "/", component: PublicPage },
{ path: "/login", component: LoginPage },
{ path: "/my", component: MyPage },
{ path: "/import", component: ImportExportPage }
]
});

View File

@@ -0,0 +1,148 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { apiFetch } from "../lib/api";
import { getToken } from "../lib/extStorage";
import { listLocalBookmarks, upsertLocalBookmark } from "../lib/localData";
const items = ref([]);
const q = ref("");
const title = ref("");
const url = ref("");
const mode = ref("local");
const loading = ref(false);
const error = ref("");
const filtered = computed(() => {
const query = q.value.trim().toLowerCase();
if (!query) return items.value;
return items.value.filter((b) => {
const t = String(b.title || "").toLowerCase();
const u = String(b.url || "").toLowerCase();
return t.includes(query) || u.includes(query);
});
});
function openUrl(url) {
if (typeof chrome !== "undefined" && chrome.tabs?.create) {
chrome.tabs.create({ url });
} else {
window.open(url, "_blank", "noopener");
}
}
function openOptions() {
if (typeof chrome !== "undefined" && chrome.runtime?.openOptionsPage) {
chrome.runtime.openOptionsPage();
}
}
async function load() {
loading.value = true;
error.value = "";
try {
const token = await getToken();
mode.value = token ? "cloud" : "local";
if (mode.value === "cloud") {
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
items.value = await apiFetch(`/bookmarks${qs}`);
items.value = items.value.slice(0, 20);
} else {
items.value = await listLocalBookmarks();
items.value = items.value.slice(0, 50);
}
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
async function quickAdd() {
error.value = "";
const t = title.value.trim();
const u = url.value.trim();
if (!t || !u) return;
try {
const token = await getToken();
if (token) {
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({ folderId: null, title: t, url: u, visibility: "public" })
});
} else {
await upsertLocalBookmark({ title: t, url: u, visibility: "public" });
}
title.value = "";
url.value = "";
await load();
} catch (e) {
error.value = e.message || String(e);
}
}
onMounted(load);
</script>
<template>
<div class="wrap">
<div class="top">
<div class="title">书签</div>
<button class="btn" @click="openOptions">设置/登录</button>
</div>
<div class="meta">
<span class="pill">{{ mode === 'cloud' ? '云端' : '本地' }}</span>
<button class="linkBtn" :disabled="loading" @click="load">刷新</button>
</div>
<div class="row">
<input v-model="q" class="input" placeholder="搜索" @keyup.enter="load" />
<button class="btn primary" :disabled="loading" @click="load"></button>
</div>
<div class="card">
<div class="cardTitle">快速添加</div>
<input v-model="title" class="input" placeholder="标题" />
<input v-model="url" class="input" placeholder="链接" @keyup.enter="quickAdd" />
<button class="btn primary" @click="quickAdd">添加</button>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="loading" class="muted">加载中</div>
<ul class="list">
<li v-for="b in filtered" :key="b.id" class="item" @click="openUrl(b.url)">
<div class="name">{{ b.title }}</div>
<div class="url">{{ b.url }}</div>
</li>
</ul>
</div>
</template>
<style scoped>
.wrap { width: 360px; padding: 12px; font-family: ui-sans-serif, system-ui; background: #f8fafc; color: #1e293b; }
.top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.title { font-weight: 800; }
.btn { padding: 6px 10px; border: 1px solid #e5e7eb; background: white; border-radius: 10px; cursor: pointer; transition: background 150ms, border-color 150ms; }
.btn:hover { background: rgba(59, 130, 246, 0.08); border-color: rgba(59, 130, 246, 0.35); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.primary { border-color: rgba(59, 130, 246, 0.6); background: rgba(59, 130, 246, 0.12); }
.primary:hover { background: rgba(59, 130, 246, 0.18); }
.linkBtn { border: none; background: transparent; color: #3b82f6; cursor: pointer; padding: 0; }
.linkBtn:disabled { opacity: 0.6; cursor: not-allowed; }
.meta { display: flex; align-items: center; justify-content: space-between; margin: 6px 0 10px; }
.pill { font-size: 12px; padding: 2px 8px; border: 1px solid #e5e7eb; border-radius: 999px; background: white; color: #334155; }
.row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin: 10px 0; }
.input { padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: white; }
.card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 10px; background: white; display: grid; gap: 8px; margin-bottom: 10px; }
.cardTitle { font-weight: 700; font-size: 12px; color: #334155; }
.list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.item { border: 1px solid #e5e7eb; border-radius: 12px; padding: 10px; cursor: pointer; background: white; transition: background 150ms, border-color 150ms; }
.item:hover { background: rgba(59, 130, 246, 0.06); border-color: rgba(59, 130, 246, 0.35); }
.name { font-weight: 700; color: #111827; }
.url { font-size: 12px; color: #475569; overflow-wrap: anywhere; margin-top: 4px; }
.muted { font-size: 12px; color: #475569; }
.error { color: #b91c1c; font-size: 12px; margin-bottom: 8px; }
</style>

View File

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

View File

@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "node:path";
export default defineConfig({
plugins: [vue()],
base: "./",
build: {
rollupOptions: {
input: {
popup: path.resolve(__dirname, "popup.html"),
options: path.resolve(__dirname, "options.html")
}
}
}
});

View File

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

View File

@@ -0,0 +1,41 @@
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',
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,
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_url_hash on bookmarks (user_id, url_hash);
create index if not exists idx_bookmarks_visibility on bookmarks (visibility);

32
apps/server/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"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"
},
"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"
}
}

View File

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

37
apps/server/src/config.js Normal file
View File

@@ -0,0 +1,37 @@
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);
return {
serverPort,
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"
}
};
}

16
apps/server/src/db.js Normal file
View File

@@ -0,0 +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
});
}

58
apps/server/src/index.js Normal file
View File

@@ -0,0 +1,58 @@
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 { 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
await app.register(cors, {
origin: true,
credentials: true
});
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 });
app.decorate("pg", createPool());
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
await authRoutes(app);
await foldersRoutes(app);
await bookmarksRoutes(app);
await importExportRoutes(app);
await syncRoutes(app);
app.addHook("onClose", async (instance) => {
await instance.pg.end();
});
const { serverPort } = getConfig();
await app.listen({ port: serverPort, host: "0.0.0.0" });

View File

@@ -0,0 +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);
}

View File

@@ -0,0 +1,93 @@
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 walkDl($dl, parentTempId) {
// Netscape format: <DL><p> contains repeating <DT> items and nested <DL>
const children = $dl.children().toArray();
for (let i = 0; i < children.length; i++) {
const node = children[i];
if (!node || node.tagName?.toLowerCase() !== "dt") continue;
const $dt = $(node);
const $h3 = $dt.children("h3").first();
const $a = $dt.children("a").first();
const $next = $(children[i + 1] || null);
const nextIsDl = $next && $next[0]?.tagName?.toLowerCase() === "dl";
if ($h3.length) {
const tempId = `${folders.length + 1}`;
const name = ($h3.text() || "").trim();
folders.push({ tempId, parentTempId: parentTempId ?? null, name });
if (nextIsDl) walkDl($next, tempId);
} else if ($a.length) {
const title = ($a.text() || "").trim();
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

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

View File

@@ -0,0 +1,37 @@
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,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function bookmarkRowToDto(row) {
return {
id: row.id,
userId: row.user_id,
folderId: row.folder_id,
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

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

View File

@@ -0,0 +1,60 @@
import { hashPassword, verifyPassword } from "../lib/auth.js";
import { httpError } from "../lib/httpErrors.js";
import { userRowToDto } from "../lib/rows.js";
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 = userRowToDto(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 token = await app.jwt.sign({ sub: user.id, role: user.role });
return { token, user };
});
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 userRowToDto(row);
}
);
}

View File

@@ -0,0 +1,197 @@
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 res = await app.pg.query(
`select * from bookmarks ${where} order by updated_at desc 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 merged = await app.pg.query(
`update bookmarks
set title=$1, url=$2, url_normalized=$3, visibility=$4, folder_id=$5, source='manual', updated_at=now()
where id=$6
returning *`,
[title, url, urlNormalized, visibility, folderId ?? null, existing.rows[0].id]
);
return bookmarkRowToDto(merged.rows[0]);
}
const res = await app.pg.query(
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
values ($1, $2, $3, $4, $5, $6, $7, 'manual')
returning *`,
[userId, folderId ?? null, title, url, urlNormalized, urlHash, visibility]
);
return bookmarkRowToDto(res.rows[0]);
}
);
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, "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

@@ -0,0 +1,96 @@
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 res = await app.pg.query(
"select * from bookmark_folders where user_id=$1 order by name",
[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 || {};
if (!name) throw httpError(400, "name required");
if (!visibility) throw httpError(400, "visibility required");
const res = await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, $2, $3, $4)
returning *`,
[userId, parentId ?? null, name, visibility]
);
return folderRowToDto(res.rows[0]);
}
);
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 (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

@@ -0,0 +1,90 @@
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);
// Create folders preserving structure
const tempToDbId = new Map();
for (const f of parsed.folders) {
const parentId = f.parentTempId ? tempToDbId.get(f.parentTempId) : null;
const res = await app.pg.query(
`insert into bookmark_folders (user_id, parent_id, name, visibility)
values ($1, $2, $3, 'private')
returning id`,
[userId, parentId, f.name]
);
tempToDbId.set(f.tempId, res.rows[0].id);
}
let imported = 0;
let merged = 0;
for (const b of parsed.bookmarks) {
const folderId = b.parentTempId ? tempToDbId.get(b.parentTempId) : 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

@@ -0,0 +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()
};
}
);
}

24
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
apps/web/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

12
apps/web/eslint.config.js Normal file
View File

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

13
apps/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

23
apps/web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node -e \"console.log('web: no tests yet')\"",
"lint": "eslint ."
},
"dependencies": {
"@browser-bookmark/shared": "0.1.0",
"vue-router": "^4.5.1",
"vue": "^3.5.24"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"eslint": "^9.17.0",
"vite": "^7.2.4"
}
}

1
apps/web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

36
apps/web/src/App.vue Normal file
View File

@@ -0,0 +1,36 @@
<script setup>
import { computed } from "vue";
import { RouterLink, RouterView } from "vue-router";
import { getToken } from "./lib/api";
const loggedIn = computed(() => Boolean(getToken()));
</script>
<template>
<header class="nav">
<div class="navInner">
<RouterLink to="/" class="brand">BrowserBookmark</RouterLink>
<nav class="links">
<RouterLink to="/" class="link">公开</RouterLink>
<RouterLink to="/my" class="link">我的</RouterLink>
<RouterLink to="/import" class="link">导入导出</RouterLink>
<RouterLink v-if="!loggedIn" to="/login" class="link primary">登录</RouterLink>
</nav>
</div>
</header>
<main>
<RouterView />
</main>
</template>
<style scoped>
.nav { position: sticky; top: 0; z-index: 10; background: rgba(248,250,252,0.9); backdrop-filter: blur(10px); border-bottom: 1px solid var(--bb-border); }
.navInner { max-width: 1100px; margin: 0 auto; padding: 10px 16px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.brand { font-weight: 800; color: var(--bb-text); text-decoration: none; }
.links { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.link { color: var(--bb-text); text-decoration: none; padding: 8px 10px; border-radius: 10px; transition: background 150ms, color 150ms; }
.link:hover { background: rgba(59,130,246,0.08); }
.link.router-link-active { background: rgba(59,130,246,0.12); }
.primary { background: var(--bb-primary); color: white; }
.primary:hover { background: var(--bb-primary-weak); }
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

38
apps/web/src/lib/api.js Normal file
View File

@@ -0,0 +1,38 @@
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
export function getToken() {
return localStorage.getItem("bb_token") || "";
}
export function setToken(token) {
if (token) localStorage.setItem("bb_token", token);
else localStorage.removeItem("bb_token");
}
export async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {});
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

@@ -0,0 +1,226 @@
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 importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
const parsed = parseNetscapeBookmarkHtml(html || "");
const state = loadLocalState();
const now = nowIso();
state.bookmarks = state.bookmarks || [];
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 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.source = "import";
existing.updatedAt = now;
merged++;
continue;
}
const created = {
id: crypto.randomUUID(),
userId: null,
folderId: null,
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: [] });
}

6
apps/web/src/main.js Normal file
View File

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

View File

@@ -0,0 +1,114 @@
<script setup>
import { computed, ref } from "vue";
import { apiFetch, getToken } from "../lib/api";
import {
exportLocalToNetscapeHtml,
importLocalFromNetscapeHtml,
listLocalBookmarks
} from "../lib/localData";
const loggedIn = computed(() => Boolean(getToken()));
const file = ref(null);
const status = ref("");
const error = ref("");
const busy = ref(false);
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;
}
}
function exportCloud() {
window.open("http://localhost:3001/bookmarks/export/html", "_blank");
}
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">
<h1>导入 / 导出</h1>
<div class="bb-card">
<h2>导入 Chrome/Edge 书签 HTML</h2>
<div class="bb-row" style="margin-top: 10px;">
<input
class="bb-input"
type="file"
accept="text/html,.html"
@change="(e) => (file.value = e.target.files?.[0] || null)"
/>
<button class="bb-btn" :disabled="busy" @click="importFile">开始导入</button>
</div>
<p v-if="status" class="bb-alert bb-alert--ok" style="margin-top: 10px;">{{ status }}</p>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 10px;">{{ error }}</p>
<p class="bb-muted" style="margin-top: 10px;">
未登录导入写入本机 localStorage并使用 urlNormalized/urlHash 自动去重登录导入写入数据库并自动去重合并
</p>
</div>
<div class="bb-grid2" style="margin-top: 12px;">
<div v-if="loggedIn" class="bb-card">
<h2>导出云端</h2>
<p class="bb-muted">导出你的云端书签保留文件夹层级</p>
<button class="bb-btn" :disabled="busy" @click="exportCloud">导出为 HTML</button>
</div>
<div class="bb-card">
<h2>导出本地</h2>
<p class="bb-muted">导出当前浏览器中的本地书签对齐扩展平铺导出</p>
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="exportLocal">导出为 HTML</button>
</div>
</div>
</section>
</template>
<style scoped>
h1 { margin: 0 0 12px; }
h2 { margin: 0 0 6px; font-size: 16px; }
</style>

View File

@@ -0,0 +1,71 @@
<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();
await router.push("/my");
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
</script>
<template>
<section class="bb-page" style="max-width: 560px;">
<h1>{{ mode === 'register' ? '注册' : '登录' }}</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="bb-input" placeholder="邮箱" autocomplete="email" />
<input v-model="password" class="bb-input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
<button class="bb-btn" :disabled="loading" @click="submit">提交</button>
<p v-if="error" class="bb-alert bb-alert--error">{{ error }}</p>
</div>
<p class="bb-muted" style="margin-top: 10px;">未登录时你的书签会保存在本机 localStorage</p>
</section>
</template>
<style scoped>
.row { display: flex; gap: 8px; margin: 12px 0; }
.tab { flex: 1; 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; }
</style>

View File

@@ -0,0 +1,410 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { apiFetch, getToken, setToken } from "../lib/api";
import { loadLocalState, markLocalDeleted, patchLocalBookmark, upsertLocalBookmark } from "../lib/localData";
const loggedIn = computed(() => Boolean(getToken()));
const error = ref("");
const loading = ref(false);
const folders = ref([]);
const folderName = ref("");
const folderVisibility = ref("private");
const folderParentId = ref(null);
const folderEditingId = ref("");
const editFolderName = ref("");
const editFolderVisibility = ref("private");
const editFolderParentId = ref(null);
const title = ref("");
const url = ref("");
const folderId = ref(null);
const visibility = ref("public");
const q = ref("");
const items = ref([]);
const editingId = ref("");
const editTitle = ref("");
const editUrl = ref("");
const editFolderId = ref(null);
const editVisibility = ref("public");
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) => 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);
}
}
// root folders
walk(null, 0);
// orphan folders (bad parentId) - append at end
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));
function folderLabel(f, depth) {
const pad = depth > 0 ? `${"—".repeat(Math.min(depth, 6))} ` : "";
return `${pad}${f.name}`;
}
async function loadRemote() {
loading.value = true;
error.value = "";
try {
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
const [fs, bs] = await Promise.all([apiFetch("/folders"), apiFetch(`/bookmarks${qs}`)]);
folders.value = fs;
items.value = bs;
} catch (e) {
error.value = e.message || String(e);
} finally {
loading.value = false;
}
}
function loadLocal() {
const state = loadLocalState();
folders.value = state.folders || [];
const query = q.value.trim().toLowerCase();
const base = (state.bookmarks || []).filter((b) => !b.deletedAt);
items.value = query
? base.filter((b) => String(b.title || "").toLowerCase().includes(query) || String(b.url || "").toLowerCase().includes(query))
: base;
}
async function createFolder() {
const name = folderName.value.trim();
if (!name) return;
try {
if (loggedIn.value) {
await apiFetch("/folders", {
method: "POST",
body: JSON.stringify({ parentId: folderParentId.value ?? null, name, visibility: folderVisibility.value })
});
folderName.value = "";
folderParentId.value = null;
await loadRemote();
} else {
// Local folders are MVP-only for now
folderName.value = "";
folderParentId.value = null;
}
} catch (e) {
error.value = e.message || String(e);
}
}
function startEditFolder(f) {
folderEditingId.value = f.id;
editFolderName.value = f.name || "";
editFolderVisibility.value = f.visibility || "private";
editFolderParentId.value = f.parentId ?? null;
}
function cancelEditFolder() {
folderEditingId.value = "";
}
async function saveFolder(id) {
const name = editFolderName.value.trim();
if (!name) return;
try {
await apiFetch(`/folders/${id}`, {
method: "PATCH",
body: JSON.stringify({
name,
visibility: editFolderVisibility.value,
parentId: editFolderParentId.value === id ? null : editFolderParentId.value ?? null
})
});
folderEditingId.value = "";
await loadRemote();
} catch (e) {
error.value = e.message || String(e);
}
}
async function removeFolder(id) {
try {
await apiFetch(`/folders/${id}`, { method: "DELETE" });
// If current selection removed, reset
if (folderId.value === id) folderId.value = null;
await loadRemote();
} catch (e) {
error.value = e.message || String(e);
}
}
async function add() {
if (!title.value || !url.value) return;
if (loggedIn.value) {
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({ folderId: folderId.value ?? null, title: title.value, url: url.value, visibility: visibility.value })
});
title.value = "";
url.value = "";
folderId.value = null;
await loadRemote();
} else {
upsertLocalBookmark({ title: title.value, url: url.value, visibility: visibility.value, folderId: folderId.value ?? null });
title.value = "";
url.value = "";
folderId.value = null;
loadLocal();
}
}
async function remove(id) {
if (loggedIn.value) {
await apiFetch(`/bookmarks/${id}`, { method: "DELETE" });
await loadRemote();
return;
}
markLocalDeleted(id);
loadLocal();
}
function startEdit(b) {
editingId.value = b.id;
editTitle.value = b.title || "";
editUrl.value = b.url || "";
editFolderId.value = b.folderId ?? null;
editVisibility.value = b.visibility || "public";
}
function cancelEdit() {
editingId.value = "";
}
async function saveEdit(id) {
const nextTitle = editTitle.value.trim();
const nextUrl = editUrl.value.trim();
if (!nextTitle || !nextUrl) return;
try {
if (loggedIn.value) {
await apiFetch(`/bookmarks/${id}`, {
method: "PATCH",
body: JSON.stringify({
title: nextTitle,
url: nextUrl,
folderId: editFolderId.value ?? null,
visibility: editVisibility.value
})
});
editingId.value = "";
await loadRemote();
} else {
patchLocalBookmark(id, {
title: nextTitle,
url: nextUrl,
folderId: editFolderId.value ?? null,
visibility: editVisibility.value
});
editingId.value = "";
loadLocal();
}
} catch (e) {
error.value = e.message || String(e);
}
}
function logout() {
setToken("");
loadLocal();
}
async function reload() {
if (loggedIn.value) await loadRemote();
else loadLocal();
}
async function clearSearch() {
q.value = "";
await reload();
}
onMounted(() => {
if (loggedIn.value) loadRemote();
else loadLocal();
});
</script>
<template>
<section class="bb-page">
<div class="bb-pageHeader">
<h1>我的书签</h1>
<button v-if="loggedIn" class="bb-btn bb-btn--secondary" @click="logout">退出登录</button>
</div>
<p v-if="!loggedIn" class="bb-muted" style="margin-top: 8px;">
当前未登录数据仅保存在本机 localStorage登录后会自动同步到云端
</p>
<div class="searchRow" style="margin-top: 12px;">
<input v-model="q" class="bb-input" placeholder="搜索标题/链接" @keyup.enter="reload" />
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="reload">搜索</button>
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="clearSearch">清除</button>
</div>
<div class="form">
<input v-model="title" class="bb-input" placeholder="标题" />
<input v-model="url" class="bb-input" placeholder="链接https://..." />
<select v-model="folderId" class="bb-select">
<option :value="null">无文件夹</option>
<option v-for="x in folderFlat" :key="x.folder.id" :value="x.folder.id">{{ folderLabel(x.folder, x.depth) }}</option>
</select>
<select v-model="visibility" class="bb-select">
<option value="public">公开</option>
<option value="private">私有</option>
</select>
<button class="bb-btn" @click="add">添加</button>
</div>
<div v-if="loggedIn" class="bb-card" style="margin: 12px 0;">
<div class="bb-row">
<div class="sectionTitle">文件夹云端</div>
</div>
<div class="bb-row" style="margin-top: 10px;">
<input v-model="folderName" class="bb-input" placeholder="新文件夹名称" />
<select v-model="folderParentId" class="bb-select">
<option :value="null">根目录</option>
<option v-for="x in folderFlat" :key="x.folder.id" :value="x.folder.id">{{ folderLabel(x.folder, x.depth) }}</option>
</select>
<select v-model="folderVisibility" class="bb-select">
<option value="private">私有</option>
<option value="public">公开</option>
</select>
<button class="bb-btn" @click="createFolder">创建文件夹</button>
</div>
<div v-if="folders.length" class="folderList">
<div v-for="x in folderFlat" :key="x.folder.id" class="folderRow">
<template v-if="folderEditingId !== x.folder.id">
<div class="folderName" :style="{ paddingLeft: `${x.depth * 14}px` }">{{ x.folder.name }}</div>
<div class="folderMeta">
{{ x.folder.visibility === 'public' ? '公开' : '私有' }} · {{ x.folder.parentId ? '子文件夹' : '根文件夹' }}
</div>
<div class="actions">
<button class="bb-btn bb-btn--secondary" @click="startEditFolder(x.folder)">编辑</button>
<button class="bb-btn bb-btn--danger" @click="removeFolder(x.folder.id)">删除</button>
</div>
</template>
<template v-else>
<input v-model="editFolderName" class="bb-input" placeholder="文件夹名称" />
<select v-model="editFolderParentId" class="bb-select">
<option :value="null">根目录</option>
<option
v-for="p in folderFlat"
:key="p.folder.id"
:value="p.folder.id"
:disabled="p.folder.id === x.folder.id"
>
{{ folderLabel(p.folder, p.depth) }}
</option>
</select>
<select v-model="editFolderVisibility" class="bb-select">
<option value="private">私有</option>
<option value="public">公开</option>
</select>
<div class="actions">
<button class="bb-btn" @click="saveFolder(x.folder.id)">保存</button>
<button class="bb-btn bb-btn--secondary" @click="cancelEditFolder">取消</button>
</div>
</template>
</div>
</div>
<div v-else class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 700;">还没有文件夹</div>
<div class="bb-muted" style="margin-top: 6px;">可以先创建一个根文件夹或为它选择父级形成层级结构</div>
</div>
</div>
<p v-if="error" class="bb-alert bb-alert--error">{{ error }}</p>
<p v-if="loading" class="bb-muted">加载中</p>
<div v-if="!loading && !error && !items.length" class="bb-empty" style="margin-top: 12px;">
<div style="font-weight: 700;">暂无书签</div>
<div class="bb-muted" style="margin-top: 6px;">用上面的输入框快速添加一个吧</div>
</div>
<ul v-if="!loading && !error && items.length" class="list">
<li v-for="b in items" :key="b.id" class="bb-card bb-card--interactive">
<div v-if="editingId !== b.id">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
<div class="actions">
<button class="bb-btn bb-btn--secondary" @click="startEdit(b)">编辑</button>
<button class="bb-btn bb-btn--danger" @click="remove(b.id)">删除</button>
</div>
</div>
<div v-else class="edit">
<input v-model="editTitle" class="bb-input" placeholder="标题" />
<input v-model="editUrl" class="bb-input" placeholder="链接" />
<select v-model="editFolderId" class="bb-select">
<option :value="null">无文件夹</option>
<option v-for="x in folderFlat" :key="x.folder.id" :value="x.folder.id">{{ folderLabel(x.folder, x.depth) }}</option>
</select>
<select v-model="editVisibility" class="bb-select">
<option value="public">公开</option>
<option value="private">私有</option>
</select>
<div class="actions">
<button class="bb-btn" @click="saveEdit(b.id)">保存</button>
<button class="bb-btn bb-btn--secondary" @click="cancelEdit">取消</button>
</div>
</div>
</li>
</ul>
</section>
</template>
<style scoped>
.bb-pageHeader h1 { margin: 0; }
.form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
@media (min-width: 768px) { .form { grid-template-columns: 2fr 3fr 2fr 1fr auto; align-items: center; } }
.searchRow { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
@media (min-width: 768px) { .searchRow { grid-template-columns: 1fr auto auto; align-items: center; } }
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 12px; }
@media (min-width: 768px) { .list { grid-template-columns: 1fr 1fr; } }
.title { color: var(--bb-text); font-weight: 700; text-decoration: none; }
.actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.edit { display: grid; gap: 10px; }
.sectionTitle { font-weight: 800; }
.folderList { display: grid; gap: 10px; margin-top: 12px; }
.folderRow { display: grid; gap: 8px; grid-template-columns: 1fr; padding: 10px; border: 1px solid var(--bb-border); border-radius: 12px; background: rgba(59,130,246,0.03); }
@media (min-width: 768px) { .folderRow { grid-template-columns: 2fr 1fr 2fr; align-items: center; } }
.folderName { font-weight: 700; }
.folderMeta { font-size: 12px; color: #475569; }
</style>

View File

@@ -0,0 +1,53 @@
<script setup>
import { onMounted, ref } from "vue";
import { apiFetch } from "../lib/api";
const q = ref("");
const loading = ref(false);
const error = ref("");
const items = 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;
}
}
onMounted(load);
</script>
<template>
<section class="bb-page">
<h1>公开书签</h1>
<div class="bb-row" style="margin: 12px 0;">
<input v-model="q" class="bb-input" placeholder="搜索标题或链接" @keyup.enter="load" />
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="load">搜索</button>
</div>
<p v-if="error" class="bb-alert bb-alert--error">{{ error }}</p>
<p v-else-if="loading" class="bb-muted">加载中</p>
<div v-else-if="!items.length" class="bb-empty">
<div style="font-weight: 700;">暂无公开书签</div>
<div class="bb-muted" style="margin-top: 6px;">换个关键词试试或稍后再来</div>
</div>
<ul v-if="!loading && !error && items.length" class="bb-grid2" style="list-style: none; padding: 0;">
<li v-for="b in items" :key="b.id" class="bb-card bb-card--interactive">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
</li>
</ul>
</section>
</template>
<style scoped>
h1 { margin: 0 0 12px; }
.title { color: var(--bb-text); font-weight: 800; text-decoration: none; }
</style>

15
apps/web/src/router.js Normal file
View File

@@ -0,0 +1,15 @@
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";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: PublicPage },
{ path: "/login", component: LoginPage },
{ path: "/my", component: MyPage },
{ path: "/import", component: ImportExportPage }
]
});

183
apps/web/src/style.css Normal file
View File

@@ -0,0 +1,183 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
:root {
--bb-bg: #f8fafc;
--bb-text: #1e293b;
--bb-primary: #3b82f6;
--bb-primary-weak: #60a5fa;
--bb-cta: #f97316;
--bb-border: #e5e7eb;
font-family: Inter, ui-sans-serif, system-ui;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--bb-bg);
color: var(--bb-text);
}
#app {
min-height: 100vh;
}
a {
color: var(--bb-primary);
text-decoration: none;
}
a:hover {
color: var(--bb-primary-weak);
}
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; }
}
/* --- Shared UI primitives (Web) --- */
.bb-page {
max-width: 960px;
margin: 0 auto;
padding: 16px;
}
.bb-pageHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.bb-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.bb-muted {
color: #475569;
font-size: 12px;
}
.bb-input,
.bb-select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--bb-border);
border-radius: 10px;
background: white;
color: var(--bb-text);
}
.bb-input--sm,
.bb-select--sm {
padding: 8px 10px;
}
.bb-btn {
padding: 10px 12px;
border: 1px solid var(--bb-primary);
border-radius: 10px;
background: var(--bb-primary);
color: white;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease, background 120ms ease;
}
.bb-btn:hover {
filter: brightness(1.03);
}
.bb-btn:active {
transform: translateY(1px);
}
.bb-btn--secondary {
border-color: var(--bb-border);
background: rgba(59, 130, 246, 0.08);
color: var(--bb-text);
}
.bb-btn--danger {
border-color: #fecaca;
background: #fee2e2;
color: #991b1b;
}
.bb-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.bb-card {
border: 1px solid var(--bb-border);
border-radius: 14px;
padding: 12px;
background: white;
}
.bb-card--interactive {
transition: transform 120ms ease, box-shadow 120ms ease;
}
.bb-card--interactive:hover {
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
.bb-alert {
border-radius: 12px;
padding: 10px 12px;
border: 1px solid var(--bb-border);
background: rgba(2, 132, 199, 0.06);
}
.bb-alert--error {
border-color: #fecaca;
background: #fef2f2;
color: #991b1b;
}
.bb-alert--ok {
border-color: #bbf7d0;
background: #f0fdf4;
color: #065f46;
}
.bb-empty {
border: 1px dashed var(--bb-border);
border-radius: 14px;
padding: 18px 14px;
background: rgba(255, 255, 255, 0.65);
}
.bb-grid2 {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
@media (min-width: 768px) {
.bb-grid2 {
grid-template-columns: 1fr 1fr;
}
}

7
apps/web/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})