'use strict'; let authReady = false; let isPublicMode = false; let publicShowNsfw = false; async function apiJSON(path, options = {}){ const res = await fetch(path, { cache: 'no-store', credentials: 'same-origin', ...options, headers: { ...(options.body && !(options.body instanceof Blob) ? {'Content-Type':'application/json'} : {}), ...(options.headers || {}) } }); if(!res.ok){ let message = res.statusText; try{ const js = await res.json(); message = js.error || message; }catch{} throw new Error(message || `HTTP ${res.status}`); } if(res.status === 204) return null; const text = await res.text(); return text ? JSON.parse(text) : null; } async function ensureSession(){ const shell = document.getElementById('loginShell'); const status = document.getElementById('loginStatus'); const form = document.getElementById('loginForm'); async function check(){ try{ const session = await apiJSON('/api/session'); authReady = !!session?.authenticated; isPublicMode = !!session?.publicMode; publicShowNsfw = !!session?.publicShowNsfw; }catch{ authReady = false; isPublicMode = false; publicShowNsfw = false; } if(shell) shell.classList.toggle('hidden', authReady || isPublicMode); return authReady || isPublicMode; } if(await check()) return true; if(status) status.textContent = ''; if(form){ form.onsubmit = async (e) => { e.preventDefault(); const input = document.getElementById('adminPasswordInput'); const button = document.getElementById('loginSubmitBtn'); if(button) button.disabled = true; if(status) status.textContent = '验证中…'; try{ await apiJSON('/api/login', {method:'POST', body:JSON.stringify({password: input?.value || ''})}); if(input) input.value = ''; location.reload(); }catch(err){ if(status) status.textContent = '密码不正确,请重试'; }finally{ if(button) button.disabled = false; } }; } return false; } async function changeAdminPassword(){ const current = $('currentAdminPasswordInput')?.value || ''; const next = $('newAdminPasswordInput')?.value || ''; if(next.length < 10){ toast('New admin password must be at least 10 characters'); return; } try{ await apiJSON('/api/admin/password', {method:'POST', body:JSON.stringify({currentPassword:current, newPassword:next})}); $('currentAdminPasswordInput').value = ''; $('newAdminPasswordInput').value = ''; toast('Admin password changed'); }catch(err){ toast('Password change failed: ' + err.message); } } async function logout(){ try{ await apiJSON('/api/logout', {method:'POST'}); }catch{} location.reload(); } function bgmApiBase(){ return (state.settings.bgmApiBase || '').trim().replace(/\/+$/,'') || 'https://api.bgm.tv'; } // Bangumi site base for opening subject/tag pages in the browser (mirror-friendly; not used server-side). function bgmSiteBase(){ return (state.settings.siteBase || '').trim().replace(/\/+$/,'') || 'https://bgm.tv'; } const TOKEN_URL = 'https://next.bgm.tv/demo/access-token'; const SUBJECT_TYPES = [2,1,4,3,6]; const COLLECTION_TYPES = [1,2,3,4,5]; const STATUS = {0:'全部',1:'想看',2:'看过',3:'在看',4:'搁置',5:'抛弃',99:'安全箱'}; const STATUS_BY_TYPE = { 1:{1:'想读',2:'读过',3:'在读'}, 3:{1:'想听',2:'听过',3:'在听'}, 4:{1:'想玩',2:'玩过',3:'在玩'}, }; function statusLabelFor(ct, st){ return (STATUS_BY_TYPE[st]?.[ct]) || STATUS[ct] || '未知'; } const SUBJECT_LABEL = {1:'书籍',2:'动画',3:'音乐',4:'游戏',6:'三次元'}; const BGM_TYPE_PATH = {1:'book',2:'anime',3:'music',4:'game',6:'real'}; const DEFAULT_STATE = {schema:9, settings:{username:'', token:'', autoCacheCovers:false, autoSyncEnabled:false, autoSyncCron:'0 2 * * *', autoSyncMode:'recent', autoSyncRangeMode:'fixed', autoSyncRangeValue:3, autoSyncRangeUnit:'month', autoSyncLastRunKey:'', theme:'dark', themeMode:'dark', recentMonths:3, fontFamily:'system', cardSize:'standard', pageSize:120, commentLines:0, motion:'full', borderWidth:1, accentColor:'pink', bgmApiBase:'', imageProxy:'', siteBase:''}, collections:{}, history:[], runs:[]}; let state = structuredClone(DEFAULT_STATE); let filters = {status:0, tag:'全部', type:0, q:'', sort:'updated_desc', nsfw:'all', hasComment:false}; let runtimeToken = ''; let activeDrawerId = null; let syncInProgress = false; let renderLimit = 0; const $ = (id) => document.getElementById(id); const nowISO = () => new Date().toISOString(); const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); const safeName = (s) => String(s || 'bangumi_vault').replace(/[\\/:*?"<>|]+/g,'_').slice(0,80); function toast(msg){ const t=$('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(t._tm); t._tm=setTimeout(()=>t.classList.remove('show'),3200); } function log(id,msg){ const el=$(id); if(!el) return; el.textContent += `[${new Date().toLocaleTimeString()}] ${msg}\n`; el.scrollTop = el.scrollHeight; } function setProgress(p){ const pct=Math.max(0,Math.min(100,p)); $('syncBar').style.width = pct + '%'; const mf=$('syncMiniFill'); if(mf) mf.style.width = pct + '%'; const ml=$('syncMiniLabel'); if(ml) ml.textContent = `同步中 ${Math.round(pct)}%`; } function showSyncMini(){ const el=$('syncMiniBar'); if(el && syncInProgress){ el.classList.remove('smi-done'); el.classList.add('show'); } } function hideSyncMini(){ const el=$('syncMiniBar'); if(el) el.classList.remove('show'); } function clampNum(v, min, max, fallback){ const n = Number(v); return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback; } function pageSize(){ return clampNum(state.settings.pageSize, 40, 360, 120); } function resetRenderLimit(){ renderLimit = pageSize(); } function effectiveTheme(){ const mode = state.settings.themeMode || state.settings.theme || 'dark'; if(mode === 'system') return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return mode === 'light' ? 'light' : 'dark'; } // Theme toggle icons (stroke = currentColor, matching .tag-search-toggle). Dark active → sun (click to lighten); light → moon. const THEME_ICON_SUN = ''; const THEME_ICON_MOON = ''; function applyAppearance(animate=false, ev=null){ const update = () => { const root = document.documentElement; const theme = effectiveTheme(); root.dataset.theme = theme; root.dataset.themeMode = state.settings.themeMode || state.settings.theme || 'dark'; root.dataset.font = state.settings.fontFamily || 'system'; root.dataset.cardSize = state.settings.cardSize || 'standard'; root.dataset.motion = state.settings.motion || 'full'; root.dataset.accent = state.settings.accentColor || 'pink'; root.style.setProperty('--comment-lines', String(clampNum(state.settings.commentLines, 0, 6, 0))); root.style.setProperty('--ui-border-width', clampNum(state.settings.borderWidth, 0, 4, 1) + 'px'); root.dataset.borderZero = clampNum(state.settings.borderWidth, 0, 4, 1) === 0 ? 'true' : 'false'; const themeIcon = theme === 'dark' ? THEME_ICON_SUN : THEME_ICON_MOON; document.querySelectorAll('.theme-toggle').forEach(b => b.innerHTML = themeIcon); }; if(!animate || !document.startViewTransition){ update(); return; } const x = ev?.clientX ?? (window.innerWidth - 96); const y = ev?.clientY ?? 38; const endRadius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)); const transition = document.startViewTransition(update); transition.ready.then(() => { document.documentElement.animate({clipPath:[`circle(0px at ${x}px ${y}px)`,`circle(${endRadius}px at ${x}px ${y}px)`], filter:['blur(18px)','blur(0px)']}, {duration:520, easing:'cubic-bezier(.22,1,.36,1)', pseudoElement:'::view-transition-new(root)'}); document.documentElement.animate({filter:['blur(0px)','blur(18px)']}, {duration:520, easing:'cubic-bezier(.22,1,.36,1)', pseudoElement:'::view-transition-old(root)'}); }).catch(()=>{}); } async function setThemeMode(mode, ev){ state.settings.themeMode = mode; state.settings.theme = mode === 'system' ? effectiveTheme() : mode; applyAppearance(true, ev); refreshSegmentedControls(); await saveSettings(); } async function toggleTheme(ev){ const next = effectiveTheme() === 'dark' ? 'light' : 'dark'; await setThemeMode(next, ev); } function refreshSegmentedControls(){ document.querySelectorAll('[data-segment]').forEach(group => { const key = group.dataset.segment; const value = String(state.settings[key] ?? DEFAULT_STATE.settings[key] ?? ''); group.querySelectorAll('button[data-value]').forEach(btn => btn.classList.toggle('active', String(btn.dataset.value) === value)); }); if($('pageSizeInput')) $('pageSizeInput').value = pageSize(); if($('commentLinesInput')) $('commentLinesInput').value = clampNum(state.settings.commentLines, 0, 6, 0); if($('borderWidthInput')) $('borderWidthInput').value = clampNum(state.settings.borderWidth, 0, 4, 1); } const APPEARANCE_SEGMENTS = ['themeMode','fontFamily','cardSize','motion','accentColor']; // Appearance preview snapshot: admin previews on click and we revert on close-without-save. let appearanceSnapshot = null; const APPEARANCE_KEYS = ['themeMode','theme','fontFamily','cardSize','motion','accentColor','pageSize','commentLines','borderWidth']; function snapshotAppearance(){ appearanceSnapshot = {}; for(const k of APPEARANCE_KEYS) appearanceSnapshot[k] = state.settings[k]; } function restoreAppearance(){ if(!appearanceSnapshot) return; let changed = false; for(const k of APPEARANCE_KEYS){ if(state.settings[k] !== appearanceSnapshot[k]){ state.settings[k] = appearanceSnapshot[k]; changed = true; } } if(changed){ applyAppearance(false); refreshSegmentedControls(); renderCards(); } } function bindSegmentedControls(){ document.querySelectorAll('[data-segment]').forEach(group => { const key = group.dataset.segment; group.querySelectorAll('button[data-value]').forEach(btn => { btn.onclick = async (e) => { const value = btn.dataset.value; state.settings[key] = value; if(key === 'themeMode'){ state.settings.theme = value === 'system' ? effectiveTheme() : value; applyAppearance(true, e); } else { applyAppearance(false); } refreshSegmentedControls(); if(APPEARANCE_SEGMENTS.includes(key)){ // Admin: click is preview-only (persisted via 保存设置). Guest: no save button, so persist locally now. if(!authReady) await saveSettings(); if(key === 'cardSize' || key === 'motion') renderCards(); } if(key === 'autoSyncMode' || key === 'autoSyncEnabled') updateAutoSyncHelp(); if(key === 'autoSyncEnabled' && typeof refreshAutoSyncCollapse === 'function') refreshAutoSyncCollapse(); }; }); }); } function openDB(){ return new Promise((resolve,reject)=>{ const req = indexedDB.open('BangumiVaultPosterWall', 1); req.onupgradeneeded = () => { const db=req.result; if(!db.objectStoreNames.contains('kv')) db.createObjectStore('kv', {keyPath:'key'}); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function idbGet(key){ const db=await openDB(); return new Promise((resolve,reject)=>{ const tx=db.transaction('kv','readonly'); const req=tx.objectStore('kv').get(key); req.onsuccess=()=>resolve(req.result?.value); req.onerror=()=>reject(req.error); }); } async function idbSet(key,value){ const db=await openDB(); return new Promise((resolve,reject)=>{ const tx=db.transaction('kv','readwrite'); tx.objectStore('kv').put({key,value}); tx.oncomplete=()=>resolve(); tx.onerror=()=>reject(tx.error); }); } async function idbDelete(key){ const db=await openDB(); return new Promise((resolve,reject)=>{ const tx=db.transaction('kv','readwrite'); tx.objectStore('kv').delete(key); tx.oncomplete=()=>resolve(); tx.onerror=()=>reject(tx.error); }); } const LOCAL_API = (location.protocol === 'http:' || location.protocol === 'https:') ? location.origin : ''; let localApiKnown = null; async function localApiAvailable(){ if(!LOCAL_API) return false; if(localApiKnown !== null) return localApiKnown; try{ const r = await fetch('/api/ping', {cache:'no-store'}); localApiKnown = !!(r.ok); } catch{ localApiKnown = false; } return localApiKnown; } async function localGetJSON(path){ return await apiJSON(path); } async function localPostJSON(path, obj){ return await apiJSON(path, {method:'POST', body:JSON.stringify(obj)}); } // History is no longer bundled into /api/state (kept the payload huge); fetch it on demand. async function fetchSubjectHistory(id){ if(window.BANGUMI_VAULT_OFFLINE_STATE) return (state.history||[]).filter(h=>String(h.subject_id)===String(id)); try{ const r = await apiJSON('/api/history?subject_id='+encodeURIComponent(id)); return r?.history || []; }catch{ return []; } } // Subject infobox (game type/engine/developer…) is stripped from /api/state. Kept in a separate // in-memory cache (NOT in `state`, so saveState never posts it back and re-bloats app_state.json). let infoboxCache = {}; let infoboxPrefetch = null; async function fetchSubjectInfobox(id){ if(window.BANGUMI_VAULT_OFFLINE_STATE){ const local = state.collections?.[id]?.infobox; return Array.isArray(local) ? local : []; } if(Array.isArray(infoboxCache[id])) return infoboxCache[id]; // Warm-up window: wait for the in-flight bulk prefetch instead of firing another slow per-subject query. if(infoboxPrefetch){ try{ await infoboxPrefetch; }catch{} if(Array.isArray(infoboxCache[id])) return infoboxCache[id]; } try{ const r = await apiJSON('/api/subject-meta?subject_id='+encodeURIComponent(id)); const ib = r?.infobox || []; infoboxCache[id] = ib; return ib; }catch{ return []; } } // Background-prefetch every infobox after load so opening any drawer shows 条目资料 instantly. function prefetchInfoboxes(){ if(window.BANGUMI_VAULT_OFFLINE_STATE) return; infoboxPrefetch = (async () => { try{ const r = await apiJSON('/api/infoboxes'); const map = (r && r.infoboxes) || {}; const next = {}; for(const id in state.collections) next[id] = Array.isArray(map[id]) ? map[id] : []; infoboxCache = next; }catch{} })(); return infoboxPrefetch; } // Pull the full history back into state (only needed before a backup export). async function loadFullHistory(){ if(window.BANGUMI_VAULT_OFFLINE_STATE) return; if(Array.isArray(state.history) && state.history.length) return; try{ const r = await apiJSON('/api/history'); if(r?.history) state.history = r.history; }catch{} } // Appearance/display prefs a guest may keep locally (never affects the shared library). const GUEST_PREF_KEYS = ['theme','themeMode','fontFamily','cardSize','motion','borderWidth','pageSize','commentLines']; async function applyGuestPrefs(){ if(authReady || !isPublicMode) return; try{ const prefs = await idbGet('guestSettings'); if(prefs) for(const k of GUEST_PREF_KEYS){ if(prefs[k] !== undefined) state.settings[k] = prefs[k]; } }catch{} } async function loadState(){ try{ if(window.BANGUMI_VAULT_OFFLINE_STATE){ state = mergeState(window.BANGUMI_VAULT_OFFLINE_STATE); } else { if(await localApiAvailable()){ const loaded = await localGetJSON('/api/state'); if(loaded){ state = mergeState(loaded); await applyGuestPrefs(); } } // Server (PostgreSQL) is the single source of truth — purge any legacy full-state browser cache. idbDelete('state').catch(()=>{}); try{ localStorage.removeItem('BangumiVaultState'); }catch{} } }catch(e){ console.warn(e); } runtimeToken = state.settings.token || ''; applyAppearance(false); resetRenderLimit(); // Warm the infobox cache ASAP (fire-and-forget; network-bound so it won't block render). Re-runs after each sync's loadState. if(!window.BANGUMI_VAULT_OFFLINE_STATE) prefetchInfoboxes(); } function mergeState(loaded){ const settings = {...DEFAULT_STATE.settings, ...(loaded.settings||{})}; if(!settings.themeMode) settings.themeMode = settings.theme || 'dark'; return {schema:9, settings, collections:loaded.collections||{}, history:loaded.history||[], runs:loaded.runs||[]}; } async function saveState(){ if(window.BANGUMI_VAULT_OFFLINE_STATE) return; if(!authReady && isPublicMode){ try{ await idbSet('guestSettings', structuredClone(state.settings)); }catch{} return; } if(await localApiAvailable()) await localPostJSON('/api/state', structuredClone(state)); } // Save only the settings subtree; never touch collections (avoids clobbering server-side sync results). async function saveSettings(){ if(window.BANGUMI_VAULT_OFFLINE_STATE) return; const settings = structuredClone(state.settings); if(!authReady && isPublicMode){ try{ await idbSet('guestSettings', settings); }catch{} return; } if(await localApiAvailable()) await localPostJSON('/api/settings', {settings}); } // Update one collection's status fields (safety box). Updates local state then patches the server row. async function setCollectionStatus(id, status, missingCount){ const row = state.collections[String(id)]; if(row){ row.status = status; if(missingCount !== undefined) row.missing_count = missingCount; } try{ await localPostJSON('/api/collections/status', {subject_id:String(id), status, missing_count:missingCount}); } catch(e){ toast('状态更新失败:' + e.message); } }