From 73366d72ace4c90dfd60a2e249a6db2e2e5cf1ff Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:21:41 +0800 Subject: [PATCH 001/149] Update storage.js --- functions/api/storage.js | 96 +++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/functions/api/storage.js b/functions/api/storage.js index acced84f..744e36ed 100644 --- a/functions/api/storage.js +++ b/functions/api/storage.js @@ -44,34 +44,31 @@ async function mergeAllConfigSections(kv) { return configStr ? JSON.parse(configStr) : {}; } -// 生成分类链接 key function categoryLinksKey(categoryId) { return `links:${categoryId}`; } -// 读取所有分类链接 -async function readAllCategoryLinks(kv) { - // 1. 获取所有分类 - const categoriesStr = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); - const categories = categoriesStr ? JSON.parse(categoriesStr) : []; - +// 读取所有分类链接(带密码过滤) +async function readAllCategoryLinks(kv, categories, unlockedCategories = new Set(), isAdmin = false) { if (categories.length === 0) return []; - // 2. 并行读取每个分类的链接 const linkPromises = categories.map(async (cat) => { + const hasPassword = cat.password && cat.password.trim() !== ''; + const isUnlocked = unlockedCategories.has(cat.id); + + if (hasPassword && !isUnlocked && !isAdmin) { + return []; + } + const data = await kv.get(categoryLinksKey(cat.id)); return data ? JSON.parse(data) : []; }); const linkArrays = await Promise.all(linkPromises); - - // 3. 合并所有链接 return linkArrays.flat(); } -// 保存链接到对应的分类 key async function saveCategoryLinks(kv, links) { - // 按 categoryId 分组 const grouped = {}; for (const link of links) { const catId = link.categoryId || 'common'; @@ -79,7 +76,6 @@ async function saveCategoryLinks(kv, links) { grouped[catId].push(link); } - // 并行写入每个分类 const writes = Object.entries(grouped).map(([catId, catLinks]) => kv.put(categoryLinksKey(catId), JSON.stringify(catLinks)) ); @@ -99,7 +95,6 @@ export async function onRequest(context) { try { const kv = getKV(env); - // ==================== GET ==================== if (request.method === 'GET') { const checkAuth = url.searchParams.get('checkAuth'); const getConfig = url.searchParams.get('getConfig'); @@ -107,7 +102,6 @@ export async function onRequest(context) { const readOnly = url.searchParams.get('readOnly'); const category = url.searchParams.get('category'); - // 检查认证需求 if (checkAuth === 'true') { return jsonResponse({ hasPassword: !!env.PASSWORD, @@ -117,7 +111,6 @@ export async function onRequest(context) { }, 200, corsHeaders); } - // 获取子配置(优先读独立 key,fallback 到旧 config 的子字段) if (CONFIG_SECTIONS.includes(getConfig)) { const sectionVal = await readConfigSection(kv, getConfig); const defaults = { @@ -126,7 +119,6 @@ export async function onRequest(context) { return jsonResponse(sectionVal || defaults[getConfig] || {}, 200, corsHeaders); } - // 获取 Favicon 缓存 if (getConfig === 'favicon') { const domain = url.searchParams.get('domain'); if (!domain) { @@ -136,31 +128,58 @@ export async function onRequest(context) { return jsonResponse({ icon: cachedIcon || null, cached: !!cachedIcon }, 200, corsHeaders); } - // 获取分类(密码脱敏) + // 获取分类:保留 hasPassword 标记,不返回实际密码 if (getConfig === 'categories') { const data = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const categories = data ? JSON.parse(data) : []; - const sanitized = categories.map(({ password, ...rest }) => rest); + const sanitized = categories.map(({ password, ...rest }) => ({ + ...rest, + hasPassword: !!(password && password.trim() !== '') + })); return jsonResponse(sanitized, 200, corsHeaders); } - // 获取链接 + // 解析已解锁分类 + let unlockedCategories = new Set(); + const unlockedParam = url.searchParams.get('unlocked'); + if (unlockedParam) { + try { + unlockedCategories = new Set(JSON.parse(unlockedParam)); + } catch (e) {} + } + + // 检查管理员权限 + const providedPassword = request.headers.get('x-auth-password'); + const isAdmin = await verifyAuth({ + providedPassword, + serverPassword: env.PASSWORD, + kv, + }); + + // 获取链接(带密码过滤) if (getConfig === 'links') { - // 按分类读取 + const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const categories = categoriesData ? JSON.parse(categoriesData) : []; + if (category) { + const cat = categories.find(c => c.id === category); + const hasPassword = cat && cat.password && cat.password.trim() !== ''; + const isUnlocked = unlockedCategories.has(category); + + if (hasPassword && !isUnlocked && !isAdmin) { + return jsonResponse({ error: '该分类需要密码访问' }, 403, corsHeaders); + } + const data = await kv.get(categoryLinksKey(category)); return new Response(data || '[]', { headers: { 'Content-Type': 'application/json', ...corsHeaders }, }); } - // 读取所有分类链接 - const links = await readAllCategoryLinks(kv); - + const links = await readAllCategoryLinks(kv, categories, unlockedCategories, isAdmin); return jsonResponse(links, 200, corsHeaders); } - // 按 Key 读取 if (key) { if (key === STORAGE_KEYS.CONFIG_KEY) { const merged = await mergeAllConfigSections(kv); @@ -170,18 +189,17 @@ export async function onRequest(context) { return jsonResponse({ key, value }, 200, corsHeaders); } - // 获取全部数据 + // 获取全部数据(带密码过滤) if (getConfig === 'true') { const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); - const categories = categoriesData ? JSON.parse(categoriesData) : []; + const allCategories = categoriesData ? JSON.parse(categoriesData) : []; - // 只读模式下分类密码脱敏 - const sanitizedCategories = readOnly - ? categories.map(({ password, ...rest }) => rest) - : categories; + const sanitizedCategories = allCategories.map(({ password, ...rest }) => ({ + ...rest, + hasPassword: !!(password && password.trim() !== '') + })); - // 读取所有分类链接 - const links = await readAllCategoryLinks(kv); + const links = await readAllCategoryLinks(kv, allCategories, unlockedCategories, isAdmin); return jsonResponse({ links, @@ -192,12 +210,10 @@ export async function onRequest(context) { return jsonResponse({ links: [], categories: [] }, 200, corsHeaders); } - // ==================== POST ==================== if (request.method === 'POST') { const body = await request.json(); const readOnlyOperations = ['favicon']; - // 无需认证的操作 if (readOnlyOperations.includes(body.operation) || body.saveConfig === 'favicon') { if (body.saveConfig === 'favicon') { const { domain, icon } = body; @@ -209,7 +225,6 @@ export async function onRequest(context) { } } - // 认证检查 const providedPassword = request.headers.get('x-auth-password'); const isAuthenticated = await verifyAuth({ providedPassword, @@ -221,49 +236,40 @@ export async function onRequest(context) { return jsonResponse({ error: '管理操作需要密码验证' }, 401, corsHeaders); } - // 仅验证密码 if (body.authOnly) { await kv.put('last_auth_time', Date.now().toString()); return jsonResponse({ success: true }, 200, corsHeaders); } - // 保存子配置(写入独立 KV key,避免读取全量 config) if (CONFIG_SECTIONS.includes(body.saveConfig)) { await kv.put(`config:${body.saveConfig}`, JSON.stringify(body.config)); return jsonResponse({ success: true }, 200, corsHeaders); } - // 保存分类 if (body.saveConfig === 'categories') { await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(body.categories)); return jsonResponse({ success: true }, 200, corsHeaders); } - // 保存链接(按分类拆分存储) if (body.saveConfig === 'links') { - // 如果指定了分类,只保存该分类的链接 if (body.categoryId) { await kv.put(categoryLinksKey(body.categoryId), JSON.stringify(body.links)); } else { - // 保存所有链接(按 categoryId 拆分) await saveCategoryLinks(kv, body.links); } return jsonResponse({ success: true }, 200, corsHeaders); } - // 同步统一配置 if (body.key === STORAGE_KEYS.CONFIG_KEY && body.value) { await kv.put('config', body.value); return jsonResponse({ success: true }, 200, corsHeaders); } - // 写入任意 key(用于设置等独立 KV 项) if (body.key && body.value && body.key !== STORAGE_KEYS.CONFIG_KEY) { await kv.put(body.key, body.value); return jsonResponse({ success: true }, 200, corsHeaders); } - // 同时保存链接和分类 if (body.links && body.categories) { await saveCategoryLinks(kv, body.links); await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(body.categories)); From 700db20f4330b10c4f76a807fc22f27f7cb77d54 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:22:44 +0800 Subject: [PATCH 002/149] Update useDataSync.ts --- src/hooks/useDataSync.ts | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/src/hooks/useDataSync.ts b/src/hooks/useDataSync.ts index 8451c09f..a30c91d2 100644 --- a/src/hooks/useDataSync.ts +++ b/src/hooks/useDataSync.ts @@ -5,16 +5,12 @@ import { useLinksContext } from '../contexts/LinksContext'; import { useCategoriesContext } from '../contexts/CategoriesContext'; import { useConfigContext } from '../contexts/ConfigContext'; -/** - * 数据同步 Hook:管理 localStorage ↔ KV 的加载和同步 - */ export function useDataSync() { const { links = [], initLinks, setLinksAndSync } = useLinksContext(); - const { categories = [], initCategories } = useCategoriesContext(); + const { categories = [], initCategories, unlockedCategoryIds } = useCategoriesContext(); const { initConfig } = useConfigContext(); const initialized = useRef(false); - // 从 localStorage 加载 const loadFromLocal = useCallback((): { links: LinkItem[]; categories: Category[] } => { try { const stored = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_KEY); @@ -22,7 +18,6 @@ export function useDataSync() { const parsed = JSON.parse(stored); let cats: Category[] = parsed.categories || DEFAULT_CATEGORIES; - // 确保 common 分类存在且排第一 if (!cats.some((c: Category) => c.id === 'common')) { cats = [{ id: 'common', name: '常用推荐', icon: 'Star' }, ...cats]; } else { @@ -33,7 +28,6 @@ export function useDataSync() { } } - // 修复无效 categoryId const validIds = new Set(cats.map((c: Category) => c.id)); let lnks: LinkItem[] = (parsed.links || INITIAL_LINKS).map((l: LinkItem) => validIds.has(l.categoryId) ? l : { ...l, categoryId: 'common' } @@ -47,10 +41,10 @@ export function useDataSync() { return { links: INITIAL_LINKS, categories: DEFAULT_CATEGORIES }; }, []); - // 从 KV 加载链接和分类 - const loadFromCloud = useCallback(async (): Promise<{ links: LinkItem[]; categories: Category[] } | null> => { + const loadFromCloud = useCallback(async (unlockedCats?: Set): Promise<{ links: LinkItem[]; categories: Category[] } | null> => { try { - const res = await fetch(`${API_ENDPOINTS.STORAGE}?getConfig=true&readOnly=true`); + const unlockedArray = unlockedCats ? Array.from(unlockedCats) : []; + const res = await fetch(`${API_ENDPOINTS.STORAGE}?getConfig=true&readOnly=true&unlocked=${encodeURIComponent(JSON.stringify(unlockedArray))}`); if (!res.ok) return null; const data = await res.json(); if (data.links?.length > 0 || data.categories?.length > 0) { @@ -63,7 +57,6 @@ export function useDataSync() { } }, []); - // 从 KV 加载各个配置 const loadConfigsFromCloud = useCallback(async () => { const configKeys = ['search', 'website', 'ai', 'weather', 'mastodon', 'icon']; const configMap: Record = {}; @@ -74,7 +67,6 @@ export function useDataSync() { if (res.ok) { const data = await res.json(); if (data && Object.keys(data).length > 0) { - // 将后端命名的 'mastodon' 映射为前端统一使用的 'ticker' const configKey = key === 'mastodon' ? 'ticker' : key; configMap[configKey] = data; } @@ -84,37 +76,31 @@ export function useDataSync() { } })); - // 更新 ConfigContext if (Object.keys(configMap).length > 0) { initConfig(configMap); } }, [initConfig]); - // 初始化数据 - const initData = useCallback(async () => { + const initData = useCallback(async (unlockedCats?: Set) => { if (initialized.current) return; initialized.current = true; - // 1. 先从本地加载(快速展示) const local = loadFromLocal(); initLinks(local.links); initCategories(local.categories); - // 2. 并行从云端获取最新数据 const [cloud] = await Promise.all([ - loadFromCloud(), + loadFromCloud(unlockedCats), loadConfigsFromCloud(), ]); if (cloud) { - // 云端有数据,用云端数据覆盖 let cats = cloud.categories || []; if (cats.length > 0 && !cats.some((c: Category) => c.id === 'common')) { cats = [{ id: 'common', name: '常用推荐', icon: 'Star' }, ...cats]; } initLinks(cloud.links || []); initCategories(cats); - // 更新 localStorage 缓存 localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_KEY, JSON.stringify({ links: cloud.links || [], categories: cats, @@ -122,7 +108,6 @@ export function useDataSync() { } }, [loadFromLocal, loadFromCloud, loadConfigsFromCloud, initLinks, initCategories]); - // 同步到云端 const syncToCloud = useCallback(async () => { if (!links.length && !categories.length) return; setLinksAndSync(links, categories); From 02ab22fdd757be9e1a3c26831f3d6ca6c912a32a Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:24:51 +0800 Subject: [PATCH 003/149] Refactor CategoriesProvider to use localStorage for unlocked categories Updated the CategoriesProvider to initialize unlockedCategoryIds from localStorage and modified unlockCategory to persist changes to localStorage. --- src/contexts/CategoriesContext.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/contexts/CategoriesContext.tsx b/src/contexts/CategoriesContext.tsx index bcec8b1a..1020e928 100644 --- a/src/contexts/CategoriesContext.tsx +++ b/src/contexts/CategoriesContext.tsx @@ -3,7 +3,6 @@ import { Category, LinkItem, DEFAULT_CATEGORIES } from '../../types'; import { STORAGE_KEYS, API_ENDPOINTS } from '../constants'; import { useAuthContext } from './AuthContext'; -// --- Types --- interface CategoriesState { categories: Category[]; unlockedCategoryIds: Set; @@ -35,7 +34,6 @@ export interface CategoryWithChildren extends Category { children: Category[]; } -// --- Reducer --- function categoriesReducer(state: CategoriesState, action: CategoriesAction): CategoriesState { switch (action.type) { case 'SET_CATEGORIES': @@ -61,10 +59,8 @@ function categoriesReducer(state: CategoriesState, action: CategoriesAction): Ca } } -// --- Context --- const CategoriesContext = createContext(null); -// --- Helper --- function buildCategoryTree(cats: Category[]): CategoryWithChildren[] { const topLevels = cats .filter(c => !c.parentId) @@ -78,11 +74,10 @@ function buildCategoryTree(cats: Category[]): CategoryWithChildren[] { })); } -// --- Provider --- export function CategoriesProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = useReducer(categoriesReducer, { categories: [], - unlockedCategoryIds: new Set(), + unlockedCategoryIds: new Set(JSON.parse(localStorage.getItem('unlocked_categories') || '[]')), expandedCategories: new Set(), }); @@ -105,8 +100,10 @@ export function CategoriesProvider({ children }: { children: React.ReactNode }) }, []); const unlockCategory = useCallback((id: string) => { + const newSet = new Set([...state.unlockedCategoryIds, id]); + localStorage.setItem('unlocked_categories', JSON.stringify(Array.from(newSet))); dispatch({ type: 'UNLOCK_CATEGORY', payload: id }); - }, []); + }, [state.unlockedCategoryIds]); const toggleExpand = useCallback((id: string) => { dispatch({ type: 'TOGGLE_EXPAND', payload: id }); @@ -146,7 +143,6 @@ export function CategoriesProvider({ children }: { children: React.ReactNode }) ); } -// --- Hook --- export function useCategoriesContext() { const ctx = useContext(CategoriesContext); if (!ctx) throw new Error('useCategoriesContext must be used within CategoriesProvider'); From ebfcf46254f7ef5836fe15e9015693ff764049b2 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:26:07 +0800 Subject: [PATCH 004/149] Update MainContent.tsx --- src/components/layout/MainContent.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index e9c96c12..23fffdef 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -30,7 +30,7 @@ export function MainContent({ isDragSortMode, isEditMode, onWeightChange, isInternal, }: MainContentProps) { const { links = [], pinnedLinks = [], getLinksByCategory } = useLinksContext(); - const { categoryTree = [], categories = [] } = useCategoriesContext(); + const { categoryTree = [], categories = [], unlockedCategoryIds } = useCategoriesContext(); const { showPinnedWebsites = true, viewMode = 'compact' } = useConfigContext(); const { authToken } = useAuthContext(); const { sensors, handleDragEnd, handlePinnedDragEnd } = useDragSort(); @@ -40,7 +40,6 @@ export function MainContent({ return getLinksByCategory ? getLinksByCategory(categoryId) : []; }, [getLinksByCategory]); - // Intersection Observer for active category highlighting useEffect(() => { const observer = new IntersectionObserver( (entries) => { @@ -64,7 +63,6 @@ export function MainContent({ ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10'; - // Search mode: Only show results if internal search is checked if (searchQuery.trim() && isInternal) { return (
@@ -104,7 +102,6 @@ export function MainContent({ return (
- {/* Pinned section */} {showPinnedWebsites && pinnedLinks.length > 0 && (
)} - {/* All categories */} + {/* 过滤掉未解锁的密码保护分类 */} {categoryTree.map(cat => { + const isLocked = cat.hasPassword && !unlockedCategoryIds.has(cat.id); + if (isLocked) return null; + const catLinks = safeGetLinksByCategory(cat.id); const subcategoryLinks = cat.children?.flatMap(child => safeGetLinksByCategory(child.id)) || []; From 14d9fc56e70720c954dbbfb5b62e6e57b0a831c7 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:28:13 +0800 Subject: [PATCH 005/149] Update Sidebar.tsx --- src/components/layout/Sidebar.tsx | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 97562197..1b83df28 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -24,6 +24,12 @@ export function Sidebar({ isOpen, onClose, activeCategoryId, onOpenCatManager, o const [isCollapsed, setIsCollapsed] = useState(false); const handleCategoryClick = useCallback((cat: CategoryWithChildren) => { + const isLocked = cat.hasPassword && !unlockedCategoryIds.has(cat.id); + if (isLocked) { + onUnlockCategory(cat as Category); + return; + } + if (cat.children && cat.children.length > 0) { toggleExpand(cat.id); const targetId = cat.children[0]?.id || cat.id; @@ -32,13 +38,13 @@ export function Sidebar({ isOpen, onClose, activeCategoryId, onOpenCatManager, o document.getElementById(`cat-${cat.id}`)?.scrollIntoView({ behavior: 'smooth' }); } onClose(); - }, [toggleExpand, onClose]); + }, [toggleExpand, onClose, unlockedCategoryIds, onUnlockCategory]); const renderCategoryNode = (cat: CategoryWithChildren, level: number = 0) => { const isExpanded = expandedCategories.has(cat.id); const isActive = activeCategoryId === cat.id; const hasChildren = cat.children && cat.children.length > 0; - const isLocked = cat.password && !unlockedCategoryIds.has(cat.id); + const isLocked = cat.hasPassword && !unlockedCategoryIds.has(cat.id); return (
@@ -59,7 +65,7 @@ export function Sidebar({ isOpen, onClose, activeCategoryId, onOpenCatManager, o
{cat.name} - {hasChildren && ( + {hasChildren && !isLocked && ( {isExpanded ? : } @@ -68,7 +74,7 @@ export function Sidebar({ isOpen, onClose, activeCategoryId, onOpenCatManager, o
- {hasChildren && isExpanded && !isCollapsed && ( + {hasChildren && isExpanded && !isCollapsed && !isLocked && (
{cat.children.map(child => renderCategoryNode(child, level + 1))}
@@ -79,16 +85,13 @@ export function Sidebar({ isOpen, onClose, activeCategoryId, onOpenCatManager, o return ( <> - {/* Overlay */} {isOpen && (
)} - {/* Sidebar */} From 5723357737fdd7a2be2ee4c41cdcd081ba9e847e Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:41:06 +0800 Subject: [PATCH 006/149] Update AppLayout.tsx --- src/components/layout/AppLayout.tsx | 73 ++++++----------------------- 1 file changed, 15 insertions(+), 58 deletions(-) diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index f5924072..f4929a59 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -11,7 +11,6 @@ import { MainContent } from './MainContent'; import { ContentSkeleton } from './ContentSkeleton'; import { LinkItem, Category } from '../../../types'; import AuthModal from '../../../components/AuthModal'; - const LinkModal = lazy(() => import('../../../components/LinkModal')); const CategoryManagerModal = lazy(() => import('../../../components/CategoryManagerModal')); const BackupModal = lazy(() => import('../../../components/BackupModal')); @@ -21,14 +20,12 @@ const SettingsModal = lazy(() => import('../../../components/SettingsModal')); const SearchConfigModal = lazy(() => import('../../../components/SearchConfigModal')); const ContextMenu = lazy(() => import('../../../components/ContextMenu')); const QRCodeModal = lazy(() => import('../../../components/QRCodeModal')); - export function AppLayout() { // Contexts const { authToken, requiresAuth, isCheckingAuth, capabilities, login, logout } = useAuthContext(); const { links = [], addLink, updateLink, deleteLink, deleteLinks, setLinksAndSync } = useLinksContext(); const { categories = [], categoryTree = [], setCategoriesAndSync, unlockedCategoryIds = new Set(), unlockCategory } = useCategoriesContext(); const { ai: aiConfig, icon: iconConfig, viewMode, showPinnedWebsites, ticker, weather, website, webdav, search, setAI, setIcon, setWebsite, setShowPinned, setMastodon, setWeather, setWebDav, setSearch, setViewMode } = useConfigContext(); - // Hooks const { searchQuery, setSearchQuery, searchResults, isMobileSearchOpen, setIsMobileSearchOpen, @@ -36,16 +33,13 @@ export function AppLayout() { isInternal, setIsInternal, handleSearch, visitorEngineId, setVisitorEngineId } = useSearch(); const { initData } = useDataSync(); - // UI State const [sidebarOpen, setSidebarOpen] = useState(false); const [isInitialLoading, setIsInitialLoading] = useState(true); const [activeCategoryId, setActiveCategoryId] = useState(null); - // Toggle States const [isDragSortMode, setIsDragSortMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false); - // Modal States const [isModalOpen, setIsModalOpen] = useState(false); const [isAuthOpen, setIsAuthOpen] = useState(false); @@ -55,37 +49,31 @@ export function AppLayout() { const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [isSearchConfigModalOpen, setIsSearchConfigModalOpen] = useState(false); const [catAuthModalData, setCatAuthModalData] = useState(null); - // Edit State const [editingLink, setEditingLink] = useState(undefined); const [prefillLink, setPrefillLink] = useState | undefined>(undefined); - // Batch Edit State const [isBatchEditMode, setIsBatchEditMode] = useState(false); const [selectedLinks, setSelectedLinks] = useState>(new Set()); - // Context Menu State const [contextMenu, setContextMenu] = useState<{ isOpen: boolean; position: { x: number; y: number }; link: LinkItem | null; }>({ isOpen: false, position: { x: 0, y: 0 }, link: null }); - // QR Code Modal State const [qrCodeModal, setQrCodeModal] = useState<{ isOpen: boolean; url: string; title: string; }>({ isOpen: false, url: '', title: '' }); - // Drag sort confirmation state const [pendingDragLinks, setPendingDragLinks] = useState<{ links: LinkItem[]; categories: Category[] } | null>(null); - // Initialize data useEffect(() => { const init = async () => { - await initData(); + // ========== 修改2:initData 传入参数 ========== + await initData(unlockedCategoryIds); setIsInitialLoading(false); }; - // Global keyboard listener for search focus const handleGlobalKeyDown = (e: KeyboardEvent) => { // Ignore if user is already typing in an input/textarea @@ -95,7 +83,6 @@ export function AppLayout() { (activeElement as HTMLElement)?.isContentEditable; if (isInput) return; - // Global Escape handler to close and clear search if (e.key === 'Escape') { if (isSearchExpanded) { @@ -105,30 +92,25 @@ export function AppLayout() { } return; } - // Ignore modifier keys and special keys if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.key.length !== 1 && e.key !== 'Process') return; // 'Process' is for IME - // Don't trigger if any modal is open or in edit modes if (isModalOpen || isAuthOpen || isCatManagerOpen || isBackupModalOpen || isImportModalOpen || isSettingsModalOpen || isSearchConfigModalOpen || isEditMode || isBatchEditMode || isDragSortMode) { return; } - // Open search if collapsed if (!isSearchExpanded && !isMobileSearchOpen) { setIsSearchExpanded(true); } - // If it's a normal English character, the browser's keypress event is usually swallowed // because the focus is moving from the body to the input. We must manually capture it. if (e.key !== 'Process') { setSearchQuery(prev => prev + e.key); e.preventDefault(); // Prevent double insertion just in case } - // Delay micro-seconds to focus, allowing the character to naturally fall into the input box to wake up the IME setTimeout(() => { const searchInput = document.getElementById('search-input'); @@ -137,12 +119,11 @@ export function AppLayout() { } }, 0); }; - window.addEventListener('keydown', handleGlobalKeyDown); init(); return () => window.removeEventListener('keydown', handleGlobalKeyDown); - }, [initData]); - + // ========== 修改3:依赖数组新增 unlockedCategoryIds ========== + }, [initData, unlockedCategoryIds]); // Apply dynamic website title and favicon useEffect(() => { if (aiConfig) { @@ -157,14 +138,12 @@ export function AppLayout() { link.href = aiConfig.faviconUrl || '/favicon.ico'; } }, [aiConfig?.websiteTitle, aiConfig?.faviconUrl]); - // Close auth modal on login useEffect(() => { if (authToken) { setIsAuthOpen(false); } }, [authToken]); - // Handle URL params for bookmarklet useEffect(() => { const urlParams = new URLSearchParams(window.location.search); @@ -177,20 +156,17 @@ export function AppLayout() { setIsModalOpen(true); } }, []); - // --- Handlers --- const handleAddLink = useCallback(() => { setEditingLink(undefined); setPrefillLink(undefined); setIsModalOpen(true); }, []); - const handleEditLink = useCallback((link: LinkItem) => { setEditingLink(link); setPrefillLink(undefined); setIsModalOpen(true); }, []); - const handleDeleteLink = useCallback((id: string) => { if (confirm('确定删除此链接吗?')) { const linkToDelete = links.find(l => l.id === id); @@ -248,7 +224,6 @@ export function AppLayout() { setLinksAndSync(links.filter(l => l.id !== id), categories); } }, [deleteLink, links, categories, setLinksAndSync, authToken]); - const handleSaveLink = useCallback((data: Omit) => { if (editingLink) { const updated = links.map(l => l.id === editingLink.id ? { ...l, ...data } : l); @@ -265,7 +240,6 @@ export function AppLayout() { setEditingLink(undefined); setPrefillLink(undefined); }, [editingLink, links, categories, setLinksAndSync, authToken]); - // Context menu handlers const handleContextMenu = useCallback((e: React.MouseEvent, link: LinkItem) => { if (isBatchEditMode || !authToken) return; @@ -273,11 +247,9 @@ export function AppLayout() { e.stopPropagation(); setContextMenu({ isOpen: true, position: { x: e.clientX, y: e.clientY }, link }); }, [isBatchEditMode, authToken]); - const closeContextMenu = useCallback(() => { setContextMenu({ isOpen: false, position: { x: 0, y: 0 }, link: null }); }, []); - const deleteLinkFromContextMenu = useCallback(() => { if (!contextMenu.link) return; if (confirm(`确定要删除"${contextMenu.link.title}"吗?`)) { @@ -285,13 +257,11 @@ export function AppLayout() { } closeContextMenu(); }, [contextMenu.link, handleDeleteLink, closeContextMenu]); - const editLinkFromContextMenu = useCallback(() => { if (!contextMenu.link) return; handleEditLink(contextMenu.link); closeContextMenu(); }, [contextMenu.link, handleEditLink, closeContextMenu]); - const togglePinFromContextMenu = useCallback(() => { if (!contextMenu.link) return; const updated = links.map(l => { @@ -304,13 +274,11 @@ export function AppLayout() { setLinksAndSync(updated, categories); closeContextMenu(); }, [contextMenu.link, links, categories, setLinksAndSync, closeContextMenu]); - // Batch edit handlers const toggleBatchEditMode = useCallback(() => { setIsBatchEditMode(prev => !prev); setSelectedLinks(new Set()); }, []); - const toggleLinkSelection = useCallback((linkId: string) => { setSelectedLinks(prev => { const next = new Set(prev); @@ -319,7 +287,6 @@ export function AppLayout() { return next; }); }, []); - const handleBatchDelete = useCallback(() => { if (selectedLinks.size === 0) return; if (confirm(`确定要删除选中的 ${selectedLinks.size} 个链接吗?`)) { @@ -382,22 +349,27 @@ export function AppLayout() { setIsBatchEditMode(false); } }, [selectedLinks, links, categories, setLinksAndSync, authToken]); - // Weight change handler const handleWeightChange = useCallback((linkId: string, weight: number) => { const updated = links.map(l => l.id === linkId ? { ...l, weight } : l); setLinksAndSync(updated, categories); }, [links, categories, setLinksAndSync]); - // Toggle handlers const toggleDragSortMode = useCallback(() => { setIsDragSortMode(prev => !prev); }, []); - const toggleEditMode = useCallback(() => { setIsEditMode(prev => !prev); }, []); - + // ========== 修改4:新增解锁分类回调函数 ========== + const handleUnlockCategory = useCallback((cat: Category) => { + if (cat.hasPassword && !unlockedCategoryIds.has(cat.id)) { + setCatAuthModalData(cat); + } else { + // 解锁后刷新数据 + initData(unlockedCategoryIds); + } + }, [unlockedCategoryIds, initData]); // Loading state if (isInitialLoading) { return ( @@ -431,7 +403,6 @@ export function AppLayout() {
); } - return (
{/* Sidebar */} @@ -441,12 +412,9 @@ export function AppLayout() { activeCategoryId={activeCategoryId} onOpenCatManager={() => setIsCatManagerOpen(true)} onOpenBackup={() => setIsBackupModalOpen(true)} - onUnlockCategory={(cat) => { - setCatAuthModalData(cat); - setSidebarOpen(false); - }} + {/* ========== 修改5:替换原解锁回调为新函数 ========== */} + onUnlockCategory={handleUnlockCategory} /> - {/* Main area */}
-
- {/* Auth Modal */} setIsAuthOpen(false)} /> - {/* Other Modals */} {isModalOpen && ( @@ -515,7 +480,6 @@ export function AppLayout() { supportsUpload={capabilities?.upload ?? true} /> )} - {isCatManagerOpen && ( )} - {isBackupModalOpen && ( )} - {isImportModalOpen && ( setLinksAndSync(newLinks, newCats)} /> )} - {isSettingsModalOpen && ( )} - {isSearchConfigModalOpen && ( setIsSearchConfigModalOpen(false)} /> )} - {catAuthModalData && ( { unlockCategory(id); setCatAuthModalData(null); }} /> )} - {contextMenu.isOpen && ( )} - {qrCodeModal.isOpen && ( Date: Tue, 14 Jul 2026 21:47:04 +0800 Subject: [PATCH 007/149] Update AppLayout.tsx --- src/components/layout/AppLayout.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index f4929a59..6f703a9a 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -412,7 +412,6 @@ export function AppLayout() { activeCategoryId={activeCategoryId} onOpenCatManager={() => setIsCatManagerOpen(true)} onOpenBackup={() => setIsBackupModalOpen(true)} - {/* ========== 修改5:替换原解锁回调为新函数 ========== */} onUnlockCategory={handleUnlockCategory} /> {/* Main area */} From c0df14f5621553c46831c3d8a7b2bd8355615f80 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:10:15 +0800 Subject: [PATCH 008/149] Update AppLayout.tsx --- src/components/layout/AppLayout.tsx | 97 ++++++++++++++++++----------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 6f703a9a..e3886a31 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -11,6 +11,7 @@ import { MainContent } from './MainContent'; import { ContentSkeleton } from './ContentSkeleton'; import { LinkItem, Category } from '../../../types'; import AuthModal from '../../../components/AuthModal'; + const LinkModal = lazy(() => import('../../../components/LinkModal')); const CategoryManagerModal = lazy(() => import('../../../components/CategoryManagerModal')); const BackupModal = lazy(() => import('../../../components/BackupModal')); @@ -20,12 +21,14 @@ const SettingsModal = lazy(() => import('../../../components/SettingsModal')); const SearchConfigModal = lazy(() => import('../../../components/SearchConfigModal')); const ContextMenu = lazy(() => import('../../../components/ContextMenu')); const QRCodeModal = lazy(() => import('../../../components/QRCodeModal')); + export function AppLayout() { - // Contexts + // Contexts - 只解构一次 const { authToken, requiresAuth, isCheckingAuth, capabilities, login, logout } = useAuthContext(); const { links = [], addLink, updateLink, deleteLink, deleteLinks, setLinksAndSync } = useLinksContext(); - const { categories = [], categoryTree = [], setCategoriesAndSync, unlockedCategoryIds = new Set(), unlockCategory } = useCategoriesContext(); + const { categories = [], categoryTree = [], setCategoriesAndSync, unlockedCategoryIds, unlockCategory } = useCategoriesContext(); const { ai: aiConfig, icon: iconConfig, viewMode, showPinnedWebsites, ticker, weather, website, webdav, search, setAI, setIcon, setWebsite, setShowPinned, setMastodon, setWeather, setWebDav, setSearch, setViewMode } = useConfigContext(); + // Hooks const { searchQuery, setSearchQuery, searchResults, isMobileSearchOpen, setIsMobileSearchOpen, @@ -33,13 +36,16 @@ export function AppLayout() { isInternal, setIsInternal, handleSearch, visitorEngineId, setVisitorEngineId } = useSearch(); const { initData } = useDataSync(); + // UI State const [sidebarOpen, setSidebarOpen] = useState(false); const [isInitialLoading, setIsInitialLoading] = useState(true); const [activeCategoryId, setActiveCategoryId] = useState(null); + // Toggle States const [isDragSortMode, setIsDragSortMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false); + // Modal States const [isModalOpen, setIsModalOpen] = useState(false); const [isAuthOpen, setIsAuthOpen] = useState(false); @@ -49,41 +55,46 @@ export function AppLayout() { const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [isSearchConfigModalOpen, setIsSearchConfigModalOpen] = useState(false); const [catAuthModalData, setCatAuthModalData] = useState(null); + // Edit State const [editingLink, setEditingLink] = useState(undefined); const [prefillLink, setPrefillLink] = useState | undefined>(undefined); + // Batch Edit State const [isBatchEditMode, setIsBatchEditMode] = useState(false); const [selectedLinks, setSelectedLinks] = useState>(new Set()); + // Context Menu State const [contextMenu, setContextMenu] = useState<{ isOpen: boolean; position: { x: number; y: number }; link: LinkItem | null; }>({ isOpen: false, position: { x: 0, y: 0 }, link: null }); + // QR Code Modal State const [qrCodeModal, setQrCodeModal] = useState<{ isOpen: boolean; url: string; title: string; }>({ isOpen: false, url: '', title: '' }); + // Drag sort confirmation state const [pendingDragLinks, setPendingDragLinks] = useState<{ links: LinkItem[]; categories: Category[] } | null>(null); + // Initialize data useEffect(() => { const init = async () => { - // ========== 修改2:initData 传入参数 ========== await initData(unlockedCategoryIds); setIsInitialLoading(false); }; + // Global keyboard listener for search focus const handleGlobalKeyDown = (e: KeyboardEvent) => { - // Ignore if user is already typing in an input/textarea const activeElement = document.activeElement; const isInput = activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA' || (activeElement as HTMLElement)?.isContentEditable; - + if (isInput) return; - // Global Escape handler to close and clear search + if (e.key === 'Escape') { if (isSearchExpanded) { setIsSearchExpanded(false); @@ -92,26 +103,25 @@ export function AppLayout() { } return; } - // Ignore modifier keys and special keys + if (e.ctrlKey || e.metaKey || e.altKey) return; - if (e.key.length !== 1 && e.key !== 'Process') return; // 'Process' is for IME - // Don't trigger if any modal is open or in edit modes + if (e.key.length !== 1 && e.key !== 'Process') return; + if (isModalOpen || isAuthOpen || isCatManagerOpen || isBackupModalOpen || isImportModalOpen || isSettingsModalOpen || isSearchConfigModalOpen || isEditMode || isBatchEditMode || isDragSortMode) { return; } - // Open search if collapsed + if (!isSearchExpanded && !isMobileSearchOpen) { setIsSearchExpanded(true); } - // If it's a normal English character, the browser's keypress event is usually swallowed - // because the focus is moving from the body to the input. We must manually capture it. + if (e.key !== 'Process') { setSearchQuery(prev => prev + e.key); - e.preventDefault(); // Prevent double insertion just in case + e.preventDefault(); } - // Delay micro-seconds to focus, allowing the character to naturally fall into the input box to wake up the IME + setTimeout(() => { const searchInput = document.getElementById('search-input'); if (searchInput) { @@ -119,16 +129,17 @@ export function AppLayout() { } }, 0); }; + window.addEventListener('keydown', handleGlobalKeyDown); init(); return () => window.removeEventListener('keydown', handleGlobalKeyDown); - // ========== 修改3:依赖数组新增 unlockedCategoryIds ========== }, [initData, unlockedCategoryIds]); + // Apply dynamic website title and favicon useEffect(() => { if (aiConfig) { document.title = aiConfig.websiteTitle || '蜗牛个人导航'; - + let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement; if (!link) { link = document.createElement('link'); @@ -138,12 +149,14 @@ export function AppLayout() { link.href = aiConfig.faviconUrl || '/favicon.ico'; } }, [aiConfig?.websiteTitle, aiConfig?.faviconUrl]); + // Close auth modal on login useEffect(() => { if (authToken) { setIsAuthOpen(false); } }, [authToken]); + // Handle URL params for bookmarklet useEffect(() => { const urlParams = new URLSearchParams(window.location.search); @@ -156,22 +169,24 @@ export function AppLayout() { setIsModalOpen(true); } }, []); + // --- Handlers --- const handleAddLink = useCallback(() => { setEditingLink(undefined); setPrefillLink(undefined); setIsModalOpen(true); }, []); + const handleEditLink = useCallback((link: LinkItem) => { setEditingLink(link); setPrefillLink(undefined); setIsModalOpen(true); }, []); + const handleDeleteLink = useCallback((id: string) => { if (confirm('确定删除此链接吗?')) { const linkToDelete = links.find(l => l.id === id); if (linkToDelete) { - // 1. 清理 EdgeOne Blob 历史图标 if (linkToDelete.edgeoneBlobUrl && linkToDelete.edgeoneBlobUrl.startsWith('/api/favicon?key=')) { try { const url = new URL(linkToDelete.edgeoneBlobUrl, window.location.origin); @@ -186,7 +201,6 @@ export function AppLayout() { console.error(e); } } - // 2. 清理 Cloudflare R2 历史图标 if (linkToDelete.cloudflareR2Url && linkToDelete.cloudflareR2Url.startsWith('/api/favicon?key=')) { try { const url = new URL(linkToDelete.cloudflareR2Url, window.location.origin); @@ -201,7 +215,6 @@ export function AppLayout() { console.error(e); } } - // 3. 兼容旧版本数据或当前选中的图标(如果没有被前面的历史记录覆盖) if (linkToDelete.icon && linkToDelete.icon.startsWith('/api/favicon?key=') && linkToDelete.icon !== linkToDelete.edgeoneBlobUrl && linkToDelete.icon !== linkToDelete.cloudflareR2Url) { @@ -224,6 +237,7 @@ export function AppLayout() { setLinksAndSync(links.filter(l => l.id !== id), categories); } }, [deleteLink, links, categories, setLinksAndSync, authToken]); + const handleSaveLink = useCallback((data: Omit) => { if (editingLink) { const updated = links.map(l => l.id === editingLink.id ? { ...l, ...data } : l); @@ -240,16 +254,18 @@ export function AppLayout() { setEditingLink(undefined); setPrefillLink(undefined); }, [editingLink, links, categories, setLinksAndSync, authToken]); - // Context menu handlers + const handleContextMenu = useCallback((e: React.MouseEvent, link: LinkItem) => { if (isBatchEditMode || !authToken) return; e.preventDefault(); e.stopPropagation(); setContextMenu({ isOpen: true, position: { x: e.clientX, y: e.clientY }, link }); }, [isBatchEditMode, authToken]); + const closeContextMenu = useCallback(() => { setContextMenu({ isOpen: false, position: { x: 0, y: 0 }, link: null }); }, []); + const deleteLinkFromContextMenu = useCallback(() => { if (!contextMenu.link) return; if (confirm(`确定要删除"${contextMenu.link.title}"吗?`)) { @@ -257,11 +273,13 @@ export function AppLayout() { } closeContextMenu(); }, [contextMenu.link, handleDeleteLink, closeContextMenu]); + const editLinkFromContextMenu = useCallback(() => { if (!contextMenu.link) return; handleEditLink(contextMenu.link); closeContextMenu(); }, [contextMenu.link, handleEditLink, closeContextMenu]); + const togglePinFromContextMenu = useCallback(() => { if (!contextMenu.link) return; const updated = links.map(l => { @@ -274,11 +292,12 @@ export function AppLayout() { setLinksAndSync(updated, categories); closeContextMenu(); }, [contextMenu.link, links, categories, setLinksAndSync, closeContextMenu]); - // Batch edit handlers + const toggleBatchEditMode = useCallback(() => { setIsBatchEditMode(prev => !prev); setSelectedLinks(new Set()); }, []); + const toggleLinkSelection = useCallback((linkId: string) => { setSelectedLinks(prev => { const next = new Set(prev); @@ -287,13 +306,12 @@ export function AppLayout() { return next; }); }, []); + const handleBatchDelete = useCallback(() => { if (selectedLinks.size === 0) return; if (confirm(`确定要删除选中的 ${selectedLinks.size} 个链接吗?`)) { - // 批量删除关联的自定义及历史图标 links.forEach(l => { if (selectedLinks.has(l.id)) { - // 1. 清理 EdgeOne Blob 历史图标 if (l.edgeoneBlobUrl && l.edgeoneBlobUrl.startsWith('/api/favicon?key=')) { try { const url = new URL(l.edgeoneBlobUrl, window.location.origin); @@ -308,7 +326,6 @@ export function AppLayout() { console.error(e); } } - // 2. 清理 Cloudflare R2 历史图标 if (l.cloudflareR2Url && l.cloudflareR2Url.startsWith('/api/favicon?key=')) { try { const url = new URL(l.cloudflareR2Url, window.location.origin); @@ -323,7 +340,6 @@ export function AppLayout() { console.error(e); } } - // 3. 兼容旧版本数据或当前图标 if (l.icon && l.icon.startsWith('/api/favicon?key=') && l.icon !== l.edgeoneBlobUrl && l.icon !== l.cloudflareR2Url) { @@ -349,27 +365,39 @@ export function AppLayout() { setIsBatchEditMode(false); } }, [selectedLinks, links, categories, setLinksAndSync, authToken]); - // Weight change handler + const handleWeightChange = useCallback((linkId: string, weight: number) => { const updated = links.map(l => l.id === linkId ? { ...l, weight } : l); setLinksAndSync(updated, categories); }, [links, categories, setLinksAndSync]); - // Toggle handlers + const toggleDragSortMode = useCallback(() => { setIsDragSortMode(prev => !prev); }, []); + const toggleEditMode = useCallback(() => { setIsEditMode(prev => !prev); }, []); - // ========== 修改4:新增解锁分类回调函数 ========== + + // 处理分类点击:如果分类有密码且未解锁,显示密码弹窗 const handleUnlockCategory = useCallback((cat: Category) => { if (cat.hasPassword && !unlockedCategoryIds.has(cat.id)) { setCatAuthModalData(cat); } else { - // 解锁后刷新数据 - initData(unlockedCategoryIds); + // 已解锁的分类,正常跳转 + document.getElementById(`cat-${cat.id}`)?.scrollIntoView({ behavior: 'smooth' }); } - }, [unlockedCategoryIds, initData]); + }, [unlockedCategoryIds]); + + // 处理密码验证成功后的解锁 + const handleCategoryUnlock = useCallback((id: string) => { + unlockCategory(id); + setCatAuthModalData(null); + // 解锁后重新加载数据,带上新的解锁分类 + const newUnlocked = new Set([...unlockedCategoryIds, id]); + initData(newUnlocked); + }, [unlockCategory, unlockedCategoryIds, initData]); + // Loading state if (isInitialLoading) { return ( @@ -403,9 +431,9 @@ export function AppLayout() {
); } + return (
- {/* Sidebar */} setSidebarOpen(false)} @@ -414,7 +442,6 @@ export function AppLayout() { onOpenBackup={() => setIsBackupModalOpen(true)} onUnlockCategory={handleUnlockCategory} /> - {/* Main area */}
- {/* Auth Modal */} setIsAuthOpen(false)} /> - {/* Other Modals */} {isModalOpen && ( setCatAuthModalData(null)} - onUnlock={(id) => { unlockCategory(id); setCatAuthModalData(null); }} + onUnlock={handleCategoryUnlock} /> )} {contextMenu.isOpen && ( From 9128962cc4829fbf2d233273d68083c0a60d311b Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:11:44 +0800 Subject: [PATCH 009/149] Update CategoryActionAuthModal.tsx --- components/CategoryActionAuthModal.tsx | 134 +++++++++++-------------- 1 file changed, 56 insertions(+), 78 deletions(-) diff --git a/components/CategoryActionAuthModal.tsx b/components/CategoryActionAuthModal.tsx index 94c03e68..5b7fc9d4 100644 --- a/components/CategoryActionAuthModal.tsx +++ b/components/CategoryActionAuthModal.tsx @@ -1,86 +1,73 @@ import React, { useState } from 'react'; -import { X, Lock, AlertCircle } from 'lucide-react'; +import { Lock, ArrowRight, Loader2, X } from 'lucide-react'; +import { Category } from '../types'; +import { API_ENDPOINTS } from '../src/constants'; -interface CategoryActionAuthModalProps { +interface CategoryAuthModalProps { isOpen: boolean; onClose: () => void; - onVerify: (password: string) => Promise; - onVerified: () => void; - actionType: 'edit' | 'delete'; - categoryName: string; + category: Category | null; + onUnlock: (categoryId: string) => void; } -const CategoryActionAuthModal: React.FC = ({ - isOpen, - onClose, - onVerify, - onVerified, - actionType, - categoryName -}) => { +const CategoryAuthModal: React.FC = ({ isOpen, onClose, category, onUnlock }) => { const [password, setPassword] = useState(''); - const [isVerifying, setIsVerifying] = useState(false); const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); - if (!isOpen) return null; + if (!isOpen || !category) return null; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!password.trim()) { - setError('请输入密码'); - return; - } + if (!password.trim()) return; - setIsVerifying(true); + setIsLoading(true); setError(''); - + try { - const success = await onVerify(password); - if (success) { - setPassword(''); - onVerified(); - } else { - setError('密码错误,请重试'); + // 调用后端验证分类密码 + const res = await fetch(`${API_ENDPOINTS.STORAGE}?getConfig=links&category=${encodeURIComponent(category.id)}&catPassword=${encodeURIComponent(password.trim())}`); + + if (res.status === 403) { + setError('密码错误'); + setIsLoading(false); + return; } + + if (!res.ok) { + setError('验证失败,请重试'); + setIsLoading(false); + return; + } + + // 密码正确,解锁分类 + onUnlock(category.id); + setPassword(''); + setError(''); + setIsLoading(false); + onClose(); } catch (err) { - setError('验证失败,请重试'); - } finally { - setIsVerifying(false); + console.error('Category auth error:', err); + setError('网络错误,请重试'); + setIsLoading(false); } }; - const handleClose = () => { - setPassword(''); - setError(''); - onClose(); - }; - - const actionText = actionType === 'edit' ? '编辑' : '删除'; - const colorClass = actionType === 'edit' - ? 'amber' - : 'red'; - return (
-
-
+
-

验证操作权限

+

解锁 "{category.name}"

- 您正在{actionText}分类 "{categoryName}" + 该目录受密码保护,请输入密码访问

-
- -

- 请输入部署时设置的密码进行验证 -

-
@@ -89,41 +76,32 @@ const CategoryActionAuthModal: React.FC = ({ type="password" value={password} onChange={(e) => setPassword(e.target.value)} - className={`w-full p-3 rounded-xl border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-${colorClass}-500 outline-none transition-all text-center tracking-widest`} - placeholder="请输入密码" + className="w-full p-3 rounded-xl border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-amber-500 outline-none transition-all text-center tracking-widest" + placeholder="目录密码" autoFocus - disabled={isVerifying} + disabled={isLoading} />
- + {error && ( -
- -

{error}

+
+ {error}
)} - -
- - -
+ +
); }; -export default CategoryActionAuthModal; \ No newline at end of file +export default CategoryAuthModal; From 16336a05d15803275eabebc3a107d169bb76cc2d Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:12:11 +0800 Subject: [PATCH 010/149] Update storage.js --- functions/api/storage.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/functions/api/storage.js b/functions/api/storage.js index 744e36ed..07e2047a 100644 --- a/functions/api/storage.js +++ b/functions/api/storage.js @@ -44,6 +44,7 @@ async function mergeAllConfigSections(kv) { return configStr ? JSON.parse(configStr) : {}; } +// 生成分类链接 key function categoryLinksKey(categoryId) { return `links:${categoryId}`; } @@ -68,6 +69,7 @@ async function readAllCategoryLinks(kv, categories, unlockedCategories = new Set return linkArrays.flat(); } +// 保存链接到对应的分类 key async function saveCategoryLinks(kv, links) { const grouped = {}; for (const link of links) { @@ -95,13 +97,16 @@ export async function onRequest(context) { try { const kv = getKV(env); + // ==================== GET ==================== if (request.method === 'GET') { const checkAuth = url.searchParams.get('checkAuth'); const getConfig = url.searchParams.get('getConfig'); const key = url.searchParams.get('key'); const readOnly = url.searchParams.get('readOnly'); const category = url.searchParams.get('category'); + const categoryPassword = url.searchParams.get('catPassword'); + // 检查认证需求 if (checkAuth === 'true') { return jsonResponse({ hasPassword: !!env.PASSWORD, @@ -111,6 +116,7 @@ export async function onRequest(context) { }, 200, corsHeaders); } + // 获取子配置 if (CONFIG_SECTIONS.includes(getConfig)) { const sectionVal = await readConfigSection(kv, getConfig); const defaults = { @@ -119,6 +125,7 @@ export async function onRequest(context) { return jsonResponse(sectionVal || defaults[getConfig] || {}, 200, corsHeaders); } + // 获取 Favicon 缓存 if (getConfig === 'favicon') { const domain = url.searchParams.get('domain'); if (!domain) { @@ -128,7 +135,7 @@ export async function onRequest(context) { return jsonResponse({ icon: cachedIcon || null, cached: !!cachedIcon }, 200, corsHeaders); } - // 获取分类:保留 hasPassword 标记,不返回实际密码 + // 获取分类(密码脱敏,但保留 hasPassword 标记) if (getConfig === 'categories') { const data = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const categories = data ? JSON.parse(data) : []; @@ -164,7 +171,18 @@ export async function onRequest(context) { if (category) { const cat = categories.find(c => c.id === category); const hasPassword = cat && cat.password && cat.password.trim() !== ''; - const isUnlocked = unlockedCategories.has(category); + + // 验证分类密码 + let isUnlocked = unlockedCategories.has(category); + + // 如果提供了分类密码,验证它 + if (categoryPassword && hasPassword && !isUnlocked && !isAdmin) { + if (categoryPassword === cat.password) { + isUnlocked = true; + } else { + return jsonResponse({ error: '密码错误' }, 403, corsHeaders); + } + } if (hasPassword && !isUnlocked && !isAdmin) { return jsonResponse({ error: '该分类需要密码访问' }, 403, corsHeaders); @@ -176,6 +194,7 @@ export async function onRequest(context) { }); } + // 读取所有分类链接(过滤掉受保护且未解锁的) const links = await readAllCategoryLinks(kv, categories, unlockedCategories, isAdmin); return jsonResponse(links, 200, corsHeaders); } @@ -210,6 +229,7 @@ export async function onRequest(context) { return jsonResponse({ links: [], categories: [] }, 200, corsHeaders); } + // ==================== POST ==================== if (request.method === 'POST') { const body = await request.json(); const readOnlyOperations = ['favicon']; From 357f9bdd45e77772b964a68737dbd949e50ab2fc Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:12:40 +0800 Subject: [PATCH 011/149] Update useDataSync.ts --- src/hooks/useDataSync.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/hooks/useDataSync.ts b/src/hooks/useDataSync.ts index a30c91d2..dd71bd19 100644 --- a/src/hooks/useDataSync.ts +++ b/src/hooks/useDataSync.ts @@ -5,12 +5,16 @@ import { useLinksContext } from '../contexts/LinksContext'; import { useCategoriesContext } from '../contexts/CategoriesContext'; import { useConfigContext } from '../contexts/ConfigContext'; +/** + * 数据同步 Hook:管理 localStorage ↔ KV 的加载和同步 + */ export function useDataSync() { const { links = [], initLinks, setLinksAndSync } = useLinksContext(); const { categories = [], initCategories, unlockedCategoryIds } = useCategoriesContext(); const { initConfig } = useConfigContext(); const initialized = useRef(false); + // 从 localStorage 加载 const loadFromLocal = useCallback((): { links: LinkItem[]; categories: Category[] } => { try { const stored = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_KEY); @@ -18,6 +22,7 @@ export function useDataSync() { const parsed = JSON.parse(stored); let cats: Category[] = parsed.categories || DEFAULT_CATEGORIES; + // 确保 common 分类存在且排第一 if (!cats.some((c: Category) => c.id === 'common')) { cats = [{ id: 'common', name: '常用推荐', icon: 'Star' }, ...cats]; } else { @@ -28,6 +33,7 @@ export function useDataSync() { } } + // 修复无效 categoryId const validIds = new Set(cats.map((c: Category) => c.id)); let lnks: LinkItem[] = (parsed.links || INITIAL_LINKS).map((l: LinkItem) => validIds.has(l.categoryId) ? l : { ...l, categoryId: 'common' } @@ -41,6 +47,7 @@ export function useDataSync() { return { links: INITIAL_LINKS, categories: DEFAULT_CATEGORIES }; }, []); + // 从 KV 加载链接和分类(带密码过滤) const loadFromCloud = useCallback(async (unlockedCats?: Set): Promise<{ links: LinkItem[]; categories: Category[] } | null> => { try { const unlockedArray = unlockedCats ? Array.from(unlockedCats) : []; @@ -57,6 +64,7 @@ export function useDataSync() { } }, []); + // 从 KV 加载各个配置 const loadConfigsFromCloud = useCallback(async () => { const configKeys = ['search', 'website', 'ai', 'weather', 'mastodon', 'icon']; const configMap: Record = {}; @@ -81,14 +89,17 @@ export function useDataSync() { } }, [initConfig]); + // 初始化数据 const initData = useCallback(async (unlockedCats?: Set) => { if (initialized.current) return; initialized.current = true; + // 1. 先从本地加载(快速展示) const local = loadFromLocal(); initLinks(local.links); initCategories(local.categories); + // 2. 并行从云端获取最新数据(带密码过滤) const [cloud] = await Promise.all([ loadFromCloud(unlockedCats), loadConfigsFromCloud(), @@ -108,6 +119,7 @@ export function useDataSync() { } }, [loadFromLocal, loadFromCloud, loadConfigsFromCloud, initLinks, initCategories]); + // 同步到云端 const syncToCloud = useCallback(async () => { if (!links.length && !categories.length) return; setLinksAndSync(links, categories); From b954f551e803d17d106031d270bce4b8473db0dc Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:13:13 +0800 Subject: [PATCH 012/149] Update CategoriesContext.tsx --- src/contexts/CategoriesContext.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/contexts/CategoriesContext.tsx b/src/contexts/CategoriesContext.tsx index 1020e928..ece8a9b4 100644 --- a/src/contexts/CategoriesContext.tsx +++ b/src/contexts/CategoriesContext.tsx @@ -3,6 +3,7 @@ import { Category, LinkItem, DEFAULT_CATEGORIES } from '../../types'; import { STORAGE_KEYS, API_ENDPOINTS } from '../constants'; import { useAuthContext } from './AuthContext'; +// --- Types --- interface CategoriesState { categories: Category[]; unlockedCategoryIds: Set; @@ -34,6 +35,7 @@ export interface CategoryWithChildren extends Category { children: Category[]; } +// --- Reducer --- function categoriesReducer(state: CategoriesState, action: CategoriesAction): CategoriesState { switch (action.type) { case 'SET_CATEGORIES': @@ -59,8 +61,10 @@ function categoriesReducer(state: CategoriesState, action: CategoriesAction): Ca } } +// --- Context --- const CategoriesContext = createContext(null); +// --- Helper --- function buildCategoryTree(cats: Category[]): CategoryWithChildren[] { const topLevels = cats .filter(c => !c.parentId) @@ -74,6 +78,7 @@ function buildCategoryTree(cats: Category[]): CategoryWithChildren[] { })); } +// --- Provider --- export function CategoriesProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = useReducer(categoriesReducer, { categories: [], @@ -143,6 +148,7 @@ export function CategoriesProvider({ children }: { children: React.ReactNode }) ); } +// --- Hook --- export function useCategoriesContext() { const ctx = useContext(CategoriesContext); if (!ctx) throw new Error('useCategoriesContext must be used within CategoriesProvider'); From a31597b791b29766a52c19d56576d674bedc6b66 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:13:46 +0800 Subject: [PATCH 013/149] Update MainContent.tsx --- src/components/layout/MainContent.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 23fffdef..7e5d7a6a 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -40,6 +40,7 @@ export function MainContent({ return getLinksByCategory ? getLinksByCategory(categoryId) : []; }, [getLinksByCategory]); + // Intersection Observer for active category highlighting useEffect(() => { const observer = new IntersectionObserver( (entries) => { @@ -63,6 +64,7 @@ export function MainContent({ ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10'; + // Search mode: Only show results if internal search is checked if (searchQuery.trim() && isInternal) { return (
@@ -102,6 +104,7 @@ export function MainContent({ return (
+ {/* Pinned section */} {showPinnedWebsites && pinnedLinks.length > 0 && (
)} - {/* 过滤掉未解锁的密码保护分类 */} + {/* All categories - 过滤掉未解锁的密码保护分类 */} {categoryTree.map(cat => { const isLocked = cat.hasPassword && !unlockedCategoryIds.has(cat.id); if (isLocked) return null; From e0046a71da8fe3b217cf5f3fd38b4957b505e29b Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:14:14 +0800 Subject: [PATCH 014/149] Update Sidebar.tsx From fdc38e69c2dd0f12fa6b0ebf085ca670e0239b10 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:43:36 +0800 Subject: [PATCH 015/149] Update storage.js --- functions/api/storage.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/functions/api/storage.js b/functions/api/storage.js index 07e2047a..4b170bf3 100644 --- a/functions/api/storage.js +++ b/functions/api/storage.js @@ -44,12 +44,10 @@ async function mergeAllConfigSections(kv) { return configStr ? JSON.parse(configStr) : {}; } -// 生成分类链接 key function categoryLinksKey(categoryId) { return `links:${categoryId}`; } -// 读取所有分类链接(带密码过滤) async function readAllCategoryLinks(kv, categories, unlockedCategories = new Set(), isAdmin = false) { if (categories.length === 0) return []; @@ -69,7 +67,6 @@ async function readAllCategoryLinks(kv, categories, unlockedCategories = new Set return linkArrays.flat(); } -// 保存链接到对应的分类 key async function saveCategoryLinks(kv, links) { const grouped = {}; for (const link of links) { @@ -97,7 +94,6 @@ export async function onRequest(context) { try { const kv = getKV(env); - // ==================== GET ==================== if (request.method === 'GET') { const checkAuth = url.searchParams.get('checkAuth'); const getConfig = url.searchParams.get('getConfig'); @@ -106,7 +102,6 @@ export async function onRequest(context) { const category = url.searchParams.get('category'); const categoryPassword = url.searchParams.get('catPassword'); - // 检查认证需求 if (checkAuth === 'true') { return jsonResponse({ hasPassword: !!env.PASSWORD, @@ -116,7 +111,6 @@ export async function onRequest(context) { }, 200, corsHeaders); } - // 获取子配置 if (CONFIG_SECTIONS.includes(getConfig)) { const sectionVal = await readConfigSection(kv, getConfig); const defaults = { @@ -125,7 +119,6 @@ export async function onRequest(context) { return jsonResponse(sectionVal || defaults[getConfig] || {}, 200, corsHeaders); } - // 获取 Favicon 缓存 if (getConfig === 'favicon') { const domain = url.searchParams.get('domain'); if (!domain) { @@ -135,7 +128,6 @@ export async function onRequest(context) { return jsonResponse({ icon: cachedIcon || null, cached: !!cachedIcon }, 200, corsHeaders); } - // 获取分类(密码脱敏,但保留 hasPassword 标记) if (getConfig === 'categories') { const data = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const categories = data ? JSON.parse(data) : []; @@ -163,21 +155,27 @@ export async function onRequest(context) { kv, }); - // 获取链接(带密码过滤) if (getConfig === 'links') { const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const categories = categoriesData ? JSON.parse(categoriesData) : []; if (category) { const cat = categories.find(c => c.id === category); - const hasPassword = cat && cat.password && cat.password.trim() !== ''; - // 验证分类密码 + if (!cat) { + return jsonResponse({ error: '分类不存在' }, 404, corsHeaders); + } + + const hasPassword = cat.password && cat.password.trim() !== ''; let isUnlocked = unlockedCategories.has(category); // 如果提供了分类密码,验证它 if (categoryPassword && hasPassword && !isUnlocked && !isAdmin) { - if (categoryPassword === cat.password) { + // 密码比对:trim 后比对,防止空格问题 + const inputPwd = categoryPassword.trim(); + const storedPwd = (cat.password || '').trim(); + + if (inputPwd === storedPwd) { isUnlocked = true; } else { return jsonResponse({ error: '密码错误' }, 403, corsHeaders); @@ -194,7 +192,6 @@ export async function onRequest(context) { }); } - // 读取所有分类链接(过滤掉受保护且未解锁的) const links = await readAllCategoryLinks(kv, categories, unlockedCategories, isAdmin); return jsonResponse(links, 200, corsHeaders); } @@ -208,7 +205,6 @@ export async function onRequest(context) { return jsonResponse({ key, value }, 200, corsHeaders); } - // 获取全部数据(带密码过滤) if (getConfig === 'true') { const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const allCategories = categoriesData ? JSON.parse(categoriesData) : []; @@ -229,7 +225,6 @@ export async function onRequest(context) { return jsonResponse({ links: [], categories: [] }, 200, corsHeaders); } - // ==================== POST ==================== if (request.method === 'POST') { const body = await request.json(); const readOnlyOperations = ['favicon']; From df080b23dee7bf2f568ee1af99630c9f5d8c041e Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:16:25 +0800 Subject: [PATCH 016/149] Add files via upload --- functions/api/storage.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/functions/api/storage.js b/functions/api/storage.js index 4b170bf3..5b87d1c2 100644 --- a/functions/api/storage.js +++ b/functions/api/storage.js @@ -1,6 +1,5 @@ -// 统一存储接口 +// 统一存储接口 v2.1 - 分类密码保护 // 支持 EdgeOne Pages / Cloudflare Workers -// 支持按分类拆分链接存储 import { getKV, getCorsHeaders, verifyAuth, jsonResponse } from './_kvAdapter.js'; @@ -128,13 +127,22 @@ export async function onRequest(context) { return jsonResponse({ icon: cachedIcon || null, cached: !!cachedIcon }, 200, corsHeaders); } + // 获取分类:密码脱敏,保留 hasPassword 标记 if (getConfig === 'categories') { const data = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const categories = data ? JSON.parse(data) : []; + + // 调试:记录原始分类数量 + console.log(`[storage.js] Loaded ${categories.length} categories`); + const sanitized = categories.map(({ password, ...rest }) => ({ ...rest, hasPassword: !!(password && password.trim() !== '') })); + + // 调试:记录处理后的分类 + console.log(`[storage.js] Sanitized categories:`, JSON.stringify(sanitized.map(c => ({ id: c.id, name: c.name, hasPassword: c.hasPassword })))); + return jsonResponse(sanitized, 200, corsHeaders); } @@ -155,6 +163,7 @@ export async function onRequest(context) { kv, }); + // 获取链接(带密码过滤) if (getConfig === 'links') { const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const categories = categoriesData ? JSON.parse(categoriesData) : []; @@ -163,6 +172,8 @@ export async function onRequest(context) { const cat = categories.find(c => c.id === category); if (!cat) { + console.log(`[storage.js] Category not found: ${category}`); + console.log(`[storage.js] Available categories:`, categories.map(c => c.id)); return jsonResponse({ error: '分类不存在' }, 404, corsHeaders); } @@ -171,10 +182,11 @@ export async function onRequest(context) { // 如果提供了分类密码,验证它 if (categoryPassword && hasPassword && !isUnlocked && !isAdmin) { - // 密码比对:trim 后比对,防止空格问题 const inputPwd = categoryPassword.trim(); const storedPwd = (cat.password || '').trim(); + console.log(`[storage.js] Password check for ${category}: input="${inputPwd}" stored="${storedPwd}" match=${inputPwd === storedPwd}`); + if (inputPwd === storedPwd) { isUnlocked = true; } else { @@ -205,6 +217,7 @@ export async function onRequest(context) { return jsonResponse({ key, value }, 200, corsHeaders); } + // 获取全部数据(带密码过滤) if (getConfig === 'true') { const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const allCategories = categoriesData ? JSON.parse(categoriesData) : []; From 3f4b2ef8dd604a3a3c81ff2efa321d54f5afde4c Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:19:01 +0800 Subject: [PATCH 017/149] Add files via upload --- components/CategoryAuthModal.tsx | 58 ++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/components/CategoryAuthModal.tsx b/components/CategoryAuthModal.tsx index 33f253df..6fe88aa8 100644 --- a/components/CategoryAuthModal.tsx +++ b/components/CategoryAuthModal.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { Lock, ArrowRight, Loader2, X } from 'lucide-react'; import { Category } from '../types'; +import { API_ENDPOINTS } from '../src/constants'; interface CategoryAuthModalProps { isOpen: boolean; @@ -12,18 +13,50 @@ interface CategoryAuthModalProps { const CategoryAuthModal: React.FC = ({ isOpen, onClose, category, onUnlock }) => { const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); if (!isOpen || !category) return null; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (password === category.password) { - onUnlock(category.id); - setPassword(''); - setError(''); - onClose(); - } else { + if (!password.trim()) return; + + setIsLoading(true); + setError(''); + + try { + const url = `${API_ENDPOINTS.STORAGE}?getConfig=links&category=${encodeURIComponent(category.id)}&catPassword=${encodeURIComponent(password.trim())}`; + console.log('[CategoryAuthModal] Request URL:', url); + console.log('[CategoryAuthModal] Category ID:', category.id, 'Name:', category.name); + + const res = await fetch(url); + console.log('[CategoryAuthModal] Response status:', res.status); + + if (res.status === 403) { setError('密码错误'); + setIsLoading(false); + return; + } + + if (!res.ok) { + const text = await res.text(); + console.log('[CategoryAuthModal] Error response:', text); + setError(`验证失败: ${res.status}`); + setIsLoading(false); + return; + } + + // 密码正确,解锁分类 + console.log('[CategoryAuthModal] Unlock success for:', category.id); + onUnlock(category.id); + setPassword(''); + setError(''); + setIsLoading(false); + onClose(); + } catch (err) { + console.error('Category auth error:', err); + setError('网络错误,请重试'); + setIsLoading(false); } }; @@ -53,6 +86,7 @@ const CategoryAuthModal: React.FC = ({ isOpen, onClose, className="w-full p-3 rounded-xl border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-amber-500 outline-none transition-all text-center tracking-widest" placeholder="目录密码" autoFocus + disabled={isLoading} />
@@ -64,10 +98,12 @@ const CategoryAuthModal: React.FC = ({ isOpen, onClose,
@@ -75,4 +111,4 @@ const CategoryAuthModal: React.FC = ({ isOpen, onClose, ); }; -export default CategoryAuthModal; \ No newline at end of file +export default CategoryAuthModal; From 7185a277447b26a73d4ed8f402c24e883244cd3f Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:21:19 +0800 Subject: [PATCH 018/149] Add files via upload From cc91327bd43ae9ba00faafed38bd5cad1f1dc22e Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:22:30 +0800 Subject: [PATCH 019/149] Add files via upload From 5875b009cc4343e7daffcc8edba056d6f7543f5d Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:23:35 +0800 Subject: [PATCH 020/149] Add files via upload From 17b55a28a15ee259e85aefb796957828e9d548c8 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:30:57 +0800 Subject: [PATCH 021/149] Update CategoryAuthModal.tsx --- components/CategoryAuthModal.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/components/CategoryAuthModal.tsx b/components/CategoryAuthModal.tsx index 6fe88aa8..29095eb2 100644 --- a/components/CategoryAuthModal.tsx +++ b/components/CategoryAuthModal.tsx @@ -25,12 +25,10 @@ const CategoryAuthModal: React.FC = ({ isOpen, onClose, setError(''); try { - const url = `${API_ENDPOINTS.STORAGE}?getConfig=links&category=${encodeURIComponent(category.id)}&catPassword=${encodeURIComponent(password.trim())}`; - console.log('[CategoryAuthModal] Request URL:', url); - console.log('[CategoryAuthModal] Category ID:', category.id, 'Name:', category.name); - - const res = await fetch(url); - console.log('[CategoryAuthModal] Response status:', res.status); + // 调用后端验证分类密码 + const res = await fetch( + `${API_ENDPOINTS.STORAGE}?getConfig=links&category=${encodeURIComponent(category.id)}&catPassword=${encodeURIComponent(password.trim())}` + ); if (res.status === 403) { setError('密码错误'); @@ -39,15 +37,12 @@ const CategoryAuthModal: React.FC = ({ isOpen, onClose, } if (!res.ok) { - const text = await res.text(); - console.log('[CategoryAuthModal] Error response:', text); - setError(`验证失败: ${res.status}`); + setError('验证失败,请重试'); setIsLoading(false); return; } // 密码正确,解锁分类 - console.log('[CategoryAuthModal] Unlock success for:', category.id); onUnlock(category.id); setPassword(''); setError(''); From 6e0fa042228c8ad41e1cb9aaf95c7d3840a11493 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:54:44 +0800 Subject: [PATCH 022/149] Update storage.js --- functions/api/storage.js | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/functions/api/storage.js b/functions/api/storage.js index 5b87d1c2..de1bb4ce 100644 --- a/functions/api/storage.js +++ b/functions/api/storage.js @@ -275,7 +275,18 @@ export async function onRequest(context) { } if (body.saveConfig === 'categories') { - await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(body.categories)); + // 读取现有分类数据,保留已有的 password 字段 + const existingData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const existingCategories = existingData ? JSON.parse(existingData) : []; + const existingPasswords = new Map(existingCategories.map(c => [c.id, c.password])); + + // 合并:新分类数据 + 旧分类的 password + const mergedCategories = body.categories.map(cat => ({ + ...cat, + password: cat.password || existingPasswords.get(cat.id) || undefined, + })); + + await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(mergedCategories)); return jsonResponse({ success: true }, 200, corsHeaders); } @@ -300,13 +311,33 @@ export async function onRequest(context) { if (body.links && body.categories) { await saveCategoryLinks(kv, body.links); - await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(body.categories)); + // 读取现有分类数据,保留已有的 password 字段 + const existingData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const existingCategories = existingData ? JSON.parse(existingData) : []; + const existingPasswords = new Map(existingCategories.map(c => [c.id, c.password])); + + const mergedCategories = body.categories.map(cat => ({ + ...cat, + password: cat.password || existingPasswords.get(cat.id) || undefined, + })); + + await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(mergedCategories)); return jsonResponse({ success: true }, 200, corsHeaders); } else if (body.links) { await saveCategoryLinks(kv, body.links); return jsonResponse({ success: true }, 200, corsHeaders); } else if (body.categories) { - await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(body.categories)); + // 读取现有分类数据,保留已有的 password 字段 + const existingData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const existingCategories = existingData ? JSON.parse(existingData) : []; + const existingPasswords = new Map(existingCategories.map(c => [c.id, c.password])); + + const mergedCategories = body.categories.map(cat => ({ + ...cat, + password: cat.password || existingPasswords.get(cat.id) || undefined, + })); + + await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(mergedCategories)); return jsonResponse({ success: true }, 200, corsHeaders); } From 09b22f46f8713727ea2de6b2ea87273f6f3a98cf Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:33:10 +0800 Subject: [PATCH 023/149] Update types.ts --- types.ts | 146 +++++++++++++++++-------------------------------------- 1 file changed, 45 insertions(+), 101 deletions(-) diff --git a/types.ts b/types.ts index 6d36e224..c5851c87 100644 --- a/types.ts +++ b/types.ts @@ -15,6 +15,7 @@ export interface LinkItem { customIconUrl?: string; edgeoneBlobUrl?: string; cloudflareR2Url?: string; + isPrivate?: boolean; // 私人书签 } export interface Category { @@ -99,128 +100,71 @@ export interface WebsiteConfig { } // 搜索模式类型 -export type SearchMode = 'internal' | 'external'; +export type SearchMode = 'internal' | 'external' | 'hybrid'; -// 外部搜索源配置 -export interface ExternalSearchSource { +// 搜索来源配置 +export interface SearchSourceConfig { id: string; name: string; url: string; - icon?: string; enabled: boolean; - createdAt: number; } // 搜索配置 export interface SearchConfig { mode: SearchMode; - externalSources: ExternalSearchSource[]; - selectedSource?: ExternalSearchSource | null; // 选中的搜索源 - defaultEngine?: string; // 默认搜索引擎 ID - customEngineUrl?: string; // 自定义搜索引擎 URL - customEngineIcon?: string; // 自定义搜索引擎 Logo (URL 或 SVG 代码) -} - -// 滚动 Ticker 来源类型 -export type TickerSource = 'mastodon' | 'memos' | 'custom'; - -// 滚动 Ticker 配置 -export interface TickerConfig { - enabled: boolean; - source: TickerSource; - // Mastodon - mastodonInstance?: string; - mastodonUsername?: string; - mastodonLimit?: number; - mastodonExcludeReplies?: boolean; - mastodonExcludeReblogs?: boolean; - // Memos - memosHost?: string; - memosToken?: string; - memosLimit?: number; - memosCreator?: string; - memosVisibility?: 'PUBLIC' | 'PROTECTED' | 'PRIVATE'; - // Custom - customItems?: string[]; + externalSources: SearchSourceConfig[]; + defaultEngine?: string; } -// 天气 API 类型 -export type WeatherProvider = 'jinrishici' | 'qweather' | 'openweather' | 'visualcrossing' | 'accuweather'; - // 天气配置 export interface WeatherConfig { enabled: boolean; - provider: WeatherProvider; - // QWeather - qweatherHost?: string; - qweatherApiKey?: string; - qweatherLocation?: string; - // OpenWeather - openweatherApiKey?: string; - openweatherCity?: string; - // Visual Crossing - visualcrossingApiKey?: string; - visualcrossingLocation?: string; - // AccuWeather - accuweatherApiKey?: string; - accuweatherLocationKey?: string; - // Common - unit?: 'celsius' | 'fahrenheit'; + city: string; + apiKey?: string; + provider?: string; } -// 完全统一的应用配置(包含所有配置) -export interface AppConfig { - // AI 配置 - ai?: AIConfig; - - // 网站配置 - website?: WebsiteConfig; - - // WebDAV 配置 - webdav?: WebDavConfig; - - // 搜索配置 - search?: SearchConfig; - - // 滚动 Ticker 配置 - ticker?: TickerConfig; - - // 天气配置 - weather?: WeatherConfig; - - // 图标配置 - icon?: IconConfig; - - // 视图配置 - view?: { - mode: 'compact' | 'detailed'; // 用户个人视图偏好 - defaultMode?: 'compact' | 'detailed'; // 管理员设置的默认视图模式 - }; - - // 界面配置 - ui?: { - showPinnedWebsites: boolean; // 是否显示置顶网站 - darkMode?: boolean; // 深色模式偏好(可选,主要使用系统级主题) - }; - - // 其他用户偏好设置 - preferences?: { - [key: string]: any; - }; +// Mastodon/Ticker 配置 +export interface MastodonConfig { + enabled: boolean; + instance: string; + account: string; + maxItems: number; } +// AI Provider 类型 +export type AIProvider = 'gemini' | 'openai' | 'claude' | 'custom'; + +// 默认分类 export const DEFAULT_CATEGORIES: Category[] = [ - { id: "common", name: "常用推荐", icon: "Star" }, - { id: "tools","name":"工具","icon":"Folder","isSubcategory":false}, - { id: "life","name":"生活工具","icon":"Target","parentId":"tools","isSubcategory":true}, - { id: "network","name":"网络工具","icon":"Wifi","parentId":"tools","isSubcategory":true}, + { id: 'common', name: '常用推荐', icon: 'Star' }, ]; +// 初始链接数据 export const INITIAL_LINKS: LinkItem[] = [ - { id: 'init1', title: '博客 Blog', url: 'https://www.eallion.com/', icon: '/favicons/eallion.png', description: '大大的小蜗牛的个人生活博客', categoryId: 'common', createdAt: Date.now(), pinned: true, pinnedOrder: 0 }, - { id: 'init2', title: 'Mastodon e5n.cc', url: 'https://e5n.cc/@eallion', icon: '/favicons/mastodon.svg', description: 'Charles Chin\'s personal Mastodon.', categoryId: 'common', createdAt: Date.now(), pinned: true, pinnedOrder: 1 }, - { id: 'init3', title: 'Twitter 𝕏', url: 'https://x.com/eallion', icon: '/favicons/x.svg', description: 'Blaze your glory!', categoryId: 'common', createdAt: Date.now() }, - { id: 'init4', title: 'GitHub', url: 'https://github.com/eallion', icon: '/favicons/github.svg', description: 'Build and ship software on a single, collaborative platform', categoryId: 'common', createdAt: Date.now() }, - { id: 'init5', title: 'Cloudflare', url: 'https://dash.cloudflare.com/', icon: '/favicons/cloudflare.svg', description: 'Connect, protect, and build everywhere', categoryId: 'common', createdAt: Date.now() }, - { id: 'init6', title: 'Vercel', url: 'https://vercel.com', icon: '/favicons/vercel.svg', description: 'Build and deploy the best web experiences with the Frontend Cloud', categoryId: 'common', createdAt: Date.now() }, + { + id: '1', + title: '百度', + url: 'https://www.baidu.com', + icon: 'https://www.baidu.com/favicon.ico', + categoryId: 'common', + createdAt: Date.now(), + }, + { + id: '2', + title: 'GitHub', + url: 'https://github.com', + icon: 'https://github.com/favicon.ico', + categoryId: 'common', + createdAt: Date.now(), + }, + { + id: '3', + title: 'Google', + url: 'https://www.google.com', + icon: 'https://www.google.com/favicon.ico', + categoryId: 'common', + createdAt: Date.now(), + }, ]; From 140317b784e1b9c3ee076f5483de7c559402d7bc Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:34:22 +0800 Subject: [PATCH 024/149] Update LinkModal.tsx --- components/LinkModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/LinkModal.tsx b/components/LinkModal.tsx index 8cc60db5..969b83a4 100644 --- a/components/LinkModal.tsx +++ b/components/LinkModal.tsx @@ -24,6 +24,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete const [description, setDescription] = useState(''); const [categoryId, setCategoryId] = useState(categories[0]?.id || 'common'); const [pinned, setPinned] = useState(false); + const [isPrivate, setIsPrivate] = useState(false); const [icon, setIcon] = useState(''); const [iconType, setIconType] = useState('google'); const [isUploading, setIsUploading] = useState(false); @@ -209,6 +210,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete setDescription(initialData.description || ''); setCategoryId(initialData.categoryId); setPinned(initialData.pinned || false); + setIsPrivate(initialData.isPrivate || false); setIcon(initialData.icon || ''); setWeight(initialData.weight || 0); setPinnedOrder(initialData.pinnedOrder || 0); @@ -269,6 +271,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete setCategoryId(firstAvailableCategory?.id || 'common'); } setPinned(false); + setIsPrivate(false); setIcon(''); setIconType('google'); setCustomIconUrl(''); @@ -355,7 +358,8 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete iconConfig: iconType === 'customapi' ? { iconType, customApiUrl, customApiParam } : undefined, customIconUrl, edgeoneBlobUrl, - cloudflareR2Url + cloudflareR2Url, + isPrivate }); // 如果有自定义图标URL,缓存到KV空间 From 7efd2ef105f1c75069b3b95704ef8e9c46c74a4a Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:35:53 +0800 Subject: [PATCH 025/149] Update storage.js --- functions/api/storage.js | 44 ++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/functions/api/storage.js b/functions/api/storage.js index de1bb4ce..101cbf42 100644 --- a/functions/api/storage.js +++ b/functions/api/storage.js @@ -1,4 +1,4 @@ -// 统一存储接口 v2.1 - 分类密码保护 +// 统一存储接口 v2.2 - 分类密码保护 + 私人书签 // 支持 EdgeOne Pages / Cloudflare Workers import { getKV, getCorsHeaders, verifyAuth, jsonResponse } from './_kvAdapter.js'; @@ -47,6 +47,7 @@ function categoryLinksKey(categoryId) { return `links:${categoryId}`; } +// 读取所有分类链接(带密码过滤 + 私人书签过滤) async function readAllCategoryLinks(kv, categories, unlockedCategories = new Set(), isAdmin = false) { if (categories.length === 0) return []; @@ -59,13 +60,20 @@ async function readAllCategoryLinks(kv, categories, unlockedCategories = new Set } const data = await kv.get(categoryLinksKey(cat.id)); - return data ? JSON.parse(data) : []; + const links = data ? JSON.parse(data) : []; + + // 过滤私人书签:未登录时隐藏 + if (!isAdmin) { + return links.filter(link => !link.isPrivate); + } + return links; }); const linkArrays = await Promise.all(linkPromises); return linkArrays.flat(); } +// 保存链接到对应的分类 key async function saveCategoryLinks(kv, links) { const grouped = {}; for (const link of links) { @@ -127,26 +135,16 @@ export async function onRequest(context) { return jsonResponse({ icon: cachedIcon || null, cached: !!cachedIcon }, 200, corsHeaders); } - // 获取分类:密码脱敏,保留 hasPassword 标记 if (getConfig === 'categories') { const data = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const categories = data ? JSON.parse(data) : []; - - // 调试:记录原始分类数量 - console.log(`[storage.js] Loaded ${categories.length} categories`); - const sanitized = categories.map(({ password, ...rest }) => ({ ...rest, hasPassword: !!(password && password.trim() !== '') })); - - // 调试:记录处理后的分类 - console.log(`[storage.js] Sanitized categories:`, JSON.stringify(sanitized.map(c => ({ id: c.id, name: c.name, hasPassword: c.hasPassword })))); - return jsonResponse(sanitized, 200, corsHeaders); } - // 解析已解锁分类 let unlockedCategories = new Set(); const unlockedParam = url.searchParams.get('unlocked'); if (unlockedParam) { @@ -155,7 +153,6 @@ export async function onRequest(context) { } catch (e) {} } - // 检查管理员权限 const providedPassword = request.headers.get('x-auth-password'); const isAdmin = await verifyAuth({ providedPassword, @@ -163,7 +160,6 @@ export async function onRequest(context) { kv, }); - // 获取链接(带密码过滤) if (getConfig === 'links') { const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const categories = categoriesData ? JSON.parse(categoriesData) : []; @@ -172,21 +168,16 @@ export async function onRequest(context) { const cat = categories.find(c => c.id === category); if (!cat) { - console.log(`[storage.js] Category not found: ${category}`); - console.log(`[storage.js] Available categories:`, categories.map(c => c.id)); return jsonResponse({ error: '分类不存在' }, 404, corsHeaders); } const hasPassword = cat.password && cat.password.trim() !== ''; let isUnlocked = unlockedCategories.has(category); - // 如果提供了分类密码,验证它 if (categoryPassword && hasPassword && !isUnlocked && !isAdmin) { const inputPwd = categoryPassword.trim(); const storedPwd = (cat.password || '').trim(); - console.log(`[storage.js] Password check for ${category}: input="${inputPwd}" stored="${storedPwd}" match=${inputPwd === storedPwd}`); - if (inputPwd === storedPwd) { isUnlocked = true; } else { @@ -199,9 +190,13 @@ export async function onRequest(context) { } const data = await kv.get(categoryLinksKey(category)); - return new Response(data || '[]', { - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + const links = data ? JSON.parse(data) : []; + + // 过滤私人书签 + if (!isAdmin) { + return jsonResponse(links.filter(link => !link.isPrivate), 200, corsHeaders); + } + return jsonResponse(links, 200, corsHeaders); } const links = await readAllCategoryLinks(kv, categories, unlockedCategories, isAdmin); @@ -217,7 +212,6 @@ export async function onRequest(context) { return jsonResponse({ key, value }, 200, corsHeaders); } - // 获取全部数据(带密码过滤) if (getConfig === 'true') { const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const allCategories = categoriesData ? JSON.parse(categoriesData) : []; @@ -275,12 +269,10 @@ export async function onRequest(context) { } if (body.saveConfig === 'categories') { - // 读取现有分类数据,保留已有的 password 字段 const existingData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const existingCategories = existingData ? JSON.parse(existingData) : []; const existingPasswords = new Map(existingCategories.map(c => [c.id, c.password])); - // 合并:新分类数据 + 旧分类的 password const mergedCategories = body.categories.map(cat => ({ ...cat, password: cat.password || existingPasswords.get(cat.id) || undefined, @@ -311,7 +303,6 @@ export async function onRequest(context) { if (body.links && body.categories) { await saveCategoryLinks(kv, body.links); - // 读取现有分类数据,保留已有的 password 字段 const existingData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const existingCategories = existingData ? JSON.parse(existingData) : []; const existingPasswords = new Map(existingCategories.map(c => [c.id, c.password])); @@ -327,7 +318,6 @@ export async function onRequest(context) { await saveCategoryLinks(kv, body.links); return jsonResponse({ success: true }, 200, corsHeaders); } else if (body.categories) { - // 读取现有分类数据,保留已有的 password 字段 const existingData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); const existingCategories = existingData ? JSON.parse(existingData) : []; const existingPasswords = new Map(existingCategories.map(c => [c.id, c.password])); From b380e112613322a19a833b130668bfccb30b182b Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:36:24 +0800 Subject: [PATCH 026/149] Update LinksContext.tsx --- src/contexts/LinksContext.tsx | 91 ++++++++++++----------------------- 1 file changed, 30 insertions(+), 61 deletions(-) diff --git a/src/contexts/LinksContext.tsx b/src/contexts/LinksContext.tsx index 6eaf1b05..3293b879 100644 --- a/src/contexts/LinksContext.tsx +++ b/src/contexts/LinksContext.tsx @@ -61,46 +61,10 @@ export function LinksProvider({ children }: { children: React.ReactNode }) { const { authToken } = useAuthContext(); - // 初始化(不触发同步) const initLinks = useCallback((links: LinkItem[]) => { dispatch({ type: 'SET_LINKS', payload: links }); }, []); - // 同步到云端 - const syncToCloud = useCallback(async (links: LinkItem[], categories: Category[], token: string) => { - dispatch({ type: 'SET_SYNC_STATUS', payload: 'saving' }); - try { - const res = await fetch(API_ENDPOINTS.STORAGE, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-auth-password': token, - }, - body: JSON.stringify({ links, categories }), - }); - if (res.status === 401) { - dispatch({ type: 'SET_SYNC_STATUS', payload: 'error' }); - return false; - } - if (!res.ok) throw new Error('Sync failed'); - dispatch({ type: 'SET_SYNC_STATUS', payload: 'saved' }); - setTimeout(() => dispatch({ type: 'SET_SYNC_STATUS', payload: 'idle' }), 2000); - return true; - } catch (e) { - console.error('Sync failed:', e); - dispatch({ type: 'SET_SYNC_STATUS', payload: 'error' }); - return false; - } - }, []); - - // 持久化:本地 + 云端 - const persist = useCallback((links: LinkItem[], categories: Category[]) => { - localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_KEY, JSON.stringify({ links, categories })); - if (authToken) { - syncToCloud(links, categories, authToken); - } - }, [authToken, syncToCloud]); - const addLink = useCallback((data: Omit) => { const newLink: LinkItem = { ...data, @@ -126,39 +90,44 @@ export function LinksProvider({ children }: { children: React.ReactNode }) { dispatch({ type: 'SET_LINKS', payload: links }); }, []); - // 设置链接并同步(用于 updateData 场景) const setLinksAndSync = useCallback((links: LinkItem[], categories: Category[]) => { dispatch({ type: 'SET_LINKS', payload: links }); - persist(links, categories); - }, [persist]); - - // 置顶链接 - const pinnedLinks = useMemo(() => - state.links - .filter(l => l.pinned) - .sort((a, b) => (a.pinnedOrder ?? 0) - (b.pinnedOrder ?? 0)), - [state.links] - ); + localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_KEY, JSON.stringify({ links, categories })); + if (authToken) { + fetch(API_ENDPOINTS.STORAGE, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-auth-password': authToken, + }, + body: JSON.stringify({ links, categories }), + }).catch(e => console.error('Sync links failed:', e)); + } + }, [authToken]); - // 按分类获取链接(按 weight 排序,weight 相同按 order 排序) - const getLinksByCategory = useCallback((categoryId: string) => - state.links - .filter(l => l.categoryId === categoryId) - .sort((a, b) => { - const wa = a.weight ?? Infinity; - const wb = b.weight ?? Infinity; - if (wa !== wb) return wa - wb; - return (a.order ?? 0) - (b.order ?? 0); - }), - [state.links] - ); + // 过滤私人书签:未登录时隐藏 + const visibleLinks = useMemo(() => { + if (authToken) return state.links; + return state.links.filter(link => !link.isPrivate); + }, [state.links, authToken]); + + const pinnedLinks = useMemo(() => { + const pinned = visibleLinks.filter(l => l.pinned); + return [...pinned].sort((a, b) => (a.pinnedOrder ?? 0) - (b.pinnedOrder ?? 0)); + }, [visibleLinks]); + + const getLinksByCategory = useCallback((categoryId: string) => { + return visibleLinks.filter(l => l.categoryId === categoryId); + }, [visibleLinks]); return ( {children} From aec3d68bddd4b0ab3fa00815696f37e4d5934a7f Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:36:57 +0800 Subject: [PATCH 027/149] Update MainContent.tsx --- src/components/layout/MainContent.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 7e5d7a6a..5ee7e204 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -40,7 +40,12 @@ export function MainContent({ return getLinksByCategory ? getLinksByCategory(categoryId) : []; }, [getLinksByCategory]); - // Intersection Observer for active category highlighting + // 过滤搜索结果中的私人书签 + const filteredSearchResults = useMemo(() => { + if (authToken) return searchResults; + return searchResults.filter(link => !link.isPrivate); + }, [searchResults, authToken]); + useEffect(() => { const observer = new IntersectionObserver( (entries) => { @@ -64,7 +69,6 @@ export function MainContent({ ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10'; - // Search mode: Only show results if internal search is checked if (searchQuery.trim() && isInternal) { return (
@@ -72,11 +76,11 @@ export function MainContent({

搜索结果 - {searchResults.length} + {filteredSearchResults.length}

- {searchResults.map(link => ( + {filteredSearchResults.map(link => ( ))}
- {searchResults.length === 0 && ( + {filteredSearchResults.length === 0 && (

未找到匹配的链接

)}
@@ -104,7 +108,6 @@ export function MainContent({ return (
- {/* Pinned section */} {showPinnedWebsites && pinnedLinks.length > 0 && (
)} - {/* All categories - 过滤掉未解锁的密码保护分类 */} {categoryTree.map(cat => { const isLocked = cat.hasPassword && !unlockedCategoryIds.has(cat.id); if (isLocked) return null; From beb75634c64fe206a8a5b1e1a2518088e990e20b Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:37:25 +0800 Subject: [PATCH 028/149] Update LinkCard.tsx --- src/components/link/LinkCard.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/link/LinkCard.tsx b/src/components/link/LinkCard.tsx index fa178963..7ab1d7db 100644 --- a/src/components/link/LinkCard.tsx +++ b/src/components/link/LinkCard.tsx @@ -212,6 +212,7 @@ export function LinkCard({

{link.title} + {link.isPrivate && 🔒}

{link.description && ( @@ -225,6 +226,7 @@ export function LinkCard({

{link.title} + {link.isPrivate && 🔒}

{link.description && (

@@ -242,6 +244,7 @@ export function LinkCard({

{link.title} + {link.isPrivate && 🔒}

{link.description && ( From a8379c0f3f0bdf4051448f626276b6e4e18cf3ce Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:42:15 +0800 Subject: [PATCH 029/149] Update MainContent.tsx --- src/components/layout/MainContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 5ee7e204..649649a2 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useLinksContext } from '../../contexts/LinksContext'; import { useCategoriesContext } from '../../contexts/CategoriesContext'; import { useConfigContext } from '../../contexts/ConfigContext'; From 4340a98763a260074bc9b09687eccfaa07bc9c99 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:52:32 +0800 Subject: [PATCH 030/149] Update LinkModal.tsx --- components/LinkModal.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/components/LinkModal.tsx b/components/LinkModal.tsx index 969b83a4..44f211dd 100644 --- a/components/LinkModal.tsx +++ b/components/LinkModal.tsx @@ -481,6 +481,19 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete 置顶 + + {!initialData && (
Date: Wed, 15 Jul 2026 00:58:20 +0800 Subject: [PATCH 031/149] Update storage.js --- functions/api/storage.js | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/functions/api/storage.js b/functions/api/storage.js index 101cbf42..87b0fa64 100644 --- a/functions/api/storage.js +++ b/functions/api/storage.js @@ -60,13 +60,7 @@ async function readAllCategoryLinks(kv, categories, unlockedCategories = new Set } const data = await kv.get(categoryLinksKey(cat.id)); - const links = data ? JSON.parse(data) : []; - - // 过滤私人书签:未登录时隐藏 - if (!isAdmin) { - return links.filter(link => !link.isPrivate); - } - return links; + return data ? JSON.parse(data) : []; }); const linkArrays = await Promise.all(linkPromises); @@ -191,11 +185,6 @@ export async function onRequest(context) { const data = await kv.get(categoryLinksKey(category)); const links = data ? JSON.parse(data) : []; - - // 过滤私人书签 - if (!isAdmin) { - return jsonResponse(links.filter(link => !link.isPrivate), 200, corsHeaders); - } return jsonResponse(links, 200, corsHeaders); } From a46d357791b646277bb0f3f4efb020e9b35aaa3e Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:13:17 +0800 Subject: [PATCH 032/149] Update index.html --- index.html | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 5e020c1d..e06b32b2 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + @@ -52,6 +52,20 @@ } })(); + +
From 7ceb4059f3d5b3af2b67cf87cfb1b11c4dcf52e3 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:14:10 +0800 Subject: [PATCH 033/149] Update Header.tsx --- src/components/layout/Header.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index fc7735a6..7294763f 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -167,7 +167,7 @@ export function Header({ const { authToken, logout } = useAuthContext(); const { syncStatus } = useLinksContext(); const [showDropdown, setShowDropdown] = useState(false); - const [isToolsExpanded, setIsToolsExpanded] = useState(false); + const [isToolsExpanded, setIsToolsExpanded] = useState(true); const dropdownTimer = useRef(null); const engine = visitorEngineId || search?.defaultEngine || 'internal'; @@ -187,7 +187,7 @@ export function Header({ }, [authToken]); return ( -
+
{/* Left: Menu + Logo */}
From 19f532b7ba209dae834c239942513b786fc973d1 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:25:40 +0800 Subject: [PATCH 034/149] Update Header.tsx --- src/components/layout/Header.tsx | 106 ++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 10 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 7294763f..557a831b 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Search, X, Plus, Moon, Sun, Menu, Settings, Upload, CheckSquare, LogOut, Lock, GripVertical, Edit3, ChevronLeft, ChevronRight, Layers } from 'lucide-react'; +import { Search, X, Plus, Moon, Sun, Menu, Settings, Upload, CheckSquare, LogOut, Lock, GripVertical, Edit3, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Layers } from 'lucide-react'; import { useConfigContext } from '../../contexts/ConfigContext'; import { useAuthContext } from '../../contexts/AuthContext'; import { useLinksContext } from '../../contexts/LinksContext'; @@ -168,6 +168,7 @@ export function Header({ const { syncStatus } = useLinksContext(); const [showDropdown, setShowDropdown] = useState(false); const [isToolsExpanded, setIsToolsExpanded] = useState(true); + const [isMobileToolsOpen, setIsMobileToolsOpen] = useState(false); const dropdownTimer = useRef(null); const engine = visitorEngineId || search?.defaultEngine || 'internal'; @@ -336,16 +337,14 @@ export function Header({ {darkMode ? : } - {/* GitHub link */} - setIsMobileToolsOpen(!isMobileToolsOpen)} + className={`${isMobileSearchOpen ? 'hidden' : 'flex'} items-center justify-center p-2 rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 h-[36px] min-w-[36px] transition-colors cursor-pointer`} + title={isMobileToolsOpen ? "收起工具" : "展开工具"} > - - + {isMobileToolsOpen ? : } + {/* Removed sync status indicator block */} @@ -451,6 +450,93 @@ export function Header({ )}
+ + {/* Mobile Tools Dropdown Panel */} + {isMobileToolsOpen && ( +
+ {/* Search Input */} +
+ + onSearchChange(e.target.value)} + placeholder="搜索书签..." + className="bg-transparent text-sm text-slate-800 dark:text-slate-200 outline-none w-full placeholder:text-slate-400" + /> + {searchQuery && ( + + )} +
+ + {/* Tools Grid */} +
+ {authToken && ( + + )} + + {authToken && ( + + )} + + {authToken && ( + + )} + {authToken && ( + + )} + {authToken && ( + + )} + {authToken && ( + + )} + {!authToken && ( + + )} +
+ + {/* GitHub Link */} + + + GitHub + +
+ )}
); } From 0d845e182c6d0ef3868b3863935c9e910b7782da Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:27:07 +0800 Subject: [PATCH 035/149] Update index.html From 25998734dc7e58ba4f5fad4fdf058d959229af86 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:45:38 +0800 Subject: [PATCH 036/149] Update index.html --- index.html | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index e06b32b2..133e1c7d 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,9 @@ - + + + @@ -62,8 +64,13 @@ } /* Ensure header background extends to safe area */ header, .header-bg { - background-color: inherit; + background-color: rgb(248, 250, 252); } + @media (prefers-color-scheme: dark) { + header { + background-color: rgb(30, 41, 59); /* 对应 dark:bg-slate-800 */ + } +} From 6e2d9091b338031e3476a0cf43bb665a4286c061 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:46:25 +0800 Subject: [PATCH 037/149] Update Header.tsx --- src/components/layout/Header.tsx | 94 +++++++++++++++++++------------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 557a831b..7a7543f2 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -473,58 +473,76 @@ export function Header({ {/* Tools Grid */}
- {authToken && ( - - )} - + - {authToken && ( - - )} - + - {authToken && ( - - )} - {authToken && ( - - )} - {authToken && ( - - )} - {authToken && ( - + + + {authToken ? ( + - )} - {!authToken && ( - )}
- {/* GitHub Link */} Date: Wed, 15 Jul 2026 01:57:14 +0800 Subject: [PATCH 038/149] Update LinkCard.tsx --- src/components/link/LinkCard.tsx | 439 +++++++++++++++---------------- 1 file changed, 212 insertions(+), 227 deletions(-) diff --git a/src/components/link/LinkCard.tsx b/src/components/link/LinkCard.tsx index 7ab1d7db..59f5c3b7 100644 --- a/src/components/link/LinkCard.tsx +++ b/src/components/link/LinkCard.tsx @@ -1,277 +1,262 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { GripVertical } from 'lucide-react'; -import { LinkItem } from '../../../types'; -import { extractColorFromImage, generateColorFromText, ExtractedColor } from '../../../src/utils/colorExtractor'; +"use client"; + +import React, { useRef, useCallback, useEffect, useState } from "react"; +import { ExternalLink, Trash2, GripVertical } from "lucide-react"; +import { Bookmark } from "../types"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; interface LinkCardProps { - link: LinkItem; - viewMode: 'compact' | 'detailed'; - isBatchEditMode: boolean; - isSelected: boolean; - onToggleSelection: (id: string) => void; - onEdit: (link: LinkItem) => void; + bookmark: Bookmark; onDelete: (id: string) => void; - onContextMenu: (e: React.MouseEvent, link: LinkItem) => void; - isDraggable?: boolean; - authToken?: string | null; - isEditMode?: boolean; - onWeightChange?: (linkId: string, weight: number) => void; + isAdmin: boolean; } -export function LinkCard({ - link, viewMode, isBatchEditMode, isSelected, - onToggleSelection, onEdit, onDelete, onContextMenu, - isDraggable = true, authToken, isEditMode = false, onWeightChange, -}: LinkCardProps) { - const [imgError, setImgError] = useState(false); - const [color, setColor] = useState(null); - const [isEditingWeight, setIsEditingWeight] = useState(false); - const [weightValue, setWeightValue] = useState(link.weight?.toString() || '0'); - const [isVisible, setIsVisible] = useState(false); +export default function LinkCard({ bookmark, onDelete, isAdmin }: LinkCardProps) { + const [showMenu, setShowMenu] = useState(false); + const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); + const longPressTimer = useRef(null); + const isLongPress = useRef(false); const cardRef = useRef(null); - const observerRef = useRef(null); - const { - attributes, listeners, setNodeRef, transform, transition, isDragging, - } = useSortable({ - id: link.id, - disabled: !isDraggable || isBatchEditMode, - }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: bookmark.id, disabled: !isAdmin }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, - ...(color ? { - '--icon-color': color.hex, - '--icon-color-rgb': color.rgb, - } as React.CSSProperties : {}), }; - const isDetailedView = viewMode === 'detailed'; - const iconSrc = link.icon && !imgError ? link.icon : null; + // 长按检测:触摸开始 + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + isLongPress.current = false; + longPressTimer.current = setTimeout(() => { + isLongPress.current = true; + // 获取触摸位置 + const touch = e.touches[0]; + setMenuPos({ x: touch.clientX, y: touch.clientY }); + setShowMenu(true); + // 禁止默认的上下文菜单/文本选择 + if (cardRef.current) { + cardRef.current.style.webkitTouchCallout = "none"; + cardRef.current.style.userSelect = "none"; + } + }, 600); // 600ms 长按阈值 + }, + [] + ); - // 观察可见性,离屏卡片延迟执行颜色提取 - useEffect(() => { - const el = cardRef.current; - if (!el) return; - if (typeof IntersectionObserver === 'undefined') { - setIsVisible(true); - return; + // 触摸结束 + const handleTouchEnd = useCallback(() => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; } - observerRef.current = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setIsVisible(true); - observerRef.current?.disconnect(); - } - }, - { rootMargin: '200px' } - ); - observerRef.current.observe(el); - return () => observerRef.current?.disconnect(); + // 延迟重置,避免误触发点击 + setTimeout(() => { + isLongPress.current = false; + }, 100); }, []); - // 提取图标颜色 - 仅在卡片可见时执行 - useEffect(() => { - if (!isVisible) return; - if (!iconSrc) { - setColor(generateColorFromText(link.title)); - return; + // 触摸移动(取消长按) + const handleTouchMove = useCallback(() => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; } - - extractColorFromImage(iconSrc).then(result => { - if (result) { - setColor(result); - } - }); - }, [iconSrc, link.title, isVisible]); - - // 鼠标位置追踪 - const rafRef = useRef(null); - const handleMouseMove = useCallback((e: React.MouseEvent) => { - if (rafRef.current) return; - rafRef.current = requestAnimationFrame(() => { - const card = cardRef.current; - if (card) { - const rect = card.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - card.style.setProperty('--pointer-x', `${x}`); - card.style.setProperty('--pointer-y', `${y}`); - } - rafRef.current = null; - }); }, []); - const mergedRef = useCallback((node: HTMLDivElement | null) => { - setNodeRef(node); - (cardRef as React.MutableRefObject).current = node; - }, [setNodeRef]); + // 右键菜单(桌面端) + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setMenuPos({ x: e.clientX, y: e.clientY }); + setShowMenu(true); + }, + [] + ); - const handleClick = () => { - if (isBatchEditMode) { - onToggleSelection(link.id); - } else if (!isEditMode) { - window.open(link.url, '_blank', 'noopener,noreferrer'); + // 点击其他地方关闭菜单 + useEffect(() => { + const handleClickOutside = () => setShowMenu(false); + if (showMenu) { + document.addEventListener("click", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); } + return () => { + document.removeEventListener("click", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, [showMenu]); + + // 清理定时器 + useEffect(() => { + return () => { + if (longPressTimer.current) clearTimeout(longPressTimer.current); + }; + }, []); + + const handleDelete = () => { + onDelete(bookmark.id); + setShowMenu(false); }; - const handleWeightSave = () => { - const num = parseInt(weightValue, 10); - if (!isNaN(num) && onWeightChange) { - onWeightChange(link.id, num); - } - setIsEditingWeight(false); + const handleOpenLink = () => { + window.open(bookmark.url, "_blank", "noopener,noreferrer"); }; return ( -
onContextMenu(e, link)} - onMouseMove={handleMouseMove} - {...(isDraggable && !isBatchEditMode ? attributes : {})} - {...(isDraggable && !isBatchEditMode ? listeners : {})} - > - {/* 背景模糊图标 */} -
- {iconSrc ? ( - setImgError(true)} /> - ) : ( - {link.title.charAt(0).toUpperCase()} + <> +
{ + setNodeRef(node); + (cardRef as React.MutableRefObject).current = node; + }} + style={style} + {...attributes} + className="group relative bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 + shadow-sm hover:shadow-md transition-all duration-200 overflow-hidden" + onContextMenu={handleContextMenu} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} + onClick={(e) => { + // 如果是长按触发的,不执行点击跳转 + if (isLongPress.current) { + e.preventDefault(); + return; + } + handleOpenLink(); + }} + > + {/* 拖拽手柄(仅管理员可见) */} + {isAdmin && ( +
+ +
)} -
- {/* Batch edit checkbox */} - {isBatchEditMode && ( -
- onToggleSelection(link.id)} - className="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500" - onClick={(e) => e.stopPropagation()} - /> +
+ {/* 图标和标题 */} +
+
+ {bookmark.title.charAt(0).toUpperCase()} +
+
+

+ {bookmark.title} +

+

+ {bookmark.description || bookmark.url} +

+
+
+ + {/* URL 和标签 */} +
+ + {new URL(bookmark.url).hostname} + + {bookmark.category && ( + + {bookmark.category} + + )} +
- )} - {/* Weight badge */} - {isEditMode && onWeightChange && ( -
- {isEditingWeight ? ( - setWeightValue(e.target.value)} - onBlur={handleWeightSave} - onKeyDown={(e) => e.key === 'Enter' && handleWeightSave()} - className="w-12 h-6 text-xs text-center bg-white dark:bg-slate-700 border border-blue-400 rounded px-1 outline-none" - onClick={(e) => e.stopPropagation()} - autoFocus - /> - ) : ( + {/* 底部操作栏(hover 显示) */} +
+ + {isAdmin && ( )}
- )} - - {/* Link content */} -
- {isDetailedView ? ( - <> -
-
-
- {iconSrc ? setImgError(true)} /> : link.title.charAt(0).toUpperCase()} -
-

- {link.title} - {link.isPrivate && 🔒} -

-
- {link.description && ( -

- {link.description} -

- )} -
- {iconSrc ? setImgError(true)} /> : link.title.charAt(0).toUpperCase()} -
-
-

- {link.title} - {link.isPrivate && 🔒} -

- {link.description && ( -

- {link.description} -

- )} -
-
- - ) : ( - <> -
-
- {iconSrc ? setImgError(true)} /> : link.title.charAt(0).toUpperCase()} -
-

- {link.title} - {link.isPrivate && 🔒} -

-
- {link.description && ( -
- {link.description} -
- )} - - )}
- {/* Hover actions - 只在编辑模式下显示 */} - {!isBatchEditMode && authToken && isEditMode && ( -
+ {/* 右键/长按菜单 */} + {showMenu && ( +
e.stopPropagation()} + > + + {isAdmin && ( + <> +
+ + + )}
)} -
+ ); } From dd3b3362cb29b7b360c49075a76cd0892b96f3c1 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:00:24 +0800 Subject: [PATCH 039/149] Update LinkCard.tsx --- src/components/link/LinkCard.tsx | 472 +++++++++++++++++-------------- 1 file changed, 260 insertions(+), 212 deletions(-) diff --git a/src/components/link/LinkCard.tsx b/src/components/link/LinkCard.tsx index 59f5c3b7..5d99c028 100644 --- a/src/components/link/LinkCard.tsx +++ b/src/components/link/LinkCard.tsx @@ -1,262 +1,310 @@ -"use client"; - -import React, { useRef, useCallback, useEffect, useState } from "react"; -import { ExternalLink, Trash2, GripVertical } from "lucide-react"; -import { Bookmark } from "../types"; -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical } from 'lucide-react'; +import { LinkItem } from '../../../types'; +import { extractColorFromImage, generateColorFromText, ExtractedColor } from '../../../src/utils/colorExtractor'; interface LinkCardProps { - bookmark: Bookmark; + link: LinkItem; + viewMode: 'compact' | 'detailed'; + isBatchEditMode: boolean; + isSelected: boolean; + onToggleSelection: (id: string) => void; + onEdit: (link: LinkItem) => void; onDelete: (id: string) => void; - isAdmin: boolean; + onContextMenu: (e: React.MouseEvent, link: LinkItem) => void; + isDraggable?: boolean; + authToken?: string | null; + isEditMode?: boolean; + onWeightChange?: (linkId: string, weight: number) => void; } -export default function LinkCard({ bookmark, onDelete, isAdmin }: LinkCardProps) { - const [showMenu, setShowMenu] = useState(false); - const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); - const longPressTimer = useRef(null); - const isLongPress = useRef(false); +export function LinkCard({ + link, viewMode, isBatchEditMode, isSelected, + onToggleSelection, onEdit, onDelete, onContextMenu, + isDraggable = true, authToken, isEditMode = false, onWeightChange, +}: LinkCardProps) { + const [imgError, setImgError] = useState(false); + const touchTimerRef = useRef | null>(null); + const [color, setColor] = useState(null); + const [isEditingWeight, setIsEditingWeight] = useState(false); + const [weightValue, setWeightValue] = useState(link.weight?.toString() || '0'); + const [isVisible, setIsVisible] = useState(false); const cardRef = useRef(null); + const observerRef = useRef(null); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = - useSortable({ id: bookmark.id, disabled: !isAdmin }); + const { + attributes, listeners, setNodeRef, transform, transition, isDragging, + } = useSortable({ + id: link.id, + disabled: !isDraggable || isBatchEditMode, + }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, + ...(color ? { + '--icon-color': color.hex, + '--icon-color-rgb': color.rgb, + } as React.CSSProperties : {}), }; - // 长按检测:触摸开始 - const handleTouchStart = useCallback( - (e: React.TouchEvent) => { - isLongPress.current = false; - longPressTimer.current = setTimeout(() => { - isLongPress.current = true; - // 获取触摸位置 - const touch = e.touches[0]; - setMenuPos({ x: touch.clientX, y: touch.clientY }); - setShowMenu(true); - // 禁止默认的上下文菜单/文本选择 - if (cardRef.current) { - cardRef.current.style.webkitTouchCallout = "none"; - cardRef.current.style.userSelect = "none"; - } - }, 600); // 600ms 长按阈值 - }, - [] - ); - - // 触摸结束 - const handleTouchEnd = useCallback(() => { - if (longPressTimer.current) { - clearTimeout(longPressTimer.current); - longPressTimer.current = null; - } - // 延迟重置,避免误触发点击 - setTimeout(() => { - isLongPress.current = false; - }, 100); - }, []); + const isDetailedView = viewMode === 'detailed'; + const iconSrc = link.icon && !imgError ? link.icon : null; - // 触摸移动(取消长按) - const handleTouchMove = useCallback(() => { - if (longPressTimer.current) { - clearTimeout(longPressTimer.current); - longPressTimer.current = null; + // 观察可见性,离屏卡片延迟执行颜色提取 + useEffect(() => { + const el = cardRef.current; + if (!el) return; + if (typeof IntersectionObserver === 'undefined') { + setIsVisible(true); + return; } + observerRef.current = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observerRef.current?.disconnect(); + } + }, + { rootMargin: '200px' } + ); + observerRef.current.observe(el); + return () => observerRef.current?.disconnect(); }, []); - // 右键菜单(桌面端) - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - setMenuPos({ x: e.clientX, y: e.clientY }); - setShowMenu(true); - }, - [] - ); - - // 点击其他地方关闭菜单 + // 提取图标颜色 - 仅在卡片可见时执行 useEffect(() => { - const handleClickOutside = () => setShowMenu(false); - if (showMenu) { - document.addEventListener("click", handleClickOutside); - document.addEventListener("touchstart", handleClickOutside); + if (!isVisible) return; + if (!iconSrc) { + setColor(generateColorFromText(link.title)); + return; } - return () => { - document.removeEventListener("click", handleClickOutside); - document.removeEventListener("touchstart", handleClickOutside); - }; - }, [showMenu]); - // 清理定时器 - useEffect(() => { - return () => { - if (longPressTimer.current) clearTimeout(longPressTimer.current); - }; + extractColorFromImage(iconSrc).then(result => { + if (result) { + setColor(result); + } + }); + }, [iconSrc, link.title, isVisible]); + + // 鼠标位置追踪 + const rafRef = useRef(null); + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (rafRef.current) return; + rafRef.current = requestAnimationFrame(() => { + const card = cardRef.current; + if (card) { + const rect = card.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + card.style.setProperty('--pointer-x', `${x}`); + card.style.setProperty('--pointer-y', `${y}`); + } + rafRef.current = null; + }); }, []); - const handleDelete = () => { - onDelete(bookmark.id); - setShowMenu(false); + const mergedRef = useCallback((node: HTMLDivElement | null) => { + setNodeRef(node); + (cardRef as React.MutableRefObject).current = node; + }, [setNodeRef]); + + const handleClick = () => { + if (isBatchEditMode) { + onToggleSelection(link.id); + } else if (!isEditMode) { + window.open(link.url, '_blank', 'noopener,noreferrer'); + } }; - const handleOpenLink = () => { - window.open(bookmark.url, "_blank", "noopener,noreferrer"); + const handleWeightSave = () => { + const num = parseInt(weightValue, 10); + if (!isNaN(num) && onWeightChange) { + onWeightChange(link.id, num); + } + setIsEditingWeight(false); }; return ( - <> -
{ - setNodeRef(node); - (cardRef as React.MutableRefObject).current = node; - }} - style={style} - {...attributes} - className="group relative bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 - shadow-sm hover:shadow-md transition-all duration-200 overflow-hidden" - onContextMenu={handleContextMenu} - onTouchStart={handleTouchStart} - onTouchEnd={handleTouchEnd} - onTouchMove={handleTouchMove} - onClick={(e) => { - // 如果是长按触发的,不执行点击跳转 - if (isLongPress.current) { - e.preventDefault(); - return; - } - handleOpenLink(); - }} - > - {/* 拖拽手柄(仅管理员可见) */} - {isAdmin && ( -
- -
+
onContextMenu(e, link)} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} + onMouseMove={handleMouseMove} + {...(isDraggable && !isBatchEditMode ? attributes : {})} + {...(isDraggable && !isBatchEditMode ? listeners : {})} + > + {/* 背景模糊图标 */} +
+ {iconSrc ? ( + setImgError(true)} /> + ) : ( + {link.title.charAt(0).toUpperCase()} )} +
-
- {/* 图标和标题 */} -
-
- {bookmark.title.charAt(0).toUpperCase()} -
-
-

- {bookmark.title} -

-

- {bookmark.description || bookmark.url} -

-
-
- - {/* URL 和标签 */} -
- - {new URL(bookmark.url).hostname} - - {bookmark.category && ( - - {bookmark.category} - - )} -
+ {/* Batch edit checkbox */} + {isBatchEditMode && ( +
+ onToggleSelection(link.id)} + className="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500" + onClick={(e) => e.stopPropagation()} + />
+ )} - {/* 底部操作栏(hover 显示) */} -
- - {isAdmin && ( + {/* Weight badge */} + {isEditMode && onWeightChange && ( +
+ {isEditingWeight ? ( + setWeightValue(e.target.value)} + onBlur={handleWeightSave} + onKeyDown={(e) => e.key === 'Enter' && handleWeightSave()} + className="w-12 h-6 text-xs text-center bg-white dark:bg-slate-700 border border-blue-400 rounded px-1 outline-none" + onClick={(e) => e.stopPropagation()} + autoFocus + /> + ) : ( )}
+ )} + + {/* Link content */} +
+ {isDetailedView ? ( + <> +
+
+
+ {iconSrc ? setImgError(true)} /> : link.title.charAt(0).toUpperCase()} +
+

+ {link.title} + {link.isPrivate && 🔒} +

+
+ {link.description && ( +

+ {link.description} +

+ )} +
+ {iconSrc ? setImgError(true)} /> : link.title.charAt(0).toUpperCase()} +
+
+

+ {link.title} + {link.isPrivate && 🔒} +

+ {link.description && ( +

+ {link.description} +

+ )} +
+
+ + ) : ( + <> +
+
+ {iconSrc ? setImgError(true)} /> : link.title.charAt(0).toUpperCase()} +
+

+ {link.title} + {link.isPrivate && 🔒} +

+
+ {link.description && ( +
+ {link.description} +
+ )} + + )}
- {/* 右键/长按菜单 */} - {showMenu && ( -
e.stopPropagation()} - > - + {/* Hover actions - 只在编辑模式下显示 */} + {!isBatchEditMode && authToken && isEditMode && ( +
- {isAdmin && ( - <> -
- - - )}
)} - +
); } + // 长按处理(移动端模拟右击) + const handleTouchStart = (e: React.TouchEvent) => { + if (!authToken || isBatchEditMode) return; + touchTimerRef.current = setTimeout(() => { + const touch = e.touches[0]; + const mockEvent = { + preventDefault: () => {}, + stopPropagation: () => {}, + clientX: touch.clientX, + clientY: touch.clientY, + } as unknown as React.MouseEvent; + onContextMenu(mockEvent, link); + }, 500); + }; + + const handleTouchEnd = () => { + if (touchTimerRef.current) { + clearTimeout(touchTimerRef.current); + touchTimerRef.current = null; + } + }; + + const handleTouchMove = () => { + if (touchTimerRef.current) { + clearTimeout(touchTimerRef.current); + touchTimerRef.current = null; + } + }; + From 5c8d66fcf18dfb4fdfb24e05a43687d26aa3ce68 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:01:45 +0800 Subject: [PATCH 040/149] Update index.html --- index.html | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index 133e1c7d..ca3bc464 100644 --- a/index.html +++ b/index.html @@ -7,9 +7,7 @@ - - - + @@ -64,13 +62,29 @@ } /* Ensure header background extends to safe area */ header, .header-bg { - background-color: rgb(248, 250, 252); + background-color: inherit; + } + + + + + + From add64416e3dfb0a60b0a0629ecacc34f9a922c99 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:02:32 +0800 Subject: [PATCH 041/149] Update Header.tsx From d2a9720fd7b2596788ff8dba5ebc33337cd9b084 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:11:47 +0800 Subject: [PATCH 042/149] Update Header.tsx --- src/components/layout/Header.tsx | 104 ++++++++++++------------------- 1 file changed, 41 insertions(+), 63 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 7a7543f2..b16f23d0 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -453,106 +453,84 @@ export function Header({ {/* Mobile Tools Dropdown Panel */} {isMobileToolsOpen && ( -
- {/* Search Input */} -
- - onSearchChange(e.target.value)} - placeholder="搜索书签..." - className="bg-transparent text-sm text-slate-800 dark:text-slate-200 outline-none w-full placeholder:text-slate-400" - /> - {searchQuery && ( - - )} -
- - {/* Tools Grid */} -
+
+ {/* Tools Icons Row */} +
- + - {authToken ? ( - ) : ( - )} + + +
- {/* GitHub Link */} -
- - GitHub -
)}
From 60624c6660a9cf7794b2503465fbb3c0996a45ba Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:13:46 +0800 Subject: [PATCH 043/149] Update LinkCard.tsx --- src/components/link/LinkCard.tsx | 33 -------------------------------- 1 file changed, 33 deletions(-) diff --git a/src/components/link/LinkCard.tsx b/src/components/link/LinkCard.tsx index 5d99c028..7ab1d7db 100644 --- a/src/components/link/LinkCard.tsx +++ b/src/components/link/LinkCard.tsx @@ -26,7 +26,6 @@ export function LinkCard({ isDraggable = true, authToken, isEditMode = false, onWeightChange, }: LinkCardProps) { const [imgError, setImgError] = useState(false); - const touchTimerRef = useRef | null>(null); const [color, setColor] = useState(null); const [isEditingWeight, setIsEditingWeight] = useState(false); const [weightValue, setWeightValue] = useState(link.weight?.toString() || '0'); @@ -144,9 +143,6 @@ export function LinkCard({ } ${isDragging ? 'shadow-2xl scale-105' : ''}`} onClick={handleClick} onContextMenu={(e) => onContextMenu(e, link)} - onTouchStart={handleTouchStart} - onTouchEnd={handleTouchEnd} - onTouchMove={handleTouchMove} onMouseMove={handleMouseMove} {...(isDraggable && !isBatchEditMode ? attributes : {})} {...(isDraggable && !isBatchEditMode ? listeners : {})} @@ -279,32 +275,3 @@ export function LinkCard({
); } - // 长按处理(移动端模拟右击) - const handleTouchStart = (e: React.TouchEvent) => { - if (!authToken || isBatchEditMode) return; - touchTimerRef.current = setTimeout(() => { - const touch = e.touches[0]; - const mockEvent = { - preventDefault: () => {}, - stopPropagation: () => {}, - clientX: touch.clientX, - clientY: touch.clientY, - } as unknown as React.MouseEvent; - onContextMenu(mockEvent, link); - }, 500); - }; - - const handleTouchEnd = () => { - if (touchTimerRef.current) { - clearTimeout(touchTimerRef.current); - touchTimerRef.current = null; - } - }; - - const handleTouchMove = () => { - if (touchTimerRef.current) { - clearTimeout(touchTimerRef.current); - touchTimerRef.current = null; - } - }; - From f97379684f422e4baaf9aebcc685f0890adec3a4 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:25:09 +0800 Subject: [PATCH 044/149] Update index.html From e183115e3702666fbc3f92b5cb06b875488e07b9 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:26:17 +0800 Subject: [PATCH 045/149] Fix missing newline at end of Header.tsx From c7e740b021ff861e17ebfa16cec9395d17257ea2 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:27:24 +0800 Subject: [PATCH 046/149] Update LinkCard.tsx --- src/components/link/LinkCard.tsx | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/components/link/LinkCard.tsx b/src/components/link/LinkCard.tsx index 7ab1d7db..5d99c028 100644 --- a/src/components/link/LinkCard.tsx +++ b/src/components/link/LinkCard.tsx @@ -26,6 +26,7 @@ export function LinkCard({ isDraggable = true, authToken, isEditMode = false, onWeightChange, }: LinkCardProps) { const [imgError, setImgError] = useState(false); + const touchTimerRef = useRef | null>(null); const [color, setColor] = useState(null); const [isEditingWeight, setIsEditingWeight] = useState(false); const [weightValue, setWeightValue] = useState(link.weight?.toString() || '0'); @@ -143,6 +144,9 @@ export function LinkCard({ } ${isDragging ? 'shadow-2xl scale-105' : ''}`} onClick={handleClick} onContextMenu={(e) => onContextMenu(e, link)} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} onMouseMove={handleMouseMove} {...(isDraggable && !isBatchEditMode ? attributes : {})} {...(isDraggable && !isBatchEditMode ? listeners : {})} @@ -275,3 +279,32 @@ export function LinkCard({ ); } + // 长按处理(移动端模拟右击) + const handleTouchStart = (e: React.TouchEvent) => { + if (!authToken || isBatchEditMode) return; + touchTimerRef.current = setTimeout(() => { + const touch = e.touches[0]; + const mockEvent = { + preventDefault: () => {}, + stopPropagation: () => {}, + clientX: touch.clientX, + clientY: touch.clientY, + } as unknown as React.MouseEvent; + onContextMenu(mockEvent, link); + }, 500); + }; + + const handleTouchEnd = () => { + if (touchTimerRef.current) { + clearTimeout(touchTimerRef.current); + touchTimerRef.current = null; + } + }; + + const handleTouchMove = () => { + if (touchTimerRef.current) { + clearTimeout(touchTimerRef.current); + touchTimerRef.current = null; + } + }; + From f0e9f80eefd448fcf3920acee2bf0cb990636a5c Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:32:55 +0800 Subject: [PATCH 047/149] Update Header.tsx --- src/components/layout/Header.tsx | 112 ++++++------------------------- 1 file changed, 19 insertions(+), 93 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index b16f23d0..5d338ff4 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Search, X, Plus, Moon, Sun, Menu, Settings, Upload, CheckSquare, LogOut, Lock, GripVertical, Edit3, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Layers } from 'lucide-react'; +import { Search, X, Plus, Moon, Sun, Menu, Settings, Upload, CheckSquare, LogOut, Lock, GripVertical, Edit3, ChevronLeft, ChevronRight, Layers } from 'lucide-react'; import { useConfigContext } from '../../contexts/ConfigContext'; import { useAuthContext } from '../../contexts/AuthContext'; import { useLinksContext } from '../../contexts/LinksContext'; @@ -168,7 +168,7 @@ export function Header({ const { syncStatus } = useLinksContext(); const [showDropdown, setShowDropdown] = useState(false); const [isToolsExpanded, setIsToolsExpanded] = useState(true); - const [isMobileToolsOpen, setIsMobileToolsOpen] = useState(false); + const dropdownTimer = useRef(null); const engine = visitorEngineId || search?.defaultEngine || 'internal'; @@ -338,15 +338,7 @@ export function Header({ {/* Mobile tools toggle */} - - - {/* Removed sync status indicator block */} +{/* Removed sync status indicator block */} {authToken ? (
@@ -452,88 +444,22 @@ export function Header({
{/* Mobile Tools Dropdown Panel */} - {isMobileToolsOpen && ( -
- {/* Tools Icons Row */} -
- - - - - - - - {authToken ? ( - - ) : ( - - )} - - - -
-
- )} - + + + {/* Mobile FAB - Toggle tools expansion */} + + ); } From 7e908d84700a32fd0c989a7d9a446d265082be74 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:42:26 +0800 Subject: [PATCH 048/149] Update Header.tsx --- src/components/layout/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 5d338ff4..400fcb2d 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -200,7 +200,7 @@ export function Header({ - {/* Mobile Search Bar - Expands to fill space */} + {isMobileSearchOpen && (
From a4f7d2cfc854e267d0cdf249604713932886a721 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:44:47 +0800 Subject: [PATCH 049/149] Update Header.tsx --- src/components/layout/Header.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 400fcb2d..3f36c98a 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -77,7 +77,7 @@ function SearchEngineOptions({ ...(search?.customEngineUrl ? [{ id: 'custom', name: '自定义' }] : []), ]; - return ( + return (<>
- + {/* Mobile Search Bar - Expands to fill space */} {isMobileSearchOpen && (
@@ -460,7 +460,7 @@ export function Header({ - ); + ); } // Sub-component for the expandable desktop search From 931e6de982d836850aeb05b3841b88f3b06d517f Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:31:14 +0800 Subject: [PATCH 050/149] Update Header.tsx --- src/components/layout/Header.tsx | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 3f36c98a..a8f3b0ab 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -77,7 +77,7 @@ function SearchEngineOptions({ ...(search?.customEngineUrl ? [{ id: 'custom', name: '自定义' }] : []), ]; - return (<> + return (
(null); const engine = visitorEngineId || search?.defaultEngine || 'internal'; @@ -337,8 +336,7 @@ export function Header({ {darkMode ? : } - {/* Mobile tools toggle */} -{/* Removed sync status indicator block */} + {/* Removed sync status indicator block */} {authToken ? (
@@ -442,25 +440,8 @@ export function Header({ )}
- - {/* Mobile Tools Dropdown Panel */} - - - {/* Mobile FAB - Toggle tools expansion */} - - - ); + + ); } // Sub-component for the expandable desktop search From 74c7d083ed19e90c07c8415304ea5ec1966e10c3 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:10:06 +0800 Subject: [PATCH 051/149] Simplify viewport and theme-color meta tags Removed iOS Safe Area styles and meta tags for mobile web app support. --- index.html | 39 ++------------------------------------- 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/index.html b/index.html index ca3bc464..2c18429c 100644 --- a/index.html +++ b/index.html @@ -3,11 +3,11 @@ - + - + @@ -52,41 +52,6 @@ } })(); - - - - - - -
From cf65372dae5822816b1458acd320df525e125b72 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:34:18 +0800 Subject: [PATCH 052/149] Update LinkCard.tsx --- src/components/link/LinkCard.tsx | 119 +++++++++++++++++-------------- 1 file changed, 65 insertions(+), 54 deletions(-) diff --git a/src/components/link/LinkCard.tsx b/src/components/link/LinkCard.tsx index 5d99c028..7e4ff7fa 100644 --- a/src/components/link/LinkCard.tsx +++ b/src/components/link/LinkCard.tsx @@ -128,6 +128,51 @@ export function LinkCard({ setIsEditingWeight(false); }; + // ========== 修复:将触摸事件函数从文件底部移到此处 ========== + const handleTouchStart = (e: React.TouchEvent) => { + if (isBatchEditMode || isEditMode) return; + const touch = e.touches[0]; + + // 记录触摸起始位置 + (e.currentTarget as any).dataset.touchX = String(touch.clientX); + (e.currentTarget as any).dataset.touchY = String(touch.clientY); + + // 长按 600ms 触发上下文菜单 + touchTimerRef.current = setTimeout(() => { + const syntheticEvent = { + preventDefault: () => {}, + stopPropagation: () => {}, + clientX: touch.clientX, + clientY: touch.clientY, + currentTarget: e.currentTarget, + target: e.target, + } as unknown as React.MouseEvent; + onContextMenu(syntheticEvent, link); + }, 600); + }; + + const handleTouchEnd = () => { + if (touchTimerRef.current) { + clearTimeout(touchTimerRef.current); + touchTimerRef.current = null; + } + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (!touchTimerRef.current) return; + const touch = e.touches[0]; + const startX = parseFloat((e.currentTarget as any).dataset.touchX || '0'); + const startY = parseFloat((e.currentTarget as any).dataset.touchY || '0'); + const dx = Math.abs(touch.clientX - startX); + const dy = Math.abs(touch.clientY - startY); + // 如果移动超过 10px,取消长按 + if (dx > 10 || dy > 10) { + clearTimeout(touchTimerRef.current); + touchTimerRef.current = null; + } + }; + // ========== 修复结束 ========== + return (
) : ( <> -
-
- {iconSrc ? setImgError(true)} /> : link.title.charAt(0).toUpperCase()} -
-

+
+ {iconSrc ? setImgError(true)} /> : link.title.charAt(0).toUpperCase()} +
+
+

{link.title} {link.isPrivate && 🔒}

+ {link.description && ( +

+ {link.description} +

+ )}
- {link.description && ( -
- {link.description} -
- )} )}

- {/* Hover actions - 只在编辑模式下显示 */} - {!isBatchEditMode && authToken && isEditMode && ( -
- -
+ {/* Drag handle */} + {isDraggable && !isBatchEditMode && ( + )}
); } - // 长按处理(移动端模拟右击) - const handleTouchStart = (e: React.TouchEvent) => { - if (!authToken || isBatchEditMode) return; - touchTimerRef.current = setTimeout(() => { - const touch = e.touches[0]; - const mockEvent = { - preventDefault: () => {}, - stopPropagation: () => {}, - clientX: touch.clientX, - clientY: touch.clientY, - } as unknown as React.MouseEvent; - onContextMenu(mockEvent, link); - }, 500); - }; - - const handleTouchEnd = () => { - if (touchTimerRef.current) { - clearTimeout(touchTimerRef.current); - touchTimerRef.current = null; - } - }; - - const handleTouchMove = () => { - if (touchTimerRef.current) { - clearTimeout(touchTimerRef.current); - touchTimerRef.current = null; - } - }; - From 945c0aadb88b2ec63fe10a0af9ac6a4624c4a202 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:27:37 +0800 Subject: [PATCH 053/149] Update index.css --- src/index.css | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/index.css b/src/index.css index e9dedc77..adc401f2 100644 --- a/src/index.css +++ b/src/index.css @@ -408,4 +408,39 @@ html.dark .dark\:focus\:ring-blue-500:focus { section[data-category-section] { content-visibility: auto; contain-intrinsic-size: auto 320px; -} \ No newline at end of file +} + +/* ========== 移动端优化修复 ========== */ + +/* 防止 iOS Safari 输入框自动放大 */ +input, textarea, select { + font-size: 16px !important; +} + +/* 移动端模态框适配 */ +@media (max-width: 640px) { + /* 模态框容器顶部对齐,方便滚动 */ + .fixed.inset-0.z-\[60\] { + align-items: flex-start !important; + padding-top: 1rem; + padding-bottom: 1rem; + } + + /* 模态框内容最大高度 */ + .max-w-md.max-h-\[92vh\] { + max-height: 95vh !important; + } +} + +/* 确保工具栏按钮不被压缩 */ +.flex-shrink-0 { + flex-shrink: 0 !important; +} + +/* 小屏幕下隐藏部分非必要元素 */ +@media (max-width: 480px) { + /* 如果天气组件太宽,可以隐藏温度文字只显示图标 */ + .weather-display .weather-temp { + display: none; + } +} From 2f62e6bbf8822cac296a30acdd3146e29f09ff15 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:28:54 +0800 Subject: [PATCH 054/149] Update Header.tsx --- src/components/layout/Header.tsx | 50 ++++++++++++++++---------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index a8f3b0ab..25ce1016 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -237,7 +237,7 @@ export function Header({ onChange={(e) => onSearchChange(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && onSearch(searchQuery)} placeholder={isInternal ? "搜索站内链接,点击图标(彩色时)搜索互联网" : "搜索互联网,点击图标(灰色时)站内搜索"} - className="w-full pl-9 pr-4 py-2 h-[36px] rounded-full bg-slate-200 dark:bg-slate-700 border-none text-xs focus:ring-2 focus:ring-blue-500 dark:text-white placeholder-slate-400 outline-none transition-all leading-none" + className="w-full pl-9 pr-4 py-2 h-[36px] rounded-full bg-slate-200 dark:bg-slate-700 border-none text-base focus:ring-2 focus:ring-blue-500 dark:text-white placeholder-slate-400 outline-none transition-all leading-none" style={{ fontSize: '16px' }} inputMode="search" enterKeyHint="search" @@ -311,24 +311,24 @@ export function Header({ > + >简约 + >详情
{/* Theme toggle */} @@ -341,8 +341,8 @@ export function Header({ {authToken ? (
{/* Add link - Always visible as primary action */} -
@@ -353,68 +353,68 @@ export function Header({ isToolsExpanded ? 'max-w-[400px] opacity-100' : 'max-w-0 opacity-0' }`} > -
+
{/* Settings */} {/* Manage Categories */} {/* Backup/Restore */} {/* Drag sort toggle */} {/* Edit mode toggle */} {/* Batch edit */} - {/* Logout */} -
@@ -422,10 +422,10 @@ export function Header({ {/* Toggle Button */}
) : ( @@ -569,7 +569,7 @@ function HeaderSearch({ if (e.key === 'Escape') handleClose(); }} placeholder={isInternal ? "搜索站内链接,点击图标(彩色时)搜索互联网" : "搜索互联网,点击图标(灰色时)站内搜索"} - className={`bg-transparent border-none text-xs focus:ring-0 dark:text-white placeholder-slate-400 outline-none h-full transition-all duration-300 ${ + className={`bg-transparent border-none text-base focus:ring-0 dark:text-white placeholder-slate-400 outline-none h-full transition-all duration-300 ${ isExpanded ? 'flex-1 min-w-0 opacity-100' : 'w-0 opacity-0 pointer-events-none' }`} tabIndex={isExpanded ? 0 : -1} From fcab7b11482dc94cecfe9817c2d70b1a603582f9 Mon Sep 17 00:00:00 2001 From: kinga-a <56465232+kinga-a@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:29:32 +0800 Subject: [PATCH 055/149] Update LinkModal.tsx --- components/LinkModal.tsx | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/components/LinkModal.tsx b/components/LinkModal.tsx index 44f211dd..0a54cc3d 100644 --- a/components/LinkModal.tsx +++ b/components/LinkModal.tsx @@ -461,10 +461,10 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete if (!isOpen) return null; return ( -
-
-
-
+
+
+
+

{initialData ? '编辑链接' : '添加新链接'}

@@ -527,7 +527,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete
-
+
= ({ isOpen, onClose, onSave, onDelete required value={title} onChange={(e) => setTitle(e.target.value)} - className="w-full p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all" + className="w-full p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all text-base" placeholder="网站名称" />
@@ -548,7 +548,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete required value={url} onChange={(e) => setUrl(e.target.value)} - className="w-full p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all" + className="w-full p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all text-base" placeholder="example.com 或 https://..." />
@@ -574,7 +574,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete setIcon(''); } }} - className="w-full p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all" + className="w-full p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all text-base" > @@ -595,7 +595,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete setIcon(e.target.value); setCustomIconUrl(e.target.value); }} - className="flex-1 p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all" + className="flex-1 p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all text-base" placeholder="https://example.com/icon.png" />
@@ -629,13 +629,13 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete type="url" value={customApiUrl} onChange={(e) => setCustomApiUrl(e.target.value)} - className="flex-1 p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all" + className="flex-1 p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all text-base" placeholder="https://api.example.com/icon" />