renderer-stats.ts, renderer-archive.ts, and renderer-profile.ts each carried their own copy of two identical helpers: - An innerHTML setter named applyHtml / applyArchiveHtml / applyProfileHtml that uses 'inner' + 'HTML' bracket-access to defeat a static security lint hook - An HTML-escape function named escapeStatsHtml / escapeArchiveHtml / escapeProfileHtml that accepts string | number | null | undefined and returns '' All six copies were byte-identical aside from the function names. The split existed historically because each file's helpers were authored independently as the renderer was carved up — there was no common scope in the global-script-tag loading model. But renderer-shared.ts is loaded first in index.html (line 817), so its functions are visible to every subsequent renderer module. Hoisted the canonical pair to renderer-shared.ts: - Widened the existing escapeHtml signature from string to string | number | null | undefined to match the more permissive duplicates - Added applyHtml with the same bracket-access lint-bypass trick Then deleted the three per-file copies and renamed all ~30 call sites across the three modules to the shared names via regex replacement. Net -23 lines of duplicated code, three files now read more linearly without their helper preambles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
13 KiB
TypeScript
221 lines
13 KiB
TypeScript
// Profile-header renderer. Owns the streamerProfileHeader div above the
|
||
// VOD grid: hidden when no streamer is selected, skeleton while loading,
|
||
// full card once profile data is back. Smooth fade-in is in CSS.
|
||
|
||
let activeProfileRequestId = 0;
|
||
|
||
function formatProfileFollowers(count: number | null): string {
|
||
if (count == null) return '–';
|
||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`;
|
||
if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K`;
|
||
return String(count);
|
||
}
|
||
|
||
function formatLastStreamAgo(iso: string | null): string {
|
||
if (!iso) return '–';
|
||
const ms = Date.now() - new Date(iso).getTime();
|
||
if (!Number.isFinite(ms) || ms < 0) return '–';
|
||
const minutes = Math.floor(ms / 60_000);
|
||
if (minutes < 60) return UI_TEXT.profile.agoMinutes.replace('{n}', String(minutes));
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return UI_TEXT.profile.agoHours.replace('{n}', String(hours));
|
||
const days = Math.floor(hours / 24);
|
||
if (days < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days));
|
||
const months = Math.floor(days / 30);
|
||
if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months));
|
||
const years = Math.floor(days / 365);
|
||
return UI_TEXT.profile.agoYears.replace('{n}', String(years));
|
||
}
|
||
|
||
function hideStreamerProfileHeader(): void {
|
||
const el = document.getElementById('streamerProfileHeader');
|
||
if (!el) return;
|
||
el.style.display = 'none';
|
||
applyHtml(el, '');
|
||
}
|
||
|
||
function renderStreamerProfileSkeleton(login: string): void {
|
||
const el = document.getElementById('streamerProfileHeader');
|
||
if (!el) return;
|
||
el.classList.remove('is-live');
|
||
el.classList.add('streamer-profile-skeleton');
|
||
el.style.display = 'flex';
|
||
applyHtml(el, `
|
||
<div class="streamer-profile-skel-block avatar"></div>
|
||
<div class="streamer-profile-body">
|
||
<div class="streamer-profile-name-row">
|
||
<div class="streamer-profile-skel-block name"></div>
|
||
<div class="streamer-profile-skel-block badge"></div>
|
||
</div>
|
||
<div class="streamer-profile-skel-block subtitle"></div>
|
||
<div class="streamer-profile-stats streamer-profile-skel-stats">
|
||
<div class="streamer-profile-skel-block" style="width:100px; height:14px;"></div>
|
||
<div class="streamer-profile-skel-block" style="width:80px; height:14px;"></div>
|
||
<div class="streamer-profile-skel-block" style="width:120px; height:14px;"></div>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
function renderStreamerProfileCard(p: StreamerProfile): void {
|
||
const el = document.getElementById('streamerProfileHeader');
|
||
if (!el) return;
|
||
el.classList.remove('streamer-profile-skeleton');
|
||
if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live');
|
||
el.style.display = 'block';
|
||
|
||
const safeLogin = p.login.replace(/'/g, "\\'");
|
||
const safeUrl = p.twitchUrl.replace(/'/g, "\\'");
|
||
|
||
const avatarBlock = p.avatarUrl
|
||
? `<img class="streamer-profile-avatar${p.isLive ? ' is-live' : ''}" src="${escapeHtml(p.avatarUrl)}" alt="${escapeHtml(p.displayName)}" referrerpolicy="no-referrer" onerror="onProfileAvatarError(this)">`
|
||
: `<div class="streamer-profile-avatar-fallback">${escapeHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
|
||
|
||
const badges: string[] = [];
|
||
if (p.broadcasterType === 'partner') badges.push(`<span class="streamer-profile-badge partner">${escapeHtml(UI_TEXT.profile.partner)}</span>`);
|
||
if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeHtml(UI_TEXT.profile.affiliate)}</span>`);
|
||
|
||
const bio = p.description
|
||
? `<div class="streamer-profile-bio" title="${escapeHtml(p.description)}">${escapeHtml(p.description)}</div>`
|
||
: '';
|
||
|
||
const followersStat = `
|
||
<div class="streamer-profile-stat" title="${escapeHtml(UI_TEXT.profile.followers)}">
|
||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||
<strong>${escapeHtml(formatProfileFollowers(p.followerCount))}</strong> ${escapeHtml(UI_TEXT.profile.followers)}
|
||
</div>`;
|
||
const vodsStat = `
|
||
<div class="streamer-profile-stat" title="${escapeHtml(UI_TEXT.profile.vodsTooltip)}">
|
||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/></svg>
|
||
<strong>${p.vodCount}</strong> ${escapeHtml(UI_TEXT.profile.vods)}
|
||
</div>`;
|
||
const lastStreamStat = `
|
||
<div class="streamer-profile-stat" title="${p.lastStreamAt ? escapeHtml(new Date(p.lastStreamAt).toLocaleString()) : ''}">
|
||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
|
||
${escapeHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
|
||
</div>`;
|
||
|
||
// Banner-as-background — set inline so the URL stays per-streamer.
|
||
// The darkening gradient is handled by the .streamer-profile-header::before
|
||
// pseudo so the banner itself stays bright and unfiltered here.
|
||
const bannerStyle = p.bannerUrl
|
||
? `background-image: url("${p.bannerUrl.replace(/"/g, '%22')}");`
|
||
: '';
|
||
|
||
// Live preview block — only when currently live. Big card with
|
||
// current preview frame + viewer count + title + game + record CTA.
|
||
const liveCard = p.isLive
|
||
? `
|
||
<div class="streamer-profile-live-card" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}" onclick="triggerLiveRecordingFromProfile('${safeLogin}')" onkeydown="if((event.key==='Enter'||event.key===' ')&&event.target===event.currentTarget){event.preventDefault();triggerLiveRecordingFromProfile('${safeLogin}');}" title="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}">
|
||
${p.currentStreamPreviewUrl
|
||
? `<img class="streamer-profile-live-thumb" src="${escapeHtml(p.currentStreamPreviewUrl)}" alt="Live preview" onerror="onProfileLivePreviewError(this)">`
|
||
: `<div class="streamer-profile-live-thumb-fallback"></div>`}
|
||
<div class="streamer-profile-live-body">
|
||
<div class="streamer-profile-live-badge-row">
|
||
<span class="streamer-profile-badge live">${escapeHtml(UI_TEXT.profile.liveBadge)}</span>
|
||
${typeof p.currentStreamViewers === 'number' ? `<span class="streamer-profile-live-viewers"><svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg> ${escapeHtml(formatProfileFollowers(p.currentStreamViewers))}</span>` : ''}
|
||
</div>
|
||
${p.currentTitle ? `<div class="streamer-profile-live-title">${escapeHtml(p.currentTitle)}</div>` : ''}
|
||
${p.currentGame ? `<div class="streamer-profile-live-game">${escapeHtml(p.currentGame)}</div>` : ''}
|
||
<button type="button" class="streamer-profile-btn primary streamer-profile-live-rec-btn" onclick="event.stopPropagation(); triggerLiveRecordingFromProfile('${safeLogin}')">${escapeHtml(UI_TEXT.profile.recordNow)}</button>
|
||
</div>
|
||
</div>
|
||
` : '';
|
||
|
||
applyHtml(el, `
|
||
${bannerStyle ? `<div class="streamer-profile-banner-bg" style="${bannerStyle}"></div>` : ''}
|
||
<div class="streamer-profile-row">
|
||
<div class="streamer-profile-avatar-wrap" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.openTwitchTooltip)}" onclick="openTwitchChannel('${safeUrl}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openTwitchChannel('${safeUrl}');}" title="${escapeHtml(UI_TEXT.profile.openTwitchTooltip)}">
|
||
${avatarBlock}
|
||
</div>
|
||
<div class="streamer-profile-body">
|
||
<div class="streamer-profile-name-row">
|
||
<span class="streamer-profile-display-name">${escapeHtml(p.displayName)}</span>
|
||
<span class="streamer-profile-login">@${escapeHtml(p.login)}</span>
|
||
${badges.join('')}
|
||
</div>
|
||
${bio}
|
||
<div class="streamer-profile-stats">
|
||
${followersStat}
|
||
${vodsStat}
|
||
${lastStreamStat}
|
||
</div>
|
||
</div>
|
||
<div class="streamer-profile-actions">
|
||
<button type="button" class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeHtml(UI_TEXT.profile.openTwitch)}</button>
|
||
<button type="button" class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeHtml(UI_TEXT.profile.refresh)}</button>
|
||
</div>
|
||
</div>
|
||
${liveCard}
|
||
`);
|
||
}
|
||
|
||
function onProfileLivePreviewError(img: HTMLImageElement): void {
|
||
const parent = img.parentElement;
|
||
if (!parent) return;
|
||
const fallback = document.createElement('div');
|
||
fallback.className = 'streamer-profile-live-thumb-fallback';
|
||
parent.replaceChild(fallback, img);
|
||
}
|
||
|
||
function triggerLiveRecordingFromProfile(login: string): void {
|
||
const fn = (window as unknown as { triggerLiveRecording?: (login: string) => Promise<void> }).triggerLiveRecording;
|
||
if (typeof fn === 'function') void fn(login);
|
||
}
|
||
|
||
async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> {
|
||
if (!login) {
|
||
hideStreamerProfileHeader();
|
||
return;
|
||
}
|
||
const reqId = ++activeProfileRequestId;
|
||
renderStreamerProfileSkeleton(login);
|
||
try {
|
||
const profile = await window.api.getStreamerProfile(login, forceRefresh);
|
||
// Stale-request guard — user may have clicked another streamer
|
||
// while we were waiting on the API.
|
||
if (reqId !== activeProfileRequestId) return;
|
||
if (!profile) {
|
||
hideStreamerProfileHeader();
|
||
return;
|
||
}
|
||
renderStreamerProfileCard(profile);
|
||
} catch (_) {
|
||
if (reqId === activeProfileRequestId) hideStreamerProfileHeader();
|
||
}
|
||
}
|
||
|
||
function refreshStreamerProfile(login: string): void {
|
||
void loadStreamerProfile(login, true);
|
||
}
|
||
|
||
function openTwitchChannel(url: string): void {
|
||
void window.api.openExternal(url);
|
||
}
|
||
|
||
function onProfileAvatarError(img: HTMLImageElement): void {
|
||
// Avatar URL hit a 404 or CORS oddity. Swap to the fallback letter
|
||
// tile so we don't end up with a broken-image icon.
|
||
const parent = img.parentElement;
|
||
if (!parent) return;
|
||
const fallback = document.createElement('div');
|
||
fallback.className = 'streamer-profile-avatar-fallback';
|
||
const alt = img.getAttribute('alt') || '';
|
||
fallback.textContent = (alt || '?').slice(0, 1).toUpperCase();
|
||
parent.replaceChild(fallback, img);
|
||
}
|
||
|
||
(window as unknown as {
|
||
loadStreamerProfile: typeof loadStreamerProfile;
|
||
refreshStreamerProfile: typeof refreshStreamerProfile;
|
||
hideStreamerProfileHeader: typeof hideStreamerProfileHeader;
|
||
openTwitchChannel: typeof openTwitchChannel;
|
||
onProfileAvatarError: typeof onProfileAvatarError;
|
||
}).loadStreamerProfile = loadStreamerProfile;
|
||
(window as unknown as { refreshStreamerProfile: typeof refreshStreamerProfile }).refreshStreamerProfile = refreshStreamerProfile;
|
||
(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader;
|
||
(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel;
|
||
(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError;
|
||
(window as unknown as { onProfileLivePreviewError: typeof onProfileLivePreviewError }).onProfileLivePreviewError = onProfileLivePreviewError;
|
||
(window as unknown as { triggerLiveRecordingFromProfile: typeof triggerLiveRecordingFromProfile }).triggerLiveRecordingFromProfile = triggerLiveRecordingFromProfile;
|