feat: 新增 BbSelect 组件,实现下拉选择功能
This commit is contained in:
Binary file not shown.
BIN
.shared/ui-ux-pro-max/scripts/__pycache__/core.cpython-314.pyc
Normal file
BIN
.shared/ui-ux-pro-max/scripts/__pycache__/core.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
101
apps/web/src/components/BbSelect.vue
Normal file
101
apps/web/src/components/BbSelect.vue
Normal 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>
|
||||
@@ -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");
|
||||
|
||||
@@ -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-card">
|
||||
<h2>导入 Chrome/Edge 书签 HTML</h2>
|
||||
<div class="bb-row" style="margin-top: 10px;">
|
||||
<input
|
||||
class="bb-input"
|
||||
type="file"
|
||||
accept="text/html,.html"
|
||||
@change="(e) => (file.value = e.target.files?.[0] || null)"
|
||||
/>
|
||||
<button class="bb-btn" :disabled="busy" @click="importFile">开始导入</button>
|
||||
<div class="bb-pageHeader">
|
||||
<div>
|
||||
<h1 style="margin: 0;">导入 / 导出</h1>
|
||||
<div class="bb-muted" style="margin-top: 4px;">把浏览器书签升级成“可同步、可复习、可分享”的知识库。</div>
|
||||
</div>
|
||||
</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>
|
||||
<div class="bb-clayCard" style="margin-top: 12px;">
|
||||
<div class="bb-cardTitle">导入 Chrome/Edge 书签 HTML</div>
|
||||
<div class="bb-cardSub">支持自动去重与合并;登录后会写入云端数据库。</div>
|
||||
|
||||
<div class="bb-row" style="margin-top: 12px;">
|
||||
<input
|
||||
ref="fileInputEl"
|
||||
class="bb-fileInput"
|
||||
type="file"
|
||||
accept="text/html,.html"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<button class="bb-btn bb-btn--secondary" :disabled="busy" @click="openFilePicker">选择 HTML 文件</button>
|
||||
<div class="bb-fileName" :class="!file ? 'is-empty' : ''">
|
||||
{{ file ? file.name : "未选择文件" }}
|
||||
</div>
|
||||
<button class="bb-btn" :disabled="busy || !file" @click="importFile">开始导入</button>
|
||||
</div>
|
||||
|
||||
<p v-if="status" class="bb-alert bb-alert--ok" style="margin-top: 12px;">{{ status }}</p>
|
||||
<p v-if="error" class="bb-alert bb-alert--error" style="margin-top: 12px;">{{ error }}</p>
|
||||
|
||||
<p class="bb-muted" style="margin-top: 10px;">
|
||||
未登录:导入写入本机 localStorage,并使用 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
<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="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 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>
|
||||
|
||||
<p class="bb-muted" style="margin-top: 10px;">未登录时,你的书签会保存在本机 localStorage。</p>
|
||||
<div class="bb-authForm">
|
||||
<label class="bb-field">
|
||||
<span class="bb-label">邮箱</span>
|
||||
<input v-model="email" class="bb-input" placeholder="name@example.com" autocomplete="email" />
|
||||
</label>
|
||||
<label class="bb-field">
|
||||
<span class="bb-label">密码</span>
|
||||
<input
|
||||
v-model="password"
|
||||
class="bb-input"
|
||||
placeholder="至少 8 位"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button class="bb-btn" :disabled="loading" @click="submit">
|
||||
{{ loading ? '处理中…' : (mode === 'register' ? '创建账号并登录' : '登录') }}
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="bb-alert bb-alert--error" style="margin: 0;">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bb-authFoot bb-muted">
|
||||
Token 会持久化保存;仅当你手动退出才会清除。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="bb-authAside bb-card">
|
||||
<div class="bb-cardTitle">你将获得</div>
|
||||
<div class="bb-cardSub">更像“课程平台”的学习型收藏体验。</div>
|
||||
|
||||
<div class="bb-authBadges">
|
||||
<span class="bb-tag">同步</span>
|
||||
<span class="bb-tag bb-tag2">去重</span>
|
||||
<span class="bb-tag bb-tag3">文件夹层级</span>
|
||||
</div>
|
||||
|
||||
<ul class="bb-bullets" style="margin-top: 10px;">
|
||||
<li>导入 Chrome/Edge 书签 HTML 一键合并</li>
|
||||
<li>公开/私有可切换,公开页可分享</li>
|
||||
<li>跨标签页登录态自动同步</li>
|
||||
</ul>
|
||||
|
||||
<div class="bb-miniQuote" style="margin-top: 12px;">
|
||||
<div class="bb-quote">“终于把书签变成了可复习的知识库。”</div>
|
||||
<div class="bb-quoteBy">— 早八人 · 收藏课学员</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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>
|
||||
|
||||
@@ -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,33 +290,38 @@ 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;">
|
||||
<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">
|
||||
<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://...)" />
|
||||
<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>
|
||||
<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;">
|
||||
<div class="bb-row">
|
||||
@@ -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>
|
||||
|
||||
@@ -24,21 +24,28 @@ onMounted(load);
|
||||
|
||||
<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-row" style="margin: 12px 0;">
|
||||
<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">{{ 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>
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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
5
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"private": true
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user