Files
Stardream e3bf77296c
Build and Push Docker Image / build (push) Successful in 17s
Initial release
2026-06-16 20:27:36 +10:00

316 lines
27 KiB
JavaScript

'use strict';
function renderStatusNav(){
const counts = {0:0,1:0,2:0,3:0,4:0,5:0,99:0};
for(const c of getAllCollections()){
if(c.status !== 'confirmed_deleted' && c.status !== 'missing_candidate') counts[0]++;
if(c.status === 'missing_candidate' || c.status === 'protected_local' || c.status === 'confirmed_deleted') counts[99]++;
if(c.status !== 'missing_candidate' && c.status !== 'confirmed_deleted') counts[c.collection_type] = (counts[c.collection_type]||0)+1;
}
// Hide the safety box (status 99) from guests (read-only public mode, not the admin).
$('statusNav').innerHTML = Object.entries(STATUS).filter(([k])=>authReady || Number(k)!==99).map(([k,v])=>`<button data-status="${k}" class="${filters.status==k?'active':''}"><span>${statusLabelFor(Number(k),filters.type)||v}</span><span class="badge ${k==99&&counts[99]?'danger':''}">${counts[k]||0}</span></button>`).join('');
$('statusNav').querySelectorAll('button').forEach(btn=>btn.onclick=()=>{ resetRenderLimit(); filters.status=Number(btn.dataset.status); filters.tag='全部'; render(); });
}
function renderTags(){
const base = getAllCollections().filter(c => {
if(filters.status === 99) return c.status === 'missing_candidate' || c.status === 'protected_local' || c.status === 'confirmed_deleted';
if(c.status === 'missing_candidate' || c.status === 'confirmed_deleted') return false;
if(filters.status && c.collection_type !== filters.status) return false;
if(filters.type && c.subject_type !== filters.type) return false;
return true;
});
const myMap = new Map(); const publicMap = new Map(); let no=0;
for(const c of base){
const my = myTagsOf(c); const pub = publicTagNames(c);
if(!my.length && !pub.length) no++;
for(const t of my) myMap.set(t,(myMap.get(t)||0)+1);
for(const t of pub) publicMap.set(t,(publicMap.get(t)||0)+1);
}
// Sidebar tag quick-filter (substring on tag name); applied BEFORE slicing so tags beyond top-N are findable.
const q = ($('tagFilterInput')?.value || '').trim().toLowerCase();
const matchTag = ([name]) => !q || String(name).toLowerCase().includes(q);
const myTags = [...myMap.entries()].filter(matchTag).sort((a,b)=>b[1]-a[1] || a[0].localeCompare(b[0],'zh-Hans-CN')).slice(0,70).map(([name,n])=>({key:tagKey('my',name), label:name, n, kind:'my', prefix:'我的'}));
const publicTags = [...publicMap.entries()].filter(matchTag).sort((a,b)=>b[1]-a[1] || a[0].localeCompare(b[0],'zh-Hans-CN')).slice(0,90).map(([name,n])=>({key:tagKey('public',name), label:name, n, kind:'public', prefix:'公共'}));
const items = [{key:'全部',label:'全部',n:base.length,kind:'all'}, ...(!q && no?[{key:'无标签',label:'无标签',n:no,kind:'none'}]:[]), ...myTags, ...publicTags];
$('tagList').innerHTML = items.map(it=>`<button class="tag-chip tag-filter-btn ${it.kind==='my'?'my-tag':it.kind==='public'?'public-tag':''} ${filters.tag===it.key?'active':''}" data-tag-key="${esc(it.key)}" title="${esc(it.prefix ? it.prefix + '标签:' + it.label : it.label)}">${it.prefix?`<span class="tag-kind">${esc(it.prefix)}</span>`:''}${esc(it.label)} · ${it.n}</button>`).join('');
$('tagList').querySelectorAll('button').forEach(b=>b.onclick=()=>{ resetRenderLimit(); filters.tag=b.dataset.tagKey; render(); });
updateTagListMore();
}
// Mobile sidebar: reveal the "展开更多" toggle only when the tag list overflows its collapsed height.
// COLLAPSED must stay in sync with the `.tag-list` max-height in the mobile media query.
function updateTagListMore(){
const tl = $('tagList'), btn = $('tagListMore'); if(!tl || !btn) return;
const COLLAPSED = 138;
const overflow = tl.scrollHeight > COLLAPSED + 8;
btn.classList.toggle('show', overflow);
if(!overflow){ tl.classList.remove('tags-expanded'); btn.textContent = '展开更多'; }
}
// Drop the hover-expand fade mask on comments whose full text fits within the 7-line expanded height
// (so a fully-shown comment isn't needlessly faded). 7-line threshold is fixed → measure once per card.
function markCommentHoverFade(){
const list = $('content').querySelectorAll('.comment:not(.cmt-checked)');
if(!list.length) return;
const lh = parseFloat(getComputedStyle(list[0]).lineHeight) || 20;
const maxHover = lh * 7 + 2;
list.forEach(c => { c.classList.add('cmt-checked'); if(c.scrollHeight <= maxHover) c.classList.add('cmt-fits-hover'); });
}
async function renderCards(){
const arr = visibleCollections();
if(!renderLimit) resetRenderLimit();
const step = pageSize();
$('countPill').textContent = `${arr.length}`;
$('filterSummary').textContent = `${STATUS[filters.status] || '全部'} / ${tagLabelFromKey(filters.tag)} / ${filters.type ? SUBJECT_LABEL[filters.type] : '全部分类'}${filters.hasComment?' / 有吐槽':''}${filters.nsfw==='only'?' / 仅 NSFW':filters.nsfw==='hide'?' / 屏蔽 NSFW':''}`;
const anyFilter = filters.status || filters.type || filters.tag!=='全部' || (filters.q && filters.q.trim()) || filters.nsfw!=='all' || filters.hasComment;
$('filterSummary').classList.toggle('has-active', !!anyFilter);
if(!arr.length){
if(filters.status === 99){
$('content').innerHTML = `<div class="safety-empty"><div class="safety-panel"><h2>安全箱现在是空的,这是好事。</h2><p>安全箱不是普通分类,它只在“本地以前有,但这次远端完整同步后突然没有出现”的时候才会有内容。</p><div class="safety-steps"><div class="safety-step"><b>1. 正常同步新增条目:</b>你在 Bangumi 网页新增或修改收藏后,点“同步最新变动”,它会写入本地。</div><div class="safety-step"><b>2. 远端疑似吞条目:</b>条目不会被本地删除,而是进入安全箱。</div><div class="safety-step"><b>3. 你手动判断:</b>确认线上删除 / 本地保留 / 等下次同步恢复。</div></div></div></div>`;
} else {
$('content').innerHTML = `<div class="empty"><div><h2>这里还没有条目</h2><p>先在设置里粘贴 Access Token,然后点“同步最新变动”。</p></div></div>`;
}
return;
}
const shown = arr.slice(0, renderLimit);
$('content').innerHTML = `<div class="poster-grid">${shown.map((c,i)=>cardHTML(c,i)).join('')}</div>${arr.length>shown.length?`<div class="load-more-wrap"><button class="btn" id="loadMoreBtn">再显示 ${Math.min(step, arr.length-shown.length)} 条(共 ${arr.length} 条)</button></div>`:''}`;
$('loadMoreBtn')?.addEventListener('click',()=>{
const btn=$('loadMoreBtn');
if(btn){ btn.disabled=true; btn.textContent='加载中…'; }
requestAnimationFrame(()=>{
const arr2=visibleCollections(); const step2=pageSize(); const from=renderLimit;
renderLimit=Math.min(renderLimit+step2, arr2.length);
const newItems=arr2.slice(from,renderLimit);
const grid=$('content')?.querySelector('.poster-grid');
if(grid && newItems.length){
const t=document.createElement('template');
t.innerHTML=newItems.map((c,i)=>cardHTML(c,from+i)).join('');
grid.append(t.content);
Array.from(grid.children).slice(from).forEach(el=>{
if(!el.classList.contains('card')) return;
el.onclick=()=>openDrawer(el.dataset.id);
el.onmousemove=(e)=>{ const r=el.getBoundingClientRect(); el.style.setProperty('--mx',((e.clientX-r.left)/r.width*100).toFixed(1)+'%'); el.style.setProperty('--my',((e.clientY-r.top)/r.height*100).toFixed(1)+'%'); };
});
grid.querySelectorAll('img[data-id]:not([data-lz])').forEach(img=>{ img.dataset.lz='1'; getCachedImage(img.dataset.id).then(c=>{ if(c?.dataUrl) img.src=c.dataUrl; else if(c?.url) img.src=c.url; }).catch(()=>{}); });
markCommentHoverFade();
}
const wrap=$('content')?.querySelector('.load-more-wrap');
if(renderLimit>=arr2.length){ if(wrap) wrap.remove(); }
else if(wrap){ const nb=wrap.querySelector('.btn'); if(nb){ nb.disabled=false; nb.textContent=`再显示 ${Math.min(step2,arr2.length-renderLimit)} 条(共 ${arr2.length} 条)`; } }
});
});
$('content').querySelectorAll('.card').forEach(el => {
el.onclick = () => openDrawer(el.dataset.id);
el.onmousemove = (e) => { const r=el.getBoundingClientRect(); el.style.setProperty('--mx', ((e.clientX-r.left)/r.width*100).toFixed(1)+'%'); el.style.setProperty('--my', ((e.clientY-r.top)/r.height*100).toFixed(1)+'%'); };
});
const imgs = [...$('content').querySelectorAll('img[data-id]')];
for(const img of imgs.slice(0, Math.min(pageSize()+80, 420))){
getCachedImage(img.dataset.id).then(cache=>{ if(cache?.dataUrl) img.src = cache.dataUrl; else if(cache?.url) img.src = cache.url; }).catch(()=>{});
}
markCommentHoverFade();
}
function cardHTML(c, i=0){
const img = coverUrl(c); const statusLabel = statusLabelFor(c.collection_type, c.subject_type); const tags=uniqStrings([...myTagsOf(c), ...publicTagNames(c)]).slice(0,5);
const flag = c.status === 'missing_candidate' ? '<span class="pill danger">疑似消失</span>' : c.status === 'protected_local' ? '<span class="pill">本地保留</span>' : c.status === 'confirmed_deleted' ? '<span class="pill danger">已确认删除</span>' : '';
const comment = c.comment ? `<div class="comment">${esc(c.comment)}</div>` : '<div class="comment no-comment">没有填写吐槽。</div>';
return `<article class="card" data-id="${c.subject_id}" style="--i:${i}" title="${esc(c.title)}">
<div class="card-glow"></div>
<div class="cover-wrap"><div class="status-ribbon" data-ct="${c.collection_type}">${esc(statusLabel)}</div>${c.rate?`<div class="rate">★ ${esc(c.rate)}</div>`:''}${img?`<img class="cover" data-id="${c.subject_id}" src="${esc(img)}" loading="lazy" referrerpolicy="no-referrer" />`:`<div class="no-cover">${esc(c.title)}</div>`}</div>
${c.nsfw?`<div class="r18-badge">NSFW</div>`:''}
<div class="card-body"><div class="name">${esc(c.title)}</div><div class="meta"><span>${esc(SUBJECT_LABEL[c.subject_type]||'条目')}</span>${c.date?`<span>${esc(c.date)}</span>`:''}${flag}</div>${comment}<div class="mini-tags">${tags.map(t=>`<span>${esc(t)}</span>`).join('')}</div></div>
</article>`;
}
function numericValue(v){
if(v === null || v === undefined || v === '') return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function formatScoreValue(v){
const n = numericValue(v);
if(!n) return '';
return Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/,'');
}
function episodeProgressHTML(c){
const watched = Math.max(0, Math.floor(numericValue(c.ep_status)));
const total = Math.max(0, Math.floor(numericValue(c.eps)));
if(!watched && !total) return '';
const count = total || watched;
if(!count) return '';
const visibleTotal = Math.min(count, 96);
const cols = Math.max(8, Math.min(24, visibleTotal));
const mobileCols = Math.max(8, Math.min(18, visibleTotal));
const cells = Array.from({length: visibleTotal}, (_,i)=>`<span class="ep-cell-v9 ${i < watched ? 'watched' : ''}" title="第 ${i+1} 集 · ${i < watched ? '已看' : '未看'}"></span>`).join('');
const more = count > visibleTotal ? ` · 显示前 ${visibleTotal}` : '';
const percent = total ? Math.round(Math.min(watched,total)/total*100) : 100;
return `<div class="episode-strip-v9 ${count>48?'compact':''}">
<div class="episode-strip-head"><span>观看进度</span><span>${watched}${total?` / ${total}`:''} 集 · ${percent}%${more}</span></div>
<div class="episode-grid-v9 ${count>48?'too-many':''}" style="--ep-cols:${cols};--ep-cols-mobile:${mobileCols}">${cells}</div>
</div>`;
}
function scoreCardHTML(c){
const personal = Math.max(0, Math.min(10, numericValue(c.rate)));
const personalPct = Math.round(personal * 10);
const avg = Math.max(0, Math.min(10, numericValue(c.bgm_score || c.raw?.subject?.score || c.raw?.subject?.rating?.score)));
const avgPct = Math.round(avg * 10);
const personalText = formatScoreValue(personal);
const avgText = formatScoreValue(avg);
const stars = Array.from({length:10}, (_,i)=>`<span class="${i < Math.round(personal) ? 'on' : ''}">★</span>`).join('');
return `<div class="score-card-v8" style="--score-pct:${personalPct}%;--sub-pct:${avgPct}%">
<div class="score-row-v8"><div class="score-big-v8 ${personal ? '' : 'score-empty-v9'}">${personal ? esc(personalText) + '<small>/10</small>' : '未评分'}</div><div class="score-word-v8">我的评分<br>${personal ? personalPct+'%' : 'NO RATE'}</div></div>
<div class="stars-v8" aria-label="我的星星评分">${stars}</div>
<div class="score-track-v8"><div class="score-fill-v8"></div></div>
<div class="score-sub-v9"><div class="score-sub-row"><b>Bangumi</b><div class="sub-track"><div class="sub-fill"></div></div><span>${avg ? esc(avgText) + '/10' : '暂无'}</span></div></div>
<div class="score-caption-v8"><span>${c.rank?`Bangumi 排名 #${esc(c.rank)}`:''}</span><span>Bangumi 平均分</span></div>
</div>`;
}
// Render Bangumi infobox into an array of kv-row HTML strings. value is a string or an array of {v:...}.
// Skips rows that exactly duplicate fields already shown in the 资料库信息 card (中文名 / 日期 / 平台).
function infoboxRowList(list, c){
if(!Array.isArray(list) || !list.length) return [];
const dup = new Set([
c?.name_cn ? `中文名 ${c.name_cn}` : '',
c?.date ? `发行日期 ${c.date}` : '',
c?.date ? `放送开始 ${c.date}` : '',
c?.platform ? `平台 ${c.platform}` : '',
].filter(Boolean));
return list.map(item=>{
const k = String(item?.key ?? '').trim();
let v = item?.value;
if(Array.isArray(v)) v = v.map(x=>(x && typeof x==='object') ? (x.v ?? '') : x).filter(s=>String(s).trim()!=='').join(' / ');
v = String(v ?? '').trim();
if(!k && !v) return '';
if(dup.has(`${k} ${v}`)) return '';
return `<div>${esc(k)}</div><div>${esc(v)}</div>`;
}).filter(Boolean);
}
// Build the 条目资料 card from rendered infobox rows (≤5 flat, >5 collapsible). '' when empty.
function infoboxCardHTML(rows, id){
const IB_HEAD = 5;
if(!rows.length) return '';
if(rows.length <= IB_HEAD) return `<div class="info-card"><h3>条目资料</h3><div class="kv">${rows.join('')}</div></div>`;
return `<div class="info-card infobox-card"><h3>条目资料</h3><input type="checkbox" id="ibx-${esc(String(id))}" class="infobox-check" hidden><div class="kv">${rows.slice(0,IB_HEAD).join('')}</div><div class="kv infobox-extra">${rows.slice(IB_HEAD).join('')}</div><label class="infobox-toggle" for="ibx-${esc(String(id))}"></label></div>`;
}
async function openDrawer(id){
activeDrawerId = id; const c = state.collections[id]; if(!c) return;
const imgCache = await getCachedImage(id); const img = imgCache?.dataUrl || imgCache?.url || coverUrl(c);
const hist = (await fetchSubjectHistory(id)).slice(-10).reverse();
const cachedIb = infoboxCache[id]; const ibCard = Array.isArray(cachedIb) ? infoboxCardHTML(infoboxRowList(cachedIb, c), id) : '<div id="infoboxSlot"></div>';
const myTags = myTagsOf(c);
const publicTags = publicTagsOf(c);
const publicTagsShown = publicTagsForDisplay(c);
const myTagsHtml = myTags.length ? myTags.map(t=>`<button class="tag-chip tag-filter-btn my-tag" data-tag-key="${esc(tagKey('my', t))}"><span class="tag-kind">我的</span>${esc(t)}</button>`).join(' ') : '<span class="muted-inline">无</span>';
const publicTagsHtml = publicTagsShown.length ? publicTagsShown.map(t=>`<span class="tag-pair"><button class="tag-chip tag-filter-btn public-tag" data-tag-key="${esc(tagKey('public', t.name))}"><span class="tag-kind">公共</span>${esc(t.name)}${t.count?` · ${esc(t.count)}`:''}</button><a class="tag-open" href="${esc(bgmTagUrl(c.subject_type, t.name))}" target="_blank" rel="noopener" title="打开 Bangumi 标签页">↗</a></span>`).join('') : '<span class="muted-inline">未缓存。可点“刷新公共标签”。</span>';
const hiddenPublicTags = Math.max(0, publicTags.length - publicTagsShown.length);
const publicTagsNote = publicTags.length
? `已缓存 ${publicTags.length} 个公共标签${c.public_tags_source === 'website' ? '。来源:Bangumi 条目页“大家将…标注为”区域' : '。来源:API ,可能与官网标签区不完全一致'}${c.public_tags_fetched_at ? '。刷新于 ' + esc(formatDate(c.public_tags_fetched_at)) : '。可点“刷新公共标签”重取。'}`
: '公共标签来自 Bangumi 条目公开信息;在线版/网络异常时可能无法补全。';
const statusPills = `<span class="pill">${esc(SUBJECT_LABEL[c.subject_type]||'条目')}</span><span class="pill">${esc(statusLabelFor(c.collection_type,c.subject_type))}</span>${c.nsfw?`<span class="pill bad">NSFW</span>`:''}${(c.platform && c.platform !== (SUBJECT_LABEL[c.subject_type]||''))?`<span class="pill">${esc(c.platform)}</span>`:''}${c.private?`<span class="pill">私密</span>`:''}${c.status==='missing_candidate'?`<span class="pill danger">安全箱 · 疑似消失</span>`:''}${c.mark_changed_at?`<span class="pill" style="opacity:.62;font-weight:600">标记于 ${esc(formatDate(c.mark_changed_at))}</span>`:''}`;
const actions = `${c.status==='missing_candidate'?`<button class="btn good small wide" id="keepLocalBtn">本地保留</button><button class="btn bad small wide" id="confirmDeleteBtn">确认线上删除</button>`:''}${c.status==='confirmed_deleted'?`<button class="btn good small wide" id="restoreBtn">恢复显示</button>`:''}`;
// Mobile (≤980px) detail layout: drop the standalone poster, move score + actions below the hero banner.
const mobileDetail = window.matchMedia('(max-width:980px)').matches;
const posterH = `<div class="detail-poster-frame">${img?`<img class="drawer-cover" src="${esc(img)}" referrerpolicy="no-referrer" />`:`<div class="drawer-cover"></div>`}</div>`;
const scoreH = scoreCardHTML(c);
const actionsH = `<div class="detail-actions-v8"><button class="btn small" id="openBgmBtn">打开 Bangumi</button><button class="btn small" id="copyCommentBtn">复制吐槽</button>${actions}</div>`;
$('drawer').innerHTML = `<div class="detail-bg">${img?`<img src="${esc(img)}" referrerpolicy="no-referrer" />`:''}</div>
<button class="btn small detail-close-v8" id="closeDrawerBtn">关闭</button>
<div class="detail-v8">
<aside class="detail-side-v8">${mobileDetail ? '' : posterH + scoreH + actionsH}</aside>
<main class="detail-main-v8">
<section class="detail-hero-v8" style="${img?`background-image:linear-gradient(90deg,rgba(8,9,15,.72),rgba(8,9,15,.30),rgba(8,9,15,.07)),url(&quot;${esc(img)}&quot;);background-size:cover;background-position:center;`:``}">
<div class="detail-hero-content">
<div class="detail-kicker-v8">Bangumi 本地备份详情</div>
<h2>${esc(c.title)}</h2>
<div class="detail-pills-v8">${statusPills}</div>
${episodeProgressHTML(c)}
</div>
</section>
${mobileDetail ? scoreH + actionsH : ''}
<section class="detail-grid-v8">
<div class="detail-stack">
<div class="info-card"><h3>我的吐槽 / 观后感</h3><div class="longtext big-comment">${esc(c.comment || '没有填写吐槽。')}</div></div>
${c.summary?`<div class="info-card"><h3>条目简介</h3><div class="longtext">${esc(c.summary)}</div></div>`:''}
</div>
<div class="detail-stack">
${ibCard}<div class="info-card"><h3>资料库信息</h3><div class="kv"><div>条目 ID</div><div>${esc(c.subject_id||'-')}</div><div>原名</div><div>${esc(c.name||'-')}</div><div>中文名</div><div>${esc(c.name_cn||'-')}</div><div>上映日期</div><div>${esc(c.date||'-')}</div>${(c.platform && c.platform !== (SUBJECT_LABEL[c.subject_type]||''))?`<div>平台</div><div>${esc(c.platform)}</div>`:''}${c.subject_fetched_at?`<div>NSFW</div><div>${c.nsfw?'是':'否'}</div>`:''}<div>进度</div><div>${esc([c.ep_status?`章节 ${c.ep_status}`:'',c.vol_status?`${c.vol_status}`:''].filter(Boolean).join(' / ') || '-')}</div>${c.subject_fetched_at?`<div>条目详情获取</div><div>${esc(formatDate(c.subject_fetched_at))}</div>`:''}<div>条目最后更新时间</div><div>${esc(formatDate(c.bangumi_updated_at)||'-')}</div><div>首次备份</div><div>${esc(formatDate(c.first_seen_at)||'-')}</div><div>本地同步</div><div>${esc(formatDate(c.last_synced_at || c.last_seen_at)||'-')}</div><div>状态</div><div>${esc(c.status||'active')}${c.missing_count?`,缺失次数 ${c.missing_count}`:''}</div></div></div>
<div class="info-card"><h3>我的标签</h3><div class="tag-cloud">${myTagsHtml}</div></div>
<div class="info-card"><h3>公共标签</h3><div class="tag-cloud">${publicTagsHtml}</div><p class="help" style="margin:10px 0 0">${publicTagsNote}<br>点击标签在本地筛选;点 ↗ 打开 Bangumi 对应标签页。</p></div>
<div class="info-card"><h3>备份历史</h3><div class="history-list">${hist.length?hist.map(h=>`<div class="hist"><b>${esc(h.change_type)}</b> · ${esc(formatDate(h.created_at))}<br>${historyChangeHtml(h)}</div>`).join(''):'<div class="hist">还没有历史变更。</div>'}</div></div>
</div>
</section>
</main>
</div>`;
$('drawerMask').classList.remove('closing'); $('drawer').classList.remove('closing'); $('drawerMask').classList.add('show'); $('drawer').classList.add('show');
$('closeDrawerBtn').onclick = closeDrawer; $('drawerMask').onclick = closeDrawer;
$('drawer').querySelectorAll('[data-tag-key]').forEach(btn=>btn.onclick=(ev)=>{ ev.stopPropagation(); resetRenderLimit(); filters.tag = btn.dataset.tagKey; closeDrawer(); render(); });
$('openBgmBtn').onclick = () => window.open(`${bgmSiteBase()}/subject/${c.subject_id}`,'_blank','noopener');
$('copyCommentBtn').onclick = async () => { await navigator.clipboard?.writeText(c.comment||''); toast('已复制吐槽'); };
const keep=$('keepLocalBtn'); if(keep) keep.onclick=async()=>{ await setCollectionStatus(id,'protected_local',0); render(); openDrawer(id); toast('已标记为本地保留'); };
const del=$('confirmDeleteBtn'); if(del) del.onclick=async()=>{ if(confirm('确认把这个条目标记为线上已删除?历史和导出仍会保留。')){ await setCollectionStatus(id,'confirmed_deleted',(c.missing_count||0)+1); render(); openDrawer(id); } };
const res=$('restoreBtn'); if(res) res.onclick=async()=>{ await setCollectionStatus(id,'active',0); render(); openDrawer(id); };
// Infobox loads off the render path (its /api/subject-meta query is slow); fill + cache async so the drawer opens instantly.
if(!Array.isArray(cachedIb)){
fetchSubjectInfobox(id).then(list=>{
infoboxCache[id] = list;
if(activeDrawerId !== id) return;
const slot = $('infoboxSlot');
if(slot) slot.outerHTML = infoboxCardHTML(infoboxRowList(list, c), id);
});
}
// Scroll the banner + columns together; once the banner is scrolled out, pin the grid so each
// column scrolls independently with their tops aligned (.cols-pinned, see styles.css).
const mainEl = $('drawer').querySelector('.detail-main-v8'), heroEl = $('drawer').querySelector('.detail-hero-v8'), gridEl = $('drawer').querySelector('.detail-grid-v8');
if(mainEl && heroEl && gridEl){
const threshold = Math.max(0, gridEl.offsetTop - heroEl.offsetTop);
const syncCols = () => gridEl.classList.toggle('cols-pinned', mainEl.scrollTop >= threshold - 1);
mainEl.addEventListener('scroll', syncCols, { passive: true });
syncCols();
}
}
function closeDrawer(){ const mask=$('drawerMask'), d=$('drawer'); if(!d.classList.contains('show')) return; d.classList.add('closing'); mask.classList.add('closing'); const ms=(state.settings.motion==='off')?0:190; setTimeout(()=>{ mask.classList.remove('show','closing'); d.classList.remove('show','closing'); activeDrawerId=null; }, ms); }
function formatDate(s){ if(!s) return ''; const d=new Date(s); return isNaN(d)?String(s):d.toLocaleString(); }
function renderStats(){
const arr = getAllCollections(); const active = arr.filter(c=>c.status!=='confirmed_deleted'&&c.status!=='missing_candidate');
$('statTotal').textContent = active.length; $('statMissing').textContent = arr.filter(c=>c.status==='missing_candidate').length;
$('statComments').textContent = active.filter(c=>c.comment).length; const tagSet = new Set(); active.forEach(c=>allTagNames(c).forEach(t=>tagSet.add(t))); $('statTags').textContent = tagSet.size;
$('statCommentsCard')?.classList.toggle('active', !!filters.hasComment);
const acctUser = state.settings.username;
$('accountPill').innerHTML = acctUser ? `<a href="${esc(bgmSiteBase())}/user/${encodeURIComponent(acctUser)}" target="_blank" rel="noopener">@${esc(acctUser)}</a>` : '未登录';
const last = state.runs.filter(r=>r.success).at(-1); $('lastSyncText').textContent = last ? `上次同步:${formatDate(last.finished_at)},新增 ${last.added||0},修改 ${last.modified||0},疑似消失 ${last.missing||0}` : '尚未同步';
const pill=$('syncModePill');
if(pill && last){ const t=last.trigger==='auto'?'定时':'手动'; const m=last.mode==='full'?'全局':'增量'; pill.textContent=`最近同步 · ${t}${m}`; }
}
function refreshNsfwBtn(){
const btn=$('nsfwBtn'); if(!btn) return;
const cfg={all:['NSFW',''],hide:['屏蔽 NSFW',' good'],only:['仅 NSFW',' bad']};
const [label,mod]=cfg[filters.nsfw]||cfg.all;
btn.textContent=label; btn.className=`btn small${mod}`;
}
function updateAutoSyncHelp(){
const el=$('autoSyncHelp'); if(!el) return;
const mode = state.settings.autoSyncMode || 'recent';
const desc = (typeof cronDescribe === 'function') ? cronDescribe(state.settings.autoSyncCron || '0 2 * * *') : (state.settings.autoSyncCron || '');
el.textContent = `${desc} · 自动${mode==='recent'?'增量':'完整'}同步`;
}
function applyAuthVisibility(){
// admin-only actions — incl. the mobile drawer duplicates (mFullSync etc.) so guests can't see/trigger them
['syncBtn','fullSyncBtn','exportBtn','cacheCoversBtn','importBtn','enrichTagsBtn','mSyncRecent','mFullSync','mExport','mCacheCovers','mImport','mEnrichTags'].forEach(id=>{
const el=$(id); if(el) el.classList.toggle('hidden',!authReady);
});
const sf=document.querySelector('.settings-footer');
if(sf) sf.classList.toggle('hidden',!authReady);
document.querySelectorAll('.settings-tab').forEach(t=>{
const tab=t.dataset.tab;
if(tab==='stab-appearance'){ t.classList.remove('hidden'); }
else if(tab==='stab-login'){ t.classList.toggle('hidden',authReady); }
else{ t.classList.toggle('hidden',!authReady); }
});
const nsfwHidden = isPublicMode && !authReady && !publicShowNsfw;
$('nsfwBtn')?.classList.toggle('hidden', nsfwHidden);
}
function render(){ renderStats(); renderStatusNav(); renderTags(); renderCards(); refreshNsfwBtn(); applyAuthVisibility(); }