316 lines
27 KiB
JavaScript
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("${esc(img)}");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(); }
|
|
|