import './StaticParty.css'; import html from './StaticParty.html'; import { deleteStaticParty, getStaticParty, joinStaticParty, leaveStaticParty, markStaticItemWatched, startCatchUp, startStaticSession } from '../../api/staticParty'; import { navigate } from '../../router'; import type { RouteParams } from '../../router'; import { escapeHtml } from '../../tiles'; import { LOADING_HINT } from '../../loading'; import { showToast } from '../../toast'; import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types'; /** * Route `/static/:id` - one static (persistent) watch party: the group's * progress through the whole series/playlist, its members, and - the * point of the feature - what *you* missed, with ways to catch up (alone, * or as a watch party others can join) or mark things as already seen. * Launching the group's session hands off to the normal player * (`/party/:id`) - a session is an ordinary party server-side. See * server/src/services/StaticPartyService.ts and spec/static-watch-party.md. */ export async function renderStaticParty(root: HTMLElement, params: RouteParams): Promise { const staticId = params.id as string; root.innerHTML = LOADING_HINT; let detail: StaticPartyDetailDto; try { detail = await getStaticParty(staticId); } catch (err) { console.error('[staticParty] load failed', staticId, err); root.innerHTML = `

${escapeHtml((err as Error).message || 'Impossible de charger cette watch party.')}

`; return; } root.innerHTML = html; root.querySelector('#back')!.addEventListener('click', () => history.back()); wireActions(root, staticId); render(root, detail); } /** Re-fetches and re-renders in place - after any action that changed * progress or membership. */ async function reload(root: HTMLElement, staticId: string): Promise { try { const detail = await getStaticParty(staticId); if (!root.querySelector('#staticTitle')) return; // navigated away mid-flight render(root, detail); } catch (err) { console.error('[staticParty] reload failed', staticId, err); showToast((err as Error).message, { variant: 'error' }); } } /** Fills every part of the page from `detail`. */ function render(root: HTMLElement, detail: StaticPartyDetailDto): void { (root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl; root.querySelector('#staticTitle')!.textContent = detail.name; const sourceLabel = detail.sourceType === 'series' ? 'Série' : 'Playlist'; root.querySelector('#staticMeta')!.textContent = `${sourceLabel} · ${detail.groupWatchedCount}/${detail.itemCount} vus par le groupe · ${detail.memberCount} membre${detail.memberCount > 1 ? 's' : ''}`; const percent = detail.itemCount ? Math.round((detail.groupWatchedCount / detail.itemCount) * 100) : 0; (root.querySelector('#staticProgressFill') as HTMLElement).style.width = `${percent}%`; root.querySelector('#staticNext')!.innerHTML = detail.nextItemName ? `Prochain : ${escapeHtml(detail.nextItemName)}` : 'Le groupe a tout vu.'; const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement; sessionBtn.textContent = detail.livePartyId ? 'Rejoindre la séance en cours' : 'Lancer la séance'; sessionBtn.disabled = !detail.livePartyId && !detail.nextItemName; root.querySelector('#joinBtn')!.classList.toggle('hide', detail.youAreMember); root.querySelector('#leaveBtn')!.classList.toggle('hide', !detail.youAreMember || detail.youAreCreator); root.querySelector('#deleteBtn')!.classList.toggle('hide', !detail.youAreCreator); renderMissed(root, detail); renderMembers(root, detail); renderItems(root, detail); } /** "Tu as manqué" - hidden entirely when there's nothing to catch up on. */ function renderMissed(root: HTMLElement, detail: StaticPartyDetailDto): void { const section = root.querySelector('#missedSection') as HTMLElement; const missed = new Set(detail.missedItemIds); const items = detail.items.filter((i) => missed.has(i.itemId)); section.hidden = !items.length; root.querySelector('#missedCount')!.textContent = String(items.length); root.querySelector('#missedList')!.innerHTML = items.map((item) => `
${escapeHtml(item.itemName)}
`).join(''); } /** One chip per member, with how many items they've watched. */ function renderMembers(root: HTMLElement, detail: StaticPartyDetailDto): void { root.querySelector('#memberList')!.innerHTML = detail.members.length ? detail.members.map((m) => ` ${escapeHtml(m.userName)}${m.isCreator ? ' ★' : ''}${m.watchedCount}/${detail.itemCount}`).join('') : '

Aucun membre pour le moment.

'; } /** The whole item list, with the group's state and the viewer's own. */ function renderItems(root: HTMLElement, detail: StaticPartyDetailDto): void { root.querySelector('#itemList')!.innerHTML = detail.items.map((item, i) => `
${i + 1}. ${escapeHtml(item.itemName)}
${stateLabel(item)}${item.viewerWatched ? '✓ vu par toi' : ''}
`).join(''); } /** The group-state label for one item. */ function stateLabel(item: StaticPartyItemDto): string { if (item.groupState === 'watched') return 'Vu par le groupe'; if (item.groupState === 'next') return 'Prochain'; return 'À venir'; } /** * Wires every button once - lists are re-rendered on reload, so row * buttons use one delegated listener on their container rather than * per-row listeners re-added on every render. */ function wireActions(root: HTMLElement, staticId: string): void { const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement; sessionBtn.addEventListener('click', () => { runBusy(sessionBtn, async () => { const { partyId } = await startStaticSession(staticId); navigate(`/party/${partyId}`); }); }); const joinBtn = root.querySelector('#joinBtn') as HTMLButtonElement; joinBtn.addEventListener('click', () => { runBusy(joinBtn, async () => { await joinStaticParty(staticId); showToast('Tu fais maintenant partie de cette watch party.'); await reload(root, staticId); }); }); const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement; leaveBtn.addEventListener('click', () => { if (!confirm('Quitter cette watch party ? Ta progression y sera oubliée.')) return; runBusy(leaveBtn, async () => { await leaveStaticParty(staticId); await reload(root, staticId); }); }); const deleteBtn = root.querySelector('#deleteBtn') as HTMLButtonElement; deleteBtn.addEventListener('click', () => { if (!confirm('Supprimer cette watch party pour tout le monde ? La progression de chacun sera perdue.')) return; runBusy(deleteBtn, async () => { await deleteStaticParty(staticId); showToast('Watch party supprimée.'); navigate('/home'); }); }); const catchUp = (btn: HTMLButtonElement, mode: 'solo' | 'party') => { const itemIds = Array.from(root.querySelectorAll('#missedList .staticCheck')) .filter((c) => c.checked) .map((c) => c.dataset.itemId as string); if (!itemIds.length) { showToast('Coche au moins un élément à rattraper.', { variant: 'error' }); return; } runBusy(btn, async () => { const { partyId } = await startCatchUp(staticId, itemIds, mode); navigate(`/party/${partyId}`); }); }; const soloBtn = root.querySelector('#catchUpSoloBtn') as HTMLButtonElement; const partyBtn = root.querySelector('#catchUpPartyBtn') as HTMLButtonElement; soloBtn.addEventListener('click', () => catchUp(soloBtn, 'solo')); partyBtn.addEventListener('click', () => catchUp(partyBtn, 'party')); root.querySelector('#missedList')!.addEventListener('click', (e) => { const btn = (e.target as HTMLElement).closest('button[data-item-id]'); if (!btn) return; const itemId = btn.dataset.itemId as string; if (btn.classList.contains('soloOneBtn')) { runBusy(btn, async () => { const { partyId } = await startCatchUp(staticId, [itemId], 'solo'); navigate(`/party/${partyId}`); }); } else if (btn.classList.contains('markWatchedBtn')) { runBusy(btn, async () => { await markStaticItemWatched(staticId, itemId); await reload(root, staticId); }); } }); } /** Disables `btn` while `action` runs; a failure is toasted, logged, and * gives the button back. */ function runBusy(btn: HTMLButtonElement, action: () => Promise): void { btn.disabled = true; action() .catch((err: unknown) => { console.error('[staticParty] action failed', err); showToast((err as Error).message, { variant: 'error' }); }) .finally(() => { btn.disabled = false; }); }