提交0.1.0版本
- 完成了书签的基本功能和插件
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Extension API base (Fastify server)
|
||||
VITE_SERVER_BASE_URL=http://mark.cloud-xl.top:6667
|
||||
|
||||
# Web app base (used by Options -> 跳转 Web)
|
||||
VITE_WEB_BASE_URL=http://mark.cloud-xl.top:6666
|
||||
# Extension API base (Fastify server)
|
||||
VITE_SERVER_BASE_URL=http://mark.cloud-xl.top:6667
|
||||
|
||||
# Web app base (used by Options -> 跳转 Web)
|
||||
VITE_WEB_BASE_URL=http://mark.cloud-xl.top:6666
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.js", "**/*.vue"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: "module"
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
}
|
||||
];
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.js", "**/*.vue"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: "module"
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
BIN
apps/extension/extension-dist.zip
Normal file
BIN
apps/extension/extension-dist.zip
Normal file
Binary file not shown.
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>云书签 选项</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/options/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>云书签 选项</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/options/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@browser-bookmark/shared": "0.1.0",
|
||||
"@browser-bookmark/shared": "file:../../packages/shared",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue": "^3.5.24"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bookmarks</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/popup/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bookmarks</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/popup/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "BrowserBookmark",
|
||||
"version": "0.1.0",
|
||||
"action": {
|
||||
"default_title": "Bookmarks",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_page": "options.html",
|
||||
"permissions": ["storage", "tabs"],
|
||||
"host_permissions": ["<all_urls>"]
|
||||
}
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "BrowserBookmark",
|
||||
"version": "0.1.0",
|
||||
"action": {
|
||||
"default_title": "Bookmarks",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_page": "options.html",
|
||||
"permissions": ["storage", "tabs"],
|
||||
"host_permissions": ["<all_urls>"]
|
||||
}
|
||||
|
||||
@@ -1,154 +1,154 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: "请确认" },
|
||||
message: { type: String, default: "" },
|
||||
confirmText: { type: String, default: "确定" },
|
||||
cancelText: { type: String, default: "取消" },
|
||||
danger: { type: Boolean, default: false },
|
||||
maxWidth: { type: String, default: "520px" }
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "confirm", "cancel"]);
|
||||
|
||||
function cancel() {
|
||||
emit("update:modelValue", false);
|
||||
emit("cancel");
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit("confirm");
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === "Escape") cancel();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div v-if="modelValue" class="bb-modalOverlay" role="dialog" aria-modal="true">
|
||||
<div class="bb-modalBackdrop" @click="cancel" />
|
||||
<div class="bb-modalPanel" :style="{ maxWidth }">
|
||||
<div class="bb-modalHeader">
|
||||
<div class="bb-modalTitle">{{ title }}</div>
|
||||
<button type="button" class="bb-modalClose" @click="cancel" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="bb-modalBody">
|
||||
<div v-if="message" class="bb-modalMessage">{{ message }}</div>
|
||||
<slot />
|
||||
<div class="bb-modalActions">
|
||||
<button type="button" class="bb-btn bb-btn--secondary" @click="cancel">{{ cancelText }}</button>
|
||||
<button type="button" class="bb-btn" :class="danger ? 'bb-btn--danger' : ''" @click="confirm">{{ confirmText }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483500;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bb-modalBackdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.38);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.bb-modalPanel {
|
||||
position: relative;
|
||||
width: min(100%, 560px);
|
||||
max-height: min(84vh, 860px);
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.22);
|
||||
color: var(--bb-text);
|
||||
}
|
||||
|
||||
.bb-modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.bb-modalTitle {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bb-modalClose {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: rgba(15, 23, 42, 0.75);
|
||||
}
|
||||
|
||||
.bb-modalClose:hover {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.bb-modalBody {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.bb-modalMessage {
|
||||
color: rgba(15, 23, 42, 0.78);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.bb-modalActions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bb-btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bb-btn--secondary {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
color: var(--bb-text);
|
||||
border-color: var(--bb-border);
|
||||
}
|
||||
|
||||
.bb-btn--danger {
|
||||
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||
}
|
||||
</style>
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: "请确认" },
|
||||
message: { type: String, default: "" },
|
||||
confirmText: { type: String, default: "确定" },
|
||||
cancelText: { type: String, default: "取消" },
|
||||
danger: { type: Boolean, default: false },
|
||||
maxWidth: { type: String, default: "520px" }
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "confirm", "cancel"]);
|
||||
|
||||
function cancel() {
|
||||
emit("update:modelValue", false);
|
||||
emit("cancel");
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit("confirm");
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === "Escape") cancel();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div v-if="modelValue" class="bb-modalOverlay" role="dialog" aria-modal="true">
|
||||
<div class="bb-modalBackdrop" @click="cancel" />
|
||||
<div class="bb-modalPanel" :style="{ maxWidth }">
|
||||
<div class="bb-modalHeader">
|
||||
<div class="bb-modalTitle">{{ title }}</div>
|
||||
<button type="button" class="bb-modalClose" @click="cancel" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="bb-modalBody">
|
||||
<div v-if="message" class="bb-modalMessage">{{ message }}</div>
|
||||
<slot />
|
||||
<div class="bb-modalActions">
|
||||
<button type="button" class="bb-btn bb-btn--secondary" @click="cancel">{{ cancelText }}</button>
|
||||
<button type="button" class="bb-btn" :class="danger ? 'bb-btn--danger' : ''" @click="confirm">{{ confirmText }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483500;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bb-modalBackdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.38);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.bb-modalPanel {
|
||||
position: relative;
|
||||
width: min(100%, 560px);
|
||||
max-height: min(84vh, 860px);
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.22);
|
||||
color: var(--bb-text);
|
||||
}
|
||||
|
||||
.bb-modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.bb-modalTitle {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bb-modalClose {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: rgba(15, 23, 42, 0.75);
|
||||
}
|
||||
|
||||
.bb-modalClose:hover {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.bb-modalBody {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.bb-modalMessage {
|
||||
color: rgba(15, 23, 42, 0.78);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.bb-modalActions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bb-btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bb-btn--secondary {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
color: var(--bb-text);
|
||||
border-color: var(--bb-border);
|
||||
}
|
||||
|
||||
.bb-btn--danger {
|
||||
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!headers.has("Accept")) headers.set("Accept", "application/json");
|
||||
|
||||
if (!(options.body instanceof FormData) && options.body != null) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
// Lazy import to avoid circular deps
|
||||
const { getToken } = await import("./extStorage.js");
|
||||
const token = await getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
const isJson = contentType.includes("application/json");
|
||||
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
|
||||
|
||||
if (!res.ok) {
|
||||
const message = payload?.message || `HTTP ${res.status}`;
|
||||
const err = new Error(message);
|
||||
err.status = res.status;
|
||||
err.payload = payload;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!headers.has("Accept")) headers.set("Accept", "application/json");
|
||||
|
||||
if (!(options.body instanceof FormData) && options.body != null) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
// Lazy import to avoid circular deps
|
||||
const { getToken } = await import("./extStorage.js");
|
||||
const token = await getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
const isJson = contentType.includes("application/json");
|
||||
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
|
||||
|
||||
if (!res.ok) {
|
||||
const message = payload?.message || `HTTP ${res.status}`;
|
||||
const err = new Error(message);
|
||||
err.status = res.status;
|
||||
err.payload = payload;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
const TOKEN_KEY = "bb_token";
|
||||
const LOCAL_STATE_KEY = "bb_local_state_v1";
|
||||
|
||||
function hasChromeStorage() {
|
||||
return typeof chrome !== "undefined" && chrome.storage?.local;
|
||||
}
|
||||
|
||||
export async function getToken() {
|
||||
if (hasChromeStorage()) {
|
||||
const res = await chrome.storage.local.get([TOKEN_KEY]);
|
||||
return res[TOKEN_KEY] || "";
|
||||
}
|
||||
return localStorage.getItem(TOKEN_KEY) || "";
|
||||
}
|
||||
|
||||
export async function setToken(token) {
|
||||
if (hasChromeStorage()) {
|
||||
await chrome.storage.local.set({ [TOKEN_KEY]: token || "" });
|
||||
return;
|
||||
}
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function loadLocalState() {
|
||||
if (hasChromeStorage()) {
|
||||
const res = await chrome.storage.local.get([LOCAL_STATE_KEY]);
|
||||
return res[LOCAL_STATE_KEY] || { folders: [], bookmarks: [] };
|
||||
}
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LOCAL_STATE_KEY) || "") || { folders: [], bookmarks: [] };
|
||||
} catch {
|
||||
return { folders: [], bookmarks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLocalState(state) {
|
||||
if (hasChromeStorage()) {
|
||||
await chrome.storage.local.set({ [LOCAL_STATE_KEY]: state });
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(LOCAL_STATE_KEY, JSON.stringify(state));
|
||||
}
|
||||
const TOKEN_KEY = "bb_token";
|
||||
const LOCAL_STATE_KEY = "bb_local_state_v1";
|
||||
|
||||
function hasChromeStorage() {
|
||||
return typeof chrome !== "undefined" && chrome.storage?.local;
|
||||
}
|
||||
|
||||
export async function getToken() {
|
||||
if (hasChromeStorage()) {
|
||||
const res = await chrome.storage.local.get([TOKEN_KEY]);
|
||||
return res[TOKEN_KEY] || "";
|
||||
}
|
||||
return localStorage.getItem(TOKEN_KEY) || "";
|
||||
}
|
||||
|
||||
export async function setToken(token) {
|
||||
if (hasChromeStorage()) {
|
||||
await chrome.storage.local.set({ [TOKEN_KEY]: token || "" });
|
||||
return;
|
||||
}
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function loadLocalState() {
|
||||
if (hasChromeStorage()) {
|
||||
const res = await chrome.storage.local.get([LOCAL_STATE_KEY]);
|
||||
return res[LOCAL_STATE_KEY] || { folders: [], bookmarks: [] };
|
||||
}
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LOCAL_STATE_KEY) || "") || { folders: [], bookmarks: [] };
|
||||
} catch {
|
||||
return { folders: [], bookmarks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLocalState(state) {
|
||||
if (hasChromeStorage()) {
|
||||
await chrome.storage.local.set({ [LOCAL_STATE_KEY]: state });
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(LOCAL_STATE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
@@ -1,218 +1,218 @@
|
||||
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
|
||||
import { loadLocalState, saveLocalState } from "./extStorage";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ensureBookmarkHashes(bookmark) {
|
||||
const urlNormalized = bookmark.urlNormalized || normalizeUrl(bookmark.url || "");
|
||||
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
|
||||
return { ...bookmark, urlNormalized, urlHash };
|
||||
}
|
||||
|
||||
export async function listLocalBookmarks({ includeDeleted = false } = {}) {
|
||||
const state = await loadLocalState();
|
||||
const items = (state.bookmarks || []).map(ensureBookmarkHashes);
|
||||
const filtered = includeDeleted ? items : items.filter((b) => !b.deletedAt);
|
||||
// Keep newest first
|
||||
filtered.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
|
||||
const state = await loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
const urlNormalized = normalizeUrl(url || "");
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
// Dedupe: same urlHash and not deleted -> update LWW
|
||||
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
|
||||
if (existing) {
|
||||
existing.title = title || existing.title;
|
||||
existing.url = url || existing.url;
|
||||
existing.urlNormalized = urlNormalized;
|
||||
existing.urlHash = urlHash;
|
||||
existing.visibility = visibility;
|
||||
existing.folderId = folderId;
|
||||
existing.source = source;
|
||||
existing.updatedAt = now;
|
||||
await saveLocalState(state);
|
||||
return { bookmark: ensureBookmarkHashes(existing), merged: true };
|
||||
}
|
||||
|
||||
const bookmark = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId,
|
||||
title: title || "",
|
||||
url: url || "",
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
visibility,
|
||||
source,
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
};
|
||||
|
||||
state.bookmarks = state.bookmarks || [];
|
||||
state.bookmarks.unshift(bookmark);
|
||||
await saveLocalState(state);
|
||||
return { bookmark: ensureBookmarkHashes(bookmark), merged: false };
|
||||
}
|
||||
|
||||
export async function markLocalDeleted(id) {
|
||||
const state = await loadLocalState();
|
||||
const now = nowIso();
|
||||
const item = (state.bookmarks || []).find((b) => b.id === id);
|
||||
if (!item) return false;
|
||||
item.deletedAt = now;
|
||||
item.updatedAt = now;
|
||||
await saveLocalState(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clearLocalState() {
|
||||
await saveLocalState({ folders: [], bookmarks: [] });
|
||||
}
|
||||
|
||||
export async function mergeLocalToUser() {
|
||||
const state = await loadLocalState();
|
||||
return {
|
||||
folders: (state.folders || []).map((f) => ({
|
||||
id: f.id,
|
||||
parentId: f.parentId ?? null,
|
||||
name: f.name || "",
|
||||
visibility: f.visibility || "private",
|
||||
updatedAt: f.updatedAt || nowIso()
|
||||
})),
|
||||
bookmarks: (state.bookmarks || []).map((b) => {
|
||||
const fixed = ensureBookmarkHashes(b);
|
||||
return {
|
||||
id: fixed.id,
|
||||
folderId: fixed.folderId ?? null,
|
||||
title: fixed.title || "",
|
||||
url: fixed.url || "",
|
||||
visibility: fixed.visibility || "private",
|
||||
source: fixed.source || "manual",
|
||||
updatedAt: fixed.updatedAt || nowIso(),
|
||||
deletedAt: fixed.deletedAt || null
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export async function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
|
||||
const parsed = parseNetscapeBookmarkHtml(html || "");
|
||||
const state = await loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
state.folders = state.folders || [];
|
||||
state.bookmarks = state.bookmarks || [];
|
||||
|
||||
// Folder id remap (avoid collisions with existing UUID ids)
|
||||
const folderIdMap = new Map();
|
||||
for (const f of parsed.folders || []) {
|
||||
folderIdMap.set(f.id, crypto.randomUUID());
|
||||
}
|
||||
|
||||
// Dedupe folders by (parentId,name)
|
||||
const folderKeyToId = new Map(
|
||||
state.folders.map((f) => [`${f.parentId ?? ""}::${(f.name || "").toLowerCase()}`, f.id])
|
||||
);
|
||||
|
||||
const oldFolderIdToActual = new Map();
|
||||
let foldersImported = 0;
|
||||
for (const f of parsed.folders || []) {
|
||||
const parentId = f.parentFolderId ? oldFolderIdToActual.get(f.parentFolderId) || null : null;
|
||||
const name = (f.name || "").trim();
|
||||
const key = `${parentId ?? ""}::${name.toLowerCase()}`;
|
||||
|
||||
let id = folderKeyToId.get(key);
|
||||
if (!id) {
|
||||
id = folderIdMap.get(f.id);
|
||||
state.folders.push({
|
||||
id,
|
||||
userId: null,
|
||||
parentId,
|
||||
name,
|
||||
visibility,
|
||||
updatedAt: now
|
||||
});
|
||||
folderKeyToId.set(key, id);
|
||||
foldersImported++;
|
||||
} else {
|
||||
// Keep existing, but ensure it has a recent updatedAt
|
||||
const existing = state.folders.find((x) => x.id === id);
|
||||
if (existing && (!existing.updatedAt || existing.updatedAt < now)) existing.updatedAt = now;
|
||||
}
|
||||
|
||||
oldFolderIdToActual.set(f.id, id);
|
||||
}
|
||||
|
||||
// Dedupe bookmarks by urlHash
|
||||
const existingByHash = new Map();
|
||||
for (const b of state.bookmarks.map(ensureBookmarkHashes)) {
|
||||
if (!b.deletedAt && b.urlHash) existingByHash.set(b.urlHash, b);
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let merged = 0;
|
||||
|
||||
for (const b of parsed.bookmarks || []) {
|
||||
const title = (b.title || "").trim();
|
||||
const url = (b.url || "").trim();
|
||||
if (!url) continue;
|
||||
|
||||
const urlNormalized = normalizeUrl(url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
const folderId = b.parentFolderId ? oldFolderIdToActual.get(b.parentFolderId) || null : null;
|
||||
|
||||
const existing = existingByHash.get(urlHash);
|
||||
if (existing) {
|
||||
existing.title = title || existing.title;
|
||||
existing.updatedAt = now;
|
||||
merged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
state.bookmarks.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId: folderId ?? null,
|
||||
title,
|
||||
url,
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
visibility,
|
||||
source: "import",
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
});
|
||||
existingByHash.set(urlHash, state.bookmarks[0]);
|
||||
imported++;
|
||||
}
|
||||
|
||||
await saveLocalState(state);
|
||||
return { foldersImported, imported, merged };
|
||||
}
|
||||
|
||||
export function exportLocalToNetscapeHtml(bookmarks) {
|
||||
const safe = (s) => String(s || "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
const lines = [];
|
||||
lines.push("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
|
||||
lines.push('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">');
|
||||
lines.push("<TITLE>Bookmarks</TITLE>");
|
||||
lines.push("<H1>Bookmarks</H1>");
|
||||
lines.push("<DL><p>");
|
||||
|
||||
for (const b of bookmarks || []) {
|
||||
if (b.deletedAt) continue;
|
||||
lines.push(` <DT><A HREF="${safe(b.url)}">${safe(b.title || b.url)}</A>`);
|
||||
}
|
||||
|
||||
lines.push("</DL><p>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
|
||||
import { loadLocalState, saveLocalState } from "./extStorage";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ensureBookmarkHashes(bookmark) {
|
||||
const urlNormalized = bookmark.urlNormalized || normalizeUrl(bookmark.url || "");
|
||||
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
|
||||
return { ...bookmark, urlNormalized, urlHash };
|
||||
}
|
||||
|
||||
export async function listLocalBookmarks({ includeDeleted = false } = {}) {
|
||||
const state = await loadLocalState();
|
||||
const items = (state.bookmarks || []).map(ensureBookmarkHashes);
|
||||
const filtered = includeDeleted ? items : items.filter((b) => !b.deletedAt);
|
||||
// Keep newest first
|
||||
filtered.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
|
||||
const state = await loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
const urlNormalized = normalizeUrl(url || "");
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
// Dedupe: same urlHash and not deleted -> update LWW
|
||||
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
|
||||
if (existing) {
|
||||
existing.title = title || existing.title;
|
||||
existing.url = url || existing.url;
|
||||
existing.urlNormalized = urlNormalized;
|
||||
existing.urlHash = urlHash;
|
||||
existing.visibility = visibility;
|
||||
existing.folderId = folderId;
|
||||
existing.source = source;
|
||||
existing.updatedAt = now;
|
||||
await saveLocalState(state);
|
||||
return { bookmark: ensureBookmarkHashes(existing), merged: true };
|
||||
}
|
||||
|
||||
const bookmark = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId,
|
||||
title: title || "",
|
||||
url: url || "",
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
visibility,
|
||||
source,
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
};
|
||||
|
||||
state.bookmarks = state.bookmarks || [];
|
||||
state.bookmarks.unshift(bookmark);
|
||||
await saveLocalState(state);
|
||||
return { bookmark: ensureBookmarkHashes(bookmark), merged: false };
|
||||
}
|
||||
|
||||
export async function markLocalDeleted(id) {
|
||||
const state = await loadLocalState();
|
||||
const now = nowIso();
|
||||
const item = (state.bookmarks || []).find((b) => b.id === id);
|
||||
if (!item) return false;
|
||||
item.deletedAt = now;
|
||||
item.updatedAt = now;
|
||||
await saveLocalState(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clearLocalState() {
|
||||
await saveLocalState({ folders: [], bookmarks: [] });
|
||||
}
|
||||
|
||||
export async function mergeLocalToUser() {
|
||||
const state = await loadLocalState();
|
||||
return {
|
||||
folders: (state.folders || []).map((f) => ({
|
||||
id: f.id,
|
||||
parentId: f.parentId ?? null,
|
||||
name: f.name || "",
|
||||
visibility: f.visibility || "private",
|
||||
updatedAt: f.updatedAt || nowIso()
|
||||
})),
|
||||
bookmarks: (state.bookmarks || []).map((b) => {
|
||||
const fixed = ensureBookmarkHashes(b);
|
||||
return {
|
||||
id: fixed.id,
|
||||
folderId: fixed.folderId ?? null,
|
||||
title: fixed.title || "",
|
||||
url: fixed.url || "",
|
||||
visibility: fixed.visibility || "private",
|
||||
source: fixed.source || "manual",
|
||||
updatedAt: fixed.updatedAt || nowIso(),
|
||||
deletedAt: fixed.deletedAt || null
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export async function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
|
||||
const parsed = parseNetscapeBookmarkHtml(html || "");
|
||||
const state = await loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
state.folders = state.folders || [];
|
||||
state.bookmarks = state.bookmarks || [];
|
||||
|
||||
// Folder id remap (avoid collisions with existing UUID ids)
|
||||
const folderIdMap = new Map();
|
||||
for (const f of parsed.folders || []) {
|
||||
folderIdMap.set(f.id, crypto.randomUUID());
|
||||
}
|
||||
|
||||
// Dedupe folders by (parentId,name)
|
||||
const folderKeyToId = new Map(
|
||||
state.folders.map((f) => [`${f.parentId ?? ""}::${(f.name || "").toLowerCase()}`, f.id])
|
||||
);
|
||||
|
||||
const oldFolderIdToActual = new Map();
|
||||
let foldersImported = 0;
|
||||
for (const f of parsed.folders || []) {
|
||||
const parentId = f.parentFolderId ? oldFolderIdToActual.get(f.parentFolderId) || null : null;
|
||||
const name = (f.name || "").trim();
|
||||
const key = `${parentId ?? ""}::${name.toLowerCase()}`;
|
||||
|
||||
let id = folderKeyToId.get(key);
|
||||
if (!id) {
|
||||
id = folderIdMap.get(f.id);
|
||||
state.folders.push({
|
||||
id,
|
||||
userId: null,
|
||||
parentId,
|
||||
name,
|
||||
visibility,
|
||||
updatedAt: now
|
||||
});
|
||||
folderKeyToId.set(key, id);
|
||||
foldersImported++;
|
||||
} else {
|
||||
// Keep existing, but ensure it has a recent updatedAt
|
||||
const existing = state.folders.find((x) => x.id === id);
|
||||
if (existing && (!existing.updatedAt || existing.updatedAt < now)) existing.updatedAt = now;
|
||||
}
|
||||
|
||||
oldFolderIdToActual.set(f.id, id);
|
||||
}
|
||||
|
||||
// Dedupe bookmarks by urlHash
|
||||
const existingByHash = new Map();
|
||||
for (const b of state.bookmarks.map(ensureBookmarkHashes)) {
|
||||
if (!b.deletedAt && b.urlHash) existingByHash.set(b.urlHash, b);
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let merged = 0;
|
||||
|
||||
for (const b of parsed.bookmarks || []) {
|
||||
const title = (b.title || "").trim();
|
||||
const url = (b.url || "").trim();
|
||||
if (!url) continue;
|
||||
|
||||
const urlNormalized = normalizeUrl(url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
const folderId = b.parentFolderId ? oldFolderIdToActual.get(b.parentFolderId) || null : null;
|
||||
|
||||
const existing = existingByHash.get(urlHash);
|
||||
if (existing) {
|
||||
existing.title = title || existing.title;
|
||||
existing.updatedAt = now;
|
||||
merged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
state.bookmarks.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId: folderId ?? null,
|
||||
title,
|
||||
url,
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
visibility,
|
||||
source: "import",
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
});
|
||||
existingByHash.set(urlHash, state.bookmarks[0]);
|
||||
imported++;
|
||||
}
|
||||
|
||||
await saveLocalState(state);
|
||||
return { foldersImported, imported, merged };
|
||||
}
|
||||
|
||||
export function exportLocalToNetscapeHtml(bookmarks) {
|
||||
const safe = (s) => String(s || "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
<script setup>
|
||||
import { RouterView } from "vue-router";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<header class="nav">
|
||||
<div class="brand">云书签 · 更多操作</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
body { margin: 0; }
|
||||
|
||||
.shell { min-height: 100vh; }
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
border-bottom: 1px solid var(--bb-border);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.brand { font-weight: 800; }
|
||||
.content { max-width: 1100px; margin: 0 auto; padding: 14px; }
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
</style>
|
||||
<script setup>
|
||||
import { RouterView } from "vue-router";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<header class="nav">
|
||||
<div class="brand">云书签 · 更多操作</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
body { margin: 0; }
|
||||
|
||||
.shell { min-height: 100vh; }
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
border-bottom: 1px solid var(--bb-border);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.brand { font-weight: 800; }
|
||||
.content { max-width: 1100px; margin: 0 auto; padding: 14px; }
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import OptionsApp from "./OptionsApp.vue";
|
||||
import { router } from "./router";
|
||||
|
||||
import "../style.css";
|
||||
|
||||
createApp(OptionsApp).use(router).mount("#app");
|
||||
import { createApp } from "vue";
|
||||
import OptionsApp from "./OptionsApp.vue";
|
||||
import { router } from "./router";
|
||||
|
||||
import "../style.css";
|
||||
|
||||
createApp(OptionsApp).use(router).mount("#app");
|
||||
|
||||
@@ -1,135 +1,135 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
import { getToken } from "../../lib/extStorage";
|
||||
import { exportLocalToNetscapeHtml, importLocalFromNetscapeHtml, listLocalBookmarks } from "../../lib/localData";
|
||||
|
||||
const file = ref(null);
|
||||
const status = ref("");
|
||||
const error = ref("");
|
||||
|
||||
function onFileChange(e) {
|
||||
file.value = e?.target?.files?.[0] || null;
|
||||
}
|
||||
|
||||
async function importToLocal() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
if (!file.value) return;
|
||||
try {
|
||||
const text = await file.value.text();
|
||||
const res = await importLocalFromNetscapeHtml(text, { visibility: "public" });
|
||||
status.value = `本地导入完成:文件夹新增 ${res.foldersImported},书签新增 ${res.imported},合并 ${res.merged}`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function importFile() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
await importToLocal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.value) return;
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file.value);
|
||||
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
|
||||
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCloud() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const html = await apiFetch("/bookmarks/export/html", {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/html" }
|
||||
});
|
||||
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bookmarks-cloud.html";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
status.value = "云端导出完成";
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportLocal() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const bookmarks = await listLocalBookmarks({ includeDeleted: false });
|
||||
const html = exportLocalToNetscapeHtml(bookmarks);
|
||||
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bookmarks.html";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
status.value = `本地导出完成:${bookmarks.length} 条`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>导入 / 导出</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>导入书签 HTML(写入云端)</h2>
|
||||
<input
|
||||
type="file"
|
||||
accept="text/html,.html"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<button class="btn" @click="importFile">开始导入</button>
|
||||
<p v-if="status" class="ok">{{ status }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>导出(本地)</h2>
|
||||
<button class="btn" @click="exportLocal">导出本地为 HTML</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>导出(云端)</h2>
|
||||
<button class="btn" @click="exportCloud">导出为 HTML</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; margin: 12px 0; }
|
||||
.btn { margin-top: 10px; padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.ok { color: #065f46; }
|
||||
.error { color: #b91c1c; }
|
||||
</style>
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
import { getToken } from "../../lib/extStorage";
|
||||
import { exportLocalToNetscapeHtml, importLocalFromNetscapeHtml, listLocalBookmarks } from "../../lib/localData";
|
||||
|
||||
const file = ref(null);
|
||||
const status = ref("");
|
||||
const error = ref("");
|
||||
|
||||
function onFileChange(e) {
|
||||
file.value = e?.target?.files?.[0] || null;
|
||||
}
|
||||
|
||||
async function importToLocal() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
if (!file.value) return;
|
||||
try {
|
||||
const text = await file.value.text();
|
||||
const res = await importLocalFromNetscapeHtml(text, { visibility: "public" });
|
||||
status.value = `本地导入完成:文件夹新增 ${res.foldersImported},书签新增 ${res.imported},合并 ${res.merged}`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function importFile() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
await importToLocal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.value) return;
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file.value);
|
||||
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
|
||||
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCloud() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const html = await apiFetch("/bookmarks/export/html", {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/html" }
|
||||
});
|
||||
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bookmarks-cloud.html";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
status.value = "云端导出完成";
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportLocal() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const bookmarks = await listLocalBookmarks({ includeDeleted: false });
|
||||
const html = exportLocalToNetscapeHtml(bookmarks);
|
||||
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bookmarks.html";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
status.value = `本地导出完成:${bookmarks.length} 条`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>导入 / 导出</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>导入书签 HTML(写入云端)</h2>
|
||||
<input
|
||||
type="file"
|
||||
accept="text/html,.html"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<button class="btn" @click="importFile">开始导入</button>
|
||||
<p v-if="status" class="ok">{{ status }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>导出(本地)</h2>
|
||||
<button class="btn" @click="exportLocal">导出本地为 HTML</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>导出(云端)</h2>
|
||||
<button class="btn" @click="exportCloud">导出为 HTML</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; margin: 12px 0; }
|
||||
.btn { margin-top: 10px; padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.ok { color: #065f46; }
|
||||
.error { color: #b91c1c; }
|
||||
</style>
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
import { setToken } from "../../lib/extStorage";
|
||||
import { clearLocalState, mergeLocalToUser } from "../../lib/localData";
|
||||
|
||||
const router = useRouter();
|
||||
const mode = ref("login");
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
|
||||
const res = await apiFetch(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
});
|
||||
|
||||
await setToken(res.token);
|
||||
|
||||
// Push local state to server on login
|
||||
const payload = await mergeLocalToUser();
|
||||
await apiFetch("/sync/push", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
// After merge, keep extension in cloud mode
|
||||
await clearLocalState();
|
||||
|
||||
await router.replace("/");
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>登录 / 注册</h1>
|
||||
|
||||
<div class="row">
|
||||
<button class="tab" :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button>
|
||||
<button class="tab" :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button>
|
||||
</div>
|
||||
|
||||
<div class="form">
|
||||
<input v-model="email" class="input" placeholder="邮箱" autocomplete="email" />
|
||||
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
|
||||
<button class="btn" :disabled="loading" @click="submit">提交</button>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<p class="muted">扩展内的本地书签存储在 chrome.storage.local。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
|
||||
.tab { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; cursor: pointer; }
|
||||
.tab.active { border-color: #111827; background: #111827; color: white; }
|
||||
.form { display: grid; gap: 10px; max-width: 560px; }
|
||||
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.error { color: #b91c1c; }
|
||||
.muted { color: #475569; font-size: 12px; margin-top: 10px; }
|
||||
</style>
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
import { setToken } from "../../lib/extStorage";
|
||||
import { clearLocalState, mergeLocalToUser } from "../../lib/localData";
|
||||
|
||||
const router = useRouter();
|
||||
const mode = ref("login");
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
|
||||
const res = await apiFetch(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
});
|
||||
|
||||
await setToken(res.token);
|
||||
|
||||
// Push local state to server on login
|
||||
const payload = await mergeLocalToUser();
|
||||
await apiFetch("/sync/push", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
// After merge, keep extension in cloud mode
|
||||
await clearLocalState();
|
||||
|
||||
await router.replace("/");
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>登录 / 注册</h1>
|
||||
|
||||
<div class="row">
|
||||
<button class="tab" :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button>
|
||||
<button class="tab" :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button>
|
||||
</div>
|
||||
|
||||
<div class="form">
|
||||
<input v-model="email" class="input" placeholder="邮箱" autocomplete="email" />
|
||||
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
|
||||
<button class="btn" :disabled="loading" @click="submit">提交</button>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<p class="muted">扩展内的本地书签存储在 chrome.storage.local。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
|
||||
.tab { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; cursor: pointer; }
|
||||
.tab.active { border-color: #111827; background: #111827; color: white; }
|
||||
.form { display: grid; gap: 10px; max-width: 560px; }
|
||||
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.error { color: #b91c1c; }
|
||||
.muted { color: #475569; font-size: 12px; margin-top: 10px; }
|
||||
</style>
|
||||
|
||||
@@ -1,104 +1,104 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { getToken, setToken } from "../../lib/extStorage";
|
||||
import BbConfirmModal from "../../components/BbConfirmModal.vue";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const token = ref("");
|
||||
const loggedIn = computed(() => Boolean(token.value));
|
||||
|
||||
const webBaseUrl = import.meta.env.VITE_WEB_BASE_URL || "http://localhost:5173";
|
||||
|
||||
async function refresh() {
|
||||
token.value = await getToken();
|
||||
}
|
||||
|
||||
function openWeb() {
|
||||
const url = String(webBaseUrl || "").trim();
|
||||
if (!url) return;
|
||||
if (typeof chrome !== "undefined" && chrome.tabs?.create) chrome.tabs.create({ url });
|
||||
else window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
const logoutModalOpen = ref(false);
|
||||
const logoutStep = ref(1);
|
||||
|
||||
function startLogout() {
|
||||
logoutStep.value = 1;
|
||||
logoutModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function confirmLogout() {
|
||||
if (logoutStep.value === 1) {
|
||||
logoutStep.value = 2;
|
||||
return;
|
||||
}
|
||||
await setToken("");
|
||||
logoutModalOpen.value = false;
|
||||
await refresh();
|
||||
await router.replace("/login");
|
||||
}
|
||||
|
||||
function cancelLogout() {
|
||||
logoutModalOpen.value = false;
|
||||
logoutStep.value = 1;
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<h1 class="h1">更多操作</h1>
|
||||
|
||||
<p v-if="!loggedIn" class="muted">当前未登录,将跳转到登录页。</p>
|
||||
|
||||
<div class="card">
|
||||
<button class="btn" type="button" @click="openWeb">跳转 Web</button>
|
||||
<button class="btn btn--secondary" type="button" @click="startLogout">退出登录</button>
|
||||
<p class="hint">Web 地址来自环境变量:VITE_WEB_BASE_URL</p>
|
||||
</div>
|
||||
|
||||
<BbConfirmModal
|
||||
v-model="logoutModalOpen"
|
||||
title="退出登录"
|
||||
:message="logoutStep === 1 ? '退出以后无法同步书签。' : '你确定要退出吗?'"
|
||||
:confirm-text="logoutStep === 1 ? '继续' : '确定退出'"
|
||||
cancel-text="取消"
|
||||
:danger="logoutStep === 2"
|
||||
@confirm="confirmLogout"
|
||||
@cancel="cancelLogout"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 14px; }
|
||||
.h1 { margin: 0 0 10px; font-size: 18px; }
|
||||
.card {
|
||||
max-width: 560px;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: var(--bb-card);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn--secondary {
|
||||
background: rgba(255,255,255,0.55);
|
||||
color: var(--bb-text);
|
||||
border-color: var(--bb-border);
|
||||
}
|
||||
.muted { color: rgba(19, 78, 74, 0.72); font-size: 12px; }
|
||||
.hint { color: rgba(19, 78, 74, 0.72); font-size: 12px; margin: 0; }
|
||||
</style>
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { getToken, setToken } from "../../lib/extStorage";
|
||||
import BbConfirmModal from "../../components/BbConfirmModal.vue";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const token = ref("");
|
||||
const loggedIn = computed(() => Boolean(token.value));
|
||||
|
||||
const webBaseUrl = import.meta.env.VITE_WEB_BASE_URL || "http://localhost:5173";
|
||||
|
||||
async function refresh() {
|
||||
token.value = await getToken();
|
||||
}
|
||||
|
||||
function openWeb() {
|
||||
const url = String(webBaseUrl || "").trim();
|
||||
if (!url) return;
|
||||
if (typeof chrome !== "undefined" && chrome.tabs?.create) chrome.tabs.create({ url });
|
||||
else window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
const logoutModalOpen = ref(false);
|
||||
const logoutStep = ref(1);
|
||||
|
||||
function startLogout() {
|
||||
logoutStep.value = 1;
|
||||
logoutModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function confirmLogout() {
|
||||
if (logoutStep.value === 1) {
|
||||
logoutStep.value = 2;
|
||||
return;
|
||||
}
|
||||
await setToken("");
|
||||
logoutModalOpen.value = false;
|
||||
await refresh();
|
||||
await router.replace("/login");
|
||||
}
|
||||
|
||||
function cancelLogout() {
|
||||
logoutModalOpen.value = false;
|
||||
logoutStep.value = 1;
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<h1 class="h1">更多操作</h1>
|
||||
|
||||
<p v-if="!loggedIn" class="muted">当前未登录,将跳转到登录页。</p>
|
||||
|
||||
<div class="card">
|
||||
<button class="btn" type="button" @click="openWeb">跳转 Web</button>
|
||||
<button class="btn btn--secondary" type="button" @click="startLogout">退出登录</button>
|
||||
<p class="hint">Web 地址来自环境变量:VITE_WEB_BASE_URL</p>
|
||||
</div>
|
||||
|
||||
<BbConfirmModal
|
||||
v-model="logoutModalOpen"
|
||||
title="退出登录"
|
||||
:message="logoutStep === 1 ? '退出以后无法同步书签。' : '你确定要退出吗?'"
|
||||
:confirm-text="logoutStep === 1 ? '继续' : '确定退出'"
|
||||
cancel-text="取消"
|
||||
:danger="logoutStep === 2"
|
||||
@confirm="confirmLogout"
|
||||
@cancel="cancelLogout"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 14px; }
|
||||
.h1 { margin: 0 0 10px; font-size: 18px; }
|
||||
.card {
|
||||
max-width: 560px;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: var(--bb-card);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn--secondary {
|
||||
background: rgba(255,255,255,0.55);
|
||||
color: var(--bb-text);
|
||||
border-color: var(--bb-border);
|
||||
}
|
||||
.muted { color: rgba(19, 78, 74, 0.72); font-size: 12px; }
|
||||
.hint { color: rgba(19, 78, 74, 0.72); font-size: 12px; margin: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,134 +1,134 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
import { getToken } from "../../lib/extStorage";
|
||||
import { listLocalBookmarks, markLocalDeleted, upsertLocalBookmark } from "../../lib/localData";
|
||||
import BbConfirmModal from "../../components/BbConfirmModal.vue";
|
||||
|
||||
const token = ref("");
|
||||
const loggedIn = computed(() => Boolean(token.value));
|
||||
const items = ref([]);
|
||||
const error = ref("");
|
||||
const mode = computed(() => (loggedIn.value ? "cloud" : "local"));
|
||||
|
||||
const title = ref("");
|
||||
const url = ref("");
|
||||
|
||||
async function load() {
|
||||
error.value = "";
|
||||
try {
|
||||
token.value = await getToken();
|
||||
if (!token.value) {
|
||||
items.value = await listLocalBookmarks();
|
||||
return;
|
||||
}
|
||||
items.value = await apiFetch("/bookmarks");
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!title.value || !url.value) return;
|
||||
if (mode.value === "cloud") {
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ folderId: null, title: title.value, url: url.value, visibility: "public" })
|
||||
});
|
||||
} else {
|
||||
await upsertLocalBookmark({ title: title.value, url: url.value, visibility: "public" });
|
||||
}
|
||||
title.value = "";
|
||||
url.value = "";
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (mode.value !== "local") return;
|
||||
pendingDeleteId.value = id;
|
||||
deleteConfirmOpen.value = true;
|
||||
}
|
||||
|
||||
const deleteConfirmOpen = ref(false);
|
||||
const pendingDeleteId = ref("");
|
||||
|
||||
async function confirmDelete() {
|
||||
const id = pendingDeleteId.value;
|
||||
if (!id) {
|
||||
deleteConfirmOpen.value = false;
|
||||
return;
|
||||
}
|
||||
await markLocalDeleted(id);
|
||||
pendingDeleteId.value = "";
|
||||
deleteConfirmOpen.value = false;
|
||||
await load();
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
pendingDeleteId.value = "";
|
||||
deleteConfirmOpen.value = false;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>我的书签({{ mode === 'cloud' ? '云端' : '本地' }})</h1>
|
||||
<p v-if="!loggedIn" class="muted">未登录时,书签保存在扩展本地(可在登录后自动合并上云)。</p>
|
||||
|
||||
<div class="form">
|
||||
<input v-model="title" class="input" placeholder="标题" />
|
||||
<input v-model="url" class="input" placeholder="链接" />
|
||||
<button class="btn" @click="add">添加</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<ul class="list">
|
||||
<li v-for="b in items" :key="b.id" class="card">
|
||||
<div class="row">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
|
||||
<button v-if="mode === 'local'" class="ghost" @click.prevent="remove(b.id)">删除</button>
|
||||
</div>
|
||||
<div class="muted">{{ b.url }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<BbConfirmModal
|
||||
v-model="deleteConfirmOpen"
|
||||
title="删除书签"
|
||||
message="确定删除该书签?"
|
||||
confirm-text="删除"
|
||||
cancel-text="取消"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="cancelDelete"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
|
||||
@media (min-width: 900px) { .form { grid-template-columns: 2fr 3fr auto; } }
|
||||
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.row { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
|
||||
.ghost { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 10px; padding: 6px 10px; cursor: pointer; }
|
||||
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
|
||||
.title {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
|
||||
.error { color: #b91c1c; }
|
||||
.muted { color: #475569; font-size: 12px; }
|
||||
</style>
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
import { getToken } from "../../lib/extStorage";
|
||||
import { listLocalBookmarks, markLocalDeleted, upsertLocalBookmark } from "../../lib/localData";
|
||||
import BbConfirmModal from "../../components/BbConfirmModal.vue";
|
||||
|
||||
const token = ref("");
|
||||
const loggedIn = computed(() => Boolean(token.value));
|
||||
const items = ref([]);
|
||||
const error = ref("");
|
||||
const mode = computed(() => (loggedIn.value ? "cloud" : "local"));
|
||||
|
||||
const title = ref("");
|
||||
const url = ref("");
|
||||
|
||||
async function load() {
|
||||
error.value = "";
|
||||
try {
|
||||
token.value = await getToken();
|
||||
if (!token.value) {
|
||||
items.value = await listLocalBookmarks();
|
||||
return;
|
||||
}
|
||||
items.value = await apiFetch("/bookmarks");
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!title.value || !url.value) return;
|
||||
if (mode.value === "cloud") {
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ folderId: null, title: title.value, url: url.value, visibility: "public" })
|
||||
});
|
||||
} else {
|
||||
await upsertLocalBookmark({ title: title.value, url: url.value, visibility: "public" });
|
||||
}
|
||||
title.value = "";
|
||||
url.value = "";
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (mode.value !== "local") return;
|
||||
pendingDeleteId.value = id;
|
||||
deleteConfirmOpen.value = true;
|
||||
}
|
||||
|
||||
const deleteConfirmOpen = ref(false);
|
||||
const pendingDeleteId = ref("");
|
||||
|
||||
async function confirmDelete() {
|
||||
const id = pendingDeleteId.value;
|
||||
if (!id) {
|
||||
deleteConfirmOpen.value = false;
|
||||
return;
|
||||
}
|
||||
await markLocalDeleted(id);
|
||||
pendingDeleteId.value = "";
|
||||
deleteConfirmOpen.value = false;
|
||||
await load();
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
pendingDeleteId.value = "";
|
||||
deleteConfirmOpen.value = false;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>我的书签({{ mode === 'cloud' ? '云端' : '本地' }})</h1>
|
||||
<p v-if="!loggedIn" class="muted">未登录时,书签保存在扩展本地(可在登录后自动合并上云)。</p>
|
||||
|
||||
<div class="form">
|
||||
<input v-model="title" class="input" placeholder="标题" />
|
||||
<input v-model="url" class="input" placeholder="链接" />
|
||||
<button class="btn" @click="add">添加</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<ul class="list">
|
||||
<li v-for="b in items" :key="b.id" class="card">
|
||||
<div class="row">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
|
||||
<button v-if="mode === 'local'" class="ghost" @click.prevent="remove(b.id)">删除</button>
|
||||
</div>
|
||||
<div class="muted">{{ b.url }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<BbConfirmModal
|
||||
v-model="deleteConfirmOpen"
|
||||
title="删除书签"
|
||||
message="确定删除该书签?"
|
||||
confirm-text="删除"
|
||||
cancel-text="取消"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="cancelDelete"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
|
||||
@media (min-width: 900px) { .form { grid-template-columns: 2fr 3fr auto; } }
|
||||
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.row { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
|
||||
.ghost { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 10px; padding: 6px 10px; cursor: pointer; }
|
||||
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
|
||||
.title {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
|
||||
.error { color: #b91c1c; }
|
||||
.muted { color: #475569; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,92 +1,92 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
|
||||
const items = ref([]);
|
||||
const q = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(
|
||||
() => q.value,
|
||||
() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(() => {
|
||||
load();
|
||||
}, 200);
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>公开书签</h1>
|
||||
<div class="row">
|
||||
<div class="searchWrap">
|
||||
<input v-model="q" class="input input--withClear" placeholder="搜索" />
|
||||
<button v-if="q.trim()" class="clearBtn" type="button" aria-label="清空搜索" @click="q = ''">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<ul class="list">
|
||||
<li v-for="b in items" :key="b.id" class="card">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
|
||||
<div class="muted">{{ b.url }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row { display: flex; gap: 8px; margin: 12px 0; }
|
||||
.searchWrap { flex: 1; position: relative; }
|
||||
.input { width: 100%; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.input.input--withClear { padding-right: 40px; }
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
|
||||
.title {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
|
||||
.error { color: #b91c1c; }
|
||||
</style>
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
|
||||
const items = ref([]);
|
||||
const q = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(
|
||||
() => q.value,
|
||||
() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(() => {
|
||||
load();
|
||||
}, 200);
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>公开书签</h1>
|
||||
<div class="row">
|
||||
<div class="searchWrap">
|
||||
<input v-model="q" class="input input--withClear" placeholder="搜索" />
|
||||
<button v-if="q.trim()" class="clearBtn" type="button" aria-label="清空搜索" @click="q = ''">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<ul class="list">
|
||||
<li v-for="b in items" :key="b.id" class="card">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
|
||||
<div class="muted">{{ b.url }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row { display: flex; gap: 8px; margin: 12px 0; }
|
||||
.searchWrap { flex: 1; position: relative; }
|
||||
.input { width: 100%; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.input.input--withClear { padding-right: 40px; }
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
|
||||
.title {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
|
||||
.error { color: #b91c1c; }
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import LoginPage from "./pages/LoginPage.vue";
|
||||
import MorePage from "./pages/MorePage.vue";
|
||||
import { getToken } from "../lib/extStorage";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: MorePage },
|
||||
{ path: "/login", component: LoginPage }
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const token = await getToken();
|
||||
const authed = Boolean(token);
|
||||
|
||||
if (!authed && to.path !== "/login") return "/login";
|
||||
if (authed && to.path === "/login") return "/";
|
||||
|
||||
return true;
|
||||
});
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import LoginPage from "./pages/LoginPage.vue";
|
||||
import MorePage from "./pages/MorePage.vue";
|
||||
import { getToken } from "../lib/extStorage";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: MorePage },
|
||||
{ path: "/login", component: LoginPage }
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const token = await getToken();
|
||||
const authed = Boolean(token);
|
||||
|
||||
if (!authed && to.path !== "/login") return "/login";
|
||||
if (authed && to.path === "/login") return "/";
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1,494 +1,494 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { getToken } from "../lib/extStorage";
|
||||
|
||||
const view = ref("list"); // add | list
|
||||
|
||||
const token = ref("");
|
||||
const loggedIn = computed(() => Boolean(token.value));
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
function openOptions() {
|
||||
if (typeof chrome !== "undefined" && chrome.runtime?.openOptionsPage) {
|
||||
chrome.runtime.openOptionsPage();
|
||||
}
|
||||
}
|
||||
|
||||
function openUrl(url) {
|
||||
if (!url) return;
|
||||
if (typeof chrome !== "undefined" && chrome.tabs?.create) {
|
||||
chrome.tabs.create({ url });
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAuth() {
|
||||
token.value = await getToken();
|
||||
}
|
||||
|
||||
// folders + bookmarks
|
||||
const q = ref("");
|
||||
const folders = ref([]);
|
||||
const items = ref([]);
|
||||
const openFolderIds = ref(new Set());
|
||||
|
||||
const bookmarksByFolderId = computed(() => {
|
||||
const map = new Map();
|
||||
for (const b of items.value || []) {
|
||||
const key = b.folderId ?? null;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(b);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function folderCount(folderId) {
|
||||
return (bookmarksByFolderId.value.get(folderId ?? null) || []).length;
|
||||
}
|
||||
|
||||
function toggleFolder(folderId) {
|
||||
const next = new Set(openFolderIds.value);
|
||||
if (next.has(folderId)) next.delete(folderId);
|
||||
else next.add(folderId);
|
||||
openFolderIds.value = next;
|
||||
}
|
||||
|
||||
function isFolderOpen(folderId) {
|
||||
if (q.value.trim()) return true;
|
||||
return openFolderIds.value.has(folderId);
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
const list = await apiFetch("/folders");
|
||||
folders.value = Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
async function loadBookmarks() {
|
||||
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
|
||||
const list = await apiFetch(`/bookmarks${qs}`);
|
||||
items.value = Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (!loggedIn.value) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await Promise.all([loadFolders(), loadBookmarks()]);
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// add current page
|
||||
const addBusy = ref(false);
|
||||
const addStatus = ref("");
|
||||
const addFolderId = ref(null);
|
||||
const addTitle = ref("");
|
||||
const addUrl = ref("");
|
||||
|
||||
async function getActiveTabPage() {
|
||||
try {
|
||||
if (typeof chrome !== "undefined" && chrome.tabs?.query) {
|
||||
const tabs = await new Promise((resolve) => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (result) => resolve(result || []));
|
||||
});
|
||||
const tab = tabs?.[0];
|
||||
return { title: tab?.title || "", url: tab?.url || "" };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { title: "", url: "" };
|
||||
}
|
||||
|
||||
async function prepareAddCurrent() {
|
||||
addStatus.value = "";
|
||||
error.value = "";
|
||||
addFolderId.value = null;
|
||||
|
||||
const page = await getActiveTabPage();
|
||||
addTitle.value = String(page.title || "").trim() || String(page.url || "").trim();
|
||||
addUrl.value = String(page.url || "").trim();
|
||||
|
||||
if (loggedIn.value) {
|
||||
await loadFolders().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAddCurrent() {
|
||||
addStatus.value = "";
|
||||
error.value = "";
|
||||
|
||||
if (!loggedIn.value) {
|
||||
error.value = "请先在『更多操作』里登录";
|
||||
return;
|
||||
}
|
||||
|
||||
const t = addTitle.value.trim() || addUrl.value.trim();
|
||||
const u = addUrl.value.trim();
|
||||
if (!u) return;
|
||||
|
||||
try {
|
||||
addBusy.value = true;
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
folderId: addFolderId.value ?? null,
|
||||
title: t,
|
||||
url: u,
|
||||
visibility: "private"
|
||||
})
|
||||
});
|
||||
addStatus.value = "已添加";
|
||||
if (view.value === "list") await loadBookmarks();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
addBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// create folder (cloud only)
|
||||
const folderName = ref("");
|
||||
const folderBusy = ref(false);
|
||||
const folderModalOpen = ref(false);
|
||||
async function createFolder() {
|
||||
error.value = "";
|
||||
const name = folderName.value.trim();
|
||||
if (!name) return;
|
||||
if (!loggedIn.value) {
|
||||
error.value = "请先登录";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
folderBusy.value = true;
|
||||
await apiFetch("/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parentId: null, name, visibility: "private" })
|
||||
});
|
||||
folderName.value = "";
|
||||
folderModalOpen.value = false;
|
||||
await loadFolders();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
folderBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAuth();
|
||||
await prepareAddCurrent();
|
||||
if (loggedIn.value) await loadAll();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => q.value,
|
||||
async () => {
|
||||
if (!loggedIn.value) return;
|
||||
await loadBookmarks();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<header class="top">
|
||||
<div class="brand">云书签</div>
|
||||
<button class="btn btn--secondary" type="button" @click="openOptions">更多操作</button>
|
||||
</header>
|
||||
|
||||
<div class="seg">
|
||||
<button class="segBtn" :class="view === 'add' ? 'is-active' : ''" type="button" @click="view = 'add'">
|
||||
一键添加书签
|
||||
</button>
|
||||
<button class="segBtn" :class="view === 'list' ? 'is-active' : ''" type="button" @click="view = 'list'">
|
||||
书签目录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!loggedIn" class="hint">未登录:请点右上角“更多操作”先登录。</p>
|
||||
|
||||
<p v-if="error" class="alert">{{ error }}</p>
|
||||
<p v-if="addStatus" class="ok">{{ addStatus }}</p>
|
||||
|
||||
<section v-if="view === 'add'" class="card">
|
||||
<div class="cardTitle">一键添加书签</div>
|
||||
<div class="muted">会自动读取标题和链接,你也可以手动修改。</div>
|
||||
|
||||
<label class="label">标题</label>
|
||||
<input v-model="addTitle" class="input" placeholder="标题" />
|
||||
|
||||
<label class="label">链接</label>
|
||||
<input v-model="addUrl" class="input" placeholder="https://..." />
|
||||
|
||||
<label class="label">文件夹(不选则未分组)</label>
|
||||
<select v-model="addFolderId" class="input" :disabled="!loggedIn">
|
||||
<option :value="null">未分组</option>
|
||||
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn--secondary" type="button" @click="prepareAddCurrent" :disabled="addBusy">
|
||||
重新读取
|
||||
</button>
|
||||
<button class="btn" type="button" @click="submitAddCurrent" :disabled="addBusy || !addUrl">
|
||||
{{ addBusy ? '添加中…' : '添加' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="card">
|
||||
<div class="titleRow">
|
||||
<div class="cardTitle">书签目录</div>
|
||||
<button class="miniBtn" type="button" :disabled="!loggedIn" @click="folderModalOpen = true">新增文件夹</button>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 8px;">
|
||||
<div class="searchWrap">
|
||||
<input v-model="q" class="input input--withClear" placeholder="搜索标题/链接" :disabled="!loggedIn" />
|
||||
<button
|
||||
v-if="q.trim()"
|
||||
class="clearBtn"
|
||||
type="button"
|
||||
aria-label="清空搜索"
|
||||
@click="q = ''"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn--secondary" type="button" @click="loadAll" :disabled="!loggedIn || loading">刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-if="folderModalOpen" class="modal" @click.self="folderModalOpen = false">
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label="新增文件夹">
|
||||
<div class="dialogTitle">新增文件夹</div>
|
||||
<input
|
||||
v-model="folderName"
|
||||
class="input"
|
||||
placeholder="文件夹名称"
|
||||
:disabled="!loggedIn || folderBusy"
|
||||
@keyup.enter="createFolder"
|
||||
/>
|
||||
<div class="dialogActions">
|
||||
<button class="btn btn--secondary" type="button" @click="folderModalOpen = false" :disabled="folderBusy">取消</button>
|
||||
<button class="btn" type="button" @click="createFolder" :disabled="!loggedIn || folderBusy">
|
||||
{{ folderBusy ? '创建中…' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="muted" style="margin-top: 10px;">加载中…</div>
|
||||
|
||||
<div v-if="loggedIn" class="tree">
|
||||
<div class="folder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
|
||||
<button class="folderHeader" type="button" @click="toggleFolder('ROOT')">
|
||||
<span class="folderName">未分组</span>
|
||||
<span class="folderMeta">{{ folderCount(null) }} 条</span>
|
||||
</button>
|
||||
<div v-if="isFolderOpen('ROOT')" class="folderBody">
|
||||
<button
|
||||
v-for="b in bookmarksByFolderId.get(null) || []"
|
||||
:key="b.id"
|
||||
type="button"
|
||||
class="bm"
|
||||
@click="openUrl(b.url)"
|
||||
>
|
||||
<div class="bmTitle">{{ b.title || b.url }}</div>
|
||||
<div class="bmUrl">{{ b.url }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="f in folders" :key="f.id" class="folder" :class="isFolderOpen(f.id) ? 'is-open' : ''">
|
||||
<button class="folderHeader" type="button" @click="toggleFolder(f.id)">
|
||||
<span class="folderName">{{ f.name }}</span>
|
||||
<span class="folderMeta">{{ folderCount(f.id) }} 条</span>
|
||||
</button>
|
||||
<div v-if="isFolderOpen(f.id)" class="folderBody">
|
||||
<button
|
||||
v-for="b in bookmarksByFolderId.get(f.id) || []"
|
||||
:key="b.id"
|
||||
type="button"
|
||||
class="bm"
|
||||
@click="openUrl(b.url)"
|
||||
>
|
||||
<div class="bmTitle">{{ b.title || b.url }}</div>
|
||||
<div class="bmUrl">{{ b.url }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap{
|
||||
width: 380px;
|
||||
padding: 12px;
|
||||
font-family: ui-sans-serif, system-ui;
|
||||
color: var(--bb-text);
|
||||
}
|
||||
|
||||
.top{ display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.brand{ font-weight: 900; letter-spacing: 0.5px; }
|
||||
|
||||
.seg{ display:flex; gap:8px; margin-top: 10px; }
|
||||
.segBtn{
|
||||
flex:1;
|
||||
border: 1px solid var(--bb-border);
|
||||
background: rgba(255,255,255,0.85);
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
cursor:pointer;
|
||||
}
|
||||
.segBtn.is-active{
|
||||
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
|
||||
color: white;
|
||||
border-color: rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
.btn{
|
||||
border: 1px solid rgba(15, 23, 42, 0.10);
|
||||
border-radius: 14px;
|
||||
padding: 8px 12px;
|
||||
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
.btn--secondary{
|
||||
background: rgba(255,255,255,0.92);
|
||||
color: var(--bb-text);
|
||||
border-color: var(--bb-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
button:disabled{ opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.hint{ margin: 10px 0 0; color: var(--bb-muted); font-size: 12px; }
|
||||
.alert{ margin: 10px 0 0; padding: 8px 10px; border-radius: 12px; border: 1px solid rgba(248,113,113,0.35); background: rgba(248,113,113,0.08); color: #7f1d1d; font-size: 12px; }
|
||||
.ok{ margin: 10px 0 0; padding: 8px 10px; border-radius: 12px; border: 1px solid rgba(34,197,94,0.35); background: rgba(34,197,94,0.10); color: #14532d; font-size: 12px; }
|
||||
|
||||
.card{
|
||||
margin-top: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: var(--bb-card);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
.cardTitle{ font-weight: 900; }
|
||||
.muted{ color: var(--bb-muted); font-size: 12px; margin-top: 4px; }
|
||||
.label{ display:block; margin-top: 10px; font-size: 12px; color: rgba(19, 78, 74, 0.72); }
|
||||
.input{
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--bb-border);
|
||||
background: rgba(255,255,255,0.92);
|
||||
}
|
||||
|
||||
.searchWrap{ position: relative; flex: 1; min-width: 0; }
|
||||
.input.input--withClear{ padding-right: 40px; }
|
||||
.clearBtn{
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bb-border);
|
||||
background: rgba(255,255,255,0.92);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(15, 23, 42, 0.7);
|
||||
padding: 0;
|
||||
}
|
||||
.clearBtn:hover{ background: rgba(255,255,255,1); }
|
||||
|
||||
.row{ display:flex; gap: 8px; align-items:center; margin-top: 10px; }
|
||||
.row .input{ margin-top: 0; }
|
||||
|
||||
.subCard{ margin-top: 10px; padding: 10px; border-radius: 16px; border: 1px dashed rgba(19,78,74,0.22); background: rgba(255,255,255,0.55); }
|
||||
.subTitle{ font-weight: 800; }
|
||||
|
||||
.titleRow{ display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.miniBtn{
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--bb-border);
|
||||
background: rgba(255,255,255,0.92);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: none;
|
||||
}
|
||||
.miniBtn:disabled{ opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.modal{
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.dialog{
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: var(--bb-card-solid);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.25);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dialogTitle{ font-weight: 900; margin-bottom: 8px; }
|
||||
.dialogActions{ display:flex; justify-content:flex-end; gap: 8px; margin-top: 10px; }
|
||||
|
||||
.tree{ margin-top: 10px; display: grid; gap: 10px; }
|
||||
.folder{ border: 1px solid rgba(255,255,255,0.55); border-radius: 16px; background: rgba(255,255,255,0.55); }
|
||||
.folderHeader{
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.folderName{ font-weight: 900; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.folderMeta{ font-size: 12px; color: rgba(19, 78, 74, 0.72); }
|
||||
.folderBody{ padding: 8px 10px 10px; display: grid; gap: 8px; }
|
||||
|
||||
.bm{
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: rgba(255,255,255,0.92);
|
||||
border-radius: 14px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.bmTitle{ font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.bmUrl{ font-size: 12px; color: rgba(19, 78, 74, 0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; }
|
||||
</style>
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { getToken } from "../lib/extStorage";
|
||||
|
||||
const view = ref("list"); // add | list
|
||||
|
||||
const token = ref("");
|
||||
const loggedIn = computed(() => Boolean(token.value));
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
function openOptions() {
|
||||
if (typeof chrome !== "undefined" && chrome.runtime?.openOptionsPage) {
|
||||
chrome.runtime.openOptionsPage();
|
||||
}
|
||||
}
|
||||
|
||||
function openUrl(url) {
|
||||
if (!url) return;
|
||||
if (typeof chrome !== "undefined" && chrome.tabs?.create) {
|
||||
chrome.tabs.create({ url });
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAuth() {
|
||||
token.value = await getToken();
|
||||
}
|
||||
|
||||
// folders + bookmarks
|
||||
const q = ref("");
|
||||
const folders = ref([]);
|
||||
const items = ref([]);
|
||||
const openFolderIds = ref(new Set());
|
||||
|
||||
const bookmarksByFolderId = computed(() => {
|
||||
const map = new Map();
|
||||
for (const b of items.value || []) {
|
||||
const key = b.folderId ?? null;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(b);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function folderCount(folderId) {
|
||||
return (bookmarksByFolderId.value.get(folderId ?? null) || []).length;
|
||||
}
|
||||
|
||||
function toggleFolder(folderId) {
|
||||
const next = new Set(openFolderIds.value);
|
||||
if (next.has(folderId)) next.delete(folderId);
|
||||
else next.add(folderId);
|
||||
openFolderIds.value = next;
|
||||
}
|
||||
|
||||
function isFolderOpen(folderId) {
|
||||
if (q.value.trim()) return true;
|
||||
return openFolderIds.value.has(folderId);
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
const list = await apiFetch("/folders");
|
||||
folders.value = Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
async function loadBookmarks() {
|
||||
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
|
||||
const list = await apiFetch(`/bookmarks${qs}`);
|
||||
items.value = Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (!loggedIn.value) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await Promise.all([loadFolders(), loadBookmarks()]);
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// add current page
|
||||
const addBusy = ref(false);
|
||||
const addStatus = ref("");
|
||||
const addFolderId = ref(null);
|
||||
const addTitle = ref("");
|
||||
const addUrl = ref("");
|
||||
|
||||
async function getActiveTabPage() {
|
||||
try {
|
||||
if (typeof chrome !== "undefined" && chrome.tabs?.query) {
|
||||
const tabs = await new Promise((resolve) => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (result) => resolve(result || []));
|
||||
});
|
||||
const tab = tabs?.[0];
|
||||
return { title: tab?.title || "", url: tab?.url || "" };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { title: "", url: "" };
|
||||
}
|
||||
|
||||
async function prepareAddCurrent() {
|
||||
addStatus.value = "";
|
||||
error.value = "";
|
||||
addFolderId.value = null;
|
||||
|
||||
const page = await getActiveTabPage();
|
||||
addTitle.value = String(page.title || "").trim() || String(page.url || "").trim();
|
||||
addUrl.value = String(page.url || "").trim();
|
||||
|
||||
if (loggedIn.value) {
|
||||
await loadFolders().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAddCurrent() {
|
||||
addStatus.value = "";
|
||||
error.value = "";
|
||||
|
||||
if (!loggedIn.value) {
|
||||
error.value = "请先在『更多操作』里登录";
|
||||
return;
|
||||
}
|
||||
|
||||
const t = addTitle.value.trim() || addUrl.value.trim();
|
||||
const u = addUrl.value.trim();
|
||||
if (!u) return;
|
||||
|
||||
try {
|
||||
addBusy.value = true;
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
folderId: addFolderId.value ?? null,
|
||||
title: t,
|
||||
url: u,
|
||||
visibility: "private"
|
||||
})
|
||||
});
|
||||
addStatus.value = "已添加";
|
||||
if (view.value === "list") await loadBookmarks();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
addBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// create folder (cloud only)
|
||||
const folderName = ref("");
|
||||
const folderBusy = ref(false);
|
||||
const folderModalOpen = ref(false);
|
||||
async function createFolder() {
|
||||
error.value = "";
|
||||
const name = folderName.value.trim();
|
||||
if (!name) return;
|
||||
if (!loggedIn.value) {
|
||||
error.value = "请先登录";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
folderBusy.value = true;
|
||||
await apiFetch("/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parentId: null, name, visibility: "private" })
|
||||
});
|
||||
folderName.value = "";
|
||||
folderModalOpen.value = false;
|
||||
await loadFolders();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
folderBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAuth();
|
||||
await prepareAddCurrent();
|
||||
if (loggedIn.value) await loadAll();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => q.value,
|
||||
async () => {
|
||||
if (!loggedIn.value) return;
|
||||
await loadBookmarks();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<header class="top">
|
||||
<div class="brand">云书签</div>
|
||||
<button class="btn btn--secondary" type="button" @click="openOptions">更多操作</button>
|
||||
</header>
|
||||
|
||||
<div class="seg">
|
||||
<button class="segBtn" :class="view === 'add' ? 'is-active' : ''" type="button" @click="view = 'add'">
|
||||
一键添加书签
|
||||
</button>
|
||||
<button class="segBtn" :class="view === 'list' ? 'is-active' : ''" type="button" @click="view = 'list'">
|
||||
书签目录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!loggedIn" class="hint">未登录:请点右上角“更多操作”先登录。</p>
|
||||
|
||||
<p v-if="error" class="alert">{{ error }}</p>
|
||||
<p v-if="addStatus" class="ok">{{ addStatus }}</p>
|
||||
|
||||
<section v-if="view === 'add'" class="card">
|
||||
<div class="cardTitle">一键添加书签</div>
|
||||
<div class="muted">会自动读取标题和链接,你也可以手动修改。</div>
|
||||
|
||||
<label class="label">标题</label>
|
||||
<input v-model="addTitle" class="input" placeholder="标题" />
|
||||
|
||||
<label class="label">链接</label>
|
||||
<input v-model="addUrl" class="input" placeholder="https://..." />
|
||||
|
||||
<label class="label">文件夹(不选则未分组)</label>
|
||||
<select v-model="addFolderId" class="input" :disabled="!loggedIn">
|
||||
<option :value="null">未分组</option>
|
||||
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn--secondary" type="button" @click="prepareAddCurrent" :disabled="addBusy">
|
||||
重新读取
|
||||
</button>
|
||||
<button class="btn" type="button" @click="submitAddCurrent" :disabled="addBusy || !addUrl">
|
||||
{{ addBusy ? '添加中…' : '添加' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="card">
|
||||
<div class="titleRow">
|
||||
<div class="cardTitle">书签目录</div>
|
||||
<button class="miniBtn" type="button" :disabled="!loggedIn" @click="folderModalOpen = true">新增文件夹</button>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 8px;">
|
||||
<div class="searchWrap">
|
||||
<input v-model="q" class="input input--withClear" placeholder="搜索标题/链接" :disabled="!loggedIn" />
|
||||
<button
|
||||
v-if="q.trim()"
|
||||
class="clearBtn"
|
||||
type="button"
|
||||
aria-label="清空搜索"
|
||||
@click="q = ''"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn--secondary" type="button" @click="loadAll" :disabled="!loggedIn || loading">刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-if="folderModalOpen" class="modal" @click.self="folderModalOpen = false">
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label="新增文件夹">
|
||||
<div class="dialogTitle">新增文件夹</div>
|
||||
<input
|
||||
v-model="folderName"
|
||||
class="input"
|
||||
placeholder="文件夹名称"
|
||||
:disabled="!loggedIn || folderBusy"
|
||||
@keyup.enter="createFolder"
|
||||
/>
|
||||
<div class="dialogActions">
|
||||
<button class="btn btn--secondary" type="button" @click="folderModalOpen = false" :disabled="folderBusy">取消</button>
|
||||
<button class="btn" type="button" @click="createFolder" :disabled="!loggedIn || folderBusy">
|
||||
{{ folderBusy ? '创建中…' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="muted" style="margin-top: 10px;">加载中…</div>
|
||||
|
||||
<div v-if="loggedIn" class="tree">
|
||||
<div class="folder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
|
||||
<button class="folderHeader" type="button" @click="toggleFolder('ROOT')">
|
||||
<span class="folderName">未分组</span>
|
||||
<span class="folderMeta">{{ folderCount(null) }} 条</span>
|
||||
</button>
|
||||
<div v-if="isFolderOpen('ROOT')" class="folderBody">
|
||||
<button
|
||||
v-for="b in bookmarksByFolderId.get(null) || []"
|
||||
:key="b.id"
|
||||
type="button"
|
||||
class="bm"
|
||||
@click="openUrl(b.url)"
|
||||
>
|
||||
<div class="bmTitle">{{ b.title || b.url }}</div>
|
||||
<div class="bmUrl">{{ b.url }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="f in folders" :key="f.id" class="folder" :class="isFolderOpen(f.id) ? 'is-open' : ''">
|
||||
<button class="folderHeader" type="button" @click="toggleFolder(f.id)">
|
||||
<span class="folderName">{{ f.name }}</span>
|
||||
<span class="folderMeta">{{ folderCount(f.id) }} 条</span>
|
||||
</button>
|
||||
<div v-if="isFolderOpen(f.id)" class="folderBody">
|
||||
<button
|
||||
v-for="b in bookmarksByFolderId.get(f.id) || []"
|
||||
:key="b.id"
|
||||
type="button"
|
||||
class="bm"
|
||||
@click="openUrl(b.url)"
|
||||
>
|
||||
<div class="bmTitle">{{ b.title || b.url }}</div>
|
||||
<div class="bmUrl">{{ b.url }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap{
|
||||
width: 380px;
|
||||
padding: 12px;
|
||||
font-family: ui-sans-serif, system-ui;
|
||||
color: var(--bb-text);
|
||||
}
|
||||
|
||||
.top{ display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.brand{ font-weight: 900; letter-spacing: 0.5px; }
|
||||
|
||||
.seg{ display:flex; gap:8px; margin-top: 10px; }
|
||||
.segBtn{
|
||||
flex:1;
|
||||
border: 1px solid var(--bb-border);
|
||||
background: rgba(255,255,255,0.85);
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
cursor:pointer;
|
||||
}
|
||||
.segBtn.is-active{
|
||||
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
|
||||
color: white;
|
||||
border-color: rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
.btn{
|
||||
border: 1px solid rgba(15, 23, 42, 0.10);
|
||||
border-radius: 14px;
|
||||
padding: 8px 12px;
|
||||
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
.btn--secondary{
|
||||
background: rgba(255,255,255,0.92);
|
||||
color: var(--bb-text);
|
||||
border-color: var(--bb-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
button:disabled{ opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.hint{ margin: 10px 0 0; color: var(--bb-muted); font-size: 12px; }
|
||||
.alert{ margin: 10px 0 0; padding: 8px 10px; border-radius: 12px; border: 1px solid rgba(248,113,113,0.35); background: rgba(248,113,113,0.08); color: #7f1d1d; font-size: 12px; }
|
||||
.ok{ margin: 10px 0 0; padding: 8px 10px; border-radius: 12px; border: 1px solid rgba(34,197,94,0.35); background: rgba(34,197,94,0.10); color: #14532d; font-size: 12px; }
|
||||
|
||||
.card{
|
||||
margin-top: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: var(--bb-card);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
.cardTitle{ font-weight: 900; }
|
||||
.muted{ color: var(--bb-muted); font-size: 12px; margin-top: 4px; }
|
||||
.label{ display:block; margin-top: 10px; font-size: 12px; color: rgba(19, 78, 74, 0.72); }
|
||||
.input{
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--bb-border);
|
||||
background: rgba(255,255,255,0.92);
|
||||
}
|
||||
|
||||
.searchWrap{ position: relative; flex: 1; min-width: 0; }
|
||||
.input.input--withClear{ padding-right: 40px; }
|
||||
.clearBtn{
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--bb-border);
|
||||
background: rgba(255,255,255,0.92);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(15, 23, 42, 0.7);
|
||||
padding: 0;
|
||||
}
|
||||
.clearBtn:hover{ background: rgba(255,255,255,1); }
|
||||
|
||||
.row{ display:flex; gap: 8px; align-items:center; margin-top: 10px; }
|
||||
.row .input{ margin-top: 0; }
|
||||
|
||||
.subCard{ margin-top: 10px; padding: 10px; border-radius: 16px; border: 1px dashed rgba(19,78,74,0.22); background: rgba(255,255,255,0.55); }
|
||||
.subTitle{ font-weight: 800; }
|
||||
|
||||
.titleRow{ display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.miniBtn{
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--bb-border);
|
||||
background: rgba(255,255,255,0.92);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: none;
|
||||
}
|
||||
.miniBtn:disabled{ opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.modal{
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.dialog{
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: var(--bb-card-solid);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.25);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dialogTitle{ font-weight: 900; margin-bottom: 8px; }
|
||||
.dialogActions{ display:flex; justify-content:flex-end; gap: 8px; margin-top: 10px; }
|
||||
|
||||
.tree{ margin-top: 10px; display: grid; gap: 10px; }
|
||||
.folder{ border: 1px solid rgba(255,255,255,0.55); border-radius: 16px; background: rgba(255,255,255,0.55); }
|
||||
.folderHeader{
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.folderName{ font-weight: 900; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.folderMeta{ font-size: 12px; color: rgba(19, 78, 74, 0.72); }
|
||||
.folderBody{ padding: 8px 10px 10px; display: grid; gap: 8px; }
|
||||
|
||||
.bm{
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: rgba(255,255,255,0.92);
|
||||
border-radius: 14px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.bmTitle{ font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.bmUrl{ font-size: 12px; color: rgba(19, 78, 74, 0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
import PopupApp from "./PopupApp.vue";
|
||||
|
||||
import "../style.css";
|
||||
|
||||
createApp(PopupApp).mount("#app");
|
||||
import { createApp } from "vue";
|
||||
import PopupApp from "./PopupApp.vue";
|
||||
|
||||
import "../style.css";
|
||||
|
||||
createApp(PopupApp).mount("#app");
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.js"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: "module"
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
}
|
||||
];
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.js"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: "module"
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
create table if not exists users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null unique,
|
||||
password_hash text not null,
|
||||
role text not null default 'user',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists bookmark_folders (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
parent_id uuid null references bookmark_folders(id) on delete cascade,
|
||||
name text not null,
|
||||
visibility text not null default 'private',
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_bookmark_folders_user_parent on bookmark_folders (user_id, parent_id);
|
||||
|
||||
create table if not exists bookmarks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
folder_id uuid null references bookmark_folders(id) on delete set null,
|
||||
sort_order integer not null default 0,
|
||||
title text not null,
|
||||
url text not null,
|
||||
url_normalized text not null,
|
||||
url_hash text not null,
|
||||
visibility text not null default 'private',
|
||||
source text not null default 'manual',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
deleted_at timestamptz null
|
||||
);
|
||||
|
||||
create index if not exists idx_bookmarks_user_updated_at on bookmarks (user_id, updated_at);
|
||||
create index if not exists idx_bookmarks_user_folder_sort on bookmarks (user_id, folder_id, sort_order);
|
||||
create index if not exists idx_bookmarks_user_url_hash on bookmarks (user_id, url_hash);
|
||||
create index if not exists idx_bookmarks_visibility on bookmarks (visibility);
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
create table if not exists users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null unique,
|
||||
password_hash text not null,
|
||||
role text not null default 'user',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists bookmark_folders (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
parent_id uuid null references bookmark_folders(id) on delete cascade,
|
||||
name text not null,
|
||||
visibility text not null default 'private',
|
||||
sort_order integer not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_bookmark_folders_user_parent on bookmark_folders (user_id, parent_id);
|
||||
|
||||
create table if not exists bookmarks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
folder_id uuid null references bookmark_folders(id) on delete set null,
|
||||
sort_order integer not null default 0,
|
||||
title text not null,
|
||||
url text not null,
|
||||
url_normalized text not null,
|
||||
url_hash text not null,
|
||||
visibility text not null default 'private',
|
||||
source text not null default 'manual',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
deleted_at timestamptz null
|
||||
);
|
||||
|
||||
create index if not exists idx_bookmarks_user_updated_at on bookmarks (user_id, updated_at);
|
||||
create index if not exists idx_bookmarks_user_folder_sort on bookmarks (user_id, folder_id, sort_order);
|
||||
create index if not exists idx_bookmarks_user_url_hash on bookmarks (user_id, url_hash);
|
||||
create index if not exists idx_bookmarks_visibility on bookmarks (visibility);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
alter table if exists bookmark_folders
|
||||
add column if not exists sort_order integer not null default 0;
|
||||
|
||||
create index if not exists idx_bookmark_folders_user_parent_sort
|
||||
on bookmark_folders (user_id, parent_id, sort_order);
|
||||
alter table if exists bookmark_folders
|
||||
add column if not exists sort_order integer not null default 0;
|
||||
|
||||
create index if not exists idx_bookmark_folders_user_parent_sort
|
||||
on bookmark_folders (user_id, parent_id, sort_order);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
alter table if exists bookmarks
|
||||
add column if not exists sort_order integer not null default 0;
|
||||
|
||||
create index if not exists idx_bookmarks_user_folder_sort
|
||||
on bookmarks (user_id, folder_id, sort_order);
|
||||
alter table if exists bookmarks
|
||||
add column if not exists sort_order integer not null default 0;
|
||||
|
||||
create index if not exists idx_bookmarks_user_folder_sort
|
||||
on bookmarks (user_id, folder_id, sort_order);
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"name": "@browser-bookmark/server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"build": "node -c src/index.js && node -c src/routes/auth.routes.js && node -c src/routes/bookmarks.routes.js && node -c src/routes/folders.routes.js && node -c src/routes/importExport.routes.js && node -c src/routes/sync.routes.js",
|
||||
"test": "node --test",
|
||||
"lint": "eslint .",
|
||||
"db:migrate": "node src/migrate.js",
|
||||
"db:reset": "node src/resetDb.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@browser-bookmark/shared": "0.1.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cheerio": "^1.1.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^9.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "@browser-bookmark/server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"build": "node -c src/index.js && node -c src/routes/auth.routes.js && node -c src/routes/bookmarks.routes.js && node -c src/routes/folders.routes.js && node -c src/routes/importExport.routes.js && node -c src/routes/sync.routes.js",
|
||||
"test": "node --test",
|
||||
"lint": "eslint .",
|
||||
"db:migrate": "node src/migrate.js",
|
||||
"db:reset": "node src/resetDb.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@browser-bookmark/shared": "file:../../packages/shared",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cheerio": "^1.1.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^9.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
test("placeholder", () => {
|
||||
assert.equal(1 + 1, 2);
|
||||
});
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
test("placeholder", () => {
|
||||
assert.equal(1 + 1, 2);
|
||||
});
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
function loadEnv() {
|
||||
// When running via npm workspaces, cwd is often apps/server.
|
||||
// Support both apps/server/.env and repo-root/.env.
|
||||
const candidates = [
|
||||
path.resolve(process.cwd(), ".env"),
|
||||
path.resolve(process.cwd(), "..", "..", ".env")
|
||||
];
|
||||
|
||||
for (const envPath of candidates) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv();
|
||||
|
||||
export function getConfig() {
|
||||
const serverPort = Number(process.env.SERVER_PORT || 3001);
|
||||
const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
|
||||
const corsOriginsRaw = String(process.env.CORS_ORIGINS || "").trim();
|
||||
const corsOrigins = corsOriginsRaw
|
||||
? corsOriginsRaw.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
: true;
|
||||
|
||||
return {
|
||||
serverPort,
|
||||
adminEmail,
|
||||
corsOrigins,
|
||||
database: {
|
||||
host: process.env.DATABASE_HOST || "127.0.0.1",
|
||||
port: Number(process.env.DATABASE_PORT || 5432),
|
||||
database: process.env.DATABASE_NAME || "postgres",
|
||||
user: process.env.DATABASE_USER || "postgres",
|
||||
password: process.env.DATABASE_PASSWORD || "",
|
||||
ssl: String(process.env.DATABASE_SSL || "false").toLowerCase() === "true"
|
||||
}
|
||||
};
|
||||
}
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
function loadEnv() {
|
||||
// When running via npm workspaces, cwd is often apps/server.
|
||||
// Support both apps/server/.env and repo-root/.env.
|
||||
const candidates = [
|
||||
path.resolve(process.cwd(), ".env"),
|
||||
path.resolve(process.cwd(), "..", "..", ".env")
|
||||
];
|
||||
|
||||
for (const envPath of candidates) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv();
|
||||
|
||||
export function getConfig() {
|
||||
const serverPort = Number(process.env.SERVER_PORT || 3001);
|
||||
const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
|
||||
const corsOriginsRaw = String(process.env.CORS_ORIGINS || "").trim();
|
||||
const corsOrigins = corsOriginsRaw
|
||||
? corsOriginsRaw.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
: true;
|
||||
|
||||
return {
|
||||
serverPort,
|
||||
adminEmail,
|
||||
corsOrigins,
|
||||
database: {
|
||||
host: process.env.DATABASE_HOST || "127.0.0.1",
|
||||
port: Number(process.env.DATABASE_PORT || 5432),
|
||||
database: process.env.DATABASE_NAME || "postgres",
|
||||
user: process.env.DATABASE_USER || "postgres",
|
||||
password: process.env.DATABASE_PASSWORD || "",
|
||||
ssl: String(process.env.DATABASE_SSL || "false").toLowerCase() === "true"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import pg from "pg";
|
||||
import { getConfig } from "./config.js";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export function createPool() {
|
||||
const { database } = getConfig();
|
||||
return new Pool({
|
||||
host: database.host,
|
||||
port: database.port,
|
||||
database: database.database,
|
||||
user: database.user,
|
||||
password: database.password,
|
||||
ssl: database.ssl ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
}
|
||||
import pg from "pg";
|
||||
import { getConfig } from "./config.js";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export function createPool() {
|
||||
const { database } = getConfig();
|
||||
return new Pool({
|
||||
host: database.host,
|
||||
port: database.port,
|
||||
database: database.database,
|
||||
user: database.user,
|
||||
password: database.password,
|
||||
ssl: database.ssl ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import multipart from "@fastify/multipart";
|
||||
import jwt from "@fastify/jwt";
|
||||
import { getConfig } from "./config.js";
|
||||
import { createPool } from "./db.js";
|
||||
import { authRoutes } from "./routes/auth.routes.js";
|
||||
import { adminRoutes } from "./routes/admin.routes.js";
|
||||
import { foldersRoutes } from "./routes/folders.routes.js";
|
||||
import { bookmarksRoutes } from "./routes/bookmarks.routes.js";
|
||||
import { importExportRoutes } from "./routes/importExport.routes.js";
|
||||
import { syncRoutes } from "./routes/sync.routes.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Plugins
|
||||
const config = getConfig();
|
||||
await app.register(cors, {
|
||||
origin: config.corsOrigins,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept"]
|
||||
});
|
||||
await app.register(multipart);
|
||||
|
||||
const jwtSecret = process.env.AUTH_JWT_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error("AUTH_JWT_SECRET is required");
|
||||
}
|
||||
await app.register(jwt, { secret: jwtSecret });
|
||||
|
||||
const pool = createPool();
|
||||
app.decorate("pg", pool);
|
||||
|
||||
// Detect optional DB features (for backwards compatibility when migrations haven't run yet).
|
||||
async function hasColumn(tableName, columnName) {
|
||||
try {
|
||||
const r = await app.pg.query(
|
||||
"select 1 from information_schema.columns where table_schema=current_schema() and table_name=$1 and column_name=$2 limit 1",
|
||||
[tableName, columnName]
|
||||
);
|
||||
return r.rowCount > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const folderSortOrderSupported = await hasColumn("bookmark_folders", "sort_order");
|
||||
const bookmarkSortOrderSupported = await hasColumn("bookmarks", "sort_order");
|
||||
|
||||
app.decorate("features", {
|
||||
folderSortOrder: folderSortOrderSupported,
|
||||
bookmarkSortOrder: bookmarkSortOrderSupported
|
||||
});
|
||||
|
||||
app.decorate("authenticate", async (req, reply) => {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.setErrorHandler((err, _req, reply) => {
|
||||
const statusCode = err.statusCode || 500;
|
||||
reply.code(statusCode).send({ message: err.message || "server error" });
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
// Routes
|
||||
app.decorate("config", config);
|
||||
|
||||
await authRoutes(app);
|
||||
await adminRoutes(app);
|
||||
await foldersRoutes(app);
|
||||
await bookmarksRoutes(app);
|
||||
await importExportRoutes(app);
|
||||
await syncRoutes(app);
|
||||
|
||||
app.addHook("onClose", async (instance) => {
|
||||
await instance.pg.end();
|
||||
});
|
||||
|
||||
const { serverPort } = config;
|
||||
await app.listen({ port: serverPort, host: "0.0.0.0" });
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import multipart from "@fastify/multipart";
|
||||
import jwt from "@fastify/jwt";
|
||||
import { getConfig } from "./config.js";
|
||||
import { createPool } from "./db.js";
|
||||
import { authRoutes } from "./routes/auth.routes.js";
|
||||
import { adminRoutes } from "./routes/admin.routes.js";
|
||||
import { foldersRoutes } from "./routes/folders.routes.js";
|
||||
import { bookmarksRoutes } from "./routes/bookmarks.routes.js";
|
||||
import { importExportRoutes } from "./routes/importExport.routes.js";
|
||||
import { syncRoutes } from "./routes/sync.routes.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Plugins
|
||||
const config = getConfig();
|
||||
await app.register(cors, {
|
||||
origin: config.corsOrigins,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept"]
|
||||
});
|
||||
await app.register(multipart);
|
||||
|
||||
const jwtSecret = process.env.AUTH_JWT_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error("AUTH_JWT_SECRET is required");
|
||||
}
|
||||
await app.register(jwt, { secret: jwtSecret });
|
||||
|
||||
const pool = createPool();
|
||||
app.decorate("pg", pool);
|
||||
|
||||
// Detect optional DB features (for backwards compatibility when migrations haven't run yet).
|
||||
async function hasColumn(tableName, columnName) {
|
||||
try {
|
||||
const r = await app.pg.query(
|
||||
"select 1 from information_schema.columns where table_schema=current_schema() and table_name=$1 and column_name=$2 limit 1",
|
||||
[tableName, columnName]
|
||||
);
|
||||
return r.rowCount > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const folderSortOrderSupported = await hasColumn("bookmark_folders", "sort_order");
|
||||
const bookmarkSortOrderSupported = await hasColumn("bookmarks", "sort_order");
|
||||
|
||||
app.decorate("features", {
|
||||
folderSortOrder: folderSortOrderSupported,
|
||||
bookmarkSortOrder: bookmarkSortOrderSupported
|
||||
});
|
||||
|
||||
app.decorate("authenticate", async (req, reply) => {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.setErrorHandler((err, _req, reply) => {
|
||||
const statusCode = err.statusCode || 500;
|
||||
reply.code(statusCode).send({ message: err.message || "server error" });
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
// Routes
|
||||
app.decorate("config", config);
|
||||
|
||||
await authRoutes(app);
|
||||
await adminRoutes(app);
|
||||
await foldersRoutes(app);
|
||||
await bookmarksRoutes(app);
|
||||
await importExportRoutes(app);
|
||||
await syncRoutes(app);
|
||||
|
||||
app.addHook("onClose", async (instance) => {
|
||||
await instance.pg.end();
|
||||
});
|
||||
|
||||
const { serverPort } = config;
|
||||
await app.listen({ port: serverPort, host: "0.0.0.0" });
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { httpError } from "./httpErrors.js";
|
||||
|
||||
function normalizeEmail(email) {
|
||||
return String(email || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export async function requireAdmin(app, req) {
|
||||
await app.authenticate(req);
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) throw httpError(401, "unauthorized");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"select id, email, role, created_at, updated_at from users where id=$1",
|
||||
[userId]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) throw httpError(401, "unauthorized");
|
||||
|
||||
const adminEmail = normalizeEmail(app.config?.adminEmail);
|
||||
const isAdmin = Boolean(adminEmail) && normalizeEmail(row.email) === adminEmail;
|
||||
if (!isAdmin) throw httpError(403, "admin only");
|
||||
|
||||
req.adminUser = row;
|
||||
}
|
||||
|
||||
export function isAdminEmail(app, email) {
|
||||
const adminEmail = normalizeEmail(app.config?.adminEmail);
|
||||
return Boolean(adminEmail) && normalizeEmail(email) === adminEmail;
|
||||
}
|
||||
import { httpError } from "./httpErrors.js";
|
||||
|
||||
function normalizeEmail(email) {
|
||||
return String(email || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export async function requireAdmin(app, req) {
|
||||
await app.authenticate(req);
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) throw httpError(401, "unauthorized");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"select id, email, role, created_at, updated_at from users where id=$1",
|
||||
[userId]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) throw httpError(401, "unauthorized");
|
||||
|
||||
const adminEmail = normalizeEmail(app.config?.adminEmail);
|
||||
const isAdmin = Boolean(adminEmail) && normalizeEmail(row.email) === adminEmail;
|
||||
if (!isAdmin) throw httpError(403, "admin only");
|
||||
|
||||
req.adminUser = row;
|
||||
}
|
||||
|
||||
export function isAdminEmail(app, email) {
|
||||
const adminEmail = normalizeEmail(app.config?.adminEmail);
|
||||
return Boolean(adminEmail) && normalizeEmail(email) === adminEmail;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export async function hashPassword(password) {
|
||||
const saltRounds = 10;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password, passwordHash) {
|
||||
return bcrypt.compare(password, passwordHash);
|
||||
}
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export async function hashPassword(password) {
|
||||
const saltRounds = 10;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password, passwordHash) {
|
||||
return bcrypt.compare(password, passwordHash);
|
||||
}
|
||||
|
||||
@@ -1,125 +1,125 @@
|
||||
import * as cheerio from "cheerio";
|
||||
|
||||
export function parseNetscapeBookmarkHtmlNode(html) {
|
||||
const $ = cheerio.load(html, { decodeEntities: false });
|
||||
const rootDl = $("dl").first();
|
||||
if (!rootDl.length) return { folders: [], bookmarks: [] };
|
||||
|
||||
const folders = [];
|
||||
const bookmarks = [];
|
||||
|
||||
function normText(s) {
|
||||
return String(s || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function collectLevelDt(node) {
|
||||
const out = [];
|
||||
const children = $(node).contents().toArray();
|
||||
for (const child of children) {
|
||||
if (!child || child.type !== "tag") continue;
|
||||
const tag = child.tagName?.toLowerCase();
|
||||
if (tag === "dt") {
|
||||
out.push(child);
|
||||
continue;
|
||||
}
|
||||
if (tag === "dl") {
|
||||
// nested list belongs to the previous <DT>
|
||||
continue;
|
||||
}
|
||||
out.push(...collectLevelDt(child));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findNextDlForDt(dtNode, stopDlNode) {
|
||||
let cur = dtNode;
|
||||
while (cur && cur !== stopDlNode) {
|
||||
let next = cur.nextSibling;
|
||||
while (next && next.type !== "tag") next = next.nextSibling;
|
||||
if (next && next.type === "tag" && next.tagName?.toLowerCase() === "dl") return $(next);
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkDl($dl, parentTempId) {
|
||||
// Netscape format: <DL><p> contains repeating <DT> items and nested <DL>.
|
||||
// When parsed, <DT> may be wrapped (e.g. inside <p>), so we must be robust.
|
||||
const dts = collectLevelDt($dl[0]);
|
||||
for (const node of dts) {
|
||||
const $dt = $(node);
|
||||
const $h3 = $dt.children("h3").first().length ? $dt.children("h3").first() : $dt.find("h3").first();
|
||||
const $a = $dt.children("a").first().length ? $dt.children("a").first() : $dt.find("a").first();
|
||||
const $nestedDl = $dt.children("dl").first();
|
||||
const $nextDl = $nestedDl.length ? $nestedDl : findNextDlForDt(node, $dl[0]);
|
||||
|
||||
if ($h3.length) {
|
||||
const tempId = `${folders.length + 1}`;
|
||||
const name = normText($h3.text() || "");
|
||||
folders.push({ tempId, parentTempId: parentTempId ?? null, name });
|
||||
if ($nextDl?.length) walkDl($nextDl, tempId);
|
||||
} else if ($a.length) {
|
||||
const title = normText($a.text() || "");
|
||||
const url = $a.attr("href") || "";
|
||||
bookmarks.push({ parentTempId: parentTempId ?? null, title, url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDl(rootDl, null);
|
||||
return { folders, bookmarks };
|
||||
}
|
||||
|
||||
export function buildNetscapeBookmarkHtml({ folders, bookmarks }) {
|
||||
// folders: [{id, parentId, name}]
|
||||
// bookmarks: [{folderId, title, url}]
|
||||
const folderChildren = new Map();
|
||||
const bookmarkChildren = new Map();
|
||||
|
||||
for (const f of folders) {
|
||||
const key = f.parentId ?? "root";
|
||||
if (!folderChildren.has(key)) folderChildren.set(key, []);
|
||||
folderChildren.get(key).push(f);
|
||||
}
|
||||
for (const b of bookmarks) {
|
||||
const key = b.folderId ?? "root";
|
||||
if (!bookmarkChildren.has(key)) bookmarkChildren.set(key, []);
|
||||
bookmarkChildren.get(key).push(b);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function renderFolder(parentId) {
|
||||
const key = parentId ?? "root";
|
||||
const subFolders = (folderChildren.get(key) || []).slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
const subBookmarks = (bookmarkChildren.get(key) || []).slice().sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
let out = "<DL><p>\n";
|
||||
|
||||
for (const f of subFolders) {
|
||||
out += ` <DT><H3>${esc(f.name)}</H3>\n`;
|
||||
out += renderFolder(f.id)
|
||||
.split("\n")
|
||||
.map((line) => (line ? ` ${line}` : line))
|
||||
.join("\n");
|
||||
out += "\n";
|
||||
}
|
||||
|
||||
for (const b of subBookmarks) {
|
||||
out += ` <DT><A HREF=\"${esc(b.url)}\">${esc(b.title)}</A>\n`;
|
||||
}
|
||||
|
||||
out += "</DL><p>";
|
||||
return out;
|
||||
}
|
||||
|
||||
const header = `<!DOCTYPE NETSCAPE-Bookmark-file-1>\n<!-- This is an automatically generated file. -->\n<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n<TITLE>Bookmarks</TITLE>\n<H1>Bookmarks</H1>\n`;
|
||||
const body = renderFolder(null);
|
||||
return header + body + "\n";
|
||||
}
|
||||
import * as cheerio from "cheerio";
|
||||
|
||||
export function parseNetscapeBookmarkHtmlNode(html) {
|
||||
const $ = cheerio.load(html, { decodeEntities: false });
|
||||
const rootDl = $("dl").first();
|
||||
if (!rootDl.length) return { folders: [], bookmarks: [] };
|
||||
|
||||
const folders = [];
|
||||
const bookmarks = [];
|
||||
|
||||
function normText(s) {
|
||||
return String(s || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function collectLevelDt(node) {
|
||||
const out = [];
|
||||
const children = $(node).contents().toArray();
|
||||
for (const child of children) {
|
||||
if (!child || child.type !== "tag") continue;
|
||||
const tag = child.tagName?.toLowerCase();
|
||||
if (tag === "dt") {
|
||||
out.push(child);
|
||||
continue;
|
||||
}
|
||||
if (tag === "dl") {
|
||||
// nested list belongs to the previous <DT>
|
||||
continue;
|
||||
}
|
||||
out.push(...collectLevelDt(child));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findNextDlForDt(dtNode, stopDlNode) {
|
||||
let cur = dtNode;
|
||||
while (cur && cur !== stopDlNode) {
|
||||
let next = cur.nextSibling;
|
||||
while (next && next.type !== "tag") next = next.nextSibling;
|
||||
if (next && next.type === "tag" && next.tagName?.toLowerCase() === "dl") return $(next);
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkDl($dl, parentTempId) {
|
||||
// Netscape format: <DL><p> contains repeating <DT> items and nested <DL>.
|
||||
// When parsed, <DT> may be wrapped (e.g. inside <p>), so we must be robust.
|
||||
const dts = collectLevelDt($dl[0]);
|
||||
for (const node of dts) {
|
||||
const $dt = $(node);
|
||||
const $h3 = $dt.children("h3").first().length ? $dt.children("h3").first() : $dt.find("h3").first();
|
||||
const $a = $dt.children("a").first().length ? $dt.children("a").first() : $dt.find("a").first();
|
||||
const $nestedDl = $dt.children("dl").first();
|
||||
const $nextDl = $nestedDl.length ? $nestedDl : findNextDlForDt(node, $dl[0]);
|
||||
|
||||
if ($h3.length) {
|
||||
const tempId = `${folders.length + 1}`;
|
||||
const name = normText($h3.text() || "");
|
||||
folders.push({ tempId, parentTempId: parentTempId ?? null, name });
|
||||
if ($nextDl?.length) walkDl($nextDl, tempId);
|
||||
} else if ($a.length) {
|
||||
const title = normText($a.text() || "");
|
||||
const url = $a.attr("href") || "";
|
||||
bookmarks.push({ parentTempId: parentTempId ?? null, title, url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDl(rootDl, null);
|
||||
return { folders, bookmarks };
|
||||
}
|
||||
|
||||
export function buildNetscapeBookmarkHtml({ folders, bookmarks }) {
|
||||
// folders: [{id, parentId, name}]
|
||||
// bookmarks: [{folderId, title, url}]
|
||||
const folderChildren = new Map();
|
||||
const bookmarkChildren = new Map();
|
||||
|
||||
for (const f of folders) {
|
||||
const key = f.parentId ?? "root";
|
||||
if (!folderChildren.has(key)) folderChildren.set(key, []);
|
||||
folderChildren.get(key).push(f);
|
||||
}
|
||||
for (const b of bookmarks) {
|
||||
const key = b.folderId ?? "root";
|
||||
if (!bookmarkChildren.has(key)) bookmarkChildren.set(key, []);
|
||||
bookmarkChildren.get(key).push(b);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export function httpError(statusCode, message) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
export function httpError(statusCode, message) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
export function userRowToDto(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function folderRowToDto(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
parentId: row.parent_id,
|
||||
name: row.name,
|
||||
visibility: row.visibility,
|
||||
sortOrder: row.sort_order ?? 0,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function bookmarkRowToDto(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
folderId: row.folder_id,
|
||||
sortOrder: row.sort_order ?? 0,
|
||||
title: row.title,
|
||||
url: row.url,
|
||||
urlNormalized: row.url_normalized,
|
||||
urlHash: row.url_hash,
|
||||
visibility: row.visibility,
|
||||
source: row.source,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at
|
||||
};
|
||||
}
|
||||
export function userRowToDto(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function folderRowToDto(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
parentId: row.parent_id,
|
||||
name: row.name,
|
||||
visibility: row.visibility,
|
||||
sortOrder: row.sort_order ?? 0,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function bookmarkRowToDto(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
folderId: row.folder_id,
|
||||
sortOrder: row.sort_order ?? 0,
|
||||
title: row.title,
|
||||
url: row.url,
|
||||
urlNormalized: row.url_normalized,
|
||||
urlHash: row.url_hash,
|
||||
visibility: row.visibility,
|
||||
source: row.source,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPool } from "./db.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function ensureMigrationsTable(pool) {
|
||||
await pool.query(`
|
||||
create table if not exists schema_migrations (
|
||||
id text primary key,
|
||||
applied_at timestamptz not null default now()
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
async function getApplied(pool) {
|
||||
const res = await pool.query("select id from schema_migrations order by id");
|
||||
return new Set(res.rows.map((r) => r.id));
|
||||
}
|
||||
|
||||
async function applyMigration(pool, id, sql) {
|
||||
await pool.query("begin");
|
||||
try {
|
||||
await pool.query(sql);
|
||||
await pool.query("insert into schema_migrations (id) values ($1)", [id]);
|
||||
await pool.query("commit");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[migrate] applied ${id}`);
|
||||
} catch (err) {
|
||||
await pool.query("rollback");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = createPool();
|
||||
try {
|
||||
await ensureMigrationsTable(pool);
|
||||
const applied = await getApplied(pool);
|
||||
|
||||
const migrationsDir = path.resolve(__dirname, "..", "migrations");
|
||||
const files = (await readdir(migrationsDir))
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = await readFile(path.join(migrationsDir, file), "utf8");
|
||||
await applyMigration(pool, file, sql);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[migrate] done");
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPool } from "./db.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function ensureMigrationsTable(pool) {
|
||||
await pool.query(`
|
||||
create table if not exists schema_migrations (
|
||||
id text primary key,
|
||||
applied_at timestamptz not null default now()
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
async function getApplied(pool) {
|
||||
const res = await pool.query("select id from schema_migrations order by id");
|
||||
return new Set(res.rows.map((r) => r.id));
|
||||
}
|
||||
|
||||
async function applyMigration(pool, id, sql) {
|
||||
await pool.query("begin");
|
||||
try {
|
||||
await pool.query(sql);
|
||||
await pool.query("insert into schema_migrations (id) values ($1)", [id]);
|
||||
await pool.query("commit");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[migrate] applied ${id}`);
|
||||
} catch (err) {
|
||||
await pool.query("rollback");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = createPool();
|
||||
try {
|
||||
await ensureMigrationsTable(pool);
|
||||
const applied = await getApplied(pool);
|
||||
|
||||
const migrationsDir = path.resolve(__dirname, "..", "migrations");
|
||||
const files = (await readdir(migrationsDir))
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = await readFile(path.join(migrationsDir, file), "utf8");
|
||||
await applyMigration(pool, file, sql);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[migrate] done");
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { createPool } from "./db.js";
|
||||
|
||||
async function main() {
|
||||
const pool = createPool();
|
||||
try {
|
||||
// Destructive: development convenience only.
|
||||
await pool.query("begin");
|
||||
try {
|
||||
await pool.query("drop table if exists bookmarks cascade");
|
||||
await pool.query("drop table if exists bookmark_folders cascade");
|
||||
await pool.query("drop table if exists users cascade");
|
||||
await pool.query("drop table if exists schema_migrations cascade");
|
||||
await pool.query("commit");
|
||||
} catch (e) {
|
||||
await pool.query("rollback");
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
// Re-apply migrations.
|
||||
await import("./migrate.js");
|
||||
}
|
||||
|
||||
main();
|
||||
import { createPool } from "./db.js";
|
||||
|
||||
async function main() {
|
||||
const pool = createPool();
|
||||
try {
|
||||
// Destructive: development convenience only.
|
||||
await pool.query("begin");
|
||||
try {
|
||||
await pool.query("drop table if exists bookmarks cascade");
|
||||
await pool.query("drop table if exists bookmark_folders cascade");
|
||||
await pool.query("drop table if exists users cascade");
|
||||
await pool.query("drop table if exists schema_migrations cascade");
|
||||
await pool.query("commit");
|
||||
} catch (e) {
|
||||
await pool.query("rollback");
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
// Re-apply migrations.
|
||||
await import("./migrate.js");
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -1,147 +1,147 @@
|
||||
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { requireAdmin, isAdminEmail } from "../lib/admin.js";
|
||||
import { bookmarkRowToDto, folderRowToDto, userRowToDto } from "../lib/rows.js";
|
||||
|
||||
function toUserDtoWithAdminOverride(app, row) {
|
||||
const dto = userRowToDto(row);
|
||||
if (isAdminEmail(app, dto.email)) dto.role = "admin";
|
||||
return dto;
|
||||
}
|
||||
|
||||
export async function adminRoutes(app) {
|
||||
app.get(
|
||||
"/admin/users",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async () => {
|
||||
const res = await app.pg.query(
|
||||
"select id, email, role, created_at, updated_at from users order by created_at desc limit 500"
|
||||
);
|
||||
return res.rows.map((r) => toUserDtoWithAdminOverride(app, r));
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/admin/users/:id/folders",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.id;
|
||||
if (!userId) throw httpError(400, "user id required");
|
||||
const orderBy = app.features?.folderSortOrder
|
||||
? "parent_id nulls first, sort_order asc, name asc"
|
||||
: "parent_id nulls first, name asc";
|
||||
const res = await app.pg.query(
|
||||
`select * from bookmark_folders where user_id=$1 order by ${orderBy} limit 1000`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows.map(folderRowToDto);
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/admin/users/:id/bookmarks",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.id;
|
||||
if (!userId) throw httpError(400, "user id required");
|
||||
|
||||
const q = (req.query?.q || "").trim();
|
||||
const params = [userId];
|
||||
let where = "where user_id=$1 and deleted_at is null";
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
where += ` and (title ilike $${params.length} or url ilike $${params.length})`;
|
||||
}
|
||||
|
||||
const orderBy = app.features?.bookmarkSortOrder
|
||||
? "folder_id nulls first, sort_order asc, updated_at desc"
|
||||
: "updated_at desc";
|
||||
|
||||
const res = await app.pg.query(
|
||||
`select * from bookmarks ${where} order by ${orderBy} limit 500`,
|
||||
params
|
||||
);
|
||||
return res.rows.map(bookmarkRowToDto);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/admin/users/:userId/bookmarks/:bookmarkId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.userId;
|
||||
const bookmarkId = req.params?.bookmarkId;
|
||||
if (!userId || !bookmarkId) throw httpError(400, "userId and bookmarkId required");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2 and deleted_at is null returning *",
|
||||
[bookmarkId, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "bookmark not found");
|
||||
return bookmarkRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/admin/users/:userId/folders/:folderId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.userId;
|
||||
const folderId = req.params?.folderId;
|
||||
if (!userId || !folderId) throw httpError(400, "userId and folderId required");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"delete from bookmark_folders where id=$1 and user_id=$2 returning *",
|
||||
[folderId, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "folder not found");
|
||||
return folderRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/admin/users/:userId/bookmarks/:bookmarkId/copy-to-me",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const sourceUserId = req.params?.userId;
|
||||
const bookmarkId = req.params?.bookmarkId;
|
||||
const adminUserId = req.adminUser?.id;
|
||||
if (!sourceUserId || !bookmarkId) throw httpError(400, "userId and bookmarkId required");
|
||||
if (!adminUserId) throw httpError(401, "unauthorized");
|
||||
|
||||
const srcRes = await app.pg.query(
|
||||
"select * from bookmarks where id=$1 and user_id=$2 and deleted_at is null",
|
||||
[bookmarkId, sourceUserId]
|
||||
);
|
||||
const src = srcRes.rows[0];
|
||||
if (!src) throw httpError(404, "bookmark not found");
|
||||
|
||||
const urlNormalized = normalizeUrl(src.url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
const existing = await app.pg.query(
|
||||
"select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1",
|
||||
[adminUserId, urlHash]
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
const merged = await app.pg.query(
|
||||
`update bookmarks
|
||||
set title=$1, url=$2, url_normalized=$3, visibility='private', folder_id=null, source='manual', updated_at=now()
|
||||
where id=$4
|
||||
returning *`,
|
||||
[src.title, src.url, urlNormalized, existing.rows[0].id]
|
||||
);
|
||||
return bookmarkRowToDto(merged.rows[0]);
|
||||
}
|
||||
|
||||
const res = await app.pg.query(
|
||||
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
|
||||
values ($1, null, $2, $3, $4, $5, 'private', 'manual')
|
||||
returning *`,
|
||||
[adminUserId, src.title, src.url, urlNormalized, urlHash]
|
||||
);
|
||||
return bookmarkRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
}
|
||||
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { requireAdmin, isAdminEmail } from "../lib/admin.js";
|
||||
import { bookmarkRowToDto, folderRowToDto, userRowToDto } from "../lib/rows.js";
|
||||
|
||||
function toUserDtoWithAdminOverride(app, row) {
|
||||
const dto = userRowToDto(row);
|
||||
if (isAdminEmail(app, dto.email)) dto.role = "admin";
|
||||
return dto;
|
||||
}
|
||||
|
||||
export async function adminRoutes(app) {
|
||||
app.get(
|
||||
"/admin/users",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async () => {
|
||||
const res = await app.pg.query(
|
||||
"select id, email, role, created_at, updated_at from users order by created_at desc limit 500"
|
||||
);
|
||||
return res.rows.map((r) => toUserDtoWithAdminOverride(app, r));
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/admin/users/:id/folders",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.id;
|
||||
if (!userId) throw httpError(400, "user id required");
|
||||
const orderBy = app.features?.folderSortOrder
|
||||
? "parent_id nulls first, sort_order asc, name asc"
|
||||
: "parent_id nulls first, name asc";
|
||||
const res = await app.pg.query(
|
||||
`select * from bookmark_folders where user_id=$1 order by ${orderBy} limit 1000`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows.map(folderRowToDto);
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/admin/users/:id/bookmarks",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.id;
|
||||
if (!userId) throw httpError(400, "user id required");
|
||||
|
||||
const q = (req.query?.q || "").trim();
|
||||
const params = [userId];
|
||||
let where = "where user_id=$1 and deleted_at is null";
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
where += ` and (title ilike $${params.length} or url ilike $${params.length})`;
|
||||
}
|
||||
|
||||
const orderBy = app.features?.bookmarkSortOrder
|
||||
? "folder_id nulls first, sort_order asc, updated_at desc"
|
||||
: "updated_at desc";
|
||||
|
||||
const res = await app.pg.query(
|
||||
`select * from bookmarks ${where} order by ${orderBy} limit 500`,
|
||||
params
|
||||
);
|
||||
return res.rows.map(bookmarkRowToDto);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/admin/users/:userId/bookmarks/:bookmarkId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.userId;
|
||||
const bookmarkId = req.params?.bookmarkId;
|
||||
if (!userId || !bookmarkId) throw httpError(400, "userId and bookmarkId required");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2 and deleted_at is null returning *",
|
||||
[bookmarkId, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "bookmark not found");
|
||||
return bookmarkRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/admin/users/:userId/folders/:folderId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.userId;
|
||||
const folderId = req.params?.folderId;
|
||||
if (!userId || !folderId) throw httpError(400, "userId and folderId required");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"delete from bookmark_folders where id=$1 and user_id=$2 returning *",
|
||||
[folderId, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "folder not found");
|
||||
return folderRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/admin/users/:userId/bookmarks/:bookmarkId/copy-to-me",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const sourceUserId = req.params?.userId;
|
||||
const bookmarkId = req.params?.bookmarkId;
|
||||
const adminUserId = req.adminUser?.id;
|
||||
if (!sourceUserId || !bookmarkId) throw httpError(400, "userId and bookmarkId required");
|
||||
if (!adminUserId) throw httpError(401, "unauthorized");
|
||||
|
||||
const srcRes = await app.pg.query(
|
||||
"select * from bookmarks where id=$1 and user_id=$2 and deleted_at is null",
|
||||
[bookmarkId, sourceUserId]
|
||||
);
|
||||
const src = srcRes.rows[0];
|
||||
if (!src) throw httpError(404, "bookmark not found");
|
||||
|
||||
const urlNormalized = normalizeUrl(src.url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
const existing = await app.pg.query(
|
||||
"select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1",
|
||||
[adminUserId, urlHash]
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
const merged = await app.pg.query(
|
||||
`update bookmarks
|
||||
set title=$1, url=$2, url_normalized=$3, visibility='private', folder_id=null, source='manual', updated_at=now()
|
||||
where id=$4
|
||||
returning *`,
|
||||
[src.title, src.url, urlNormalized, existing.rows[0].id]
|
||||
);
|
||||
return bookmarkRowToDto(merged.rows[0]);
|
||||
}
|
||||
|
||||
const res = await app.pg.query(
|
||||
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
|
||||
values ($1, null, $2, $3, $4, $5, 'private', 'manual')
|
||||
returning *`,
|
||||
[adminUserId, src.title, src.url, urlNormalized, urlHash]
|
||||
);
|
||||
return bookmarkRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
import { hashPassword, verifyPassword } from "../lib/auth.js";
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { userRowToDto } from "../lib/rows.js";
|
||||
|
||||
function normalizeEmail(email) {
|
||||
return String(email || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function toUserDtoWithAdminOverride(app, row) {
|
||||
const dto = userRowToDto(row);
|
||||
const adminEmail = normalizeEmail(app.config?.adminEmail);
|
||||
if (adminEmail && normalizeEmail(dto.email) === adminEmail) {
|
||||
dto.role = "admin";
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
export async function authRoutes(app) {
|
||||
app.post("/auth/register", async (req) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) throw httpError(400, "email and password required");
|
||||
if (String(password).length < 8) throw httpError(400, "password too short");
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
try {
|
||||
const res = await app.pg.query(
|
||||
"insert into users (email, password_hash) values ($1, $2) returning id, email, role, created_at, updated_at",
|
||||
[email, passwordHash]
|
||||
);
|
||||
const user = toUserDtoWithAdminOverride(app, res.rows[0]);
|
||||
const token = await app.jwt.sign({ sub: user.id, role: user.role });
|
||||
return { token, user };
|
||||
} catch (err) {
|
||||
if (String(err?.code) === "23505") throw httpError(409, "email already exists");
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/auth/login", async (req) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) throw httpError(400, "email and password required");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"select id, email, role, password_hash, created_at, updated_at from users where email=$1",
|
||||
[email]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) throw httpError(401, "invalid credentials");
|
||||
|
||||
const ok = await verifyPassword(password, row.password_hash);
|
||||
if (!ok) throw httpError(401, "invalid credentials");
|
||||
|
||||
const user = userRowToDto(row);
|
||||
const userWithRole = toUserDtoWithAdminOverride(app, row);
|
||||
const token = await app.jwt.sign({ sub: userWithRole.id, role: userWithRole.role });
|
||||
return { token, user: userWithRole };
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/auth/me",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const res = await app.pg.query(
|
||||
"select id, email, role, created_at, updated_at from users where id=$1",
|
||||
[userId]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) throw httpError(404, "user not found");
|
||||
return toUserDtoWithAdminOverride(app, row);
|
||||
}
|
||||
);
|
||||
}
|
||||
import { hashPassword, verifyPassword } from "../lib/auth.js";
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { userRowToDto } from "../lib/rows.js";
|
||||
|
||||
function normalizeEmail(email) {
|
||||
return String(email || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function toUserDtoWithAdminOverride(app, row) {
|
||||
const dto = userRowToDto(row);
|
||||
const adminEmail = normalizeEmail(app.config?.adminEmail);
|
||||
if (adminEmail && normalizeEmail(dto.email) === adminEmail) {
|
||||
dto.role = "admin";
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
export async function authRoutes(app) {
|
||||
app.post("/auth/register", async (req) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) throw httpError(400, "email and password required");
|
||||
if (String(password).length < 8) throw httpError(400, "password too short");
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
try {
|
||||
const res = await app.pg.query(
|
||||
"insert into users (email, password_hash) values ($1, $2) returning id, email, role, created_at, updated_at",
|
||||
[email, passwordHash]
|
||||
);
|
||||
const user = toUserDtoWithAdminOverride(app, res.rows[0]);
|
||||
const token = await app.jwt.sign({ sub: user.id, role: user.role });
|
||||
return { token, user };
|
||||
} catch (err) {
|
||||
if (String(err?.code) === "23505") throw httpError(409, "email already exists");
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/auth/login", async (req) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) throw httpError(400, "email and password required");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"select id, email, role, password_hash, created_at, updated_at from users where email=$1",
|
||||
[email]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) throw httpError(401, "invalid credentials");
|
||||
|
||||
const ok = await verifyPassword(password, row.password_hash);
|
||||
if (!ok) throw httpError(401, "invalid credentials");
|
||||
|
||||
const user = userRowToDto(row);
|
||||
const userWithRole = toUserDtoWithAdminOverride(app, row);
|
||||
const token = await app.jwt.sign({ sub: userWithRole.id, role: userWithRole.role });
|
||||
return { token, user: userWithRole };
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/auth/me",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const res = await app.pg.query(
|
||||
"select id, email, role, created_at, updated_at from users where id=$1",
|
||||
[userId]
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) throw httpError(404, "user not found");
|
||||
return toUserDtoWithAdminOverride(app, row);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,305 +1,305 @@
|
||||
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { bookmarkRowToDto } from "../lib/rows.js";
|
||||
|
||||
export async function bookmarksRoutes(app) {
|
||||
app.get("/bookmarks/public", async (req) => {
|
||||
const q = (req.query?.q || "").trim();
|
||||
|
||||
const params = [];
|
||||
let where = "where visibility='public' and deleted_at is null";
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
where += ` and (title ilike $${params.length} or url ilike $${params.length})`;
|
||||
}
|
||||
|
||||
const res = await app.pg.query(
|
||||
`select * from bookmarks ${where} order by updated_at desc limit 200`,
|
||||
params
|
||||
);
|
||||
return res.rows.map(bookmarkRowToDto);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/bookmarks",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const q = (req.query?.q || "").trim();
|
||||
|
||||
const params = [userId];
|
||||
let where = "where user_id=$1 and deleted_at is null";
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
where += ` and (title ilike $${params.length} or url ilike $${params.length})`;
|
||||
}
|
||||
|
||||
const orderBy = app.features?.bookmarkSortOrder
|
||||
? "folder_id nulls first, sort_order asc, updated_at desc"
|
||||
: "updated_at desc";
|
||||
|
||||
const res = await app.pg.query(
|
||||
`select * from bookmarks ${where} order by ${orderBy} limit 500`,
|
||||
params
|
||||
);
|
||||
return res.rows.map(bookmarkRowToDto);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/bookmarks",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const { folderId, title, url, visibility } = req.body || {};
|
||||
if (!title) throw httpError(400, "title required");
|
||||
if (!url) throw httpError(400, "url required");
|
||||
if (!visibility) throw httpError(400, "visibility required");
|
||||
|
||||
const urlNormalized = normalizeUrl(url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
const existing = await app.pg.query(
|
||||
"select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1",
|
||||
[userId, urlHash]
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
// auto-merge
|
||||
const targetFolderId = folderId ?? null;
|
||||
const merged = app.features?.bookmarkSortOrder
|
||||
? await app.pg.query(
|
||||
`update bookmarks
|
||||
set title=$1,
|
||||
url=$2,
|
||||
url_normalized=$3,
|
||||
visibility=$4,
|
||||
folder_id=$5,
|
||||
sort_order = case
|
||||
when folder_id is distinct from $5 then (
|
||||
select coalesce(max(sort_order), -1) + 1
|
||||
from bookmarks
|
||||
where user_id=$7 and folder_id is not distinct from $5 and deleted_at is null
|
||||
)
|
||||
else sort_order
|
||||
end,
|
||||
source='manual',
|
||||
updated_at=now()
|
||||
where id=$6
|
||||
returning *`,
|
||||
[title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id, userId]
|
||||
)
|
||||
: await app.pg.query(
|
||||
`update bookmarks
|
||||
set title=$1, url=$2, url_normalized=$3, visibility=$4, folder_id=$5, source='manual', updated_at=now()
|
||||
where id=$6
|
||||
returning *`,
|
||||
[title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id]
|
||||
);
|
||||
return bookmarkRowToDto(merged.rows[0]);
|
||||
}
|
||||
|
||||
const targetFolderId = folderId ?? null;
|
||||
const res = app.features?.bookmarkSortOrder
|
||||
? await app.pg.query(
|
||||
`insert into bookmarks (user_id, folder_id, sort_order, title, url, url_normalized, url_hash, visibility, source)
|
||||
values (
|
||||
$1,
|
||||
$2,
|
||||
(select coalesce(max(sort_order), -1) + 1 from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null),
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
'manual'
|
||||
)
|
||||
returning *`,
|
||||
[userId, targetFolderId, title, url, urlNormalized, urlHash, visibility]
|
||||
)
|
||||
: await app.pg.query(
|
||||
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, 'manual')
|
||||
returning *`,
|
||||
[userId, targetFolderId, title, url, urlNormalized, urlHash, visibility]
|
||||
);
|
||||
return bookmarkRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/bookmarks/reorder",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
if (!app.features?.bookmarkSortOrder) {
|
||||
throw httpError(
|
||||
409,
|
||||
"bookmark sort order is not supported by current database schema. Please run server migrations (db:migrate)."
|
||||
);
|
||||
}
|
||||
|
||||
const userId = req.user.sub;
|
||||
const { folderId, orderedIds } = req.body || {};
|
||||
const folder = folderId ?? null;
|
||||
|
||||
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
|
||||
throw httpError(400, "orderedIds required");
|
||||
}
|
||||
|
||||
const siblings = await app.pg.query(
|
||||
"select id from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null",
|
||||
[userId, folder]
|
||||
);
|
||||
const siblingIds = siblings.rows.map((r) => r.id);
|
||||
|
||||
const want = new Set(orderedIds);
|
||||
if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique");
|
||||
if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all bookmarks in the folder");
|
||||
for (const id of siblingIds) {
|
||||
if (!want.has(id)) throw httpError(400, "orderedIds must include all bookmarks in the folder");
|
||||
}
|
||||
|
||||
await app.pg.query("begin");
|
||||
try {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await app.pg.query(
|
||||
"update bookmarks set sort_order=$1, updated_at=now() where id=$2 and user_id=$3 and deleted_at is null",
|
||||
[i, orderedIds[i], userId]
|
||||
);
|
||||
}
|
||||
await app.pg.query("commit");
|
||||
} catch (e) {
|
||||
await app.pg.query("rollback");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/bookmarks/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const body = req.body || {};
|
||||
|
||||
const existingRes = await app.pg.query(
|
||||
"select * from bookmarks where id=$1 and user_id=$2 and deleted_at is null",
|
||||
[id, userId]
|
||||
);
|
||||
const existing = existingRes.rows[0];
|
||||
if (!existing) throw httpError(404, "bookmark not found");
|
||||
|
||||
const sets = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
|
||||
// url update implies url_normalized + url_hash update
|
||||
let nextUrl = existing.url;
|
||||
if (Object.prototype.hasOwnProperty.call(body, "url")) {
|
||||
nextUrl = String(body.url || "").trim();
|
||||
if (!nextUrl) throw httpError(400, "url required");
|
||||
}
|
||||
|
||||
let urlNormalized = existing.url_normalized;
|
||||
let urlHash = existing.url_hash;
|
||||
const urlChanged = nextUrl !== existing.url;
|
||||
if (urlChanged) {
|
||||
urlNormalized = normalizeUrl(nextUrl);
|
||||
urlHash = computeUrlHash(urlNormalized);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "title")) {
|
||||
const title = String(body.title || "").trim();
|
||||
if (!title) throw httpError(400, "title required");
|
||||
sets.push(`title=$${i++}`);
|
||||
params.push(title);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "folderId")) {
|
||||
sets.push(`folder_id=$${i++}`);
|
||||
params.push(body.folderId ?? null);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "visibility")) {
|
||||
if (!body.visibility) throw httpError(400, "visibility required");
|
||||
sets.push(`visibility=$${i++}`);
|
||||
params.push(body.visibility);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) {
|
||||
if (!app.features?.bookmarkSortOrder) {
|
||||
throw httpError(
|
||||
409,
|
||||
"sortOrder is not supported by current database schema. Please run server migrations (db:migrate)."
|
||||
);
|
||||
}
|
||||
const n = Number(body.sortOrder);
|
||||
if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number");
|
||||
sets.push(`sort_order=$${i++}`);
|
||||
params.push(Math.trunc(n));
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "url")) {
|
||||
sets.push(`url=$${i++}`);
|
||||
params.push(nextUrl);
|
||||
sets.push(`url_normalized=$${i++}`);
|
||||
params.push(urlNormalized);
|
||||
sets.push(`url_hash=$${i++}`);
|
||||
params.push(urlHash);
|
||||
}
|
||||
|
||||
if (sets.length === 0) throw httpError(400, "no fields to update");
|
||||
|
||||
// If URL changed and collides with another bookmark, auto-merge by keeping the existing row.
|
||||
if (urlChanged) {
|
||||
const dup = await app.pg.query(
|
||||
"select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null and id<>$3 limit 1",
|
||||
[userId, urlHash, id]
|
||||
);
|
||||
if (dup.rows[0]) {
|
||||
const targetId = dup.rows[0].id;
|
||||
const merged = await app.pg.query(
|
||||
`update bookmarks
|
||||
set ${sets.join(", ")}, source='manual', updated_at=now()
|
||||
where id=$${i++} and user_id=$${i}
|
||||
returning *`,
|
||||
[...params, targetId, userId]
|
||||
);
|
||||
await app.pg.query(
|
||||
"update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2",
|
||||
[id, userId]
|
||||
);
|
||||
return bookmarkRowToDto(merged.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
params.push(id, userId);
|
||||
const res = await app.pg.query(
|
||||
`update bookmarks
|
||||
set ${sets.join(", ")}, source='manual', updated_at=now()
|
||||
where id=$${i++} and user_id=$${i}
|
||||
returning *`,
|
||||
params
|
||||
);
|
||||
return bookmarkRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/bookmarks/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const res = await app.pg.query(
|
||||
"update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2 and deleted_at is null returning *",
|
||||
[id, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "bookmark not found");
|
||||
return res.rows.map(bookmarkRowToDto)[0];
|
||||
}
|
||||
);
|
||||
}
|
||||
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { bookmarkRowToDto } from "../lib/rows.js";
|
||||
|
||||
export async function bookmarksRoutes(app) {
|
||||
app.get("/bookmarks/public", async (req) => {
|
||||
const q = (req.query?.q || "").trim();
|
||||
|
||||
const params = [];
|
||||
let where = "where visibility='public' and deleted_at is null";
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
where += ` and (title ilike $${params.length} or url ilike $${params.length})`;
|
||||
}
|
||||
|
||||
const res = await app.pg.query(
|
||||
`select * from bookmarks ${where} order by updated_at desc limit 200`,
|
||||
params
|
||||
);
|
||||
return res.rows.map(bookmarkRowToDto);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/bookmarks",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const q = (req.query?.q || "").trim();
|
||||
|
||||
const params = [userId];
|
||||
let where = "where user_id=$1 and deleted_at is null";
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
where += ` and (title ilike $${params.length} or url ilike $${params.length})`;
|
||||
}
|
||||
|
||||
const orderBy = app.features?.bookmarkSortOrder
|
||||
? "folder_id nulls first, sort_order asc, updated_at desc"
|
||||
: "updated_at desc";
|
||||
|
||||
const res = await app.pg.query(
|
||||
`select * from bookmarks ${where} order by ${orderBy} limit 500`,
|
||||
params
|
||||
);
|
||||
return res.rows.map(bookmarkRowToDto);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/bookmarks",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const { folderId, title, url, visibility } = req.body || {};
|
||||
if (!title) throw httpError(400, "title required");
|
||||
if (!url) throw httpError(400, "url required");
|
||||
if (!visibility) throw httpError(400, "visibility required");
|
||||
|
||||
const urlNormalized = normalizeUrl(url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
const existing = await app.pg.query(
|
||||
"select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1",
|
||||
[userId, urlHash]
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
// auto-merge
|
||||
const targetFolderId = folderId ?? null;
|
||||
const merged = app.features?.bookmarkSortOrder
|
||||
? await app.pg.query(
|
||||
`update bookmarks
|
||||
set title=$1,
|
||||
url=$2,
|
||||
url_normalized=$3,
|
||||
visibility=$4,
|
||||
folder_id=$5,
|
||||
sort_order = case
|
||||
when folder_id is distinct from $5 then (
|
||||
select coalesce(max(sort_order), -1) + 1
|
||||
from bookmarks
|
||||
where user_id=$7 and folder_id is not distinct from $5 and deleted_at is null
|
||||
)
|
||||
else sort_order
|
||||
end,
|
||||
source='manual',
|
||||
updated_at=now()
|
||||
where id=$6
|
||||
returning *`,
|
||||
[title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id, userId]
|
||||
)
|
||||
: await app.pg.query(
|
||||
`update bookmarks
|
||||
set title=$1, url=$2, url_normalized=$3, visibility=$4, folder_id=$5, source='manual', updated_at=now()
|
||||
where id=$6
|
||||
returning *`,
|
||||
[title, url, urlNormalized, visibility, targetFolderId, existing.rows[0].id]
|
||||
);
|
||||
return bookmarkRowToDto(merged.rows[0]);
|
||||
}
|
||||
|
||||
const targetFolderId = folderId ?? null;
|
||||
const res = app.features?.bookmarkSortOrder
|
||||
? await app.pg.query(
|
||||
`insert into bookmarks (user_id, folder_id, sort_order, title, url, url_normalized, url_hash, visibility, source)
|
||||
values (
|
||||
$1,
|
||||
$2,
|
||||
(select coalesce(max(sort_order), -1) + 1 from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null),
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
'manual'
|
||||
)
|
||||
returning *`,
|
||||
[userId, targetFolderId, title, url, urlNormalized, urlHash, visibility]
|
||||
)
|
||||
: await app.pg.query(
|
||||
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, 'manual')
|
||||
returning *`,
|
||||
[userId, targetFolderId, title, url, urlNormalized, urlHash, visibility]
|
||||
);
|
||||
return bookmarkRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/bookmarks/reorder",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
if (!app.features?.bookmarkSortOrder) {
|
||||
throw httpError(
|
||||
409,
|
||||
"bookmark sort order is not supported by current database schema. Please run server migrations (db:migrate)."
|
||||
);
|
||||
}
|
||||
|
||||
const userId = req.user.sub;
|
||||
const { folderId, orderedIds } = req.body || {};
|
||||
const folder = folderId ?? null;
|
||||
|
||||
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
|
||||
throw httpError(400, "orderedIds required");
|
||||
}
|
||||
|
||||
const siblings = await app.pg.query(
|
||||
"select id from bookmarks where user_id=$1 and folder_id is not distinct from $2 and deleted_at is null",
|
||||
[userId, folder]
|
||||
);
|
||||
const siblingIds = siblings.rows.map((r) => r.id);
|
||||
|
||||
const want = new Set(orderedIds);
|
||||
if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique");
|
||||
if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all bookmarks in the folder");
|
||||
for (const id of siblingIds) {
|
||||
if (!want.has(id)) throw httpError(400, "orderedIds must include all bookmarks in the folder");
|
||||
}
|
||||
|
||||
await app.pg.query("begin");
|
||||
try {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await app.pg.query(
|
||||
"update bookmarks set sort_order=$1, updated_at=now() where id=$2 and user_id=$3 and deleted_at is null",
|
||||
[i, orderedIds[i], userId]
|
||||
);
|
||||
}
|
||||
await app.pg.query("commit");
|
||||
} catch (e) {
|
||||
await app.pg.query("rollback");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/bookmarks/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const body = req.body || {};
|
||||
|
||||
const existingRes = await app.pg.query(
|
||||
"select * from bookmarks where id=$1 and user_id=$2 and deleted_at is null",
|
||||
[id, userId]
|
||||
);
|
||||
const existing = existingRes.rows[0];
|
||||
if (!existing) throw httpError(404, "bookmark not found");
|
||||
|
||||
const sets = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
|
||||
// url update implies url_normalized + url_hash update
|
||||
let nextUrl = existing.url;
|
||||
if (Object.prototype.hasOwnProperty.call(body, "url")) {
|
||||
nextUrl = String(body.url || "").trim();
|
||||
if (!nextUrl) throw httpError(400, "url required");
|
||||
}
|
||||
|
||||
let urlNormalized = existing.url_normalized;
|
||||
let urlHash = existing.url_hash;
|
||||
const urlChanged = nextUrl !== existing.url;
|
||||
if (urlChanged) {
|
||||
urlNormalized = normalizeUrl(nextUrl);
|
||||
urlHash = computeUrlHash(urlNormalized);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "title")) {
|
||||
const title = String(body.title || "").trim();
|
||||
if (!title) throw httpError(400, "title required");
|
||||
sets.push(`title=$${i++}`);
|
||||
params.push(title);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "folderId")) {
|
||||
sets.push(`folder_id=$${i++}`);
|
||||
params.push(body.folderId ?? null);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "visibility")) {
|
||||
if (!body.visibility) throw httpError(400, "visibility required");
|
||||
sets.push(`visibility=$${i++}`);
|
||||
params.push(body.visibility);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) {
|
||||
if (!app.features?.bookmarkSortOrder) {
|
||||
throw httpError(
|
||||
409,
|
||||
"sortOrder is not supported by current database schema. Please run server migrations (db:migrate)."
|
||||
);
|
||||
}
|
||||
const n = Number(body.sortOrder);
|
||||
if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number");
|
||||
sets.push(`sort_order=$${i++}`);
|
||||
params.push(Math.trunc(n));
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "url")) {
|
||||
sets.push(`url=$${i++}`);
|
||||
params.push(nextUrl);
|
||||
sets.push(`url_normalized=$${i++}`);
|
||||
params.push(urlNormalized);
|
||||
sets.push(`url_hash=$${i++}`);
|
||||
params.push(urlHash);
|
||||
}
|
||||
|
||||
if (sets.length === 0) throw httpError(400, "no fields to update");
|
||||
|
||||
// If URL changed and collides with another bookmark, auto-merge by keeping the existing row.
|
||||
if (urlChanged) {
|
||||
const dup = await app.pg.query(
|
||||
"select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null and id<>$3 limit 1",
|
||||
[userId, urlHash, id]
|
||||
);
|
||||
if (dup.rows[0]) {
|
||||
const targetId = dup.rows[0].id;
|
||||
const merged = await app.pg.query(
|
||||
`update bookmarks
|
||||
set ${sets.join(", ")}, source='manual', updated_at=now()
|
||||
where id=$${i++} and user_id=$${i}
|
||||
returning *`,
|
||||
[...params, targetId, userId]
|
||||
);
|
||||
await app.pg.query(
|
||||
"update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2",
|
||||
[id, userId]
|
||||
);
|
||||
return bookmarkRowToDto(merged.rows[0]);
|
||||
}
|
||||
}
|
||||
|
||||
params.push(id, userId);
|
||||
const res = await app.pg.query(
|
||||
`update bookmarks
|
||||
set ${sets.join(", ")}, source='manual', updated_at=now()
|
||||
where id=$${i++} and user_id=$${i}
|
||||
returning *`,
|
||||
params
|
||||
);
|
||||
return bookmarkRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/bookmarks/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const res = await app.pg.query(
|
||||
"update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2 and deleted_at is null returning *",
|
||||
[id, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "bookmark not found");
|
||||
return res.rows.map(bookmarkRowToDto)[0];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,199 +1,199 @@
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { folderRowToDto } from "../lib/rows.js";
|
||||
|
||||
export async function foldersRoutes(app) {
|
||||
app.get(
|
||||
"/folders",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const orderBy = app.features?.folderSortOrder
|
||||
? "parent_id nulls first, sort_order asc, name asc"
|
||||
: "parent_id nulls first, name asc";
|
||||
const res = await app.pg.query(
|
||||
`select * from bookmark_folders where user_id=$1 order by ${orderBy}`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows.map(folderRowToDto);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/folders",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const { parentId, name, visibility } = req.body || {};
|
||||
|
||||
await app.pg.query("begin");
|
||||
try {
|
||||
// Move bookmarks in this folder back to root (so they remain visible).
|
||||
await app.pg.query(
|
||||
"update bookmarks set folder_id=null, updated_at=now() where user_id=$1 and folder_id=$2 and deleted_at is null",
|
||||
[userId, id]
|
||||
);
|
||||
|
||||
// Lift child folders to root.
|
||||
await app.pg.query(
|
||||
"update bookmark_folders set parent_id=null, updated_at=now() where user_id=$1 and parent_id=$2",
|
||||
[userId, id]
|
||||
);
|
||||
|
||||
const res = await app.pg.query(
|
||||
"delete from bookmark_folders where id=$1 and user_id=$2 returning id",
|
||||
[id, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "folder not found");
|
||||
await app.pg.query("commit");
|
||||
} catch (e) {
|
||||
await app.pg.query("rollback");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
const res = app.features?.folderSortOrder
|
||||
? await app.pg.query(
|
||||
`insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order)
|
||||
values (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
(select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is not distinct from $2)
|
||||
)
|
||||
returning *`,
|
||||
[userId, parent, name, visibility]
|
||||
)
|
||||
: await app.pg.query(
|
||||
`insert into bookmark_folders (user_id, parent_id, name, visibility)
|
||||
values ($1, $2, $3, $4)
|
||||
returning *`,
|
||||
[userId, parent, name, visibility]
|
||||
);
|
||||
return folderRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/folders/reorder",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
if (!app.features?.folderSortOrder) {
|
||||
throw httpError(
|
||||
409,
|
||||
"folder sort order is not supported by current database schema. Please run server migrations (db:migrate)."
|
||||
);
|
||||
}
|
||||
|
||||
const userId = req.user.sub;
|
||||
const { parentId, orderedIds } = req.body || {};
|
||||
const parent = parentId ?? null;
|
||||
|
||||
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
|
||||
throw httpError(400, "orderedIds required");
|
||||
}
|
||||
|
||||
const siblings = await app.pg.query(
|
||||
"select id from bookmark_folders where user_id=$1 and parent_id is not distinct from $2",
|
||||
[userId, parent]
|
||||
);
|
||||
const siblingIds = siblings.rows.map((r) => r.id);
|
||||
|
||||
// ensure same set
|
||||
const want = new Set(orderedIds);
|
||||
if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique");
|
||||
if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all sibling folders");
|
||||
for (const id of siblingIds) {
|
||||
if (!want.has(id)) throw httpError(400, "orderedIds must include all sibling folders");
|
||||
}
|
||||
|
||||
await app.pg.query("begin");
|
||||
try {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await app.pg.query(
|
||||
"update bookmark_folders set sort_order=$1, updated_at=now() where id=$2 and user_id=$3",
|
||||
[i, orderedIds[i], userId]
|
||||
);
|
||||
}
|
||||
await app.pg.query("commit");
|
||||
} catch (e) {
|
||||
await app.pg.query("rollback");
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/folders/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const body = req.body || {};
|
||||
|
||||
const existing = await app.pg.query(
|
||||
"select * from bookmark_folders where id=$1 and user_id=$2",
|
||||
[id, userId]
|
||||
);
|
||||
if (!existing.rows[0]) throw httpError(404, "folder not found");
|
||||
|
||||
const sets = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "parentId")) {
|
||||
sets.push(`parent_id=$${i++}`);
|
||||
params.push(body.parentId ?? null);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, "name")) {
|
||||
const name = String(body.name || "").trim();
|
||||
if (!name) throw httpError(400, "name required");
|
||||
sets.push(`name=$${i++}`);
|
||||
params.push(name);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, "visibility")) {
|
||||
if (!body.visibility) throw httpError(400, "visibility required");
|
||||
sets.push(`visibility=$${i++}`);
|
||||
params.push(body.visibility);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) {
|
||||
if (!app.features?.folderSortOrder) {
|
||||
throw httpError(
|
||||
409,
|
||||
"sortOrder is not supported by current database schema. Please run server migrations (db:migrate)."
|
||||
);
|
||||
}
|
||||
const n = Number(body.sortOrder);
|
||||
if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number");
|
||||
sets.push(`sort_order=$${i++}`);
|
||||
params.push(Math.trunc(n));
|
||||
}
|
||||
|
||||
if (sets.length === 0) throw httpError(400, "no fields to update");
|
||||
|
||||
params.push(id, userId);
|
||||
const res = await app.pg.query(
|
||||
`update bookmark_folders set ${sets.join(", ")}, updated_at=now() where id=$${i++} and user_id=$${i} returning *`,
|
||||
params
|
||||
);
|
||||
return folderRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/folders/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const res = await app.pg.query(
|
||||
"delete from bookmark_folders where id=$1 and user_id=$2 returning id",
|
||||
[id, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "folder not found");
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
}
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { folderRowToDto } from "../lib/rows.js";
|
||||
|
||||
export async function foldersRoutes(app) {
|
||||
app.get(
|
||||
"/folders",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const orderBy = app.features?.folderSortOrder
|
||||
? "parent_id nulls first, sort_order asc, name asc"
|
||||
: "parent_id nulls first, name asc";
|
||||
const res = await app.pg.query(
|
||||
`select * from bookmark_folders where user_id=$1 order by ${orderBy}`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows.map(folderRowToDto);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/folders",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const { parentId, name, visibility } = req.body || {};
|
||||
|
||||
await app.pg.query("begin");
|
||||
try {
|
||||
// Move bookmarks in this folder back to root (so they remain visible).
|
||||
await app.pg.query(
|
||||
"update bookmarks set folder_id=null, updated_at=now() where user_id=$1 and folder_id=$2 and deleted_at is null",
|
||||
[userId, id]
|
||||
);
|
||||
|
||||
// Lift child folders to root.
|
||||
await app.pg.query(
|
||||
"update bookmark_folders set parent_id=null, updated_at=now() where user_id=$1 and parent_id=$2",
|
||||
[userId, id]
|
||||
);
|
||||
|
||||
const res = await app.pg.query(
|
||||
"delete from bookmark_folders where id=$1 and user_id=$2 returning id",
|
||||
[id, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "folder not found");
|
||||
await app.pg.query("commit");
|
||||
} catch (e) {
|
||||
await app.pg.query("rollback");
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
const res = app.features?.folderSortOrder
|
||||
? await app.pg.query(
|
||||
`insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order)
|
||||
values (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
(select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is not distinct from $2)
|
||||
)
|
||||
returning *`,
|
||||
[userId, parent, name, visibility]
|
||||
)
|
||||
: await app.pg.query(
|
||||
`insert into bookmark_folders (user_id, parent_id, name, visibility)
|
||||
values ($1, $2, $3, $4)
|
||||
returning *`,
|
||||
[userId, parent, name, visibility]
|
||||
);
|
||||
return folderRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/folders/reorder",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
if (!app.features?.folderSortOrder) {
|
||||
throw httpError(
|
||||
409,
|
||||
"folder sort order is not supported by current database schema. Please run server migrations (db:migrate)."
|
||||
);
|
||||
}
|
||||
|
||||
const userId = req.user.sub;
|
||||
const { parentId, orderedIds } = req.body || {};
|
||||
const parent = parentId ?? null;
|
||||
|
||||
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
|
||||
throw httpError(400, "orderedIds required");
|
||||
}
|
||||
|
||||
const siblings = await app.pg.query(
|
||||
"select id from bookmark_folders where user_id=$1 and parent_id is not distinct from $2",
|
||||
[userId, parent]
|
||||
);
|
||||
const siblingIds = siblings.rows.map((r) => r.id);
|
||||
|
||||
// ensure same set
|
||||
const want = new Set(orderedIds);
|
||||
if (want.size !== orderedIds.length) throw httpError(400, "orderedIds must be unique");
|
||||
if (siblingIds.length !== orderedIds.length) throw httpError(400, "orderedIds must include all sibling folders");
|
||||
for (const id of siblingIds) {
|
||||
if (!want.has(id)) throw httpError(400, "orderedIds must include all sibling folders");
|
||||
}
|
||||
|
||||
await app.pg.query("begin");
|
||||
try {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await app.pg.query(
|
||||
"update bookmark_folders set sort_order=$1, updated_at=now() where id=$2 and user_id=$3",
|
||||
[i, orderedIds[i], userId]
|
||||
);
|
||||
}
|
||||
await app.pg.query("commit");
|
||||
} catch (e) {
|
||||
await app.pg.query("rollback");
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/folders/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const body = req.body || {};
|
||||
|
||||
const existing = await app.pg.query(
|
||||
"select * from bookmark_folders where id=$1 and user_id=$2",
|
||||
[id, userId]
|
||||
);
|
||||
if (!existing.rows[0]) throw httpError(404, "folder not found");
|
||||
|
||||
const sets = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "parentId")) {
|
||||
sets.push(`parent_id=$${i++}`);
|
||||
params.push(body.parentId ?? null);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, "name")) {
|
||||
const name = String(body.name || "").trim();
|
||||
if (!name) throw httpError(400, "name required");
|
||||
sets.push(`name=$${i++}`);
|
||||
params.push(name);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, "visibility")) {
|
||||
if (!body.visibility) throw httpError(400, "visibility required");
|
||||
sets.push(`visibility=$${i++}`);
|
||||
params.push(body.visibility);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "sortOrder")) {
|
||||
if (!app.features?.folderSortOrder) {
|
||||
throw httpError(
|
||||
409,
|
||||
"sortOrder is not supported by current database schema. Please run server migrations (db:migrate)."
|
||||
);
|
||||
}
|
||||
const n = Number(body.sortOrder);
|
||||
if (!Number.isFinite(n)) throw httpError(400, "sortOrder must be a number");
|
||||
sets.push(`sort_order=$${i++}`);
|
||||
params.push(Math.trunc(n));
|
||||
}
|
||||
|
||||
if (sets.length === 0) throw httpError(400, "no fields to update");
|
||||
|
||||
params.push(id, userId);
|
||||
const res = await app.pg.query(
|
||||
`update bookmark_folders set ${sets.join(", ")}, updated_at=now() where id=$${i++} and user_id=$${i} returning *`,
|
||||
params
|
||||
);
|
||||
return folderRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/folders/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const res = await app.pg.query(
|
||||
"delete from bookmark_folders where id=$1 and user_id=$2 returning id",
|
||||
[id, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "folder not found");
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,131 +1,131 @@
|
||||
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
import { parseNetscapeBookmarkHtmlNode, buildNetscapeBookmarkHtml } from "../lib/bookmarkHtmlNode.js";
|
||||
|
||||
export async function importExportRoutes(app) {
|
||||
app.post(
|
||||
"/bookmarks/import/html",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const file = await req.file();
|
||||
if (!file) return { imported: 0, merged: 0 };
|
||||
|
||||
const chunks = [];
|
||||
for await (const c of file.file) chunks.push(c);
|
||||
const html = Buffer.concat(chunks).toString("utf8");
|
||||
|
||||
const parsed = parseNetscapeBookmarkHtmlNode(html);
|
||||
|
||||
// Flatten folders (no nesting): dedupe/merge by folder name for this user.
|
||||
const normName = (s) => String(s || "").replace(/\s+/g, " ").trim().toLowerCase();
|
||||
|
||||
const existingFolders = await app.pg.query(
|
||||
"select id, name from bookmark_folders where user_id=$1",
|
||||
[userId]
|
||||
);
|
||||
const folderIdByName = new Map(
|
||||
existingFolders.rows.map((r) => [normName(r.name), r.id])
|
||||
);
|
||||
|
||||
const tempIdToFolderName = new Map(
|
||||
(parsed.folders || []).map((f) => [f.tempId, f.name])
|
||||
);
|
||||
|
||||
const tempToDbId = new Map();
|
||||
for (const f of parsed.folders || []) {
|
||||
const key = normName(f.name);
|
||||
if (!key) continue;
|
||||
|
||||
let id = folderIdByName.get(key);
|
||||
if (!id) {
|
||||
const res = app.features?.folderSortOrder
|
||||
? await app.pg.query(
|
||||
`insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order)
|
||||
values (
|
||||
$1,
|
||||
null,
|
||||
$2,
|
||||
'private',
|
||||
(select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is null)
|
||||
)
|
||||
returning id`,
|
||||
[userId, f.name]
|
||||
)
|
||||
: await app.pg.query(
|
||||
`insert into bookmark_folders (user_id, parent_id, name, visibility)
|
||||
values ($1, null, $2, 'private')
|
||||
returning id`,
|
||||
[userId, f.name]
|
||||
);
|
||||
id = res.rows[0].id;
|
||||
folderIdByName.set(key, id);
|
||||
}
|
||||
|
||||
tempToDbId.set(f.tempId, id);
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let merged = 0;
|
||||
|
||||
for (const b of parsed.bookmarks) {
|
||||
// Map bookmark's folder via folder name (flattened).
|
||||
let folderId = null;
|
||||
if (b.parentTempId) {
|
||||
const fname = tempIdToFolderName.get(b.parentTempId);
|
||||
const key = normName(fname);
|
||||
folderId = key ? (folderIdByName.get(key) || tempToDbId.get(b.parentTempId) || null) : null;
|
||||
}
|
||||
const urlNormalized = normalizeUrl(b.url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
const existing = await app.pg.query(
|
||||
"select id from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1",
|
||||
[userId, urlHash]
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
await app.pg.query(
|
||||
`update bookmarks
|
||||
set title=$1, url=$2, url_normalized=$3, folder_id=$4, source='import', updated_at=now()
|
||||
where id=$5`,
|
||||
[b.title || "", b.url || "", urlNormalized, folderId, existing.rows[0].id]
|
||||
);
|
||||
merged++;
|
||||
} else {
|
||||
await app.pg.query(
|
||||
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
|
||||
values ($1, $2, $3, $4, $5, $6, 'private', 'import')`,
|
||||
[userId, folderId, b.title || "", b.url || "", urlNormalized, urlHash]
|
||||
);
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, merged };
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/bookmarks/export/html",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req, reply) => {
|
||||
const userId = req.user.sub;
|
||||
const folders = await app.pg.query(
|
||||
"select id, parent_id, name from bookmark_folders where user_id=$1 order by name",
|
||||
[userId]
|
||||
);
|
||||
const bookmarks = await app.pg.query(
|
||||
"select folder_id, title, url from bookmarks where user_id=$1 and deleted_at is null order by title",
|
||||
[userId]
|
||||
);
|
||||
|
||||
const html = buildNetscapeBookmarkHtml({
|
||||
folders: folders.rows.map((r) => ({ id: r.id, parentId: r.parent_id, name: r.name })),
|
||||
bookmarks: bookmarks.rows.map((r) => ({ folderId: r.folder_id, title: r.title, url: r.url }))
|
||||
});
|
||||
|
||||
reply.type("text/html; charset=utf-8");
|
||||
return html;
|
||||
}
|
||||
);
|
||||
}
|
||||
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
import { parseNetscapeBookmarkHtmlNode, buildNetscapeBookmarkHtml } from "../lib/bookmarkHtmlNode.js";
|
||||
|
||||
export async function importExportRoutes(app) {
|
||||
app.post(
|
||||
"/bookmarks/import/html",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const file = await req.file();
|
||||
if (!file) return { imported: 0, merged: 0 };
|
||||
|
||||
const chunks = [];
|
||||
for await (const c of file.file) chunks.push(c);
|
||||
const html = Buffer.concat(chunks).toString("utf8");
|
||||
|
||||
const parsed = parseNetscapeBookmarkHtmlNode(html);
|
||||
|
||||
// Flatten folders (no nesting): dedupe/merge by folder name for this user.
|
||||
const normName = (s) => String(s || "").replace(/\s+/g, " ").trim().toLowerCase();
|
||||
|
||||
const existingFolders = await app.pg.query(
|
||||
"select id, name from bookmark_folders where user_id=$1",
|
||||
[userId]
|
||||
);
|
||||
const folderIdByName = new Map(
|
||||
existingFolders.rows.map((r) => [normName(r.name), r.id])
|
||||
);
|
||||
|
||||
const tempIdToFolderName = new Map(
|
||||
(parsed.folders || []).map((f) => [f.tempId, f.name])
|
||||
);
|
||||
|
||||
const tempToDbId = new Map();
|
||||
for (const f of parsed.folders || []) {
|
||||
const key = normName(f.name);
|
||||
if (!key) continue;
|
||||
|
||||
let id = folderIdByName.get(key);
|
||||
if (!id) {
|
||||
const res = app.features?.folderSortOrder
|
||||
? await app.pg.query(
|
||||
`insert into bookmark_folders (user_id, parent_id, name, visibility, sort_order)
|
||||
values (
|
||||
$1,
|
||||
null,
|
||||
$2,
|
||||
'private',
|
||||
(select coalesce(max(sort_order), -1) + 1 from bookmark_folders where user_id=$1 and parent_id is null)
|
||||
)
|
||||
returning id`,
|
||||
[userId, f.name]
|
||||
)
|
||||
: await app.pg.query(
|
||||
`insert into bookmark_folders (user_id, parent_id, name, visibility)
|
||||
values ($1, null, $2, 'private')
|
||||
returning id`,
|
||||
[userId, f.name]
|
||||
);
|
||||
id = res.rows[0].id;
|
||||
folderIdByName.set(key, id);
|
||||
}
|
||||
|
||||
tempToDbId.set(f.tempId, id);
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let merged = 0;
|
||||
|
||||
for (const b of parsed.bookmarks) {
|
||||
// Map bookmark's folder via folder name (flattened).
|
||||
let folderId = null;
|
||||
if (b.parentTempId) {
|
||||
const fname = tempIdToFolderName.get(b.parentTempId);
|
||||
const key = normName(fname);
|
||||
folderId = key ? (folderIdByName.get(key) || tempToDbId.get(b.parentTempId) || null) : null;
|
||||
}
|
||||
const urlNormalized = normalizeUrl(b.url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
const existing = await app.pg.query(
|
||||
"select id from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1",
|
||||
[userId, urlHash]
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
await app.pg.query(
|
||||
`update bookmarks
|
||||
set title=$1, url=$2, url_normalized=$3, folder_id=$4, source='import', updated_at=now()
|
||||
where id=$5`,
|
||||
[b.title || "", b.url || "", urlNormalized, folderId, existing.rows[0].id]
|
||||
);
|
||||
merged++;
|
||||
} else {
|
||||
await app.pg.query(
|
||||
`insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source)
|
||||
values ($1, $2, $3, $4, $5, $6, 'private', 'import')`,
|
||||
[userId, folderId, b.title || "", b.url || "", urlNormalized, urlHash]
|
||||
);
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, merged };
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/bookmarks/export/html",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req, reply) => {
|
||||
const userId = req.user.sub;
|
||||
const folders = await app.pg.query(
|
||||
"select id, parent_id, name from bookmark_folders where user_id=$1 order by name",
|
||||
[userId]
|
||||
);
|
||||
const bookmarks = await app.pg.query(
|
||||
"select folder_id, title, url from bookmarks where user_id=$1 and deleted_at is null order by title",
|
||||
[userId]
|
||||
);
|
||||
|
||||
const html = buildNetscapeBookmarkHtml({
|
||||
folders: folders.rows.map((r) => ({ id: r.id, parentId: r.parent_id, name: r.name })),
|
||||
bookmarks: bookmarks.rows.map((r) => ({ folderId: r.folder_id, title: r.title, url: r.url }))
|
||||
});
|
||||
|
||||
reply.type("text/html; charset=utf-8");
|
||||
return html;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,162 +1,162 @@
|
||||
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
|
||||
function toDate(v) {
|
||||
if (!v) return null;
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export async function syncRoutes(app) {
|
||||
app.post(
|
||||
"/sync/push",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const { bookmarks = [], folders = [] } = req.body || {};
|
||||
|
||||
// folders: upsert by id with LWW
|
||||
for (const f of folders) {
|
||||
const incomingUpdatedAt = toDate(f.updatedAt) || new Date();
|
||||
const existing = await app.pg.query(
|
||||
"select id, updated_at from bookmark_folders where id=$1 and user_id=$2",
|
||||
[f.id, userId]
|
||||
);
|
||||
|
||||
if (!existing.rows[0]) {
|
||||
await app.pg.query(
|
||||
`insert into bookmark_folders (id, user_id, parent_id, name, visibility, updated_at)
|
||||
values ($1, $2, $3, $4, $5, $6)`,
|
||||
[f.id, userId, f.parentId ?? null, f.name || "", f.visibility || "private", incomingUpdatedAt]
|
||||
);
|
||||
} else {
|
||||
const serverUpdatedAt = new Date(existing.rows[0].updated_at);
|
||||
if (incomingUpdatedAt > serverUpdatedAt) {
|
||||
await app.pg.query(
|
||||
`update bookmark_folders
|
||||
set parent_id=$1, name=$2, visibility=$3, updated_at=$4
|
||||
where id=$5 and user_id=$6`,
|
||||
[f.parentId ?? null, f.name || "", f.visibility || "private", incomingUpdatedAt, f.id, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bookmarks: upsert by id with LWW; keep urlHash normalized
|
||||
for (const b of bookmarks) {
|
||||
const incomingUpdatedAt = toDate(b.updatedAt) || new Date();
|
||||
const incomingDeletedAt = toDate(b.deletedAt);
|
||||
|
||||
const urlNormalized = normalizeUrl(b.url || "");
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
const existing = await app.pg.query(
|
||||
"select id, updated_at from bookmarks where id=$1 and user_id=$2",
|
||||
[b.id, userId]
|
||||
);
|
||||
|
||||
if (!existing.rows[0]) {
|
||||
await app.pg.query(
|
||||
`insert into bookmarks (
|
||||
id, user_id, folder_id, title, url, url_normalized, url_hash, visibility, source, updated_at, deleted_at
|
||||
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||
[
|
||||
b.id,
|
||||
userId,
|
||||
b.folderId ?? null,
|
||||
b.title || "",
|
||||
b.url || "",
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
b.visibility || "private",
|
||||
b.source || "manual",
|
||||
incomingUpdatedAt,
|
||||
incomingDeletedAt
|
||||
]
|
||||
);
|
||||
} else {
|
||||
const serverUpdatedAt = new Date(existing.rows[0].updated_at);
|
||||
if (incomingUpdatedAt > serverUpdatedAt) {
|
||||
await app.pg.query(
|
||||
`update bookmarks
|
||||
set folder_id=$1, title=$2, url=$3, url_normalized=$4, url_hash=$5, visibility=$6, source=$7, updated_at=$8, deleted_at=$9
|
||||
where id=$10 and user_id=$11`,
|
||||
[
|
||||
b.folderId ?? null,
|
||||
b.title || "",
|
||||
b.url || "",
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
b.visibility || "private",
|
||||
b.source || "manual",
|
||||
incomingUpdatedAt,
|
||||
incomingDeletedAt,
|
||||
b.id,
|
||||
userId
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/sync/pull",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const since = toDate(req.query?.since);
|
||||
|
||||
const paramsFolders = [userId];
|
||||
let whereFolders = "where user_id=$1";
|
||||
if (since) {
|
||||
paramsFolders.push(since);
|
||||
whereFolders += ` and updated_at > $${paramsFolders.length}`;
|
||||
}
|
||||
|
||||
const paramsBookmarks = [userId];
|
||||
let whereBookmarks = "where user_id=$1";
|
||||
if (since) {
|
||||
paramsBookmarks.push(since);
|
||||
whereBookmarks += ` and updated_at > $${paramsBookmarks.length}`;
|
||||
}
|
||||
|
||||
const foldersRes = await app.pg.query(
|
||||
`select id, user_id, parent_id, name, visibility, created_at, updated_at from bookmark_folders ${whereFolders}`,
|
||||
paramsFolders
|
||||
);
|
||||
const bookmarksRes = await app.pg.query(
|
||||
`select id, user_id, folder_id, title, url, url_normalized, url_hash, visibility, source, updated_at, deleted_at from bookmarks ${whereBookmarks}`,
|
||||
paramsBookmarks
|
||||
);
|
||||
|
||||
return {
|
||||
folders: foldersRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
parentId: r.parent_id,
|
||||
name: r.name,
|
||||
visibility: r.visibility,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
})),
|
||||
bookmarks: bookmarksRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
folderId: r.folder_id,
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
urlNormalized: r.url_normalized,
|
||||
urlHash: r.url_hash,
|
||||
visibility: r.visibility,
|
||||
source: r.source,
|
||||
updatedAt: r.updated_at,
|
||||
deletedAt: r.deleted_at
|
||||
})),
|
||||
serverTime: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
|
||||
function toDate(v) {
|
||||
if (!v) return null;
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export async function syncRoutes(app) {
|
||||
app.post(
|
||||
"/sync/push",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const { bookmarks = [], folders = [] } = req.body || {};
|
||||
|
||||
// folders: upsert by id with LWW
|
||||
for (const f of folders) {
|
||||
const incomingUpdatedAt = toDate(f.updatedAt) || new Date();
|
||||
const existing = await app.pg.query(
|
||||
"select id, updated_at from bookmark_folders where id=$1 and user_id=$2",
|
||||
[f.id, userId]
|
||||
);
|
||||
|
||||
if (!existing.rows[0]) {
|
||||
await app.pg.query(
|
||||
`insert into bookmark_folders (id, user_id, parent_id, name, visibility, updated_at)
|
||||
values ($1, $2, $3, $4, $5, $6)`,
|
||||
[f.id, userId, f.parentId ?? null, f.name || "", f.visibility || "private", incomingUpdatedAt]
|
||||
);
|
||||
} else {
|
||||
const serverUpdatedAt = new Date(existing.rows[0].updated_at);
|
||||
if (incomingUpdatedAt > serverUpdatedAt) {
|
||||
await app.pg.query(
|
||||
`update bookmark_folders
|
||||
set parent_id=$1, name=$2, visibility=$3, updated_at=$4
|
||||
where id=$5 and user_id=$6`,
|
||||
[f.parentId ?? null, f.name || "", f.visibility || "private", incomingUpdatedAt, f.id, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bookmarks: upsert by id with LWW; keep urlHash normalized
|
||||
for (const b of bookmarks) {
|
||||
const incomingUpdatedAt = toDate(b.updatedAt) || new Date();
|
||||
const incomingDeletedAt = toDate(b.deletedAt);
|
||||
|
||||
const urlNormalized = normalizeUrl(b.url || "");
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
const existing = await app.pg.query(
|
||||
"select id, updated_at from bookmarks where id=$1 and user_id=$2",
|
||||
[b.id, userId]
|
||||
);
|
||||
|
||||
if (!existing.rows[0]) {
|
||||
await app.pg.query(
|
||||
`insert into bookmarks (
|
||||
id, user_id, folder_id, title, url, url_normalized, url_hash, visibility, source, updated_at, deleted_at
|
||||
) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||
[
|
||||
b.id,
|
||||
userId,
|
||||
b.folderId ?? null,
|
||||
b.title || "",
|
||||
b.url || "",
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
b.visibility || "private",
|
||||
b.source || "manual",
|
||||
incomingUpdatedAt,
|
||||
incomingDeletedAt
|
||||
]
|
||||
);
|
||||
} else {
|
||||
const serverUpdatedAt = new Date(existing.rows[0].updated_at);
|
||||
if (incomingUpdatedAt > serverUpdatedAt) {
|
||||
await app.pg.query(
|
||||
`update bookmarks
|
||||
set folder_id=$1, title=$2, url=$3, url_normalized=$4, url_hash=$5, visibility=$6, source=$7, updated_at=$8, deleted_at=$9
|
||||
where id=$10 and user_id=$11`,
|
||||
[
|
||||
b.folderId ?? null,
|
||||
b.title || "",
|
||||
b.url || "",
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
b.visibility || "private",
|
||||
b.source || "manual",
|
||||
incomingUpdatedAt,
|
||||
incomingDeletedAt,
|
||||
b.id,
|
||||
userId
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/sync/pull",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const since = toDate(req.query?.since);
|
||||
|
||||
const paramsFolders = [userId];
|
||||
let whereFolders = "where user_id=$1";
|
||||
if (since) {
|
||||
paramsFolders.push(since);
|
||||
whereFolders += ` and updated_at > $${paramsFolders.length}`;
|
||||
}
|
||||
|
||||
const paramsBookmarks = [userId];
|
||||
let whereBookmarks = "where user_id=$1";
|
||||
if (since) {
|
||||
paramsBookmarks.push(since);
|
||||
whereBookmarks += ` and updated_at > $${paramsBookmarks.length}`;
|
||||
}
|
||||
|
||||
const foldersRes = await app.pg.query(
|
||||
`select id, user_id, parent_id, name, visibility, created_at, updated_at from bookmark_folders ${whereFolders}`,
|
||||
paramsFolders
|
||||
);
|
||||
const bookmarksRes = await app.pg.query(
|
||||
`select id, user_id, folder_id, title, url, url_normalized, url_hash, visibility, source, updated_at, deleted_at from bookmarks ${whereBookmarks}`,
|
||||
paramsBookmarks
|
||||
);
|
||||
|
||||
return {
|
||||
folders: foldersRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
parentId: r.parent_id,
|
||||
name: r.name,
|
||||
visibility: r.visibility,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
})),
|
||||
bookmarks: bookmarksRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
folderId: r.folder_id,
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
urlNormalized: r.url_normalized,
|
||||
urlHash: r.url_hash,
|
||||
visibility: r.visibility,
|
||||
source: r.source,
|
||||
updatedAt: r.updated_at,
|
||||
deletedAt: r.deleted_at
|
||||
})),
|
||||
serverTime: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.js", "**/*.vue"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: "module"
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
}
|
||||
];
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.js", "**/*.vue"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: "module"
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@browser-bookmark/shared": "0.1.0",
|
||||
"@browser-bookmark/shared": "file:../../packages/shared",
|
||||
"sortablejs": "^1.15.6",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.5.1"
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: "" },
|
||||
maxWidth: { type: String, default: "720px" }
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === "Escape") close();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div v-if="modelValue" class="bb-modalOverlay" role="dialog" aria-modal="true">
|
||||
<div class="bb-modalBackdrop" @click="close" />
|
||||
<div class="bb-modalPanel" :style="{ maxWidth }">
|
||||
<div class="bb-modalHeader">
|
||||
<div class="bb-modalTitle">{{ title }}</div>
|
||||
<button type="button" class="bb-modalClose" @click="close" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="bb-modalBody">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483500;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.bb-modalBackdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.bb-modalPanel {
|
||||
position: relative;
|
||||
width: min(100%, var(--bb-modal-max, 720px));
|
||||
max-height: min(84vh, 860px);
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: rgba(255,255,255,0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.bb-modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.bb-modalTitle {
|
||||
font-weight: 900;
|
||||
color: var(--bb-text);
|
||||
}
|
||||
|
||||
.bb-modalClose {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.55);
|
||||
background: rgba(255,255,255,0.45);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: rgba(15, 23, 42, 0.72);
|
||||
}
|
||||
|
||||
.bb-modalClose:hover {
|
||||
background: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.bb-modalBody {
|
||||
padding: 14px;
|
||||
}
|
||||
</style>
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: "" },
|
||||
maxWidth: { type: String, default: "720px" }
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === "Escape") close();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div v-if="modelValue" class="bb-modalOverlay" role="dialog" aria-modal="true">
|
||||
<div class="bb-modalBackdrop" @click="close" />
|
||||
<div class="bb-modalPanel" :style="{ maxWidth }">
|
||||
<div class="bb-modalHeader">
|
||||
<div class="bb-modalTitle">{{ title }}</div>
|
||||
<button type="button" class="bb-modalClose" @click="close" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="bb-modalBody">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483500;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.bb-modalBackdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.bb-modalPanel {
|
||||
position: relative;
|
||||
width: min(100%, var(--bb-modal-max, 720px));
|
||||
max-height: min(84vh, 860px);
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: rgba(255,255,255,0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.bb-modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.bb-modalTitle {
|
||||
font-weight: 900;
|
||||
color: var(--bb-text);
|
||||
}
|
||||
|
||||
.bb-modalClose {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.55);
|
||||
background: rgba(255,255,255,0.45);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: rgba(15, 23, 42, 0.72);
|
||||
}
|
||||
|
||||
.bb-modalClose:hover {
|
||||
background: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.bb-modalBody {
|
||||
padding: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,153 +1,153 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Boolean, Object, null], default: null },
|
||||
options: { type: Array, required: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: "请选择" },
|
||||
size: { type: String, default: "md" } // md | sm
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const rootEl = ref(null);
|
||||
const triggerEl = ref(null);
|
||||
const menuEl = ref(null);
|
||||
const open = ref(false);
|
||||
const menuStyle = ref({ left: "0px", top: "0px", width: "0px" });
|
||||
|
||||
const selected = computed(() => props.options.find((o) => Object.is(o.value, props.modelValue)) || null);
|
||||
const label = computed(() => selected.value?.label ?? "");
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
async function updateMenuPosition() {
|
||||
const el = triggerEl.value;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
|
||||
menuStyle.value = {
|
||||
left: `${Math.round(rect.left)}px`,
|
||||
top: `${Math.round(rect.bottom + gap)}px`,
|
||||
width: `${Math.round(rect.width)}px`
|
||||
};
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
if (props.disabled) return;
|
||||
open.value = true;
|
||||
await nextTick();
|
||||
await updateMenuPosition();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled) return;
|
||||
if (open.value) close();
|
||||
else openMenu();
|
||||
}
|
||||
|
||||
function choose(value, isDisabled) {
|
||||
if (props.disabled || isDisabled) return;
|
||||
emit("update:modelValue", value);
|
||||
close();
|
||||
}
|
||||
|
||||
function onKeydownTrigger(e) {
|
||||
if (props.disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function onDocPointerDown(e) {
|
||||
const el = rootEl.value;
|
||||
const menu = menuEl.value;
|
||||
if (!el) return;
|
||||
if (el.contains(e.target)) return;
|
||||
if (menu && menu.contains(e.target)) return;
|
||||
close();
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
if (!open.value) return;
|
||||
updateMenuPosition();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("pointerdown", onDocPointerDown);
|
||||
window.addEventListener("resize", onViewportChange);
|
||||
// capture scroll events from any scroll container
|
||||
window.addEventListener("scroll", onViewportChange, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("pointerdown", onDocPointerDown);
|
||||
window.removeEventListener("resize", onViewportChange);
|
||||
window.removeEventListener("scroll", onViewportChange, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="bb-selectWrap"
|
||||
:class="[
|
||||
size === 'sm' ? 'bb-selectWrap--sm' : '',
|
||||
open ? 'is-open' : ''
|
||||
]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="bb-selectTrigger"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="open ? 'true' : 'false'"
|
||||
@click="toggle"
|
||||
@keydown="onKeydownTrigger"
|
||||
ref="triggerEl"
|
||||
>
|
||||
<span class="bb-selectValue" :class="!label ? 'bb-selectPlaceholder' : ''">
|
||||
{{ label || placeholder }}
|
||||
</span>
|
||||
<span class="bb-selectChevron" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="menuEl"
|
||||
class="bb-selectMenu bb-selectMenu--portal"
|
||||
role="listbox"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<button
|
||||
v-for="(o, idx) in options"
|
||||
:key="idx"
|
||||
type="button"
|
||||
class="bb-selectOption"
|
||||
:class="[
|
||||
Object.is(o.value, modelValue) ? 'is-selected' : '',
|
||||
o.disabled ? 'is-disabled' : ''
|
||||
]"
|
||||
role="option"
|
||||
:aria-selected="Object.is(o.value, modelValue) ? 'true' : 'false'"
|
||||
@click="choose(o.value, o.disabled)"
|
||||
>
|
||||
<span class="bb-selectOptionLabel">{{ o.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Boolean, Object, null], default: null },
|
||||
options: { type: Array, required: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: "请选择" },
|
||||
size: { type: String, default: "md" } // md | sm
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const rootEl = ref(null);
|
||||
const triggerEl = ref(null);
|
||||
const menuEl = ref(null);
|
||||
const open = ref(false);
|
||||
const menuStyle = ref({ left: "0px", top: "0px", width: "0px" });
|
||||
|
||||
const selected = computed(() => props.options.find((o) => Object.is(o.value, props.modelValue)) || null);
|
||||
const label = computed(() => selected.value?.label ?? "");
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
async function updateMenuPosition() {
|
||||
const el = triggerEl.value;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
|
||||
menuStyle.value = {
|
||||
left: `${Math.round(rect.left)}px`,
|
||||
top: `${Math.round(rect.bottom + gap)}px`,
|
||||
width: `${Math.round(rect.width)}px`
|
||||
};
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
if (props.disabled) return;
|
||||
open.value = true;
|
||||
await nextTick();
|
||||
await updateMenuPosition();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled) return;
|
||||
if (open.value) close();
|
||||
else openMenu();
|
||||
}
|
||||
|
||||
function choose(value, isDisabled) {
|
||||
if (props.disabled || isDisabled) return;
|
||||
emit("update:modelValue", value);
|
||||
close();
|
||||
}
|
||||
|
||||
function onKeydownTrigger(e) {
|
||||
if (props.disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function onDocPointerDown(e) {
|
||||
const el = rootEl.value;
|
||||
const menu = menuEl.value;
|
||||
if (!el) return;
|
||||
if (el.contains(e.target)) return;
|
||||
if (menu && menu.contains(e.target)) return;
|
||||
close();
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
if (!open.value) return;
|
||||
updateMenuPosition();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("pointerdown", onDocPointerDown);
|
||||
window.addEventListener("resize", onViewportChange);
|
||||
// capture scroll events from any scroll container
|
||||
window.addEventListener("scroll", onViewportChange, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("pointerdown", onDocPointerDown);
|
||||
window.removeEventListener("resize", onViewportChange);
|
||||
window.removeEventListener("scroll", onViewportChange, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="bb-selectWrap"
|
||||
:class="[
|
||||
size === 'sm' ? 'bb-selectWrap--sm' : '',
|
||||
open ? 'is-open' : ''
|
||||
]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="bb-selectTrigger"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="open ? 'true' : 'false'"
|
||||
@click="toggle"
|
||||
@keydown="onKeydownTrigger"
|
||||
ref="triggerEl"
|
||||
>
|
||||
<span class="bb-selectValue" :class="!label ? 'bb-selectPlaceholder' : ''">
|
||||
{{ label || placeholder }}
|
||||
</span>
|
||||
<span class="bb-selectChevron" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="menuEl"
|
||||
class="bb-selectMenu bb-selectMenu--portal"
|
||||
role="listbox"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<button
|
||||
v-for="(o, idx) in options"
|
||||
:key="idx"
|
||||
type="button"
|
||||
class="bb-selectOption"
|
||||
:class="[
|
||||
Object.is(o.value, modelValue) ? 'is-selected' : '',
|
||||
o.disabled ? 'is-disabled' : ''
|
||||
]"
|
||||
role="option"
|
||||
:aria-selected="Object.is(o.value, modelValue) ? 'true' : 'false'"
|
||||
@click="choose(o.value, o.disabled)"
|
||||
>
|
||||
<span class="bb-selectOptionLabel">{{ o.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
|
||||
|
||||
export const tokenRef = ref(localStorage.getItem("bb_token") || "");
|
||||
|
||||
export const userRef = ref(null);
|
||||
let mePromise = null;
|
||||
|
||||
export function getToken() {
|
||||
return tokenRef.value || "";
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
const next = token || "";
|
||||
tokenRef.value = next;
|
||||
if (next) localStorage.setItem("bb_token", next);
|
||||
else localStorage.removeItem("bb_token");
|
||||
|
||||
// reset cached user when auth changes
|
||||
userRef.value = null;
|
||||
mePromise = null;
|
||||
}
|
||||
|
||||
// Keep auth state in sync across tabs.
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "bb_token") {
|
||||
tokenRef.value = e.newValue || "";
|
||||
userRef.value = null;
|
||||
mePromise = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureMe() {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
userRef.value = null;
|
||||
mePromise = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (userRef.value) return userRef.value;
|
||||
if (mePromise) return mePromise;
|
||||
|
||||
mePromise = (async () => {
|
||||
try {
|
||||
const me = await apiFetch("/auth/me", { method: "GET" });
|
||||
userRef.value = me;
|
||||
return me;
|
||||
} catch {
|
||||
// token may be invalid/expired
|
||||
userRef.value = null;
|
||||
return null;
|
||||
} finally {
|
||||
mePromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return mePromise;
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!headers.has("Accept")) headers.set("Accept", "application/json");
|
||||
|
||||
if (!(options.body instanceof FormData) && options.body != null) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
const isJson = contentType.includes("application/json");
|
||||
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
|
||||
|
||||
if (!res.ok) {
|
||||
const message = payload?.message || `HTTP ${res.status}`;
|
||||
const err = new Error(message);
|
||||
err.status = res.status;
|
||||
err.payload = payload;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
import { ref } from "vue";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
|
||||
|
||||
export const tokenRef = ref(localStorage.getItem("bb_token") || "");
|
||||
|
||||
export const userRef = ref(null);
|
||||
let mePromise = null;
|
||||
|
||||
export function getToken() {
|
||||
return tokenRef.value || "";
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
const next = token || "";
|
||||
tokenRef.value = next;
|
||||
if (next) localStorage.setItem("bb_token", next);
|
||||
else localStorage.removeItem("bb_token");
|
||||
|
||||
// reset cached user when auth changes
|
||||
userRef.value = null;
|
||||
mePromise = null;
|
||||
}
|
||||
|
||||
// Keep auth state in sync across tabs.
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "bb_token") {
|
||||
tokenRef.value = e.newValue || "";
|
||||
userRef.value = null;
|
||||
mePromise = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureMe() {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
userRef.value = null;
|
||||
mePromise = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (userRef.value) return userRef.value;
|
||||
if (mePromise) return mePromise;
|
||||
|
||||
mePromise = (async () => {
|
||||
try {
|
||||
const me = await apiFetch("/auth/me", { method: "GET" });
|
||||
userRef.value = me;
|
||||
return me;
|
||||
} catch {
|
||||
// token may be invalid/expired
|
||||
userRef.value = null;
|
||||
return null;
|
||||
} finally {
|
||||
mePromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return mePromise;
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!headers.has("Accept")) headers.set("Accept", "application/json");
|
||||
|
||||
if (!(options.body instanceof FormData) && options.body != null) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
const isJson = contentType.includes("application/json");
|
||||
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
|
||||
|
||||
if (!res.ok) {
|
||||
const message = payload?.message || `HTTP ${res.status}`;
|
||||
const err = new Error(message);
|
||||
err.status = res.status;
|
||||
err.payload = payload;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -1,290 +1,290 @@
|
||||
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
|
||||
|
||||
const KEY = "bb_local_state_v1";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ensureBookmarkHashes(bookmark) {
|
||||
if (!bookmark) return bookmark;
|
||||
const url = bookmark.url || "";
|
||||
const urlNormalized = bookmark.urlNormalized || normalizeUrl(url);
|
||||
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
|
||||
bookmark.urlNormalized = urlNormalized;
|
||||
bookmark.urlHash = urlHash;
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
export function loadLocalState() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(KEY) || "") || { folders: [], bookmarks: [] };
|
||||
parsed.folders = parsed.folders || [];
|
||||
parsed.bookmarks = (parsed.bookmarks || []).map((b) => ensureBookmarkHashes(b));
|
||||
return parsed;
|
||||
} catch {
|
||||
return { folders: [], bookmarks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLocalState(state) {
|
||||
localStorage.setItem(KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function listLocalBookmarks({ includeDeleted = false } = {}) {
|
||||
const state = loadLocalState();
|
||||
const all = state.bookmarks || [];
|
||||
return includeDeleted ? all : all.filter((b) => !b.deletedAt);
|
||||
}
|
||||
|
||||
export function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
const urlNormalized = normalizeUrl(url || "");
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
// Dedupe: same urlHash and not deleted -> update LWW
|
||||
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
|
||||
if (existing) {
|
||||
ensureBookmarkHashes(existing);
|
||||
existing.title = title || existing.title;
|
||||
existing.url = url || existing.url;
|
||||
existing.urlNormalized = urlNormalized;
|
||||
existing.urlHash = urlHash;
|
||||
existing.visibility = visibility;
|
||||
existing.folderId = folderId;
|
||||
existing.source = source;
|
||||
existing.updatedAt = now;
|
||||
saveLocalState(state);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const bookmark = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId,
|
||||
title,
|
||||
url,
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
visibility,
|
||||
source,
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
};
|
||||
|
||||
state.bookmarks.unshift(bookmark);
|
||||
saveLocalState(state);
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
export function markLocalDeleted(id) {
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
const item = state.bookmarks.find((b) => b.id === id);
|
||||
if (item) {
|
||||
item.deletedAt = now;
|
||||
item.updatedAt = now;
|
||||
saveLocalState(state);
|
||||
}
|
||||
}
|
||||
|
||||
export function patchLocalBookmark(id, patch) {
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
const item = state.bookmarks.find((b) => b.id === id);
|
||||
if (!item) return null;
|
||||
|
||||
if (patch.url !== undefined) {
|
||||
const nextUrl = patch.url || "";
|
||||
const nextNormalized = normalizeUrl(nextUrl);
|
||||
const nextHash = computeUrlHash(nextNormalized);
|
||||
|
||||
const target = (state.bookmarks || []).find((b) => !b.deletedAt && b.id !== id && (b.urlHash || "") === nextHash);
|
||||
if (target) {
|
||||
// Merge: write changes to target and soft-delete current
|
||||
ensureBookmarkHashes(target);
|
||||
target.title = patch.title !== undefined ? patch.title : item.title;
|
||||
target.url = nextUrl;
|
||||
target.urlNormalized = nextNormalized;
|
||||
target.urlHash = nextHash;
|
||||
if (patch.folderId !== undefined) target.folderId = patch.folderId;
|
||||
if (patch.visibility !== undefined) target.visibility = patch.visibility;
|
||||
target.updatedAt = now;
|
||||
|
||||
item.deletedAt = now;
|
||||
item.updatedAt = now;
|
||||
saveLocalState(state);
|
||||
return target;
|
||||
}
|
||||
|
||||
item.url = nextUrl;
|
||||
item.urlNormalized = nextNormalized;
|
||||
item.urlHash = nextHash;
|
||||
}
|
||||
|
||||
if (patch.title !== undefined) item.title = patch.title;
|
||||
if (patch.folderId !== undefined) item.folderId = patch.folderId;
|
||||
if (patch.visibility !== undefined) item.visibility = patch.visibility;
|
||||
item.updatedAt = now;
|
||||
saveLocalState(state);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function deleteLocalFolder(folderId) {
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
state.folders = (state.folders || []).filter((f) => f.id !== folderId);
|
||||
state.bookmarks = (state.bookmarks || []).map((b) => {
|
||||
if (b.deletedAt) return b;
|
||||
if ((b.folderId ?? null) !== (folderId ?? null)) return b;
|
||||
return { ...ensureBookmarkHashes(b), folderId: null, updatedAt: now };
|
||||
});
|
||||
|
||||
saveLocalState(state);
|
||||
}
|
||||
|
||||
export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
|
||||
const parsed = parseNetscapeBookmarkHtml(html || "");
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
state.folders = state.folders || [];
|
||||
state.bookmarks = state.bookmarks || [];
|
||||
|
||||
// Flatten folders (no nesting): dedupe local folders by name.
|
||||
const normName = (s) => String(s || "").replace(/\s+/g, " ").trim();
|
||||
const normKey = (s) => normName(s).toLowerCase();
|
||||
|
||||
const existingFolderByName = new Map(
|
||||
(state.folders || [])
|
||||
.filter((f) => !f.deletedAt)
|
||||
.map((f) => [normKey(f.name), f])
|
||||
);
|
||||
|
||||
const tempIdToFolderName = new Map(
|
||||
(parsed.folders || []).map((f) => [String(f.id), f.name])
|
||||
);
|
||||
|
||||
const folderIdByName = new Map(
|
||||
(state.folders || [])
|
||||
.filter((f) => !f.deletedAt)
|
||||
.map((f) => [normKey(f.name), f.id])
|
||||
);
|
||||
|
||||
for (const f of parsed.folders || []) {
|
||||
const name = normName(f.name);
|
||||
const key = normKey(name);
|
||||
if (!key) continue;
|
||||
|
||||
let id = folderIdByName.get(key);
|
||||
if (!id) {
|
||||
const created = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
parentId: null,
|
||||
name,
|
||||
visibility: "private",
|
||||
sortOrder: (state.folders || []).filter((x) => !x.deletedAt && (x.parentId ?? null) === null).length,
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
};
|
||||
state.folders.push(created);
|
||||
existingFolderByName.set(key, created);
|
||||
folderIdByName.set(key, created.id);
|
||||
id = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
const existingByHash = new Map(
|
||||
(state.bookmarks || [])
|
||||
.filter((b) => !b.deletedAt)
|
||||
.map((b) => {
|
||||
const fixed = ensureBookmarkHashes(b);
|
||||
return [fixed.urlHash, fixed];
|
||||
})
|
||||
);
|
||||
|
||||
let imported = 0;
|
||||
let merged = 0;
|
||||
|
||||
for (const b of parsed.bookmarks || []) {
|
||||
const url = (b.url || "").trim();
|
||||
if (!url) continue;
|
||||
const title = (b.title || "").trim() || url;
|
||||
|
||||
const folderTempId = b.parentFolderId ?? null;
|
||||
const folderName = folderTempId ? tempIdToFolderName.get(String(folderTempId)) : null;
|
||||
const folderId = folderName ? (folderIdByName.get(normKey(folderName)) ?? null) : null;
|
||||
|
||||
const urlNormalized = normalizeUrl(url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
const existing = existingByHash.get(urlHash);
|
||||
if (existing) {
|
||||
existing.title = title || existing.title;
|
||||
existing.url = url;
|
||||
existing.urlNormalized = urlNormalized;
|
||||
existing.urlHash = urlHash;
|
||||
existing.visibility = visibility;
|
||||
existing.folderId = folderId;
|
||||
existing.source = "import";
|
||||
existing.updatedAt = now;
|
||||
merged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId,
|
||||
title,
|
||||
url,
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
visibility,
|
||||
source: "import",
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
};
|
||||
state.bookmarks.unshift(created);
|
||||
existingByHash.set(urlHash, created);
|
||||
imported++;
|
||||
}
|
||||
|
||||
saveLocalState(state);
|
||||
return { imported, merged };
|
||||
}
|
||||
|
||||
export function exportLocalToNetscapeHtml(bookmarks) {
|
||||
const safe = (s) => String(s || "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
const lines = [];
|
||||
lines.push("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
|
||||
lines.push('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">');
|
||||
lines.push("<TITLE>Bookmarks</TITLE>");
|
||||
lines.push("<H1>Bookmarks</H1>");
|
||||
lines.push("<DL><p>");
|
||||
|
||||
for (const b of bookmarks || []) {
|
||||
if (b.deletedAt) continue;
|
||||
lines.push(` <DT><A HREF="${safe(b.url)}">${safe(b.title || b.url)}</A>`);
|
||||
}
|
||||
|
||||
lines.push("</DL><p>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function mergeLocalToUser(userId) {
|
||||
const state = loadLocalState();
|
||||
return {
|
||||
folders: state.folders.map((f) => ({ ...f, userId })),
|
||||
bookmarks: state.bookmarks.map((b) => ({ ...ensureBookmarkHashes(b), userId }))
|
||||
};
|
||||
}
|
||||
|
||||
export function clearLocalState() {
|
||||
saveLocalState({ folders: [], bookmarks: [] });
|
||||
}
|
||||
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
|
||||
|
||||
const KEY = "bb_local_state_v1";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ensureBookmarkHashes(bookmark) {
|
||||
if (!bookmark) return bookmark;
|
||||
const url = bookmark.url || "";
|
||||
const urlNormalized = bookmark.urlNormalized || normalizeUrl(url);
|
||||
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
|
||||
bookmark.urlNormalized = urlNormalized;
|
||||
bookmark.urlHash = urlHash;
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
export function loadLocalState() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(KEY) || "") || { folders: [], bookmarks: [] };
|
||||
parsed.folders = parsed.folders || [];
|
||||
parsed.bookmarks = (parsed.bookmarks || []).map((b) => ensureBookmarkHashes(b));
|
||||
return parsed;
|
||||
} catch {
|
||||
return { folders: [], bookmarks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLocalState(state) {
|
||||
localStorage.setItem(KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function listLocalBookmarks({ includeDeleted = false } = {}) {
|
||||
const state = loadLocalState();
|
||||
const all = state.bookmarks || [];
|
||||
return includeDeleted ? all : all.filter((b) => !b.deletedAt);
|
||||
}
|
||||
|
||||
export function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
const urlNormalized = normalizeUrl(url || "");
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
// Dedupe: same urlHash and not deleted -> update LWW
|
||||
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
|
||||
if (existing) {
|
||||
ensureBookmarkHashes(existing);
|
||||
existing.title = title || existing.title;
|
||||
existing.url = url || existing.url;
|
||||
existing.urlNormalized = urlNormalized;
|
||||
existing.urlHash = urlHash;
|
||||
existing.visibility = visibility;
|
||||
existing.folderId = folderId;
|
||||
existing.source = source;
|
||||
existing.updatedAt = now;
|
||||
saveLocalState(state);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const bookmark = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId,
|
||||
title,
|
||||
url,
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
visibility,
|
||||
source,
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
};
|
||||
|
||||
state.bookmarks.unshift(bookmark);
|
||||
saveLocalState(state);
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
export function markLocalDeleted(id) {
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
const item = state.bookmarks.find((b) => b.id === id);
|
||||
if (item) {
|
||||
item.deletedAt = now;
|
||||
item.updatedAt = now;
|
||||
saveLocalState(state);
|
||||
}
|
||||
}
|
||||
|
||||
export function patchLocalBookmark(id, patch) {
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
const item = state.bookmarks.find((b) => b.id === id);
|
||||
if (!item) return null;
|
||||
|
||||
if (patch.url !== undefined) {
|
||||
const nextUrl = patch.url || "";
|
||||
const nextNormalized = normalizeUrl(nextUrl);
|
||||
const nextHash = computeUrlHash(nextNormalized);
|
||||
|
||||
const target = (state.bookmarks || []).find((b) => !b.deletedAt && b.id !== id && (b.urlHash || "") === nextHash);
|
||||
if (target) {
|
||||
// Merge: write changes to target and soft-delete current
|
||||
ensureBookmarkHashes(target);
|
||||
target.title = patch.title !== undefined ? patch.title : item.title;
|
||||
target.url = nextUrl;
|
||||
target.urlNormalized = nextNormalized;
|
||||
target.urlHash = nextHash;
|
||||
if (patch.folderId !== undefined) target.folderId = patch.folderId;
|
||||
if (patch.visibility !== undefined) target.visibility = patch.visibility;
|
||||
target.updatedAt = now;
|
||||
|
||||
item.deletedAt = now;
|
||||
item.updatedAt = now;
|
||||
saveLocalState(state);
|
||||
return target;
|
||||
}
|
||||
|
||||
item.url = nextUrl;
|
||||
item.urlNormalized = nextNormalized;
|
||||
item.urlHash = nextHash;
|
||||
}
|
||||
|
||||
if (patch.title !== undefined) item.title = patch.title;
|
||||
if (patch.folderId !== undefined) item.folderId = patch.folderId;
|
||||
if (patch.visibility !== undefined) item.visibility = patch.visibility;
|
||||
item.updatedAt = now;
|
||||
saveLocalState(state);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function deleteLocalFolder(folderId) {
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
state.folders = (state.folders || []).filter((f) => f.id !== folderId);
|
||||
state.bookmarks = (state.bookmarks || []).map((b) => {
|
||||
if (b.deletedAt) return b;
|
||||
if ((b.folderId ?? null) !== (folderId ?? null)) return b;
|
||||
return { ...ensureBookmarkHashes(b), folderId: null, updatedAt: now };
|
||||
});
|
||||
|
||||
saveLocalState(state);
|
||||
}
|
||||
|
||||
export function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
|
||||
const parsed = parseNetscapeBookmarkHtml(html || "");
|
||||
const state = loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
state.folders = state.folders || [];
|
||||
state.bookmarks = state.bookmarks || [];
|
||||
|
||||
// Flatten folders (no nesting): dedupe local folders by name.
|
||||
const normName = (s) => String(s || "").replace(/\s+/g, " ").trim();
|
||||
const normKey = (s) => normName(s).toLowerCase();
|
||||
|
||||
const existingFolderByName = new Map(
|
||||
(state.folders || [])
|
||||
.filter((f) => !f.deletedAt)
|
||||
.map((f) => [normKey(f.name), f])
|
||||
);
|
||||
|
||||
const tempIdToFolderName = new Map(
|
||||
(parsed.folders || []).map((f) => [String(f.id), f.name])
|
||||
);
|
||||
|
||||
const folderIdByName = new Map(
|
||||
(state.folders || [])
|
||||
.filter((f) => !f.deletedAt)
|
||||
.map((f) => [normKey(f.name), f.id])
|
||||
);
|
||||
|
||||
for (const f of parsed.folders || []) {
|
||||
const name = normName(f.name);
|
||||
const key = normKey(name);
|
||||
if (!key) continue;
|
||||
|
||||
let id = folderIdByName.get(key);
|
||||
if (!id) {
|
||||
const created = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
parentId: null,
|
||||
name,
|
||||
visibility: "private",
|
||||
sortOrder: (state.folders || []).filter((x) => !x.deletedAt && (x.parentId ?? null) === null).length,
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
};
|
||||
state.folders.push(created);
|
||||
existingFolderByName.set(key, created);
|
||||
folderIdByName.set(key, created.id);
|
||||
id = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
const existingByHash = new Map(
|
||||
(state.bookmarks || [])
|
||||
.filter((b) => !b.deletedAt)
|
||||
.map((b) => {
|
||||
const fixed = ensureBookmarkHashes(b);
|
||||
return [fixed.urlHash, fixed];
|
||||
})
|
||||
);
|
||||
|
||||
let imported = 0;
|
||||
let merged = 0;
|
||||
|
||||
for (const b of parsed.bookmarks || []) {
|
||||
const url = (b.url || "").trim();
|
||||
if (!url) continue;
|
||||
const title = (b.title || "").trim() || url;
|
||||
|
||||
const folderTempId = b.parentFolderId ?? null;
|
||||
const folderName = folderTempId ? tempIdToFolderName.get(String(folderTempId)) : null;
|
||||
const folderId = folderName ? (folderIdByName.get(normKey(folderName)) ?? null) : null;
|
||||
|
||||
const urlNormalized = normalizeUrl(url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
const existing = existingByHash.get(urlHash);
|
||||
if (existing) {
|
||||
existing.title = title || existing.title;
|
||||
existing.url = url;
|
||||
existing.urlNormalized = urlNormalized;
|
||||
existing.urlHash = urlHash;
|
||||
existing.visibility = visibility;
|
||||
existing.folderId = folderId;
|
||||
existing.source = "import";
|
||||
existing.updatedAt = now;
|
||||
merged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId,
|
||||
title,
|
||||
url,
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
visibility,
|
||||
source: "import",
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
};
|
||||
state.bookmarks.unshift(created);
|
||||
existingByHash.set(urlHash, created);
|
||||
imported++;
|
||||
}
|
||||
|
||||
saveLocalState(state);
|
||||
return { imported, merged };
|
||||
}
|
||||
|
||||
export function exportLocalToNetscapeHtml(bookmarks) {
|
||||
const safe = (s) => String(s || "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
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: [] });
|
||||
}
|
||||
|
||||
@@ -1,435 +1,435 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { apiFetch, ensureMe, userRef } from "../lib/api";
|
||||
import BbModal from "../components/BbModal.vue";
|
||||
|
||||
const loadingUsers = ref(false);
|
||||
const usersError = ref("");
|
||||
const users = ref([]);
|
||||
|
||||
const selectedUserId = ref("");
|
||||
const selectedUser = computed(() => users.value.find((u) => u.id === selectedUserId.value) || null);
|
||||
|
||||
const q = ref("");
|
||||
const loadingBookmarks = ref(false);
|
||||
const bookmarksError = ref("");
|
||||
const bookmarks = ref([]);
|
||||
|
||||
const loadingFolders = ref(false);
|
||||
const foldersError = ref("");
|
||||
const folders = ref([]);
|
||||
|
||||
const openFolderIds = ref(new Set());
|
||||
|
||||
const isAdmin = computed(() => userRef.value?.role === "admin");
|
||||
|
||||
const confirmOpen = ref(false);
|
||||
const confirmTitle = ref("请确认");
|
||||
const confirmMessage = ref("");
|
||||
const confirmOkText = ref("确定");
|
||||
const confirmDanger = ref(false);
|
||||
let confirmResolve = null;
|
||||
|
||||
function askConfirm(message, { title = "请确认", okText = "确定", danger = false } = {}) {
|
||||
confirmTitle.value = title;
|
||||
confirmMessage.value = message;
|
||||
confirmOkText.value = okText;
|
||||
confirmDanger.value = danger;
|
||||
confirmOpen.value = true;
|
||||
return new Promise((resolve) => {
|
||||
confirmResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConfirm(result) {
|
||||
const resolve = confirmResolve;
|
||||
confirmResolve = null;
|
||||
confirmOpen.value = false;
|
||||
if (resolve) resolve(Boolean(result));
|
||||
}
|
||||
|
||||
function onConfirmModalUpdate(v) {
|
||||
if (!v) resolveConfirm(false);
|
||||
else confirmOpen.value = true;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loadingUsers.value = true;
|
||||
usersError.value = "";
|
||||
try {
|
||||
users.value = await apiFetch("/admin/users");
|
||||
if (!selectedUserId.value && users.value.length) selectedUserId.value = users.value[0].id;
|
||||
} catch (e) {
|
||||
usersError.value = e.message || String(e);
|
||||
} finally {
|
||||
loadingUsers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
if (!selectedUserId.value) return;
|
||||
loadingFolders.value = true;
|
||||
foldersError.value = "";
|
||||
try {
|
||||
folders.value = await apiFetch(`/admin/users/${selectedUserId.value}/folders`);
|
||||
} catch (e) {
|
||||
foldersError.value = e.message || String(e);
|
||||
} finally {
|
||||
loadingFolders.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookmarks() {
|
||||
if (!selectedUserId.value) return;
|
||||
loadingBookmarks.value = true;
|
||||
bookmarksError.value = "";
|
||||
try {
|
||||
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
|
||||
bookmarks.value = await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks${qs}`);
|
||||
} catch (e) {
|
||||
bookmarksError.value = e.message || String(e);
|
||||
} finally {
|
||||
loadingBookmarks.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(
|
||||
() => q.value,
|
||||
() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(() => {
|
||||
loadBookmarks();
|
||||
}, 200);
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
function selectUser(id) {
|
||||
selectedUserId.value = id;
|
||||
q.value = "";
|
||||
folders.value = [];
|
||||
bookmarks.value = [];
|
||||
openFolderIds.value = new Set(["ROOT"]);
|
||||
loadFolders();
|
||||
loadBookmarks();
|
||||
}
|
||||
|
||||
function openUrl(url) {
|
||||
if (!url) return;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
function buildFolderFlat(list) {
|
||||
const byId = new Map((list || []).map((f) => [f.id, f]));
|
||||
const children = new Map();
|
||||
for (const f of list || []) {
|
||||
const key = f.parentId ?? null;
|
||||
if (!children.has(key)) children.set(key, []);
|
||||
children.get(key).push(f);
|
||||
}
|
||||
for (const arr of children.values()) {
|
||||
arr.sort((a, b) => {
|
||||
const ao = Number.isFinite(a.sortOrder) ? a.sortOrder : 0;
|
||||
const bo = Number.isFinite(b.sortOrder) ? b.sortOrder : 0;
|
||||
if (ao !== bo) return ao - bo;
|
||||
return String(a.name || "").localeCompare(String(b.name || ""));
|
||||
});
|
||||
}
|
||||
|
||||
const out = [];
|
||||
const visited = new Set();
|
||||
function walk(parentId, depth) {
|
||||
const arr = children.get(parentId ?? null) || [];
|
||||
for (const f of arr) {
|
||||
if (!f?.id || visited.has(f.id)) continue;
|
||||
visited.add(f.id);
|
||||
out.push({ folder: f, depth });
|
||||
walk(f.id, depth + 1);
|
||||
}
|
||||
}
|
||||
walk(null, 0);
|
||||
|
||||
for (const f of list || []) {
|
||||
if (f?.id && !visited.has(f.id) && byId.has(f.id)) {
|
||||
out.push({ folder: f, depth: 0 });
|
||||
visited.add(f.id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const folderFlat = computed(() => buildFolderFlat(folders.value));
|
||||
const bookmarksByFolderId = computed(() => {
|
||||
const map = new Map();
|
||||
for (const b of bookmarks.value || []) {
|
||||
const key = b.folderId ?? null;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(b);
|
||||
}
|
||||
for (const arr of map.values()) {
|
||||
arr.sort((a, b) => {
|
||||
const ao = Number.isFinite(a.sortOrder) ? a.sortOrder : 0;
|
||||
const bo = Number.isFinite(b.sortOrder) ? b.sortOrder : 0;
|
||||
if (ao !== bo) return ao - bo;
|
||||
return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
|
||||
});
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function folderCount(folderId) {
|
||||
return (bookmarksByFolderId.value.get(folderId ?? null) || []).length;
|
||||
}
|
||||
|
||||
function toggleFolder(folderId) {
|
||||
const set = new Set(openFolderIds.value);
|
||||
if (set.has(folderId)) set.delete(folderId);
|
||||
else set.add(folderId);
|
||||
openFolderIds.value = set;
|
||||
}
|
||||
|
||||
function isFolderOpen(folderId) {
|
||||
return openFolderIds.value.has(folderId);
|
||||
}
|
||||
|
||||
async function deleteBookmark(bookmarkId) {
|
||||
if (!selectedUserId.value) return;
|
||||
if (!(await askConfirm("确定删除该书签?", { title: "删除书签", okText: "删除", danger: true }))) return;
|
||||
try {
|
||||
await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks/${bookmarkId}`, { method: "DELETE" });
|
||||
await loadBookmarks();
|
||||
} catch (e) {
|
||||
bookmarksError.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(folderId) {
|
||||
if (!selectedUserId.value) return;
|
||||
if (!(await askConfirm("确定删除该文件夹?子文件夹会一起删除,文件夹内书签会变成未分组。", { title: "删除文件夹", okText: "删除", danger: true }))) return;
|
||||
try {
|
||||
await apiFetch(`/admin/users/${selectedUserId.value}/folders/${folderId}`, { method: "DELETE" });
|
||||
await loadFolders();
|
||||
await loadBookmarks();
|
||||
} catch (e) {
|
||||
foldersError.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToMe(bookmarkId) {
|
||||
if (!selectedUserId.value) return;
|
||||
try {
|
||||
await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks/${bookmarkId}/copy-to-me`, { method: "POST" });
|
||||
} catch (e) {
|
||||
bookmarksError.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureMe();
|
||||
if (!isAdmin.value) return;
|
||||
await loadUsers();
|
||||
if (selectedUserId.value) openFolderIds.value = new Set(["ROOT"]);
|
||||
await loadFolders();
|
||||
await loadBookmarks();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bb-page">
|
||||
<div class="bb-pageHeader">
|
||||
<div>
|
||||
<h1 style="margin: 0;">管理用户</h1>
|
||||
<div class="bb-muted" style="margin-top: 4px;">仅管理员可见:查看用户列表与其书签。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAdmin" class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 900;">无权限</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">当前账号不是管理员。</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bb-adminGrid" style="margin-top: 12px;">
|
||||
<aside class="bb-card">
|
||||
<div class="bb-cardTitle">用户</div>
|
||||
<div class="bb-muted" style="margin-top: 4px;">点击查看该用户书签</div>
|
||||
|
||||
<p v-if="usersError" class="bb-alert bb-alert--error" style="margin-top: 10px;">{{ usersError }}</p>
|
||||
<p v-else-if="loadingUsers" class="bb-muted" style="margin-top: 10px;">加载中…</p>
|
||||
|
||||
<div v-else class="bb-adminUserList" style="margin-top: 10px;">
|
||||
<button
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
class="bb-adminUser"
|
||||
:class="u.id === selectedUserId ? 'is-active' : ''"
|
||||
@click="selectUser(u.id)"
|
||||
>
|
||||
<div class="email">{{ u.email }}</div>
|
||||
<div class="meta">{{ u.role === 'admin' ? '管理员' : '用户' }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="bb-card">
|
||||
<div class="bb-row" style="justify-content: space-between; align-items: flex-end;">
|
||||
<div>
|
||||
<div class="bb-cardTitle">书签</div>
|
||||
<div class="bb-muted" style="margin-top: 4px;">
|
||||
<span v-if="selectedUser">当前:{{ selectedUser.email }}</span>
|
||||
<span v-else>请选择一个用户</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-row" style="gap: 8px;">
|
||||
<div class="bb-searchWrap" style="min-width: 260px;">
|
||||
<input v-model="q" class="bb-input bb-input--sm bb-input--withClear" placeholder="搜索标题/链接" />
|
||||
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="bookmarksError" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ bookmarksError }}</p>
|
||||
<p v-else-if="loadingBookmarks" class="bb-muted" style="margin-top: 12px;">加载中…</p>
|
||||
|
||||
<p v-if="foldersError" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ foldersError }}</p>
|
||||
|
||||
<div v-else-if="(!folders.length && !bookmarks.length)" class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 800;">暂无数据</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">该用户还没有书签或文件夹。</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bb-adminTree" style="margin-top: 12px;">
|
||||
<!-- Root group -->
|
||||
<div class="bb-adminFolder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
|
||||
<button type="button" class="bb-adminFolderHeader" @click="toggleFolder('ROOT')">
|
||||
<span class="name">未分组</span>
|
||||
<span class="meta">{{ folderCount(null) }} 条</span>
|
||||
</button>
|
||||
<div v-if="isFolderOpen('ROOT')" class="bb-adminFolderBody">
|
||||
<ul class="bb-adminBookmarks">
|
||||
<li
|
||||
v-for="b in (bookmarksByFolderId.get(null) || [])"
|
||||
:key="b.id"
|
||||
class="bb-card bb-card--interactive bb-clickCard"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
@click="openUrl(b.url)"
|
||||
@keydown.enter.prevent="openUrl(b.url)"
|
||||
@keydown.space.prevent="openUrl(b.url)"
|
||||
>
|
||||
<div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
|
||||
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
|
||||
<div class="bb-adminActions" style="margin-top: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click.stop="copyToMe(b.id)">复制到我</button>
|
||||
<button class="bb-btn bb-btn--danger" type="button" @click.stop="deleteBookmark(b.id)">删除</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder tree (flat with indent) -->
|
||||
<div
|
||||
v-for="x in folderFlat"
|
||||
:key="x.folder.id"
|
||||
class="bb-adminFolder"
|
||||
:class="isFolderOpen(x.folder.id) ? 'is-open' : ''"
|
||||
:style="{ paddingLeft: `${x.depth * 14}px` }"
|
||||
>
|
||||
<div class="bb-adminFolderHeaderRow">
|
||||
<button type="button" class="bb-adminFolderHeader" @click="toggleFolder(x.folder.id)">
|
||||
<span class="name">{{ x.folder.name }}</span>
|
||||
<span class="meta">{{ folderCount(x.folder.id) }} 条</span>
|
||||
</button>
|
||||
<button class="bb-btn bb-btn--danger bb-adminFolderDel" type="button" @click.stop="deleteFolder(x.folder.id)">删除文件夹</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isFolderOpen(x.folder.id)" class="bb-adminFolderBody">
|
||||
<ul class="bb-adminBookmarks">
|
||||
<li
|
||||
v-for="b in (bookmarksByFolderId.get(x.folder.id) || [])"
|
||||
:key="b.id"
|
||||
class="bb-card bb-card--interactive bb-clickCard"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
@click="openUrl(b.url)"
|
||||
@keydown.enter.prevent="openUrl(b.url)"
|
||||
@keydown.space.prevent="openUrl(b.url)"
|
||||
>
|
||||
<div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
|
||||
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
|
||||
<div class="bb-adminActions" style="margin-top: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click.stop="copyToMe(b.id)">复制到我</button>
|
||||
<button class="bb-btn bb-btn--danger" type="button" @click.stop="deleteBookmark(b.id)">删除</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BbModal :model-value="confirmOpen" :title="confirmTitle" max-width="520px" @update:model-value="onConfirmModalUpdate">
|
||||
<div class="bb-muted" style="white-space: pre-wrap; line-height: 1.6;">{{ confirmMessage }}</div>
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px; margin-top: 14px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="resolveConfirm(false)">取消</button>
|
||||
<button class="bb-btn" :class="confirmDanger ? 'bb-btn--danger' : ''" type="button" @click="resolveConfirm(true)">{{ confirmOkText }}</button>
|
||||
</div>
|
||||
</BbModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-adminGrid { display: grid; grid-template-columns: 1fr; gap: 12px; }
|
||||
@media (min-width: 960px) { .bb-adminGrid { grid-template-columns: 1.1fr 2fr; } }
|
||||
|
||||
.bb-adminUserList { display: grid; gap: 8px; }
|
||||
.bb-adminUser {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.45);
|
||||
background: rgba(255,255,255,0.35);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bb-adminUser:hover { background: rgba(255,255,255,0.6); }
|
||||
.bb-adminUser.is-active { background: rgba(13, 148, 136, 0.12); border-color: rgba(13, 148, 136, 0.22); }
|
||||
.bb-adminUser .email { font-weight: 800; color: var(--bb-text); }
|
||||
.bb-adminUser .meta { font-size: 12px; color: rgba(19, 78, 74, 0.72); margin-top: 2px; }
|
||||
|
||||
.bb-adminBookmarks { list-style: none; padding: 0; margin: 12px 0 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
@media (min-width: 768px) { .bb-adminBookmarks { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
.bb-clickCard { cursor: pointer; }
|
||||
.title { font-weight: 900; color: var(--bb-text); }
|
||||
|
||||
.bb-adminFolder { margin-top: 10px; }
|
||||
.bb-adminFolderHeaderRow { display: flex; gap: 10px; align-items: center; }
|
||||
.bb-adminFolderHeader {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.45);
|
||||
background: rgba(255,255,255,0.35);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.bb-adminFolderHeader:hover { background: rgba(255,255,255,0.6); }
|
||||
.bb-adminFolderHeader .name { font-weight: 900; color: var(--bb-text); }
|
||||
.bb-adminFolderHeader .meta { font-size: 12px; color: rgba(19, 78, 74, 0.72); }
|
||||
.bb-adminFolderBody { margin-top: 10px; }
|
||||
.bb-adminFolderDel { white-space: nowrap; }
|
||||
.bb-adminActions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
</style>
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { apiFetch, ensureMe, userRef } from "../lib/api";
|
||||
import BbModal from "../components/BbModal.vue";
|
||||
|
||||
const loadingUsers = ref(false);
|
||||
const usersError = ref("");
|
||||
const users = ref([]);
|
||||
|
||||
const selectedUserId = ref("");
|
||||
const selectedUser = computed(() => users.value.find((u) => u.id === selectedUserId.value) || null);
|
||||
|
||||
const q = ref("");
|
||||
const loadingBookmarks = ref(false);
|
||||
const bookmarksError = ref("");
|
||||
const bookmarks = ref([]);
|
||||
|
||||
const loadingFolders = ref(false);
|
||||
const foldersError = ref("");
|
||||
const folders = ref([]);
|
||||
|
||||
const openFolderIds = ref(new Set());
|
||||
|
||||
const isAdmin = computed(() => userRef.value?.role === "admin");
|
||||
|
||||
const confirmOpen = ref(false);
|
||||
const confirmTitle = ref("请确认");
|
||||
const confirmMessage = ref("");
|
||||
const confirmOkText = ref("确定");
|
||||
const confirmDanger = ref(false);
|
||||
let confirmResolve = null;
|
||||
|
||||
function askConfirm(message, { title = "请确认", okText = "确定", danger = false } = {}) {
|
||||
confirmTitle.value = title;
|
||||
confirmMessage.value = message;
|
||||
confirmOkText.value = okText;
|
||||
confirmDanger.value = danger;
|
||||
confirmOpen.value = true;
|
||||
return new Promise((resolve) => {
|
||||
confirmResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConfirm(result) {
|
||||
const resolve = confirmResolve;
|
||||
confirmResolve = null;
|
||||
confirmOpen.value = false;
|
||||
if (resolve) resolve(Boolean(result));
|
||||
}
|
||||
|
||||
function onConfirmModalUpdate(v) {
|
||||
if (!v) resolveConfirm(false);
|
||||
else confirmOpen.value = true;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loadingUsers.value = true;
|
||||
usersError.value = "";
|
||||
try {
|
||||
users.value = await apiFetch("/admin/users");
|
||||
if (!selectedUserId.value && users.value.length) selectedUserId.value = users.value[0].id;
|
||||
} catch (e) {
|
||||
usersError.value = e.message || String(e);
|
||||
} finally {
|
||||
loadingUsers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
if (!selectedUserId.value) return;
|
||||
loadingFolders.value = true;
|
||||
foldersError.value = "";
|
||||
try {
|
||||
folders.value = await apiFetch(`/admin/users/${selectedUserId.value}/folders`);
|
||||
} catch (e) {
|
||||
foldersError.value = e.message || String(e);
|
||||
} finally {
|
||||
loadingFolders.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookmarks() {
|
||||
if (!selectedUserId.value) return;
|
||||
loadingBookmarks.value = true;
|
||||
bookmarksError.value = "";
|
||||
try {
|
||||
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
|
||||
bookmarks.value = await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks${qs}`);
|
||||
} catch (e) {
|
||||
bookmarksError.value = e.message || String(e);
|
||||
} finally {
|
||||
loadingBookmarks.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(
|
||||
() => q.value,
|
||||
() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(() => {
|
||||
loadBookmarks();
|
||||
}, 200);
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
function selectUser(id) {
|
||||
selectedUserId.value = id;
|
||||
q.value = "";
|
||||
folders.value = [];
|
||||
bookmarks.value = [];
|
||||
openFolderIds.value = new Set(["ROOT"]);
|
||||
loadFolders();
|
||||
loadBookmarks();
|
||||
}
|
||||
|
||||
function openUrl(url) {
|
||||
if (!url) return;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
function buildFolderFlat(list) {
|
||||
const byId = new Map((list || []).map((f) => [f.id, f]));
|
||||
const children = new Map();
|
||||
for (const f of list || []) {
|
||||
const key = f.parentId ?? null;
|
||||
if (!children.has(key)) children.set(key, []);
|
||||
children.get(key).push(f);
|
||||
}
|
||||
for (const arr of children.values()) {
|
||||
arr.sort((a, b) => {
|
||||
const ao = Number.isFinite(a.sortOrder) ? a.sortOrder : 0;
|
||||
const bo = Number.isFinite(b.sortOrder) ? b.sortOrder : 0;
|
||||
if (ao !== bo) return ao - bo;
|
||||
return String(a.name || "").localeCompare(String(b.name || ""));
|
||||
});
|
||||
}
|
||||
|
||||
const out = [];
|
||||
const visited = new Set();
|
||||
function walk(parentId, depth) {
|
||||
const arr = children.get(parentId ?? null) || [];
|
||||
for (const f of arr) {
|
||||
if (!f?.id || visited.has(f.id)) continue;
|
||||
visited.add(f.id);
|
||||
out.push({ folder: f, depth });
|
||||
walk(f.id, depth + 1);
|
||||
}
|
||||
}
|
||||
walk(null, 0);
|
||||
|
||||
for (const f of list || []) {
|
||||
if (f?.id && !visited.has(f.id) && byId.has(f.id)) {
|
||||
out.push({ folder: f, depth: 0 });
|
||||
visited.add(f.id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const folderFlat = computed(() => buildFolderFlat(folders.value));
|
||||
const bookmarksByFolderId = computed(() => {
|
||||
const map = new Map();
|
||||
for (const b of bookmarks.value || []) {
|
||||
const key = b.folderId ?? null;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(b);
|
||||
}
|
||||
for (const arr of map.values()) {
|
||||
arr.sort((a, b) => {
|
||||
const ao = Number.isFinite(a.sortOrder) ? a.sortOrder : 0;
|
||||
const bo = Number.isFinite(b.sortOrder) ? b.sortOrder : 0;
|
||||
if (ao !== bo) return ao - bo;
|
||||
return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
|
||||
});
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function folderCount(folderId) {
|
||||
return (bookmarksByFolderId.value.get(folderId ?? null) || []).length;
|
||||
}
|
||||
|
||||
function toggleFolder(folderId) {
|
||||
const set = new Set(openFolderIds.value);
|
||||
if (set.has(folderId)) set.delete(folderId);
|
||||
else set.add(folderId);
|
||||
openFolderIds.value = set;
|
||||
}
|
||||
|
||||
function isFolderOpen(folderId) {
|
||||
return openFolderIds.value.has(folderId);
|
||||
}
|
||||
|
||||
async function deleteBookmark(bookmarkId) {
|
||||
if (!selectedUserId.value) return;
|
||||
if (!(await askConfirm("确定删除该书签?", { title: "删除书签", okText: "删除", danger: true }))) return;
|
||||
try {
|
||||
await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks/${bookmarkId}`, { method: "DELETE" });
|
||||
await loadBookmarks();
|
||||
} catch (e) {
|
||||
bookmarksError.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(folderId) {
|
||||
if (!selectedUserId.value) return;
|
||||
if (!(await askConfirm("确定删除该文件夹?子文件夹会一起删除,文件夹内书签会变成未分组。", { title: "删除文件夹", okText: "删除", danger: true }))) return;
|
||||
try {
|
||||
await apiFetch(`/admin/users/${selectedUserId.value}/folders/${folderId}`, { method: "DELETE" });
|
||||
await loadFolders();
|
||||
await loadBookmarks();
|
||||
} catch (e) {
|
||||
foldersError.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToMe(bookmarkId) {
|
||||
if (!selectedUserId.value) return;
|
||||
try {
|
||||
await apiFetch(`/admin/users/${selectedUserId.value}/bookmarks/${bookmarkId}/copy-to-me`, { method: "POST" });
|
||||
} catch (e) {
|
||||
bookmarksError.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureMe();
|
||||
if (!isAdmin.value) return;
|
||||
await loadUsers();
|
||||
if (selectedUserId.value) openFolderIds.value = new Set(["ROOT"]);
|
||||
await loadFolders();
|
||||
await loadBookmarks();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bb-page">
|
||||
<div class="bb-pageHeader">
|
||||
<div>
|
||||
<h1 style="margin: 0;">管理用户</h1>
|
||||
<div class="bb-muted" style="margin-top: 4px;">仅管理员可见:查看用户列表与其书签。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAdmin" class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 900;">无权限</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">当前账号不是管理员。</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bb-adminGrid" style="margin-top: 12px;">
|
||||
<aside class="bb-card">
|
||||
<div class="bb-cardTitle">用户</div>
|
||||
<div class="bb-muted" style="margin-top: 4px;">点击查看该用户书签</div>
|
||||
|
||||
<p v-if="usersError" class="bb-alert bb-alert--error" style="margin-top: 10px;">{{ usersError }}</p>
|
||||
<p v-else-if="loadingUsers" class="bb-muted" style="margin-top: 10px;">加载中…</p>
|
||||
|
||||
<div v-else class="bb-adminUserList" style="margin-top: 10px;">
|
||||
<button
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
class="bb-adminUser"
|
||||
:class="u.id === selectedUserId ? 'is-active' : ''"
|
||||
@click="selectUser(u.id)"
|
||||
>
|
||||
<div class="email">{{ u.email }}</div>
|
||||
<div class="meta">{{ u.role === 'admin' ? '管理员' : '用户' }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="bb-card">
|
||||
<div class="bb-row" style="justify-content: space-between; align-items: flex-end;">
|
||||
<div>
|
||||
<div class="bb-cardTitle">书签</div>
|
||||
<div class="bb-muted" style="margin-top: 4px;">
|
||||
<span v-if="selectedUser">当前:{{ selectedUser.email }}</span>
|
||||
<span v-else>请选择一个用户</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-row" style="gap: 8px;">
|
||||
<div class="bb-searchWrap" style="min-width: 260px;">
|
||||
<input v-model="q" class="bb-input bb-input--sm bb-input--withClear" placeholder="搜索标题/链接" />
|
||||
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="bookmarksError" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ bookmarksError }}</p>
|
||||
<p v-else-if="loadingBookmarks" class="bb-muted" style="margin-top: 12px;">加载中…</p>
|
||||
|
||||
<p v-if="foldersError" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ foldersError }}</p>
|
||||
|
||||
<div v-else-if="(!folders.length && !bookmarks.length)" class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 800;">暂无数据</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">该用户还没有书签或文件夹。</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bb-adminTree" style="margin-top: 12px;">
|
||||
<!-- Root group -->
|
||||
<div class="bb-adminFolder" :class="isFolderOpen('ROOT') ? 'is-open' : ''">
|
||||
<button type="button" class="bb-adminFolderHeader" @click="toggleFolder('ROOT')">
|
||||
<span class="name">未分组</span>
|
||||
<span class="meta">{{ folderCount(null) }} 条</span>
|
||||
</button>
|
||||
<div v-if="isFolderOpen('ROOT')" class="bb-adminFolderBody">
|
||||
<ul class="bb-adminBookmarks">
|
||||
<li
|
||||
v-for="b in (bookmarksByFolderId.get(null) || [])"
|
||||
:key="b.id"
|
||||
class="bb-card bb-card--interactive bb-clickCard"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
@click="openUrl(b.url)"
|
||||
@keydown.enter.prevent="openUrl(b.url)"
|
||||
@keydown.space.prevent="openUrl(b.url)"
|
||||
>
|
||||
<div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
|
||||
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
|
||||
<div class="bb-adminActions" style="margin-top: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click.stop="copyToMe(b.id)">复制到我</button>
|
||||
<button class="bb-btn bb-btn--danger" type="button" @click.stop="deleteBookmark(b.id)">删除</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder tree (flat with indent) -->
|
||||
<div
|
||||
v-for="x in folderFlat"
|
||||
:key="x.folder.id"
|
||||
class="bb-adminFolder"
|
||||
:class="isFolderOpen(x.folder.id) ? 'is-open' : ''"
|
||||
:style="{ paddingLeft: `${x.depth * 14}px` }"
|
||||
>
|
||||
<div class="bb-adminFolderHeaderRow">
|
||||
<button type="button" class="bb-adminFolderHeader" @click="toggleFolder(x.folder.id)">
|
||||
<span class="name">{{ x.folder.name }}</span>
|
||||
<span class="meta">{{ folderCount(x.folder.id) }} 条</span>
|
||||
</button>
|
||||
<button class="bb-btn bb-btn--danger bb-adminFolderDel" type="button" @click.stop="deleteFolder(x.folder.id)">删除文件夹</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isFolderOpen(x.folder.id)" class="bb-adminFolderBody">
|
||||
<ul class="bb-adminBookmarks">
|
||||
<li
|
||||
v-for="b in (bookmarksByFolderId.get(x.folder.id) || [])"
|
||||
:key="b.id"
|
||||
class="bb-card bb-card--interactive bb-clickCard"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
@click="openUrl(b.url)"
|
||||
@keydown.enter.prevent="openUrl(b.url)"
|
||||
@keydown.space.prevent="openUrl(b.url)"
|
||||
>
|
||||
<div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
|
||||
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
|
||||
<div class="bb-adminActions" style="margin-top: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click.stop="copyToMe(b.id)">复制到我</button>
|
||||
<button class="bb-btn bb-btn--danger" type="button" @click.stop="deleteBookmark(b.id)">删除</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BbModal :model-value="confirmOpen" :title="confirmTitle" max-width="520px" @update:model-value="onConfirmModalUpdate">
|
||||
<div class="bb-muted" style="white-space: pre-wrap; line-height: 1.6;">{{ confirmMessage }}</div>
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px; margin-top: 14px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="resolveConfirm(false)">取消</button>
|
||||
<button class="bb-btn" :class="confirmDanger ? 'bb-btn--danger' : ''" type="button" @click="resolveConfirm(true)">{{ confirmOkText }}</button>
|
||||
</div>
|
||||
</BbModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-adminGrid { display: grid; grid-template-columns: 1fr; gap: 12px; }
|
||||
@media (min-width: 960px) { .bb-adminGrid { grid-template-columns: 1.1fr 2fr; } }
|
||||
|
||||
.bb-adminUserList { display: grid; gap: 8px; }
|
||||
.bb-adminUser {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.45);
|
||||
background: rgba(255,255,255,0.35);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bb-adminUser:hover { background: rgba(255,255,255,0.6); }
|
||||
.bb-adminUser.is-active { background: rgba(13, 148, 136, 0.12); border-color: rgba(13, 148, 136, 0.22); }
|
||||
.bb-adminUser .email { font-weight: 800; color: var(--bb-text); }
|
||||
.bb-adminUser .meta { font-size: 12px; color: rgba(19, 78, 74, 0.72); margin-top: 2px; }
|
||||
|
||||
.bb-adminBookmarks { list-style: none; padding: 0; margin: 12px 0 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
@media (min-width: 768px) { .bb-adminBookmarks { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
.bb-clickCard { cursor: pointer; }
|
||||
.title { font-weight: 900; color: var(--bb-text); }
|
||||
|
||||
.bb-adminFolder { margin-top: 10px; }
|
||||
.bb-adminFolderHeaderRow { display: flex; gap: 10px; align-items: center; }
|
||||
.bb-adminFolderHeader {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.45);
|
||||
background: rgba(255,255,255,0.35);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.bb-adminFolderHeader:hover { background: rgba(255,255,255,0.6); }
|
||||
.bb-adminFolderHeader .name { font-weight: 900; color: var(--bb-text); }
|
||||
.bb-adminFolderHeader .meta { font-size: 12px; color: rgba(19, 78, 74, 0.72); }
|
||||
.bb-adminFolderBody { margin-top: 10px; }
|
||||
.bb-adminFolderDel { white-space: nowrap; }
|
||||
.bb-adminActions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
</style>
|
||||
|
||||
@@ -1,168 +1,168 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { apiFetch, tokenRef } from "../lib/api";
|
||||
import {
|
||||
exportLocalToNetscapeHtml,
|
||||
importLocalFromNetscapeHtml,
|
||||
listLocalBookmarks
|
||||
} from "../lib/localData";
|
||||
|
||||
const loggedIn = computed(() => Boolean(tokenRef.value));
|
||||
const file = ref(null);
|
||||
const status = ref("");
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
const fileInputEl = ref(null);
|
||||
|
||||
function onFileChange(e) {
|
||||
file.value = e?.target?.files?.[0] || null;
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInputEl.value?.click?.();
|
||||
}
|
||||
|
||||
async function importFile() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
if (!file.value) return;
|
||||
|
||||
try {
|
||||
busy.value = true;
|
||||
if (loggedIn.value) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file.value);
|
||||
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
|
||||
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
|
||||
} else {
|
||||
const text = await file.value.text();
|
||||
const res = importLocalFromNetscapeHtml(text, { visibility: "public" });
|
||||
status.value = `本地导入完成:新增 ${res.imported},合并 ${res.merged}`;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCloud() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
busy.value = true;
|
||||
const html = await apiFetch("/bookmarks/export/html", {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/html" }
|
||||
});
|
||||
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bookmarks-cloud.html";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
status.value = "云端导出完成";
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportLocal() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
try {
|
||||
busy.value = true;
|
||||
const bookmarks = listLocalBookmarks({ includeDeleted: false });
|
||||
const html = exportLocalToNetscapeHtml(bookmarks);
|
||||
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bookmarks.html";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
status.value = `本地导出完成:${bookmarks.length} 条`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bb-page">
|
||||
<div class="bb-pageHeader">
|
||||
<div>
|
||||
<h1 style="margin: 0;">导入 / 导出</h1>
|
||||
<div class="bb-muted" style="margin-top: 4px;">把浏览器书签升级成“可同步、可复习、可分享”的知识库。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-clayCard" style="margin-top: 12px;">
|
||||
<div class="bb-cardTitle">导入 Chrome/Edge 书签 HTML</div>
|
||||
<div class="bb-cardSub">支持自动去重与合并;登录后会写入云端数据库。</div>
|
||||
|
||||
<div class="bb-row" style="margin-top: 12px;">
|
||||
<input
|
||||
ref="fileInputEl"
|
||||
class="bb-fileInput"
|
||||
type="file"
|
||||
accept="text/html,.html"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="openFilePicker">选择 HTML 文件</button>
|
||||
<div class="bb-fileName" :class="!file ? 'is-empty' : ''">
|
||||
{{ file ? file.name : "未选择文件" }}
|
||||
</div>
|
||||
<button class="bb-btn" :disabled="busy || !file" @click="importFile">开始导入</button>
|
||||
</div>
|
||||
|
||||
<p v-if="status" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ status }}</p>
|
||||
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
|
||||
|
||||
<p class="bb-muted" style="margin-top: 10px;">
|
||||
未登录:导入写入本机 localStorage,并自动去重;登录:导入写入数据库并自动去重合并。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loggedIn" class="bb-grid2" style="margin-top: 12px;">
|
||||
<div class="bb-card bb-card--interactive">
|
||||
<div class="bb-cardTitle">导出(云端)</div>
|
||||
<div class="bb-cardSub">保留文件夹层级;已携带登录态导出。</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<button class="bb-btn" :disabled="busy" @click="exportCloud">导出为 HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-card bb-card--interactive">
|
||||
<div class="bb-cardTitle">导出(本地)</div>
|
||||
<div class="bb-cardSub">导出当前浏览器 localStorage 中的书签(平铺导出)。</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="exportLocal">导出为 HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 900;">登录后可导出云端书签</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">你仍可在未登录状态下导入到本机,并导出本机数据。</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* page-level tweaks only */
|
||||
</style>
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { apiFetch, tokenRef } from "../lib/api";
|
||||
import {
|
||||
exportLocalToNetscapeHtml,
|
||||
importLocalFromNetscapeHtml,
|
||||
listLocalBookmarks
|
||||
} from "../lib/localData";
|
||||
|
||||
const loggedIn = computed(() => Boolean(tokenRef.value));
|
||||
const file = ref(null);
|
||||
const status = ref("");
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
const fileInputEl = ref(null);
|
||||
|
||||
function onFileChange(e) {
|
||||
file.value = e?.target?.files?.[0] || null;
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInputEl.value?.click?.();
|
||||
}
|
||||
|
||||
async function importFile() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
if (!file.value) return;
|
||||
|
||||
try {
|
||||
busy.value = true;
|
||||
if (loggedIn.value) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file.value);
|
||||
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
|
||||
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
|
||||
} else {
|
||||
const text = await file.value.text();
|
||||
const res = importLocalFromNetscapeHtml(text, { visibility: "public" });
|
||||
status.value = `本地导入完成:新增 ${res.imported},合并 ${res.merged}`;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCloud() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
busy.value = true;
|
||||
const html = await apiFetch("/bookmarks/export/html", {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/html" }
|
||||
});
|
||||
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bookmarks-cloud.html";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
status.value = "云端导出完成";
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportLocal() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
try {
|
||||
busy.value = true;
|
||||
const bookmarks = listLocalBookmarks({ includeDeleted: false });
|
||||
const html = exportLocalToNetscapeHtml(bookmarks);
|
||||
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bookmarks.html";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
status.value = `本地导出完成:${bookmarks.length} 条`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bb-page">
|
||||
<div class="bb-pageHeader">
|
||||
<div>
|
||||
<h1 style="margin: 0;">导入 / 导出</h1>
|
||||
<div class="bb-muted" style="margin-top: 4px;">把浏览器书签升级成“可同步、可复习、可分享”的知识库。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-clayCard" style="margin-top: 12px;">
|
||||
<div class="bb-cardTitle">导入 Chrome/Edge 书签 HTML</div>
|
||||
<div class="bb-cardSub">支持自动去重与合并;登录后会写入云端数据库。</div>
|
||||
|
||||
<div class="bb-row" style="margin-top: 12px;">
|
||||
<input
|
||||
ref="fileInputEl"
|
||||
class="bb-fileInput"
|
||||
type="file"
|
||||
accept="text/html,.html"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="openFilePicker">选择 HTML 文件</button>
|
||||
<div class="bb-fileName" :class="!file ? 'is-empty' : ''">
|
||||
{{ file ? file.name : "未选择文件" }}
|
||||
</div>
|
||||
<button class="bb-btn" :disabled="busy || !file" @click="importFile">开始导入</button>
|
||||
</div>
|
||||
|
||||
<p v-if="status" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ status }}</p>
|
||||
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
|
||||
|
||||
<p class="bb-muted" style="margin-top: 10px;">
|
||||
未登录:导入写入本机 localStorage,并自动去重;登录:导入写入数据库并自动去重合并。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loggedIn" class="bb-grid2" style="margin-top: 12px;">
|
||||
<div class="bb-card bb-card--interactive">
|
||||
<div class="bb-cardTitle">导出(云端)</div>
|
||||
<div class="bb-cardSub">保留文件夹层级;已携带登录态导出。</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<button class="bb-btn" :disabled="busy" @click="exportCloud">导出为 HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-card bb-card--interactive">
|
||||
<div class="bb-cardTitle">导出(本地)</div>
|
||||
<div class="bb-cardSub">导出当前浏览器 localStorage 中的书签(平铺导出)。</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="exportLocal">导出为 HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 900;">登录后可导出云端书签</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">你仍可在未登录状态下导入到本机,并导出本机数据。</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* page-level tweaks only */
|
||||
</style>
|
||||
|
||||
@@ -1,133 +1,133 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiFetch, setToken } from "../lib/api";
|
||||
import { clearLocalState, mergeLocalToUser } from "../lib/localData";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mode = ref("login");
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
|
||||
const res = await apiFetch(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
});
|
||||
|
||||
setToken(res.token);
|
||||
|
||||
// Bootstrap: push local data to server (server enforces userId from token)
|
||||
const payload = mergeLocalToUser(res.user.id);
|
||||
await apiFetch("/sync/push", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
clearLocalState();
|
||||
|
||||
const next = String(router.currentRoute.value.query?.next || "").trim();
|
||||
await router.push(next || "/my");
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bb-page bb-auth">
|
||||
<div class="bb-authCard bb-clayCard">
|
||||
<div class="bb-authHeader">
|
||||
<div class="bb-pill">云端同步 · 本地不丢</div>
|
||||
<h1 class="bb-authTitle">{{ mode === 'register' ? '注册账号' : '登录账号' }}</h1>
|
||||
<p class="bb-heroSub">登录后自动同步到云端;不登录也能本地管理(localStorage)。</p>
|
||||
</div>
|
||||
|
||||
<div class="bb-seg" role="tablist" aria-label="登录模式">
|
||||
<button
|
||||
class="bb-segBtn"
|
||||
:class="{ active: mode === 'login' }"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'login'"
|
||||
@click="mode = 'login'"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
class="bb-segBtn"
|
||||
:class="{ active: mode === 'register' }"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'register'"
|
||||
@click="mode = 'register'"
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bb-authForm">
|
||||
<label class="bb-field">
|
||||
<span class="bb-label">邮箱</span>
|
||||
<input v-model="email" class="bb-input" placeholder="name@example.com" autocomplete="email" />
|
||||
</label>
|
||||
<label class="bb-field">
|
||||
<span class="bb-label">密码</span>
|
||||
<input
|
||||
v-model="password"
|
||||
class="bb-input"
|
||||
placeholder="至少 8 位"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button class="bb-btn" :disabled="loading" @click="submit">
|
||||
{{ loading ? '处理中…' : (mode === 'register' ? '创建账号并登录' : '登录') }}
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="bb-alert bb-alert--error" style="margin: 0;">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bb-authFoot bb-muted">
|
||||
Token 会持久化保存;仅当你手动退出才会清除。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="bb-authAside bb-card">
|
||||
<div class="bb-cardTitle">你将获得</div>
|
||||
<div class="bb-cardSub">更像“课程平台”的学习型收藏体验。</div>
|
||||
|
||||
<div class="bb-authBadges">
|
||||
<span class="bb-tag">同步</span>
|
||||
<span class="bb-tag bb-tag2">去重</span>
|
||||
<span class="bb-tag bb-tag3">文件夹层级</span>
|
||||
</div>
|
||||
|
||||
<ul class="bb-bullets" style="margin-top: 10px;">
|
||||
<li>导入 Chrome/Edge 书签 HTML 一键合并</li>
|
||||
<li>公开/私有可切换,公开页可分享</li>
|
||||
<li>跨标签页登录态自动同步</li>
|
||||
</ul>
|
||||
|
||||
<div class="bb-miniQuote" style="margin-top: 12px;">
|
||||
<div class="bb-quote">“终于把书签变成了可复习的知识库。”</div>
|
||||
<div class="bb-quoteBy">— 早八人 · 收藏课学员</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-authTitle { margin: 10px 0 6px; }
|
||||
.bb-authForm { display: grid; gap: 10px; margin-top: 12px; }
|
||||
.bb-authFoot { margin-top: 10px; }
|
||||
</style>
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiFetch, setToken } from "../lib/api";
|
||||
import { clearLocalState, mergeLocalToUser } from "../lib/localData";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mode = ref("login");
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
|
||||
const res = await apiFetch(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
});
|
||||
|
||||
setToken(res.token);
|
||||
|
||||
// Bootstrap: push local data to server (server enforces userId from token)
|
||||
const payload = mergeLocalToUser(res.user.id);
|
||||
await apiFetch("/sync/push", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
clearLocalState();
|
||||
|
||||
const next = String(router.currentRoute.value.query?.next || "").trim();
|
||||
await router.push(next || "/my");
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bb-page bb-auth">
|
||||
<div class="bb-authCard bb-clayCard">
|
||||
<div class="bb-authHeader">
|
||||
<div class="bb-pill">云端同步 · 本地不丢</div>
|
||||
<h1 class="bb-authTitle">{{ mode === 'register' ? '注册账号' : '登录账号' }}</h1>
|
||||
<p class="bb-heroSub">登录后自动同步到云端;不登录也能本地管理(localStorage)。</p>
|
||||
</div>
|
||||
|
||||
<div class="bb-seg" role="tablist" aria-label="登录模式">
|
||||
<button
|
||||
class="bb-segBtn"
|
||||
:class="{ active: mode === 'login' }"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'login'"
|
||||
@click="mode = 'login'"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
class="bb-segBtn"
|
||||
:class="{ active: mode === 'register' }"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'register'"
|
||||
@click="mode = 'register'"
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bb-authForm">
|
||||
<label class="bb-field">
|
||||
<span class="bb-label">邮箱</span>
|
||||
<input v-model="email" class="bb-input" placeholder="name@example.com" autocomplete="email" />
|
||||
</label>
|
||||
<label class="bb-field">
|
||||
<span class="bb-label">密码</span>
|
||||
<input
|
||||
v-model="password"
|
||||
class="bb-input"
|
||||
placeholder="至少 8 位"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button class="bb-btn" :disabled="loading" @click="submit">
|
||||
{{ loading ? '处理中…' : (mode === 'register' ? '创建账号并登录' : '登录') }}
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="bb-alert bb-alert--error" style="margin: 0;">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bb-authFoot bb-muted">
|
||||
Token 会持久化保存;仅当你手动退出才会清除。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="bb-authAside bb-card">
|
||||
<div class="bb-cardTitle">你将获得</div>
|
||||
<div class="bb-cardSub">更像“课程平台”的学习型收藏体验。</div>
|
||||
|
||||
<div class="bb-authBadges">
|
||||
<span class="bb-tag">同步</span>
|
||||
<span class="bb-tag bb-tag2">去重</span>
|
||||
<span class="bb-tag bb-tag3">文件夹层级</span>
|
||||
</div>
|
||||
|
||||
<ul class="bb-bullets" style="margin-top: 10px;">
|
||||
<li>导入 Chrome/Edge 书签 HTML 一键合并</li>
|
||||
<li>公开/私有可切换,公开页可分享</li>
|
||||
<li>跨标签页登录态自动同步</li>
|
||||
</ul>
|
||||
|
||||
<div class="bb-miniQuote" style="margin-top: 12px;">
|
||||
<div class="bb-quote">“终于把书签变成了可复习的知识库。”</div>
|
||||
<div class="bb-quoteBy">— 早八人 · 收藏课学员</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-authTitle { margin: 10px 0 6px; }
|
||||
.bb-authForm { display: grid; gap: 10px; margin-top: 12px; }
|
||||
.bb-authFoot { margin-top: 10px; }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,180 +1,180 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import BbSelect from "../components/BbSelect.vue";
|
||||
import BbModal from "../components/BbModal.vue";
|
||||
import { apiFetch, tokenRef } from "../lib/api";
|
||||
|
||||
const q = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const items = ref([]);
|
||||
|
||||
const loggedIn = computed(() => Boolean(tokenRef.value));
|
||||
|
||||
const addTitle = ref("");
|
||||
const addUrl = ref("");
|
||||
const addVisibility = ref("public");
|
||||
const addFolderId = ref(null);
|
||||
const addBusy = ref(false);
|
||||
const addStatus = ref("");
|
||||
const addModalOpen = ref(false);
|
||||
|
||||
const folders = ref([]);
|
||||
const foldersLoading = ref(false);
|
||||
|
||||
const folderOptions = computed(() => [
|
||||
{ value: null, label: "(无文件夹)" },
|
||||
...folders.value.map((f) => ({ value: f.id, label: f.name }))
|
||||
]);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "public", label: "公开" },
|
||||
{ value: "private", label: "私有" }
|
||||
];
|
||||
|
||||
async function loadFolders() {
|
||||
if (!loggedIn.value) return;
|
||||
foldersLoading.value = true;
|
||||
try {
|
||||
folders.value = await apiFetch("/folders");
|
||||
} finally {
|
||||
foldersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addBookmark() {
|
||||
const title = addTitle.value.trim();
|
||||
const url = addUrl.value.trim();
|
||||
if (!title || !url) return;
|
||||
|
||||
addBusy.value = true;
|
||||
addStatus.value = "";
|
||||
error.value = "";
|
||||
try {
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
folderId: addFolderId.value ?? null,
|
||||
title,
|
||||
url,
|
||||
visibility: addVisibility.value
|
||||
})
|
||||
});
|
||||
addTitle.value = "";
|
||||
addUrl.value = "";
|
||||
addFolderId.value = null;
|
||||
addVisibility.value = "public";
|
||||
addStatus.value = "已添加";
|
||||
addModalOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
addBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(
|
||||
() => q.value,
|
||||
() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(() => {
|
||||
load();
|
||||
}, 200);
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
onMounted(load);
|
||||
onMounted(loadFolders);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bb-page">
|
||||
<div class="bb-pageHeader">
|
||||
<div class="bb-row" style="justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap;">
|
||||
<div>
|
||||
<h1 style="margin: 0;">公开书签</h1>
|
||||
<div class="bb-muted" style="margin-top: 4px;">无需登录即可浏览本页面书签。</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loggedIn" class="bb-row" style="gap: 10px; flex-wrap: wrap;">
|
||||
<button class="bb-btn" type="button" @click="addModalOpen = true">添加书签</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-card" style="margin-top: 12px;">
|
||||
<div class="bb-row">
|
||||
<div class="bb-searchWrap">
|
||||
<input v-model="q" class="bb-input bb-input--withClear" placeholder="搜索标题或链接" />
|
||||
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
|
||||
<p v-else-if="loading" class="bb-muted" style="margin-top: 12px;">加载中…</p>
|
||||
<div v-else-if="!items.length" class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 800;">暂无公开书签</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">换个关键词试试,或稍后再来。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BbModal v-if="loggedIn" v-model="addModalOpen" title="添加书签">
|
||||
<div class="bb-publicAdd" style="margin-top: 2px;">
|
||||
<input v-model="addTitle" class="bb-input" placeholder="标题" />
|
||||
<input v-model="addUrl" class="bb-input" placeholder="链接(https://...)" />
|
||||
<BbSelect
|
||||
v-model="addFolderId"
|
||||
:options="folderOptions"
|
||||
:disabled="foldersLoading"
|
||||
placeholder="选择文件夹"
|
||||
/>
|
||||
<BbSelect v-model="addVisibility" :options="visibilityOptions" />
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="addModalOpen = false">取消</button>
|
||||
<button class="bb-btn" type="button" :disabled="addBusy" @click="addBookmark">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</BbModal>
|
||||
|
||||
<p v-if="addStatus" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ addStatus }}</p>
|
||||
|
||||
<ul v-if="!loading && !error && items.length" class="bb-publicList">
|
||||
<li v-for="b in items" :key="b.id" class="bb-card bb-card--interactive bb-clickCard">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="bb-cardLink">
|
||||
<div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
|
||||
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-publicList { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 12px; }
|
||||
@media (min-width: 768px) { .bb-publicList { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
.bb-publicAdd { display: grid; gap: 10px; grid-template-columns: 1fr; }
|
||||
|
||||
|
||||
.bb-clickCard { padding: 0; }
|
||||
.bb-cardLink { display: block; padding: 12px; text-decoration: none; color: inherit; }
|
||||
.bb-cardLink:hover .bb-bookmarkTitle { color: var(--bb-cta); }
|
||||
</style>
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import BbSelect from "../components/BbSelect.vue";
|
||||
import BbModal from "../components/BbModal.vue";
|
||||
import { apiFetch, tokenRef } from "../lib/api";
|
||||
|
||||
const q = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const items = ref([]);
|
||||
|
||||
const loggedIn = computed(() => Boolean(tokenRef.value));
|
||||
|
||||
const addTitle = ref("");
|
||||
const addUrl = ref("");
|
||||
const addVisibility = ref("public");
|
||||
const addFolderId = ref(null);
|
||||
const addBusy = ref(false);
|
||||
const addStatus = ref("");
|
||||
const addModalOpen = ref(false);
|
||||
|
||||
const folders = ref([]);
|
||||
const foldersLoading = ref(false);
|
||||
|
||||
const folderOptions = computed(() => [
|
||||
{ value: null, label: "(无文件夹)" },
|
||||
...folders.value.map((f) => ({ value: f.id, label: f.name }))
|
||||
]);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "public", label: "公开" },
|
||||
{ value: "private", label: "私有" }
|
||||
];
|
||||
|
||||
async function loadFolders() {
|
||||
if (!loggedIn.value) return;
|
||||
foldersLoading.value = true;
|
||||
try {
|
||||
folders.value = await apiFetch("/folders");
|
||||
} finally {
|
||||
foldersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addBookmark() {
|
||||
const title = addTitle.value.trim();
|
||||
const url = addUrl.value.trim();
|
||||
if (!title || !url) return;
|
||||
|
||||
addBusy.value = true;
|
||||
addStatus.value = "";
|
||||
error.value = "";
|
||||
try {
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
folderId: addFolderId.value ?? null,
|
||||
title,
|
||||
url,
|
||||
visibility: addVisibility.value
|
||||
})
|
||||
});
|
||||
addTitle.value = "";
|
||||
addUrl.value = "";
|
||||
addFolderId.value = null;
|
||||
addVisibility.value = "public";
|
||||
addStatus.value = "已添加";
|
||||
addModalOpen.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
addBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimer = 0;
|
||||
watch(
|
||||
() => q.value,
|
||||
() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(() => {
|
||||
load();
|
||||
}, 200);
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
onMounted(load);
|
||||
onMounted(loadFolders);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bb-page">
|
||||
<div class="bb-pageHeader">
|
||||
<div class="bb-row" style="justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap;">
|
||||
<div>
|
||||
<h1 style="margin: 0;">公开书签</h1>
|
||||
<div class="bb-muted" style="margin-top: 4px;">无需登录即可浏览本页面书签。</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loggedIn" class="bb-row" style="gap: 10px; flex-wrap: wrap;">
|
||||
<button class="bb-btn" type="button" @click="addModalOpen = true">添加书签</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-card" style="margin-top: 12px;">
|
||||
<div class="bb-row">
|
||||
<div class="bb-searchWrap">
|
||||
<input v-model="q" class="bb-input bb-input--withClear" placeholder="搜索标题或链接" />
|
||||
<button v-if="q.trim()" class="bb-searchClear" type="button" aria-label="清空搜索" @click="q = ''">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
|
||||
<p v-else-if="loading" class="bb-muted" style="margin-top: 12px;">加载中…</p>
|
||||
<div v-else-if="!items.length" class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 800;">暂无公开书签</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">换个关键词试试,或稍后再来。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BbModal v-if="loggedIn" v-model="addModalOpen" title="添加书签">
|
||||
<div class="bb-publicAdd" style="margin-top: 2px;">
|
||||
<input v-model="addTitle" class="bb-input" placeholder="标题" />
|
||||
<input v-model="addUrl" class="bb-input" placeholder="链接(https://...)" />
|
||||
<BbSelect
|
||||
v-model="addFolderId"
|
||||
:options="folderOptions"
|
||||
:disabled="foldersLoading"
|
||||
placeholder="选择文件夹"
|
||||
/>
|
||||
<BbSelect v-model="addVisibility" :options="visibilityOptions" />
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="addModalOpen = false">取消</button>
|
||||
<button class="bb-btn" type="button" :disabled="addBusy" @click="addBookmark">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</BbModal>
|
||||
|
||||
<p v-if="addStatus" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ addStatus }}</p>
|
||||
|
||||
<ul v-if="!loading && !error && items.length" class="bb-publicList">
|
||||
<li v-for="b in items" :key="b.id" class="bb-card bb-card--interactive bb-clickCard">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="bb-cardLink">
|
||||
<div class="bb-bookmarkTitle" style="font-weight: 900; color: var(--bb-text);">{{ b.title }}</div>
|
||||
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-publicList { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 12px; }
|
||||
@media (min-width: 768px) { .bb-publicList { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
.bb-publicAdd { display: grid; gap: 10px; grid-template-columns: 1fr; }
|
||||
|
||||
|
||||
.bb-clickCard { padding: 0; }
|
||||
.bb-cardLink { display: block; padding: 12px; text-decoration: none; color: inherit; }
|
||||
.bb-cardLink:hover .bb-bookmarkTitle { color: var(--bb-cta); }
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import PublicPage from "./pages/PublicPage.vue";
|
||||
import LoginPage from "./pages/LoginPage.vue";
|
||||
import MyPage from "./pages/MyPage.vue";
|
||||
import ImportExportPage from "./pages/ImportExportPage.vue";
|
||||
import AdminPage from "./pages/AdminPage.vue";
|
||||
import { ensureMe, tokenRef } from "./lib/api";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: PublicPage },
|
||||
{ path: "/login", component: LoginPage },
|
||||
{ path: "/my", component: MyPage },
|
||||
{ path: "/import", component: ImportExportPage },
|
||||
{ path: "/admin", component: AdminPage }
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const loggedIn = Boolean(tokenRef.value);
|
||||
|
||||
// 主页(/)永远是公共首页;不因登录态自动跳转
|
||||
|
||||
// 已登录访问登录页:直接去“我的”
|
||||
if (to.path === "/login" && loggedIn) return { path: "/my" };
|
||||
|
||||
// 导入导出:登录后才可见/可用
|
||||
if (to.path === "/import" && !loggedIn) {
|
||||
return { path: "/login", query: { next: to.fullPath } };
|
||||
}
|
||||
|
||||
// 管理界面:仅管理员可见
|
||||
if (to.path.startsWith("/admin")) {
|
||||
if (!loggedIn) return { path: "/login", query: { next: to.fullPath } };
|
||||
const me = await ensureMe();
|
||||
if (!me || me.role !== "admin") return { path: "/my" };
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import PublicPage from "./pages/PublicPage.vue";
|
||||
import LoginPage from "./pages/LoginPage.vue";
|
||||
import MyPage from "./pages/MyPage.vue";
|
||||
import ImportExportPage from "./pages/ImportExportPage.vue";
|
||||
import AdminPage from "./pages/AdminPage.vue";
|
||||
import { ensureMe, tokenRef } from "./lib/api";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: PublicPage },
|
||||
{ path: "/login", component: LoginPage },
|
||||
{ path: "/my", component: MyPage },
|
||||
{ path: "/import", component: ImportExportPage },
|
||||
{ path: "/admin", component: AdminPage }
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const loggedIn = Boolean(tokenRef.value);
|
||||
|
||||
// 主页(/)永远是公共首页;不因登录态自动跳转
|
||||
|
||||
// 已登录访问登录页:直接去“我的”
|
||||
if (to.path === "/login" && loggedIn) return { path: "/my" };
|
||||
|
||||
// 导入导出:登录后才可见/可用
|
||||
if (to.path === "/import" && !loggedIn) {
|
||||
return { path: "/login", query: { next: to.fullPath } };
|
||||
}
|
||||
|
||||
// 管理界面:仅管理员可见
|
||||
if (to.path.startsWith("/admin")) {
|
||||
if (!loggedIn) return { path: "/login", query: { next: to.fullPath } };
|
||||
const me = await ensureMe();
|
||||
if (!me || me.role !== "admin") return { path: "/my" };
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user