This commit is contained in:
+320
@@ -0,0 +1,320 @@
|
||||
'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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2M12 19.3v2.2M4.5 4.5l1.6 1.6M17.9 17.9l1.6 1.6M2.5 12h2.2M19.3 12h2.2M4.5 19.5l1.6-1.6M17.9 6.1l1.6-1.6"/></svg>';
|
||||
const THEME_ICON_MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
|
||||
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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user