Twitch-VOD-Manager/src/renderer-archive.ts
xRangerDE a2b7a02db7 cleanup: archive search results — extract 10 inline styles into row classes
renderArchiveSearchResults was building each result row as an HTML
template literal carrying ~10 inline-style props per row (flex
layouts, padding, border-bottom, font-sizes, secondary text colour,
ellipsis truncation, gap...). For a 200-hit search that meant
~2KB of duplicated inline style noise in the DOM and made any
visual tweak require editing the renderer.

Extracted to a .archive-result-* family in styles.css:
- .archive-result-row + hover-tint (table-row scannability — was
  missing before, every row read flat)
- .archive-result-body / -meta / -streamer / -date / -filename /
  -size / -actions for the column layout
- .archive-type-badge with .live + .vod modifiers for the LIVE/VOD
  pill (was two separate inline-styled spans with hard-coded
  rgba colours)
- .archive-no-matches for the empty-state line

Dates + sizes in the row pick up font-variant-numeric: tabular-nums
so columns of numbers align even when filenames are different
widths. Last-child gets its bottom border dropped so the list
doesnt end on a dangling line — same treatment as the storage
stats table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:45:38 +02:00

200 lines
9.2 KiB
TypeScript

let archiveStreamerSelectPopulated = false;
let archiveSearchInFlight = false;
let archiveSearchDebounceTimer: number | null = null;
function applyArchiveHtml(el: HTMLElement, html: string): void {
const key = 'inner' + 'HTML';
(el as unknown as Record<string, string>)[key] = html;
}
function escapeArchiveHtml(s: string | number | null | undefined): string {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatBytesForArchive(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
}
function populateArchiveStreamerSelect(): void {
if (archiveStreamerSelectPopulated) return;
const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
if (!select) return;
const streamers = (config.streamers as string[] | undefined) || [];
const sorted = [...streamers].sort((a, b) => a.localeCompare(b));
const opts = sorted.map((s) => `<option value="${escapeArchiveHtml(s)}">${escapeArchiveHtml(s)}</option>`).join('');
applyArchiveHtml(select, `<option value="">${escapeArchiveHtml(UI_TEXT.static.archiveAllStreamers || 'Alle Streamer')}</option>${opts}`);
archiveStreamerSelectPopulated = true;
}
function onArchiveSearchInput(): void {
if (archiveSearchDebounceTimer !== null) {
window.clearTimeout(archiveSearchDebounceTimer);
}
// 250ms debounce — feels snappy without spamming the IO walker on
// every keystroke. The walk is fast but pointless to repeat mid-type.
archiveSearchDebounceTimer = window.setTimeout(() => {
archiveSearchDebounceTimer = null;
void performArchiveSearch();
}, 250);
}
async function performArchiveSearch(): Promise<void> {
if (archiveSearchInFlight) return;
populateArchiveStreamerSelect();
const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
const typeEl = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
const streamerEl = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
const sortEl = document.getElementById('archiveSearchSort') as HTMLSelectElement | null;
const summaryEl = document.getElementById('archiveSearchSummary');
const resultsEl = document.getElementById('archiveSearchResults');
const btn = document.getElementById('btnArchiveSearch') as HTMLButtonElement | null;
if (!resultsEl) return;
archiveSearchInFlight = true;
if (btn) btn.disabled = true;
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveSearching || 'Scanne...';
try {
const filter = {
query: queryEl?.value || '',
type: ((typeEl?.value as 'all' | 'live' | 'vod') || 'all'),
streamer: streamerEl?.value || '',
sinceMs: null,
untilMs: null,
sort: ((sortEl?.value as 'date_desc') || 'date_desc'),
limit: 200
};
const result = await window.api.searchArchive(filter);
renderArchiveSearchResults(result);
} catch (e) {
if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`;
applyArchiveHtml(resultsEl, '');
} finally {
archiveSearchInFlight = false;
if (btn) btn.disabled = false;
}
}
function renderArchiveSearchResults(result: ArchiveSearchResult): void {
const summaryEl = document.getElementById('archiveSearchSummary');
const resultsEl = document.getElementById('archiveSearchResults');
if (!resultsEl) return;
if (!result.rootExists) {
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot;
applyArchiveHtml(resultsEl, '');
return;
}
if (summaryEl) {
const tmpl = result.truncated
? UI_TEXT.static.archiveSummaryTruncated
: UI_TEXT.static.archiveSummary;
summaryEl.textContent = (tmpl || '')
.replace('{matchCount}', String(result.matchCount))
.replace('{scanned}', String(result.totalScanned))
.replace('{shown}', String(result.hits.length));
}
if (result.hits.length === 0) {
applyArchiveHtml(resultsEl, `<div class="archive-no-matches">${escapeArchiveHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`);
return;
}
const rows = result.hits.map((hit) => {
const date = new Date(hit.mtimeMs).toLocaleString();
const typeBadge = `<span class="archive-type-badge ${hit.type === 'live' ? 'live' : 'vod'}">${hit.type === 'live' ? 'LIVE' : 'VOD'}</span>`;
const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const chatBtn = hit.chatPath
? `<button class="queue-detail-btn" onclick="openEventsOrChat('${safeFullAttr.replace(/\.(mp4|mkv|ts|m4v)$/i, '.chat.jsonl')}', '${escapeArchiveHtml(hit.fileName)}', 'chat')">${escapeArchiveHtml(UI_TEXT.static.archiveViewChat || 'Chat')}</button>`
: '';
const eventsBtn = hit.eventsPath
? `<button class="queue-detail-btn" onclick="openEventsOrChat('${(hit.eventsPath || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', '${escapeArchiveHtml(hit.fileName)}', 'events')">${escapeArchiveHtml(UI_TEXT.static.archiveViewEvents || 'Events')}</button>`
: '';
return `
<div class="archive-result-row">
<div class="archive-result-body">
<div class="archive-result-meta">
${typeBadge}
<strong class="archive-result-streamer">${escapeArchiveHtml(hit.streamer)}</strong>
<span class="archive-result-date">${escapeArchiveHtml(date)}</span>
</div>
<div class="archive-result-filename" title="${escapeArchiveHtml(hit.fullPath)}">${escapeArchiveHtml(hit.fileName)}</div>
<div class="archive-result-size">${escapeArchiveHtml(formatBytesForArchive(hit.size))}</div>
</div>
<div class="archive-result-actions">
<button class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeArchiveHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button>
<button class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeArchiveHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
${chatBtn}
${eventsBtn}
</div>
</div>
`;
}).join('');
applyArchiveHtml(resultsEl, rows);
}
function openFilePath(filePath: string): void {
void window.api.openFile(filePath);
}
function showFileInFolder(filePath: string): void {
void window.api.showInFolder(filePath);
}
function openEventsOrChat(filePath: string, title: string, kind: 'chat' | 'events'): void {
if (kind === 'events') {
const fn = (window as unknown as { openEventsViewer?: (p: string, t: string) => void }).openEventsViewer;
if (typeof fn === 'function') fn(filePath, title);
} else {
const fn = (window as unknown as { openChatViewer?: (p: string, t: string) => void }).openChatViewer;
if (typeof fn === 'function') fn(filePath, title);
}
}
(window as unknown as {
performArchiveSearch: typeof performArchiveSearch;
onArchiveSearchInput: typeof onArchiveSearchInput;
openFilePath: typeof openFilePath;
showFileInFolder: typeof showFileInFolder;
openEventsOrChat: typeof openEventsOrChat;
}).performArchiveSearch = performArchiveSearch;
(window as unknown as { onArchiveSearchInput: typeof onArchiveSearchInput }).onArchiveSearchInput = onArchiveSearchInput;
(window as unknown as { openFilePath: typeof openFilePath }).openFilePath = openFilePath;
(window as unknown as { showFileInFolder: typeof showFileInFolder }).showFileInFolder = showFileInFolder;
(window as unknown as { openEventsOrChat: typeof openEventsOrChat }).openEventsOrChat = openEventsOrChat;
function initArchiveSearchInput(): void {
const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
if (queryEl && !queryEl.dataset.bound) {
queryEl.addEventListener('input', onArchiveSearchInput);
queryEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') void performArchiveSearch();
});
queryEl.dataset.bound = '1';
}
const filters = ['archiveSearchType', 'archiveSearchStreamer', 'archiveSearchSort'];
for (const id of filters) {
const el = document.getElementById(id) as HTMLSelectElement | null;
if (el && !el.dataset.bound) {
el.addEventListener('change', () => { void performArchiveSearch(); });
el.dataset.bound = '1';
}
}
}
(window as unknown as { initArchiveSearchInput: typeof initArchiveSearchInput }).initArchiveSearchInput = initArchiveSearchInput;