feat: 新增 BbSelect 组件,实现下拉选择功能

This commit is contained in:
2026-01-18 13:07:54 +08:00
parent 00ca4c1b0d
commit 6eb3c730bb
21 changed files with 1346 additions and 163 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BrowserBookmark Options</title>
<title>云书签 选项</title>
</head>
<body>
<div id="app"></div>

View File

@@ -2,7 +2,7 @@ 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 (!headers.has("Accept")) headers.set("Accept", "application/json");
if (!(options.body instanceof FormData) && options.body != null) {
headers.set("Content-Type", "application/json");

View File

@@ -5,7 +5,7 @@ import { RouterLink, RouterView } from "vue-router";
<template>
<div class="shell">
<header class="nav">
<div class="brand">BrowserBookmark 扩展</div>
<div class="brand">云书签</div>
<nav class="links">
<RouterLink to="/" class="link">公开</RouterLink>
<RouterLink to="/my" class="link">我的</RouterLink>

View File

@@ -8,6 +8,10 @@ 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 = "";
@@ -44,8 +48,30 @@ async function importFile() {
}
}
function exportCloud() {
window.open("http://localhost:3001/bookmarks/export/html", "_blank");
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() {
@@ -82,7 +108,7 @@ async function exportLocal() {
<input
type="file"
accept="text/html,.html"
@change="(e) => (file.value = e.target.files?.[0] || null)"
@change="onFileChange"
/>
<button class="btn" @click="importFile">开始导入</button>
<p v-if="status" class="ok">{{ status }}</p>

View File

@@ -12,6 +12,16 @@ const mode = ref("local");
const loading = ref(false);
const error = ref("");
const cloudAvailable = ref(false);
const addCurrentOpen = ref(false);
const foldersLoading = ref(false);
const folders = ref([]);
const selectedFolderId = ref(null);
const addCurrentBusy = ref(false);
const addCurrentStatus = ref("");
const activePage = ref({ title: "", url: "" });
const filtered = computed(() => {
const query = q.value.trim().toLowerCase();
if (!query) return items.value;
@@ -36,11 +46,89 @@ function openOptions() {
}
}
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: document.title || "", url: window.location?.href || "" };
}
async function loadFolders() {
foldersLoading.value = true;
try {
const list = await apiFetch("/folders");
folders.value = Array.isArray(list) ? list : [];
} finally {
foldersLoading.value = false;
}
}
async function openAddCurrent() {
addCurrentStatus.value = "";
error.value = "";
selectedFolderId.value = null;
activePage.value = await getActiveTabPage();
const token = await getToken();
cloudAvailable.value = Boolean(token);
if (!token) {
addCurrentOpen.value = true;
return;
}
addCurrentOpen.value = true;
await loadFolders();
}
async function addCurrentToCloud() {
addCurrentStatus.value = "";
error.value = "";
const token = await getToken();
if (!token) {
error.value = "请先登录后再添加到云书签";
return;
}
const page = await getActiveTabPage();
const t = String(page.title || "").trim() || String(page.url || "").trim();
const u = String(page.url || "").trim();
if (!u) return;
try {
addCurrentBusy.value = true;
await apiFetch("/bookmarks", {
method: "POST",
body: JSON.stringify({
folderId: selectedFolderId.value ?? null,
title: t,
url: u,
visibility: "private"
})
});
addCurrentStatus.value = "已添加到云书签";
await load();
} catch (e) {
error.value = e.message || String(e);
} finally {
addCurrentBusy.value = false;
}
}
async function load() {
loading.value = true;
error.value = "";
try {
const token = await getToken();
cloudAvailable.value = Boolean(token);
mode.value = token ? "cloud" : "local";
if (mode.value === "cloud") {
@@ -109,6 +197,37 @@ onMounted(load);
<button class="btn primary" @click="quickAdd">添加</button>
</div>
<div class="card">
<div class="cardTitle">添加当前页面到云书签</div>
<div class="muted">只需选一个文件夹不选默认根目录</div>
<button class="btn primary" :disabled="addCurrentBusy" @click="openAddCurrent">添加当前页面</button>
<div v-if="addCurrentOpen" class="subCard">
<div class="subTitle">当前页面</div>
<div class="subText">{{ activePage.title || '(无标题)' }}</div>
<div class="subUrl">{{ activePage.url || '(无法获取链接)' }}</div>
<template v-if="cloudAvailable">
<div class="subTitle" style="margin-top: 10px;">选择文件夹</div>
<select v-model="selectedFolderId" class="input" :disabled="foldersLoading || addCurrentBusy">
<option :value="null">根目录</option>
<option v-for="f in folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
<button class="btn primary" :disabled="addCurrentBusy || !activePage.url" @click="addCurrentToCloud">
{{ addCurrentBusy ? '添加中' : '确认添加到云端' }}
</button>
<div v-if="foldersLoading" class="muted">加载文件夹中</div>
</template>
<template v-else>
<div class="muted" style="margin-top: 10px;">当前未登录无法添加到云端</div>
<button class="btn" @click="openOptions">去登录</button>
</template>
<div v-if="addCurrentStatus" class="ok">{{ addCurrentStatus }}</div>
</div>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="loading" class="muted">加载中</div>
@@ -138,6 +257,11 @@ onMounted(load);
.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; }
.subCard { border: 1px dashed #e5e7eb; border-radius: 12px; padding: 10px; background: #f8fafc; display: grid; gap: 8px; }
.subTitle { font-weight: 700; font-size: 12px; color: #334155; }
.subText { font-weight: 700; color: #0f172a; overflow-wrap: anywhere; }
.subUrl { font-size: 12px; color: #475569; overflow-wrap: anywhere; }
.ok { font-size: 12px; color: #065f46; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 10px; padding: 8px 10px; }
.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); }

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<title>云书签</title>
</head>
<body>
<div id="app"></div>

View File

@@ -1,36 +1,124 @@
<script setup>
import { computed } from "vue";
import { RouterLink, RouterView } from "vue-router";
import { getToken } from "./lib/api";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink, RouterView, useRouter } from "vue-router";
import { setToken, tokenRef } from "./lib/api";
const loggedIn = computed(() => Boolean(getToken()));
const router = useRouter();
const loggedIn = computed(() => Boolean(tokenRef.value));
const menuOpen = ref(false);
function toggleMenu() {
menuOpen.value = !menuOpen.value;
}
function closeMenu() {
menuOpen.value = false;
}
async function logout() {
setToken("");
closeMenu();
await router.push("/");
}
function onDocPointerDown(e) {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
if (target.closest("[data-bb-menu]")) return;
closeMenu();
}
onMounted(() => {
document.addEventListener("pointerdown", onDocPointerDown);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDocPointerDown);
});
router.afterEach(() => {
closeMenu();
});
</script>
<template>
<header class="nav">
<header class="nav" data-bb-menu>
<div class="navInner">
<RouterLink to="/" class="brand">BrowserBookmark</RouterLink>
<RouterLink to="/" class="brand">
<span class="brandMark">X</span>
<span class="brandText">云书签</span>
</RouterLink>
<nav class="links">
<RouterLink to="/" class="link">公开</RouterLink>
<RouterLink to="/" class="link">首页</RouterLink>
<RouterLink to="/my" class="link">我的</RouterLink>
<RouterLink to="/import" class="link">导入导出</RouterLink>
<RouterLink v-if="!loggedIn" to="/login" class="link primary">登录</RouterLink>
<div v-else class="menuWrap" data-bb-menu>
<button class="link menuBtn" type="button" :aria-expanded="menuOpen" @click="toggleMenu">
菜单
<span class="chev" aria-hidden="true"></span>
</button>
<div v-if="menuOpen" class="menu" role="menu">
<RouterLink class="menuItem" to="/import" role="menuitem">导入 / 导出</RouterLink>
<button class="menuItem danger" type="button" role="menuitem" @click="logout">退出登录</button>
</div>
</div>
</nav>
</div>
</header>
<main>
<RouterView />
</main>
</template>
<style scoped>
.nav { position: sticky; top: 0; z-index: 10; background: rgba(248,250,252,0.9); backdrop-filter: blur(10px); border-bottom: 1px solid var(--bb-border); }
.nav { position: sticky; top: 0; z-index: 10; background: rgba(255,255,255,0.55); backdrop-filter: blur(14px); border-bottom: 1px solid rgba(255,255,255,0.35); }
.navInner { max-width: 1100px; margin: 0 auto; padding: 10px 16px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.brand { font-weight: 800; color: var(--bb-text); text-decoration: none; }
.brand { display: inline-flex; align-items: center; gap: 10px; font-weight: 900; color: var(--bb-text); text-decoration: none; letter-spacing: -0.02em; }
.brandMark { width: 34px; height: 34px; border-radius: 12px; display: grid; place-items: center; background: var(--bb-clay); box-shadow: var(--bb-shadow-clay); border: 1px solid rgba(255,255,255,0.7); }
.brandText { font-size: 14px; }
.links { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.link { color: var(--bb-text); text-decoration: none; padding: 8px 10px; border-radius: 10px; transition: background 150ms, color 150ms; }
.link:hover { background: rgba(59,130,246,0.08); }
.link.router-link-active { background: rgba(59,130,246,0.12); }
.primary { background: var(--bb-primary); color: white; }
.primary:hover { background: var(--bb-primary-weak); }
.link { color: var(--bb-text); text-decoration: none; padding: 9px 12px; border-radius: 14px; transition: transform 120ms ease, box-shadow 120ms ease, background 150ms, color 150ms; background: rgba(255,255,255,0.25); border: 1px solid rgba(255,255,255,0.45); }
.link:hover { box-shadow: 0 10px 30px rgba(15, 23, 42, 0.10); transform: translateY(-1px); }
.link.router-link-active { background: rgba(255,255,255,0.55); }
.primary { background: linear-gradient(135deg, var(--bb-primary), var(--bb-primary-weak)); color: white; border-color: rgba(255,255,255,0.25); }
.primary:hover { filter: brightness(1.03); }
.menuWrap { position: relative; }
.menuBtn { cursor: pointer; }
.chev { margin-left: 6px; opacity: 0.8; }
.menu {
position: absolute;
right: 0;
top: calc(100% + 10px);
width: 220px;
padding: 8px;
border-radius: 16px;
background: rgba(255,255,255,0.72);
border: 1px solid rgba(255,255,255,0.5);
backdrop-filter: blur(16px);
box-shadow: 0 20px 70px rgba(15, 23, 42, 0.16);
}
.menuItem {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.35);
background: rgba(255,255,255,0.35);
color: var(--bb-text);
text-decoration: none;
cursor: pointer;
}
.menuItem + .menuItem { margin-top: 8px; }
.menuItem:hover { background: rgba(255,255,255,0.6); }
.menuItem.danger { color: #991b1b; background: rgba(254, 202, 202, 0.35); border-color: rgba(254, 202, 202, 0.6); }
</style>

View File

@@ -0,0 +1,101 @@
<script setup>
import { computed, 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 open = ref(false);
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;
}
function toggle() {
if (props.disabled) return;
open.value = !open.value;
}
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;
if (!el) return;
if (el.contains(e.target)) return;
close();
}
onMounted(() => {
document.addEventListener("pointerdown", onDocPointerDown);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDocPointerDown);
});
</script>
<template>
<div ref="rootEl" class="bb-selectWrap" :class="size === 'sm' ? 'bb-selectWrap--sm' : ''">
<button
type="button"
class="bb-selectTrigger"
:disabled="disabled"
:aria-expanded="open ? 'true' : 'false'"
@click="toggle"
@keydown="onKeydownTrigger"
>
<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>
<div v-if="open" class="bb-selectMenu" role="listbox">
<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>
</div>
</template>

View File

@@ -1,17 +1,30 @@
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 function getToken() {
return localStorage.getItem("bb_token") || "";
return tokenRef.value || "";
}
export function setToken(token) {
if (token) localStorage.setItem("bb_token", token);
const next = token || "";
tokenRef.value = next;
if (next) localStorage.setItem("bb_token", next);
else localStorage.removeItem("bb_token");
}
// Keep auth state in sync across tabs.
if (typeof window !== "undefined") {
window.addEventListener("storage", (e) => {
if (e.key === "bb_token") tokenRef.value = e.newValue || "";
});
}
export async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {});
headers.set("Accept", "application/json");
if (!headers.has("Accept")) headers.set("Accept", "application/json");
if (!(options.body instanceof FormData) && options.body != null) {
headers.set("Content-Type", "application/json");

View File

@@ -1,17 +1,26 @@
<script setup>
import { computed, ref } from "vue";
import { apiFetch, getToken } from "../lib/api";
import { apiFetch, tokenRef } from "../lib/api";
import {
exportLocalToNetscapeHtml,
importLocalFromNetscapeHtml,
listLocalBookmarks
} from "../lib/localData";
const loggedIn = computed(() => Boolean(getToken()));
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 = "";
@@ -37,8 +46,33 @@ async function importFile() {
}
}
function exportCloud() {
window.open("http://localhost:3001/bookmarks/export/html", "_blank");
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() {
@@ -70,45 +104,65 @@ async function exportLocal() {
<template>
<section class="bb-page">
<h1>导入 / 导出</h1>
<div class="bb-pageHeader">
<div>
<h1 style="margin: 0;">导入 / 导出</h1>
<div class="bb-muted" style="margin-top: 4px;">把浏览器书签升级成可同步可复习可分享的知识库</div>
</div>
</div>
<div class="bb-card">
<h2>导入 Chrome/Edge 书签 HTML</h2>
<div class="bb-row" style="margin-top: 10px;">
<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
class="bb-input"
ref="fileInputEl"
class="bb-fileInput"
type="file"
accept="text/html,.html"
@change="(e) => (file.value = e.target.files?.[0] || null)"
@change="onFileChange"
/>
<button class="bb-btn" :disabled="busy" @click="importFile">开始导入</button>
<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: 10px;">{{ status }}</p>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 10px;">{{ error }}</p>
<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并使用 urlNormalized/urlHash 自动去重登录导入写入数据库并自动去重合并
</p>
</div>
<div class="bb-grid2" style="margin-top: 12px;">
<div v-if="loggedIn" class="bb-card">
<h2>导出云端</h2>
<p class="bb-muted">导出你的云端书签保留文件夹层级</p>
<button class="bb-btn" :disabled="busy" @click="exportCloud">导出为 HTML</button>
<div 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">
<h2>导出本地</h2>
<p class="bb-muted">导出当前浏览器中的本地书签对齐扩展平铺导出</p>
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="exportLocal">导出为 HTML</button>
<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>
h1 { margin: 0 0 12px; }
h2 { margin: 0 0 6px; font-size: 16px; }
/* page-level tweaks only */
</style>

View File

@@ -34,7 +34,8 @@ async function submit() {
clearLocalState();
await router.push("/my");
const next = String(router.currentRoute.value.query?.next || "").trim();
await router.push(next || "/my");
} catch (e) {
error.value = e.message || String(e);
} finally {
@@ -44,28 +45,89 @@ async function submit() {
</script>
<template>
<section class="bb-page" style="max-width: 560px;">
<h1>{{ mode === 'register' ? '注册' : '登录' }}</h1>
<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="row">
<button class="tab" :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button>
<button class="tab" :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button>
<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>
<div class="form">
<input v-model="email" class="bb-input" placeholder="邮箱" autocomplete="email" />
<input v-model="password" class="bb-input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
<button class="bb-btn" :disabled="loading" @click="submit">提交</button>
<p v-if="error" class="bb-alert bb-alert--error">{{ error }}</p>
</div>
<aside class="bb-authAside bb-card">
<div class="bb-cardTitle">你将获得</div>
<div class="bb-cardSub">更像课程平台的学习型收藏体验</div>
<p class="bb-muted" style="margin-top: 10px;">未登录时你的书签会保存在本机 localStorage</p>
<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>
.row { display: flex; gap: 8px; margin: 12px 0; }
.tab { flex: 1; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; cursor: pointer; }
.tab.active { border-color: #111827; background: #111827; color: white; }
.form { display: grid; gap: 10px; }
.bb-authTitle { margin: 10px 0 6px; }
.bb-authForm { display: grid; gap: 10px; margin-top: 12px; }
.bb-authFoot { margin-top: 10px; }
</style>

View File

@@ -1,9 +1,10 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { apiFetch, getToken, setToken } from "../lib/api";
import BbSelect from "../components/BbSelect.vue";
import { apiFetch, tokenRef } from "../lib/api";
import { loadLocalState, markLocalDeleted, patchLocalBookmark, upsertLocalBookmark } from "../lib/localData";
const loggedIn = computed(() => Boolean(getToken()));
const loggedIn = computed(() => Boolean(tokenRef.value));
const error = ref("");
const loading = ref(false);
@@ -74,6 +75,37 @@ function folderLabel(f, depth) {
return `${pad}${f.name}`;
}
const folderOptions = computed(() => [
{ value: null, label: "(无文件夹)" },
...folderFlat.value.map((x) => ({ value: x.folder.id, label: folderLabel(x.folder, x.depth) }))
]);
const folderRootOptions = computed(() => [
{ value: null, label: "(根目录)" },
...folderFlat.value.map((x) => ({ value: x.folder.id, label: folderLabel(x.folder, x.depth) }))
]);
function folderRootOptionsForEdit(currentFolderId) {
return [
{ value: null, label: "(根目录)" },
...folderFlat.value.map((x) => ({
value: x.folder.id,
label: folderLabel(x.folder, x.depth),
disabled: x.folder.id === currentFolderId
}))
];
}
const visibilityOptions = [
{ value: "public", label: "公开" },
{ value: "private", label: "私有" }
];
const folderVisibilityOptions = [
{ value: "private", label: "私有" },
{ value: "public", label: "公开" }
];
async function loadRemote() {
loading.value = true;
error.value = "";
@@ -239,11 +271,6 @@ async function saveEdit(id) {
}
}
function logout() {
setToken("");
loadLocal();
}
async function reload() {
if (loggedIn.value) await loadRemote();
else loadLocal();
@@ -263,32 +290,37 @@ onMounted(() => {
<template>
<section class="bb-page">
<div class="bb-pageHeader">
<h1>我的书签</h1>
<button v-if="loggedIn" class="bb-btn bb-btn--secondary" @click="logout">退出登录</button>
<div>
<h1 style="margin: 0;">我的书签</h1>
<div class="bb-muted" style="margin-top: 4px;">像课程进度一样管理你的链接分组搜索可公开</div>
</div>
</div>
<p v-if="!loggedIn" class="bb-muted" style="margin-top: 8px;">
当前未登录数据仅保存在本机 localStorage登录后会自动同步到云端
</p>
<div class="searchRow" style="margin-top: 12px;">
<input v-model="q" class="bb-input" placeholder="搜索标题/链接" @keyup.enter="reload" />
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="reload">搜索</button>
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="clearSearch">清除</button>
<div class="bb-card" style="margin-top: 12px;">
<div class="sectionTitle">快速搜索</div>
<div class="searchRow" style="margin-top: 10px;">
<input v-model="q" class="bb-input" placeholder="搜索标题/链接" @keyup.enter="reload" />
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="reload">搜索</button>
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="clearSearch">清除</button>
</div>
</div>
<div class="form">
<input v-model="title" class="bb-input" placeholder="标题" />
<input v-model="url" class="bb-input" placeholder="链接https://..." />
<select v-model="folderId" class="bb-select">
<option :value="null">无文件夹</option>
<option v-for="x in folderFlat" :key="x.folder.id" :value="x.folder.id">{{ folderLabel(x.folder, x.depth) }}</option>
</select>
<select v-model="visibility" class="bb-select">
<option value="public">公开</option>
<option value="private">私有</option>
</select>
<button class="bb-btn" @click="add">添加</button>
<div class="bb-card" style="margin-top: 12px;">
<div class="sectionTitle">添加书签</div>
<div class="form" style="margin-top: 10px;">
<input v-model="title" class="bb-input" placeholder="标题" />
<input v-model="url" class="bb-input" placeholder="链接https://..." />
<BbSelect v-model="folderId" :options="folderOptions" />
<BbSelect v-model="visibility" :options="visibilityOptions" />
<button class="bb-btn" @click="add">添加</button>
</div>
<div class="bb-muted" style="margin-top: 10px;">
小贴士标题可写为什么收藏/怎么用以后复习更快
</div>
</div>
<div v-if="loggedIn" class="bb-card" style="margin: 12px 0;">
@@ -297,14 +329,8 @@ onMounted(() => {
</div>
<div class="bb-row" style="margin-top: 10px;">
<input v-model="folderName" class="bb-input" placeholder="新文件夹名称" />
<select v-model="folderParentId" class="bb-select">
<option :value="null">根目录</option>
<option v-for="x in folderFlat" :key="x.folder.id" :value="x.folder.id">{{ folderLabel(x.folder, x.depth) }}</option>
</select>
<select v-model="folderVisibility" class="bb-select">
<option value="private">私有</option>
<option value="public">公开</option>
</select>
<BbSelect v-model="folderParentId" :options="folderRootOptions" />
<BbSelect v-model="folderVisibility" :options="folderVisibilityOptions" />
<button class="bb-btn" @click="createFolder">创建文件夹</button>
</div>
@@ -322,21 +348,11 @@ onMounted(() => {
</template>
<template v-else>
<input v-model="editFolderName" class="bb-input" placeholder="文件夹名称" />
<select v-model="editFolderParentId" class="bb-select">
<option :value="null">根目录</option>
<option
v-for="p in folderFlat"
:key="p.folder.id"
:value="p.folder.id"
:disabled="p.folder.id === x.folder.id"
>
{{ folderLabel(p.folder, p.depth) }}
</option>
</select>
<select v-model="editFolderVisibility" class="bb-select">
<option value="private">私有</option>
<option value="public">公开</option>
</select>
<BbSelect
v-model="editFolderParentId"
:options="folderRootOptionsForEdit(x.folder.id)"
/>
<BbSelect v-model="editFolderVisibility" :options="folderVisibilityOptions" />
<div class="actions">
<button class="bb-btn" @click="saveFolder(x.folder.id)">保存</button>
<button class="bb-btn bb-btn--secondary" @click="cancelEditFolder">取消</button>
@@ -350,7 +366,7 @@ onMounted(() => {
</div>
</div>
<p v-if="error" class="bb-alert bb-alert--error">{{ error }}</p>
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
<p v-if="loading" class="bb-muted">加载中</p>
<div v-if="!loading && !error && !items.length" class="bb-empty" style="margin-top: 12px;">
@@ -372,14 +388,8 @@ onMounted(() => {
<div v-else class="edit">
<input v-model="editTitle" class="bb-input" placeholder="标题" />
<input v-model="editUrl" class="bb-input" placeholder="链接" />
<select v-model="editFolderId" class="bb-select">
<option :value="null">无文件夹</option>
<option v-for="x in folderFlat" :key="x.folder.id" :value="x.folder.id">{{ folderLabel(x.folder, x.depth) }}</option>
</select>
<select v-model="editVisibility" class="bb-select">
<option value="public">公开</option>
<option value="private">私有</option>
</select>
<BbSelect v-model="editFolderId" :options="folderOptions" />
<BbSelect v-model="editVisibility" :options="visibilityOptions" />
<div class="actions">
<button class="bb-btn" @click="saveEdit(b.id)">保存</button>
<button class="bb-btn bb-btn--secondary" @click="cancelEdit">取消</button>

View File

@@ -24,21 +24,28 @@ onMounted(load);
<template>
<section class="bb-page">
<h1>公开书签</h1>
<div class="bb-row" style="margin: 12px 0;">
<input v-model="q" class="bb-input" placeholder="搜索标题或链接" @keyup.enter="load" />
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="load">搜索</button>
<div class="bb-pageHeader">
<div>
<h1 style="margin: 0;">公开书签</h1>
<div class="bb-muted" style="margin-top: 4px;">无需登录即可浏览来自用户设置为公开的书签</div>
</div>
</div>
<p v-if="error" class="bb-alert bb-alert--error">{{ error }}</p>
<p v-else-if="loading" class="bb-muted">加载中</p>
<div v-else-if="!items.length" class="bb-empty">
<div style="font-weight: 700;">暂无公开书签</div>
<div class="bb-muted" style="margin-top: 6px;">换个关键词试试或稍后再来</div>
<div class="bb-card" style="margin-top: 12px;">
<div class="bb-row">
<input v-model="q" class="bb-input" placeholder="搜索标题或链接" @keyup.enter="load" />
<button class="bb-btn bb-btn--secondary" :disabled="loading" @click="load">搜索</button>
</div>
<p v-if="error" class="bb-alert bb-alert--error" 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>
<ul v-if="!loading && !error && items.length" class="bb-grid2" style="list-style: none; padding: 0;">
<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">
<a :href="b.url" target="_blank" rel="noopener" class="title">{{ b.title }}</a>
<div class="bb-muted" style="overflow-wrap: anywhere; margin-top: 6px;">{{ b.url }}</div>
@@ -48,6 +55,7 @@ onMounted(load);
</template>
<style scoped>
h1 { margin: 0 0 12px; }
.title { color: var(--bb-text); font-weight: 800; text-decoration: none; }
.title { color: var(--bb-text); font-weight: 900; text-decoration: none; }
.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; } }
</style>

View File

@@ -3,6 +3,7 @@ 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 { tokenRef } from "./lib/api";
export const router = createRouter({
history: createWebHistory(),
@@ -13,3 +14,19 @@ export const router = createRouter({
{ path: "/import", component: ImportExportPage }
]
});
router.beforeEach((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 } };
}
return true;
});

View File

@@ -1,12 +1,24 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Inter:wght@300;400;500;600;700&display=swap");
:root {
--bb-bg: #f8fafc;
--bb-text: #1e293b;
--bb-primary: #3b82f6;
--bb-primary-weak: #60a5fa;
--bb-cta: #f97316;
--bb-border: #e5e7eb;
/* Educational-platform claymorphism palette (vibrant + friendly) */
--bb-bg: #f0fdfa;
--bb-text: #134e4a;
--bb-primary: #0d9488;
--bb-primary-weak: #2dd4bf;
--bb-cta: #ea580c;
--bb-border: rgba(19, 78, 74, 0.12);
--bb-border-strong: rgba(19, 78, 74, 0.20);
--bb-font-heading: "Baloo 2", Inter, ui-sans-serif, system-ui;
--bb-gradient: radial-gradient(1100px 520px at 12% 10%, rgba(13, 148, 136, 0.30), transparent 55%),
radial-gradient(920px 620px at 92% 18%, rgba(45, 212, 191, 0.22), transparent 56%),
radial-gradient(900px 600px at 45% 92%, rgba(234, 88, 12, 0.20), transparent 55%),
linear-gradient(180deg, #f0fdfa 0%, #f8fafc 60%, #ffffff 100%);
--bb-clay: linear-gradient(145deg, rgba(255,255,255,0.96), rgba(255,255,255,0.70));
--bb-shadow-clay: 0 22px 60px rgba(19, 78, 74, 0.16), 0 2px 0 rgba(255,255,255,0.92) inset, 0 -2px 0 rgba(19, 78, 74, 0.05) inset;
font-family: Inter, ui-sans-serif, system-ui;
line-height: 1.5;
@@ -23,7 +35,7 @@ body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--bb-bg);
background: var(--bb-gradient);
color: var(--bb-text);
}
@@ -37,13 +49,13 @@ a {
}
a:hover {
color: var(--bb-primary-weak);
color: var(--bb-cta);
}
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: 2px solid rgba(59, 130, 246, 0.6);
outline: 2px solid rgba(13, 148, 136, 0.55);
outline-offset: 2px;
}
@@ -73,7 +85,7 @@ input:focus-visible {
}
.bb-muted {
color: #475569;
color: rgba(19, 78, 74, 0.72);
font-size: 12px;
}
@@ -81,12 +93,133 @@ input:focus-visible {
.bb-select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--bb-border);
border-radius: 10px;
background: white;
border: 1px solid rgba(255,255,255,0.55);
border-radius: 16px;
background: rgba(255,255,255,0.55);
backdrop-filter: blur(10px);
color: var(--bb-text);
}
/* Hide native file control (we use styled buttons) */
.bb-fileInput {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.bb-fileName {
flex: 1;
min-width: 180px;
padding: 10px 12px;
border: 1px solid rgba(255,255,255,0.55);
border-radius: 16px;
background: rgba(255,255,255,0.55);
backdrop-filter: blur(10px);
color: var(--bb-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bb-fileName.is-empty {
color: rgba(19, 78, 74, 0.55);
}
/* Custom select (consistent dropdown menu styling) */
.bb-selectWrap {
position: relative;
width: 100%;
}
.bb-selectWrap--sm .bb-selectTrigger {
padding: 8px 10px;
}
.bb-selectTrigger {
width: 100%;
padding: 10px 12px;
border: 1px solid rgba(255,255,255,0.55);
border-radius: 16px;
background: rgba(255,255,255,0.55);
backdrop-filter: blur(10px);
color: var(--bb-text);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
cursor: pointer;
text-align: left;
}
.bb-selectTrigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bb-selectValue {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bb-selectPlaceholder {
color: rgba(19, 78, 74, 0.55);
}
.bb-selectChevron {
flex: 0 0 auto;
opacity: 0.85;
}
.bb-selectMenu {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
z-index: 50;
border-radius: 18px;
padding: 6px;
border: 1px solid rgba(255,255,255,0.65);
background: rgba(255,255,255,0.70);
backdrop-filter: blur(14px);
box-shadow: 0 22px 60px rgba(19, 78, 74, 0.16);
max-height: 320px;
overflow: auto;
}
.bb-selectOption {
width: 100%;
border: 1px solid transparent;
background: transparent;
color: var(--bb-text);
padding: 10px 10px;
border-radius: 14px;
cursor: pointer;
text-align: left;
display: flex;
align-items: center;
}
.bb-selectOption:hover {
background: rgba(13, 148, 136, 0.10);
}
.bb-selectOption.is-selected {
background: rgba(234, 88, 12, 0.12);
border-color: rgba(234, 88, 12, 0.22);
}
.bb-selectOption.is-disabled {
opacity: 0.45;
cursor: not-allowed;
}
.bb-input--sm,
.bb-select--sm {
padding: 8px 10px;
@@ -94,9 +227,9 @@ input:focus-visible {
.bb-btn {
padding: 10px 12px;
border: 1px solid var(--bb-primary);
border-radius: 10px;
background: var(--bb-primary);
border: 1px solid rgba(255,255,255,0.25);
border-radius: 16px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
color: white;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease, background 120ms ease;
@@ -111,8 +244,8 @@ input:focus-visible {
}
.bb-btn--secondary {
border-color: var(--bb-border);
background: rgba(59, 130, 246, 0.08);
border-color: rgba(255,255,255,0.45);
background: rgba(255,255,255,0.35);
color: var(--bb-text);
}
@@ -129,10 +262,11 @@ input:focus-visible {
}
.bb-card {
border: 1px solid var(--bb-border);
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.55);
border-radius: 18px;
padding: 12px;
background: white;
background: rgba(255,255,255,0.55);
backdrop-filter: blur(12px);
}
.bb-card--interactive {
@@ -164,10 +298,430 @@ input:focus-visible {
}
.bb-empty {
border: 1px dashed var(--bb-border);
border-radius: 14px;
border: 1px dashed rgba(15, 23, 42, 0.18);
border-radius: 18px;
padding: 18px 14px;
background: rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.55);
}
/* --- Landing page (ui-ux-pro-max) --- */
.bb-landing {
max-width: 1100px;
margin: 0 auto;
padding: 18px 16px 28px;
}
.bb-hero {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
align-items: stretch;
}
@media (min-width: 960px) {
.bb-hero {
grid-template-columns: 1.25fr 0.75fr;
gap: 16px;
}
}
.bb-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.6);
background: rgba(255,255,255,0.55);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.10);
font-weight: 800;
font-size: 12px;
}
.bb-pillDot {
width: 10px;
height: 10px;
border-radius: 999px;
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
box-shadow: 0 10px 24px rgba(13, 148, 136, 0.18);
}
.bb-heroTitle {
margin: 12px 0 8px;
font-family: var(--bb-font-heading);
font-size: 34px;
line-height: 1.05;
letter-spacing: -0.03em;
}
@media (min-width: 960px) {
.bb-heroTitle { font-size: 44px; }
}
.bb-heroSub {
margin: 0;
font-size: 14px;
color: rgba(19, 78, 74, 0.78);
}
.bb-heroCtaRow {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-top: 14px;
}
.bb-ctaPrimary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 16px;
border-radius: 18px;
color: white;
text-decoration: none;
font-weight: 900;
border: 1px solid rgba(255,255,255,0.25);
background: linear-gradient(135deg, var(--bb-primary), var(--bb-cta));
box-shadow: 0 18px 45px rgba(13, 148, 136, 0.20);
}
.bb-ctaGhost {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 16px;
border-radius: 18px;
color: var(--bb-text);
text-decoration: none;
font-weight: 900;
border: 1px solid rgba(255,255,255,0.55);
background: rgba(255,255,255,0.45);
}
.bb-miniStats {
margin-top: 14px;
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
@media (min-width: 768px) {
.bb-miniStats { grid-template-columns: repeat(3, 1fr); }
}
.bb-miniStat {
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.6);
background: rgba(255,255,255,0.45);
padding: 12px;
box-shadow: 0 14px 40px rgba(19, 78, 74, 0.10);
}
.bb-miniStat .k { font-weight: 900; font-size: 12px; }
.bb-miniStat .v { margin-top: 6px; font-size: 12px; color: rgba(19,78,74,0.72); }
.bb-clayCard {
border-radius: 22px;
border: 2px solid rgba(255,255,255,0.78);
background: var(--bb-clay);
box-shadow: var(--bb-shadow-clay);
padding: 14px;
}
.bb-statRow {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.bb-stat {
border-radius: 18px;
border: 2px solid rgba(255,255,255,0.72);
background: rgba(255,255,255,0.48);
padding: 12px;
box-shadow: 0 16px 44px rgba(19, 78, 74, 0.12);
}
.bb-stat .n {
font-family: var(--bb-font-heading);
font-size: 20px;
letter-spacing: -0.02em;
font-weight: 800;
}
.bb-stat .t {
margin-top: 4px;
font-size: 12px;
font-weight: 800;
color: rgba(19, 78, 74, 0.72);
}
.bb-cardTitle { font-weight: 950; letter-spacing: -0.02em; }
.bb-cardSub { margin-top: 6px; font-size: 12px; color: rgba(19,78,74,0.72); }
.bb-tag {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
font-weight: 950;
font-size: 11px;
background: rgba(124, 58, 237, 0.14);
border: 1px solid rgba(124, 58, 237, 0.28);
width: fit-content;
}
.bb-tag2 { background: rgba(34, 197, 94, 0.14); border-color: rgba(34, 197, 94, 0.28); }
.bb-tag3 { background: rgba(251, 113, 133, 0.14); border-color: rgba(251, 113, 133, 0.28); }
.bb-bullets { margin: 10px 0 0; padding-left: 18px; }
.bb-bullets li { margin: 6px 0; font-weight: 700; color: rgba(19,78,74,0.78); }
.bb-section { margin-top: 18px; }
.bb-sectionHeader h2 { margin: 0; font-size: 18px; letter-spacing: -0.02em; font-family: var(--bb-font-heading); }
.bb-grid3 {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-top: 12px;
}
@media (min-width: 960px) {
.bb-grid3 { grid-template-columns: repeat(3, 1fr); }
}
.bb-courseGrid {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 720px) {
.bb-courseGrid { grid-template-columns: 1fr 1fr; }
}
@media (min-width: 1100px) {
.bb-courseGrid { grid-template-columns: repeat(4, 1fr); }
}
.bb-courseCard {
border-radius: 22px;
border: 2px solid rgba(255,255,255,0.78);
background: var(--bb-clay);
box-shadow: var(--bb-shadow-clay);
padding: 14px;
display: grid;
gap: 10px;
}
.bb-courseTitle {
font-weight: 950;
letter-spacing: -0.02em;
}
.bb-courseBy {
margin-top: 6px;
font-size: 12px;
font-weight: 900;
color: rgba(19, 78, 74, 0.72);
}
.bb-courseMeta {
font-size: 12px;
color: rgba(19, 78, 74, 0.78);
font-weight: 800;
}
.bb-courseCtaRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.bb-courseCtaRow .bb-btn {
width: 100%;
display: inline-flex;
justify-content: center;
}
.bb-centerRow {
display: flex;
justify-content: center;
}
.bb-featureCard {
display: grid;
gap: 8px;
}
.bb-iconBubble {
width: 42px;
height: 42px;
border-radius: 16px;
display: grid;
place-items: center;
color: var(--bb-text);
border: 2px solid rgba(255,255,255,0.78);
background: rgba(255,255,255,0.55);
box-shadow: 0 14px 40px rgba(19, 78, 74, 0.12);
}
.bb-progressDemo .bb-tip { margin-top: 10px; font-size: 12px; color: rgba(19,78,74,0.72); }
.bb-progressItem { margin-top: 10px; }
.bb-progressItem .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-weight: 800; font-size: 12px; }
.bb-progressItem .pct { font-weight: 950; }
.bb-progressItem .bar { margin-top: 8px; height: 12px; border-radius: 999px; background: rgba(15,23,42,0.08); overflow: hidden; border: 1px solid rgba(255,255,255,0.55); }
.bb-progressItem .fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--bb-primary), var(--bb-cta)); }
.bb-progressItem .fill2 { background: linear-gradient(90deg, #22c55e, #06b6d4); }
.bb-progressItem .fill3 { background: linear-gradient(90deg, #fb7185, #f59e0b); }
.bb-quote { font-weight: 800; line-height: 1.35; }
.bb-quoteBy { margin-top: 10px; font-size: 12px; color: rgba(15,23,42,0.72); font-weight: 900; }
.bb-story { position: relative; }
.bb-stars {
display: flex;
gap: 3px;
color: rgba(234, 179, 8, 0.95);
font-weight: 900;
}
.bb-star {
width: 16px;
height: 16px;
}
.bb-ctaBand {
margin-top: 18px;
border-radius: 24px;
border: 1px solid rgba(255,255,255,0.6);
background: linear-gradient(135deg, rgba(13, 148, 136, 0.22), rgba(45, 212, 191, 0.18), rgba(234, 88, 12, 0.16));
padding: 14px;
box-shadow: 0 24px 80px rgba(19, 78, 74, 0.14);
}
.bb-ctaBandInner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.bb-ctaBandTitle { font-weight: 950; letter-spacing: -0.02em; }
.bb-ctaBandSub { margin-top: 4px; font-size: 12px; color: rgba(15,23,42,0.78); }
.bb-ctaBandHint {
margin-top: 8px;
font-size: 12px;
font-weight: 900;
color: rgba(19, 78, 74, 0.78);
}
.bb-ctaBandActions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
/* Footer (educational platform style) */
.bb-footer {
margin-top: 18px;
padding: 18px 0 28px;
}
.bb-footerInner {
border-radius: 24px;
border: 2px solid rgba(255,255,255,0.68);
background: rgba(255,255,255,0.55);
backdrop-filter: blur(14px);
box-shadow: 0 24px 80px rgba(19, 78, 74, 0.14);
padding: 16px;
}
.bb-footerBrand {
display: flex;
align-items: center;
gap: 12px;
}
.bb-footerLogo {
width: 42px;
height: 42px;
border-radius: 16px;
display: grid;
place-items: center;
font-family: var(--bb-font-heading);
font-weight: 800;
border: 2px solid rgba(255,255,255,0.78);
background: var(--bb-clay);
box-shadow: var(--bb-shadow-clay);
}
.bb-footerTitle {
font-family: var(--bb-font-heading);
font-weight: 800;
}
.bb-footerCols {
margin-top: 14px;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 900px) {
.bb-footerCols { grid-template-columns: repeat(3, 1fr); }
}
.bb-footerCol {
display: grid;
gap: 8px;
}
.bb-footerCol .h {
font-weight: 950;
color: rgba(19, 78, 74, 0.88);
}
.bb-footerCol a {
color: rgba(19, 78, 74, 0.78);
font-weight: 800;
}
.bb-footerCol a:hover {
color: var(--bb-cta);
}
.bb-footerBottom {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(19, 78, 74, 0.10);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.bb-footerSocial {
display: flex;
gap: 8px;
}
.bb-footerSocial .s {
width: 34px;
height: 34px;
border-radius: 14px;
display: grid;
place-items: center;
border: 2px solid rgba(255,255,255,0.72);
background: rgba(255,255,255,0.45);
color: rgba(19, 78, 74, 0.88);
font-weight: 950;
}
.bb-grid2 {
@@ -181,3 +735,125 @@ input:focus-visible {
grid-template-columns: 1fr 1fr;
}
}
/* --- Auth / segmented controls / subtle motion --- */
.bb-auth {
max-width: 1100px;
padding-top: 22px;
padding-bottom: 28px;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
@media (min-width: 960px) {
.bb-auth {
grid-template-columns: 1.05fr 0.95fr;
align-items: start;
}
}
.bb-authCard {
position: relative;
overflow: hidden;
}
.bb-authCard::before,
.bb-authCard::after {
content: "";
position: absolute;
width: 320px;
height: 320px;
border-radius: 999px;
filter: blur(22px);
opacity: 0.55;
pointer-events: none;
}
.bb-authCard::before {
background: radial-gradient(circle at 30% 30%, rgba(13,148,136,0.42), transparent 60%);
top: -160px;
left: -140px;
}
.bb-authCard::after {
background: radial-gradient(circle at 40% 40%, rgba(234,88,12,0.34), transparent 62%);
bottom: -170px;
right: -160px;
}
.bb-authHeader {
position: relative;
z-index: 1;
}
.bb-seg {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 8px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.65);
background: rgba(255,255,255,0.45);
backdrop-filter: blur(12px);
position: relative;
z-index: 1;
}
.bb-segBtn {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.30);
background: rgba(255,255,255,0.38);
color: var(--bb-text);
font-weight: 950;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease, background 120ms ease;
}
.bb-segBtn:hover {
filter: brightness(1.03);
}
.bb-segBtn:active {
transform: translateY(1px);
}
.bb-segBtn.active {
background: linear-gradient(135deg, rgba(124,58,237,0.92), rgba(251,113,133,0.92));
color: white;
border-color: rgba(255,255,255,0.45);
}
.bb-field {
display: grid;
gap: 6px;
}
.bb-label {
font-size: 12px;
font-weight: 900;
color: rgba(15,23,42,0.78);
}
.bb-authAside {
box-shadow: 0 18px 55px rgba(15, 23, 42, 0.10);
}
.bb-authBadges {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
@keyframes bb-float {
0% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -8px, 0); }
100% { transform: translate3d(0, 0, 0); }
}
.bb-card--interactive:hover {
animation: bb-float 2.8s ease-in-out infinite;
}

5
package-lock.json generated
View File

@@ -19,10 +19,13 @@
"apps/extension": {
"version": "0.0.0",
"dependencies": {
"vue": "^3.5.24"
"@browser-bookmark/shared": "0.1.0",
"vue": "^3.5.24",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"eslint": "^9.17.0",
"vite": "^7.2.4"
}
},

View File

@@ -1,3 +1,4 @@
{
"private": true
"private": true,
"type": "module"
}