'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); }
}