初始化项目
This commit is contained in:
24
apps/extension/.gitignore
vendored
Normal file
24
apps/extension/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
apps/extension/README.md
Normal file
5
apps/extension/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
12
apps/extension/eslint.config.js
Normal file
12
apps/extension/eslint.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.js", "**/*.vue"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: "module"
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
}
|
||||
];
|
||||
13
apps/extension/index.html
Normal file
13
apps/extension/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>extension</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
apps/extension/options.html
Normal file
12
apps/extension/options.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BrowserBookmark Options</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/options/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
apps/extension/package.json
Normal file
23
apps/extension/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "extension",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node -e \"console.log('extension: no tests yet')\"",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@browser-bookmark/shared": "0.1.0",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue": "^3.5.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"eslint": "^9.17.0",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
12
apps/extension/popup.html
Normal file
12
apps/extension/popup.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bookmarks</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/popup/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
0
apps/extension/public/icon-128.png
Normal file
0
apps/extension/public/icon-128.png
Normal file
0
apps/extension/public/icon-16.png
Normal file
0
apps/extension/public/icon-16.png
Normal file
0
apps/extension/public/icon-48.png
Normal file
0
apps/extension/public/icon-48.png
Normal file
12
apps/extension/public/manifest.json
Normal file
12
apps/extension/public/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "BrowserBookmark",
|
||||
"version": "0.1.0",
|
||||
"action": {
|
||||
"default_title": "Bookmarks",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_page": "options.html",
|
||||
"permissions": ["storage", "tabs"],
|
||||
"host_permissions": ["http://localhost:3001/*"]
|
||||
}
|
||||
1
apps/extension/public/vite.svg
Normal file
1
apps/extension/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
30
apps/extension/src/App.vue
Normal file
30
apps/extension/src/App.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src="/vite.svg" class="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
|
||||
</a>
|
||||
</div>
|
||||
<HelloWorld msg="Vite + Vue" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.vue:hover {
|
||||
filter: drop-shadow(0 0 2em #42b883aa);
|
||||
}
|
||||
</style>
|
||||
1
apps/extension/src/assets/vue.svg
Normal file
1
apps/extension/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
43
apps/extension/src/components/HelloWorld.vue
Normal file
43
apps/extension/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
msg: String,
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
31
apps/extension/src/lib/api.js
Normal file
31
apps/extension/src/lib/api.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || "http://localhost:3001";
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
if (!(options.body instanceof FormData) && options.body != null) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
// Lazy import to avoid circular deps
|
||||
const { getToken } = await import("./extStorage.js");
|
||||
const token = await getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
const isJson = contentType.includes("application/json");
|
||||
const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => "");
|
||||
|
||||
if (!res.ok) {
|
||||
const message = payload?.message || `HTTP ${res.status}`;
|
||||
const err = new Error(message);
|
||||
err.status = res.status;
|
||||
err.payload = payload;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
43
apps/extension/src/lib/extStorage.js
Normal file
43
apps/extension/src/lib/extStorage.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const TOKEN_KEY = "bb_token";
|
||||
const LOCAL_STATE_KEY = "bb_local_state_v1";
|
||||
|
||||
function hasChromeStorage() {
|
||||
return typeof chrome !== "undefined" && chrome.storage?.local;
|
||||
}
|
||||
|
||||
export async function getToken() {
|
||||
if (hasChromeStorage()) {
|
||||
const res = await chrome.storage.local.get([TOKEN_KEY]);
|
||||
return res[TOKEN_KEY] || "";
|
||||
}
|
||||
return localStorage.getItem(TOKEN_KEY) || "";
|
||||
}
|
||||
|
||||
export async function setToken(token) {
|
||||
if (hasChromeStorage()) {
|
||||
await chrome.storage.local.set({ [TOKEN_KEY]: token || "" });
|
||||
return;
|
||||
}
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function loadLocalState() {
|
||||
if (hasChromeStorage()) {
|
||||
const res = await chrome.storage.local.get([LOCAL_STATE_KEY]);
|
||||
return res[LOCAL_STATE_KEY] || { folders: [], bookmarks: [] };
|
||||
}
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LOCAL_STATE_KEY) || "") || { folders: [], bookmarks: [] };
|
||||
} catch {
|
||||
return { folders: [], bookmarks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLocalState(state) {
|
||||
if (hasChromeStorage()) {
|
||||
await chrome.storage.local.set({ [LOCAL_STATE_KEY]: state });
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(LOCAL_STATE_KEY, JSON.stringify(state));
|
||||
}
|
||||
218
apps/extension/src/lib/localData.js
Normal file
218
apps/extension/src/lib/localData.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import { computeUrlHash, normalizeUrl, parseNetscapeBookmarkHtml } from "@browser-bookmark/shared";
|
||||
import { loadLocalState, saveLocalState } from "./extStorage";
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ensureBookmarkHashes(bookmark) {
|
||||
const urlNormalized = bookmark.urlNormalized || normalizeUrl(bookmark.url || "");
|
||||
const urlHash = bookmark.urlHash || computeUrlHash(urlNormalized);
|
||||
return { ...bookmark, urlNormalized, urlHash };
|
||||
}
|
||||
|
||||
export async function listLocalBookmarks({ includeDeleted = false } = {}) {
|
||||
const state = await loadLocalState();
|
||||
const items = (state.bookmarks || []).map(ensureBookmarkHashes);
|
||||
const filtered = includeDeleted ? items : items.filter((b) => !b.deletedAt);
|
||||
// Keep newest first
|
||||
filtered.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function upsertLocalBookmark({ title, url, visibility = "public", folderId = null, source = "manual" }) {
|
||||
const state = await loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
const urlNormalized = normalizeUrl(url || "");
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
|
||||
// Dedupe: same urlHash and not deleted -> update LWW
|
||||
const existing = (state.bookmarks || []).find((b) => !b.deletedAt && (b.urlHash || "") === urlHash);
|
||||
if (existing) {
|
||||
existing.title = title || existing.title;
|
||||
existing.url = url || existing.url;
|
||||
existing.urlNormalized = urlNormalized;
|
||||
existing.urlHash = urlHash;
|
||||
existing.visibility = visibility;
|
||||
existing.folderId = folderId;
|
||||
existing.source = source;
|
||||
existing.updatedAt = now;
|
||||
await saveLocalState(state);
|
||||
return { bookmark: ensureBookmarkHashes(existing), merged: true };
|
||||
}
|
||||
|
||||
const bookmark = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId,
|
||||
title: title || "",
|
||||
url: url || "",
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
visibility,
|
||||
source,
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
};
|
||||
|
||||
state.bookmarks = state.bookmarks || [];
|
||||
state.bookmarks.unshift(bookmark);
|
||||
await saveLocalState(state);
|
||||
return { bookmark: ensureBookmarkHashes(bookmark), merged: false };
|
||||
}
|
||||
|
||||
export async function markLocalDeleted(id) {
|
||||
const state = await loadLocalState();
|
||||
const now = nowIso();
|
||||
const item = (state.bookmarks || []).find((b) => b.id === id);
|
||||
if (!item) return false;
|
||||
item.deletedAt = now;
|
||||
item.updatedAt = now;
|
||||
await saveLocalState(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clearLocalState() {
|
||||
await saveLocalState({ folders: [], bookmarks: [] });
|
||||
}
|
||||
|
||||
export async function mergeLocalToUser() {
|
||||
const state = await loadLocalState();
|
||||
return {
|
||||
folders: (state.folders || []).map((f) => ({
|
||||
id: f.id,
|
||||
parentId: f.parentId ?? null,
|
||||
name: f.name || "",
|
||||
visibility: f.visibility || "private",
|
||||
updatedAt: f.updatedAt || nowIso()
|
||||
})),
|
||||
bookmarks: (state.bookmarks || []).map((b) => {
|
||||
const fixed = ensureBookmarkHashes(b);
|
||||
return {
|
||||
id: fixed.id,
|
||||
folderId: fixed.folderId ?? null,
|
||||
title: fixed.title || "",
|
||||
url: fixed.url || "",
|
||||
visibility: fixed.visibility || "private",
|
||||
source: fixed.source || "manual",
|
||||
updatedAt: fixed.updatedAt || nowIso(),
|
||||
deletedAt: fixed.deletedAt || null
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export async function importLocalFromNetscapeHtml(html, { visibility = "public" } = {}) {
|
||||
const parsed = parseNetscapeBookmarkHtml(html || "");
|
||||
const state = await loadLocalState();
|
||||
const now = nowIso();
|
||||
|
||||
state.folders = state.folders || [];
|
||||
state.bookmarks = state.bookmarks || [];
|
||||
|
||||
// Folder id remap (avoid collisions with existing UUID ids)
|
||||
const folderIdMap = new Map();
|
||||
for (const f of parsed.folders || []) {
|
||||
folderIdMap.set(f.id, crypto.randomUUID());
|
||||
}
|
||||
|
||||
// Dedupe folders by (parentId,name)
|
||||
const folderKeyToId = new Map(
|
||||
state.folders.map((f) => [`${f.parentId ?? ""}::${(f.name || "").toLowerCase()}`, f.id])
|
||||
);
|
||||
|
||||
const oldFolderIdToActual = new Map();
|
||||
let foldersImported = 0;
|
||||
for (const f of parsed.folders || []) {
|
||||
const parentId = f.parentFolderId ? oldFolderIdToActual.get(f.parentFolderId) || null : null;
|
||||
const name = (f.name || "").trim();
|
||||
const key = `${parentId ?? ""}::${name.toLowerCase()}`;
|
||||
|
||||
let id = folderKeyToId.get(key);
|
||||
if (!id) {
|
||||
id = folderIdMap.get(f.id);
|
||||
state.folders.push({
|
||||
id,
|
||||
userId: null,
|
||||
parentId,
|
||||
name,
|
||||
visibility,
|
||||
updatedAt: now
|
||||
});
|
||||
folderKeyToId.set(key, id);
|
||||
foldersImported++;
|
||||
} else {
|
||||
// Keep existing, but ensure it has a recent updatedAt
|
||||
const existing = state.folders.find((x) => x.id === id);
|
||||
if (existing && (!existing.updatedAt || existing.updatedAt < now)) existing.updatedAt = now;
|
||||
}
|
||||
|
||||
oldFolderIdToActual.set(f.id, id);
|
||||
}
|
||||
|
||||
// Dedupe bookmarks by urlHash
|
||||
const existingByHash = new Map();
|
||||
for (const b of state.bookmarks.map(ensureBookmarkHashes)) {
|
||||
if (!b.deletedAt && b.urlHash) existingByHash.set(b.urlHash, b);
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let merged = 0;
|
||||
|
||||
for (const b of parsed.bookmarks || []) {
|
||||
const title = (b.title || "").trim();
|
||||
const url = (b.url || "").trim();
|
||||
if (!url) continue;
|
||||
|
||||
const urlNormalized = normalizeUrl(url);
|
||||
const urlHash = computeUrlHash(urlNormalized);
|
||||
const folderId = b.parentFolderId ? oldFolderIdToActual.get(b.parentFolderId) || null : null;
|
||||
|
||||
const existing = existingByHash.get(urlHash);
|
||||
if (existing) {
|
||||
existing.title = title || existing.title;
|
||||
existing.updatedAt = now;
|
||||
merged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
state.bookmarks.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
userId: null,
|
||||
folderId: folderId ?? null,
|
||||
title,
|
||||
url,
|
||||
urlNormalized,
|
||||
urlHash,
|
||||
visibility,
|
||||
source: "import",
|
||||
updatedAt: now,
|
||||
deletedAt: null
|
||||
});
|
||||
existingByHash.set(urlHash, state.bookmarks[0]);
|
||||
imported++;
|
||||
}
|
||||
|
||||
await saveLocalState(state);
|
||||
return { foldersImported, imported, merged };
|
||||
}
|
||||
|
||||
export function exportLocalToNetscapeHtml(bookmarks) {
|
||||
const safe = (s) => String(s || "").replaceAll("&", "&").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");
|
||||
}
|
||||
5
apps/extension/src/main.js
Normal file
5
apps/extension/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
72
apps/extension/src/options/OptionsApp.vue
Normal file
72
apps/extension/src/options/OptionsApp.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from "vue-router";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<header class="nav">
|
||||
<div class="brand">BrowserBookmark 扩展</div>
|
||||
<nav class="links">
|
||||
<RouterLink to="/" class="link">公开</RouterLink>
|
||||
<RouterLink to="/my" class="link">我的</RouterLink>
|
||||
<RouterLink to="/import" class="link">导入导出</RouterLink>
|
||||
<RouterLink to="/login" class="link primary">登录</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bb-bg: #f8fafc;
|
||||
--bb-text: #1e293b;
|
||||
--bb-primary: #3b82f6;
|
||||
--bb-primary-weak: #60a5fa;
|
||||
--bb-cta: #f97316;
|
||||
--bb-border: #e5e7eb;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bb-bg);
|
||||
color: var(--bb-text);
|
||||
font-family: ui-sans-serif, system-ui;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.shell { min-height: 100vh; }
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
border-bottom: 1px solid var(--bb-border);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.brand { font-weight: 800; }
|
||||
.links { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.link { text-decoration: none; color: var(--bb-text); padding: 8px 10px; border-radius: 10px; transition: background 150ms, color 150ms, border-color 150ms; }
|
||||
.link:hover { background: rgba(59, 130, 246, 0.08); }
|
||||
.link.router-link-active { background: rgba(59, 130, 246, 0.12); }
|
||||
.primary { background: var(--bb-primary); color: white; }
|
||||
.primary:hover { background: var(--bb-primary-weak); }
|
||||
.content { max-width: 1100px; margin: 0 auto; padding: 14px; }
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
</style>
|
||||
5
apps/extension/src/options/main.js
Normal file
5
apps/extension/src/options/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import OptionsApp from "./OptionsApp.vue";
|
||||
import { router } from "./router";
|
||||
|
||||
createApp(OptionsApp).use(router).mount("#app");
|
||||
109
apps/extension/src/options/pages/ImportExportPage.vue
Normal file
109
apps/extension/src/options/pages/ImportExportPage.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
import { getToken } from "../../lib/extStorage";
|
||||
import { exportLocalToNetscapeHtml, importLocalFromNetscapeHtml, listLocalBookmarks } from "../../lib/localData";
|
||||
|
||||
const file = ref(null);
|
||||
const status = ref("");
|
||||
const error = ref("");
|
||||
|
||||
async function importToLocal() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
if (!file.value) return;
|
||||
try {
|
||||
const text = await file.value.text();
|
||||
const res = await importLocalFromNetscapeHtml(text, { visibility: "public" });
|
||||
status.value = `本地导入完成:文件夹新增 ${res.foldersImported},书签新增 ${res.imported},合并 ${res.merged}`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function importFile() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
await importToLocal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.value) return;
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file.value);
|
||||
const res = await apiFetch("/bookmarks/import/html", { method: "POST", body: fd });
|
||||
status.value = `导入完成:新增 ${res.imported},合并 ${res.merged}`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function exportCloud() {
|
||||
window.open("http://localhost:3001/bookmarks/export/html", "_blank");
|
||||
}
|
||||
|
||||
async function exportLocal() {
|
||||
status.value = "";
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const bookmarks = await listLocalBookmarks({ includeDeleted: false });
|
||||
const html = exportLocalToNetscapeHtml(bookmarks);
|
||||
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "bookmarks.html";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
status.value = `本地导出完成:${bookmarks.length} 条`;
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>导入 / 导出</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>导入书签 HTML(写入云端)</h2>
|
||||
<input
|
||||
type="file"
|
||||
accept="text/html,.html"
|
||||
@change="(e) => (file.value = e.target.files?.[0] || null)"
|
||||
/>
|
||||
<button class="btn" @click="importFile">开始导入</button>
|
||||
<p v-if="status" class="ok">{{ status }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>导出(本地)</h2>
|
||||
<button class="btn" @click="exportLocal">导出本地为 HTML</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>导出(云端)</h2>
|
||||
<button class="btn" @click="exportCloud">导出为 HTML</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; margin: 12px 0; }
|
||||
.btn { margin-top: 10px; padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.ok { color: #065f46; }
|
||||
.error { color: #b91c1c; }
|
||||
</style>
|
||||
85
apps/extension/src/options/pages/LoginPage.vue
Normal file
85
apps/extension/src/options/pages/LoginPage.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
import { getToken, setToken } from "../../lib/extStorage";
|
||||
import { clearLocalState, mergeLocalToUser } from "../../lib/localData";
|
||||
|
||||
const router = useRouter();
|
||||
const mode = ref("login");
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const path = mode.value === "register" ? "/auth/register" : "/auth/login";
|
||||
const res = await apiFetch(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: email.value, password: password.value })
|
||||
});
|
||||
|
||||
await setToken(res.token);
|
||||
|
||||
// Push local state to server on login
|
||||
const payload = await mergeLocalToUser();
|
||||
await apiFetch("/sync/push", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
// After merge, keep extension in cloud mode
|
||||
await clearLocalState();
|
||||
|
||||
await router.push("/my");
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
await setToken("");
|
||||
await router.push("/");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>登录 / 注册</h1>
|
||||
|
||||
<div class="row">
|
||||
<button class="tab" :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button>
|
||||
<button class="tab" :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button>
|
||||
<button class="tab" @click="logout">退出</button>
|
||||
</div>
|
||||
|
||||
<div class="form">
|
||||
<input v-model="email" class="input" placeholder="邮箱" autocomplete="email" />
|
||||
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
|
||||
<button class="btn" :disabled="loading" @click="submit">提交</button>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<p class="muted">扩展内的本地书签存储在 chrome.storage.local。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }
|
||||
.tab { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; cursor: pointer; }
|
||||
.tab.active { border-color: #111827; background: #111827; color: white; }
|
||||
.form { display: grid; gap: 10px; max-width: 560px; }
|
||||
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.error { color: #b91c1c; }
|
||||
.muted { color: #475569; font-size: 12px; margin-top: 10px; }
|
||||
</style>
|
||||
93
apps/extension/src/options/pages/MyPage.vue
Normal file
93
apps/extension/src/options/pages/MyPage.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
import { getToken } from "../../lib/extStorage";
|
||||
import { listLocalBookmarks, markLocalDeleted, upsertLocalBookmark } from "../../lib/localData";
|
||||
|
||||
const token = ref("");
|
||||
const loggedIn = computed(() => Boolean(token.value));
|
||||
const items = ref([]);
|
||||
const error = ref("");
|
||||
const mode = computed(() => (loggedIn.value ? "cloud" : "local"));
|
||||
|
||||
const title = ref("");
|
||||
const url = ref("");
|
||||
|
||||
async function load() {
|
||||
error.value = "";
|
||||
try {
|
||||
token.value = await getToken();
|
||||
if (!token.value) {
|
||||
items.value = await listLocalBookmarks();
|
||||
return;
|
||||
}
|
||||
items.value = await apiFetch("/bookmarks");
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!title.value || !url.value) return;
|
||||
if (mode.value === "cloud") {
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ folderId: null, title: title.value, url: url.value, visibility: "public" })
|
||||
});
|
||||
} else {
|
||||
await upsertLocalBookmark({ title: title.value, url: url.value, visibility: "public" });
|
||||
}
|
||||
title.value = "";
|
||||
url.value = "";
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (mode.value !== "local") return;
|
||||
await markLocalDeleted(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>我的书签({{ mode === 'cloud' ? '云端' : '本地' }})</h1>
|
||||
<p v-if="!loggedIn" class="muted">未登录时,书签保存在扩展本地(可在登录后自动合并上云)。</p>
|
||||
|
||||
<div class="form">
|
||||
<input v-model="title" class="input" placeholder="标题" />
|
||||
<input v-model="url" class="input" placeholder="链接" />
|
||||
<button class="btn" @click="add">添加</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<ul class="list">
|
||||
<li v-for="b in items" :key="b.id" class="card">
|
||||
<div class="row">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
|
||||
<button v-if="mode === 'local'" class="ghost" @click.prevent="remove(b.id)">删除</button>
|
||||
</div>
|
||||
<div class="muted">{{ b.url }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form { display: grid; gap: 10px; grid-template-columns: 1fr; margin: 12px 0; }
|
||||
@media (min-width: 900px) { .form { grid-template-columns: 2fr 3fr auto; } }
|
||||
.input { padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.row { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
|
||||
.ghost { border: 1px solid #e5e7eb; background: #f8fafc; border-radius: 10px; padding: 6px 10px; cursor: pointer; }
|
||||
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
|
||||
.title { color: #111827; font-weight: 700; text-decoration: none; }
|
||||
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
|
||||
.error { color: #b91c1c; }
|
||||
.muted { color: #475569; font-size: 12px; }
|
||||
</style>
|
||||
53
apps/extension/src/options/pages/PublicPage.vue
Normal file
53
apps/extension/src/options/pages/PublicPage.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
|
||||
const items = ref([]);
|
||||
const q = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
items.value = await apiFetch(`/bookmarks/public?q=${encodeURIComponent(q.value)}`);
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>公开书签</h1>
|
||||
<div class="row">
|
||||
<input v-model="q" class="input" placeholder="搜索" @keyup.enter="load" />
|
||||
<button class="btn" :disabled="loading" @click="load">搜索</button>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<ul class="list">
|
||||
<li v-for="b in items" :key="b.id" class="card">
|
||||
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
|
||||
<div class="muted">{{ b.url }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row { display: flex; gap: 8px; margin: 12px 0; }
|
||||
.input { flex: 1; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.btn { padding: 10px 12px; border: 1px solid #111827; border-radius: 10px; background: #111827; color: white; cursor: pointer; }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.list { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
@media (min-width: 900px) { .list { grid-template-columns: 1fr 1fr; } }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px; background: white; }
|
||||
.title { color: #111827; font-weight: 700; text-decoration: none; }
|
||||
.muted { color: #475569; font-size: 12px; overflow-wrap: anywhere; margin-top: 6px; }
|
||||
.error { color: #b91c1c; }
|
||||
</style>
|
||||
15
apps/extension/src/options/router.js
Normal file
15
apps/extension/src/options/router.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import PublicPage from "./pages/PublicPage.vue";
|
||||
import LoginPage from "./pages/LoginPage.vue";
|
||||
import MyPage from "./pages/MyPage.vue";
|
||||
import ImportExportPage from "./pages/ImportExportPage.vue";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: PublicPage },
|
||||
{ path: "/login", component: LoginPage },
|
||||
{ path: "/my", component: MyPage },
|
||||
{ path: "/import", component: ImportExportPage }
|
||||
]
|
||||
});
|
||||
148
apps/extension/src/popup/PopupApp.vue
Normal file
148
apps/extension/src/popup/PopupApp.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { getToken } from "../lib/extStorage";
|
||||
import { listLocalBookmarks, upsertLocalBookmark } from "../lib/localData";
|
||||
|
||||
const items = ref([]);
|
||||
const q = ref("");
|
||||
const title = ref("");
|
||||
const url = ref("");
|
||||
const mode = ref("local");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const filtered = computed(() => {
|
||||
const query = q.value.trim().toLowerCase();
|
||||
if (!query) return items.value;
|
||||
return items.value.filter((b) => {
|
||||
const t = String(b.title || "").toLowerCase();
|
||||
const u = String(b.url || "").toLowerCase();
|
||||
return t.includes(query) || u.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
function openUrl(url) {
|
||||
if (typeof chrome !== "undefined" && chrome.tabs?.create) {
|
||||
chrome.tabs.create({ url });
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
}
|
||||
|
||||
function openOptions() {
|
||||
if (typeof chrome !== "undefined" && chrome.runtime?.openOptionsPage) {
|
||||
chrome.runtime.openOptionsPage();
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const token = await getToken();
|
||||
mode.value = token ? "cloud" : "local";
|
||||
|
||||
if (mode.value === "cloud") {
|
||||
const qs = q.value.trim() ? `?q=${encodeURIComponent(q.value.trim())}` : "";
|
||||
items.value = await apiFetch(`/bookmarks${qs}`);
|
||||
items.value = items.value.slice(0, 20);
|
||||
} else {
|
||||
items.value = await listLocalBookmarks();
|
||||
items.value = items.value.slice(0, 50);
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function quickAdd() {
|
||||
error.value = "";
|
||||
const t = title.value.trim();
|
||||
const u = url.value.trim();
|
||||
if (!t || !u) return;
|
||||
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (token) {
|
||||
await apiFetch("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ folderId: null, title: t, url: u, visibility: "public" })
|
||||
});
|
||||
} else {
|
||||
await upsertLocalBookmark({ title: t, url: u, visibility: "public" });
|
||||
}
|
||||
title.value = "";
|
||||
url.value = "";
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<div class="top">
|
||||
<div class="title">书签</div>
|
||||
<button class="btn" @click="openOptions">设置/登录</button>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span class="pill">{{ mode === 'cloud' ? '云端' : '本地' }}</span>
|
||||
<button class="linkBtn" :disabled="loading" @click="load">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<input v-model="q" class="input" placeholder="搜索" @keyup.enter="load" />
|
||||
<button class="btn primary" :disabled="loading" @click="load">搜</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="cardTitle">快速添加</div>
|
||||
<input v-model="title" class="input" placeholder="标题" />
|
||||
<input v-model="url" class="input" placeholder="链接" @keyup.enter="quickAdd" />
|
||||
<button class="btn primary" @click="quickAdd">添加</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="loading" class="muted">加载中…</div>
|
||||
|
||||
<ul class="list">
|
||||
<li v-for="b in filtered" :key="b.id" class="item" @click="openUrl(b.url)">
|
||||
<div class="name">{{ b.title }}</div>
|
||||
<div class="url">{{ b.url }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap { width: 360px; padding: 12px; font-family: ui-sans-serif, system-ui; background: #f8fafc; color: #1e293b; }
|
||||
.top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.title { font-weight: 800; }
|
||||
.btn { padding: 6px 10px; border: 1px solid #e5e7eb; background: white; border-radius: 10px; cursor: pointer; transition: background 150ms, border-color 150ms; }
|
||||
.btn:hover { background: rgba(59, 130, 246, 0.08); border-color: rgba(59, 130, 246, 0.35); }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.primary { border-color: rgba(59, 130, 246, 0.6); background: rgba(59, 130, 246, 0.12); }
|
||||
.primary:hover { background: rgba(59, 130, 246, 0.18); }
|
||||
.linkBtn { border: none; background: transparent; color: #3b82f6; cursor: pointer; padding: 0; }
|
||||
.linkBtn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.meta { display: flex; align-items: center; justify-content: space-between; margin: 6px 0 10px; }
|
||||
.pill { font-size: 12px; padding: 2px 8px; border: 1px solid #e5e7eb; border-radius: 999px; background: white; color: #334155; }
|
||||
.row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin: 10px 0; }
|
||||
.input { padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: white; }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 10px; background: white; display: grid; gap: 8px; margin-bottom: 10px; }
|
||||
.cardTitle { font-weight: 700; font-size: 12px; color: #334155; }
|
||||
.list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
|
||||
.item { border: 1px solid #e5e7eb; border-radius: 12px; padding: 10px; cursor: pointer; background: white; transition: background 150ms, border-color 150ms; }
|
||||
.item:hover { background: rgba(59, 130, 246, 0.06); border-color: rgba(59, 130, 246, 0.35); }
|
||||
.name { font-weight: 700; color: #111827; }
|
||||
.url { font-size: 12px; color: #475569; overflow-wrap: anywhere; margin-top: 4px; }
|
||||
.muted { font-size: 12px; color: #475569; }
|
||||
.error { color: #b91c1c; font-size: 12px; margin-bottom: 8px; }
|
||||
</style>
|
||||
4
apps/extension/src/popup/main.js
Normal file
4
apps/extension/src/popup/main.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import PopupApp from "./PopupApp.vue";
|
||||
|
||||
createApp(PopupApp).mount("#app");
|
||||
79
apps/extension/src/style.css
Normal file
79
apps/extension/src/style.css
Normal file
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
16
apps/extension/vite.config.js
Normal file
16
apps/extension/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: "./",
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
popup: path.resolve(__dirname, "popup.html"),
|
||||
options: path.resolve(__dirname, "options.html")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user