Feat : page streamer
Some checks failed
release / build (push) Successful in 27s
release / verify-windows (push) Failing after 1m3s

This commit is contained in:
jeanotx32
2026-08-11 20:04:08 +02:00
parent da9025fbf1
commit 4f7b8f56ae
12 changed files with 698 additions and 330 deletions

View File

@@ -0,0 +1,264 @@
import { useState, type FormEvent } from 'react';
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared';
import { api } from '../api';
import { formatDateTime, formatDuration, formatRelative } from '../format';
const STATE_LABELS: Record<StreamState, { text: string; tone: string }> = {
public: { text: 'en direct', tone: 'rec' },
private: { text: 'show privé', tone: 'warn' },
offline: { text: 'hors-ligne', tone: 'offline' },
unknown: { text: '…', tone: 'offline' },
};
interface Props {
targets: WatchTarget[];
agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void;
notificationsEnabled: boolean;
onEnableNotifications: () => void;
}
export function StreamersPage({
targets,
agents,
notify,
notificationsEnabled,
onEnableNotifications,
}: Props) {
const [url, setUrl] = useState('');
const [busy, setBusy] = useState(false);
async function add(event: FormEvent) {
event.preventDefault();
if (!url.trim()) return;
setBusy(true);
try {
const { target } = await api.addTarget(url.trim(), null);
notify(`« ${target.username} » ajouté au suivi`);
setUrl('');
} catch (err) {
notify(err instanceof Error ? err.message : 'Ajout impossible', 'error');
} finally {
setBusy(false);
}
}
// En direct d'abord, puis show privé, puis le reste par ordre alphabétique.
const ordered = [...targets].sort((a, b) => {
const rank = (target: WatchTarget) =>
target.state === 'public' ? 0 : target.state === 'private' ? 1 : 2;
return rank(a) - rank(b) || a.username.localeCompare(b.username);
});
return (
<div className="page">
<form className="row streamer-add" onSubmit={add}>
<input
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://fr.stripchat.com/pseudo — ou simplement le pseudo"
aria-label="Lien du profil à suivre"
/>
<button className="primary" type="submit" disabled={busy || !url.trim()}>
Suivre
</button>
{!notificationsEnabled && (
<button type="button" className="ghost" onClick={onEnableNotifications}>
🔔 Activer les notifications
</button>
)}
</form>
{ordered.length === 0 ? (
<div className="empty">
<h2>Aucun streamer suivi</h2>
<p className="muted">
Colle le lien d'un profil ci-dessus : tu seras prévenu dès qu'il passe en direct,
et tu pourras lancer l'enregistrement d'un clic.
</p>
</div>
) : (
<div className="streamer-grid">
{ordered.map((target) => (
<StreamerCard key={target.id} target={target} agents={agents} notify={notify} />
))}
</div>
)}
</div>
);
}
function StreamerCard({
target,
agents,
notify,
}: {
target: WatchTarget;
agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void;
}) {
const [busy, setBusy] = useState(false);
const [avatarBroken, setAvatarBroken] = useState(false);
const state = STATE_LABELS[target.state];
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const recording = agent?.status.recording ?? false;
const isLive = target.state === 'public';
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
const lastDuration =
target.lastLiveStartedAt && target.lastLiveEndedAt
? target.lastLiveEndedAt - target.lastLiveStartedAt
: null;
async function run(action: () => Promise<unknown>, success?: string) {
setBusy(true);
try {
await action();
if (success) notify(success);
} catch (err) {
notify(err instanceof Error ? err.message : 'Action impossible', 'error');
} finally {
setBusy(false);
}
}
const recordTitle = !target.agentId
? 'Assigne un agent à ce streamer'
: !agent?.online
? 'Agent hors-ligne'
: !isLive
? "Le streamer n'est pas en direct"
: 'Lancer l\'enregistrement';
return (
<article className={`streamer tone-${state.tone}`}>
<header className="streamer-head">
{target.avatarUrl && !avatarBroken ? (
<img
className="avatar"
src={target.avatarUrl}
alt=""
loading="lazy"
// Pas de fuite de l'URL du dashboard vers le CDN de la plateforme.
referrerPolicy="no-referrer"
onError={() => setAvatarBroken(true)}
/>
) : (
<div className="avatar avatar-fallback" aria-hidden="true">
{target.username.slice(0, 1).toUpperCase()}
</div>
)}
<div className="streamer-identity">
<a href={target.url} target="_blank" rel="noreferrer" className="streamer-name">
{target.label ?? target.username}
</a>
<span className="muted small">@{target.username}</span>
</div>
<span className={`badge ${state.tone}`}>{state.text}</span>
</header>
<dl className="streamer-facts">
<div>
<dt>{isLive ? 'En direct depuis' : 'Statut depuis'}</dt>
<dd>
{isLive
? formatDuration(liveFor)
: target.statusChangedAt
? formatRelative(target.statusChangedAt)
: '—'}
</dd>
</div>
<div>
<dt>Dernier stream</dt>
<dd>
{isLive ? (
<span className="muted">en cours</span>
) : target.lastLiveEndedAt ? (
<>
{formatDateTime(target.lastLiveEndedAt)}
{lastDuration ? <span className="muted"> · {formatDuration(lastDuration)}</span> : null}
</>
) : (
<span className="muted">jamais observé</span>
)}
</dd>
</div>
</dl>
{target.lastError && <p className="error small">Sonde en échec : {target.lastError}</p>}
<label className="field">
<span>Agent d'enregistrement</span>
<select
value={target.agentId ?? ''}
disabled={busy}
onChange={(event) =>
void run(() => api.updateTarget(target.id, { agentId: event.target.value || null }))
}
>
<option value="">— aucun —</option>
{agents.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.name}
{candidate.online ? '' : ' (hors-ligne)'}
</option>
))}
</select>
</label>
<div className="actions">
{recording ? (
<button
className="danger"
disabled={busy || !agent?.online}
onClick={() => void run(() => api.stopTarget(target.id), 'Enregistrement arrêté')}
>
■ Arrêter
</button>
) : (
<button
className="primary"
disabled={busy || !agent?.online || !isLive}
title={recordTitle}
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
>
● Enregistrer
</button>
)}
<div className="spacer" />
<button
className="icon"
disabled={busy}
title="Sonder maintenant"
onClick={() => void run(() => api.checkTarget(target.id))}
>
</button>
<button
className="icon"
disabled={busy}
title="Ne plus suivre"
onClick={() => {
if (confirm(`Ne plus suivre « ${target.username} » ?`)) {
void run(() => api.removeTarget(target.id));
}
}}
>
</button>
</div>
<footer className="card-foot muted small">
<span>
{target.lastCheckedAt ? `sondé ${formatRelative(target.lastCheckedAt)}` : 'pas encore sondé'}
</span>
{target.notify ? <span>🔔</span> : <span title="Notifications désactivées">🔕</span>}
</footer>
</article>
);
}