初始化项目

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/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()],
})