Files
stream-control/packages/web/src/components/StreamersPage.tsx
jeanotx32 a1295003fd
All checks were successful
release / build (push) Successful in 28s
release / verify-windows (push) Successful in 1m26s
Feat : Interface scalable size
2026-08-12 22:44:10 +02:00

488 lines
17 KiB
TypeScript

import { useMemo, useState, type FormEvent } from 'react';
import type { AgentView, PreemptionVerdict, WatchTarget } from '@stream-control/shared';
import { DEFAULT_PRIORITY, priorityLabel } from '@stream-control/shared';
import { api } from '../api';
import { formatDateTime, formatDuration, formatRelative } from '../format';
import {
PREVIEW_SIZES,
loadPreviewSize,
preemption,
previewSrc,
recordingContext,
savePreviewSize,
stateLabel,
tagStyle,
usedTags,
type PreviewSize,
type RecordingContext,
} from '../streamers';
import { StreamerDetail } from './StreamerDetail';
import { TagChip, TagManager } from './Tags';
interface Props {
targets: WatchTarget[];
agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void;
notificationsEnabled: boolean;
onEnableNotifications: () => void;
}
const FILTERS = [
{ id: 'all', label: 'Tous' },
{ id: 'favorites', label: '★ Favoris' },
{ id: 'live', label: 'En direct' },
] as const;
type FilterId = (typeof FILTERS)[number]['id'];
export function StreamersPage({
targets,
agents,
notify,
notificationsEnabled,
onEnableNotifications,
}: Props) {
const [url, setUrl] = useState('');
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState<FilterId>('all');
const [openId, setOpenId] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [managingTags, setManagingTags] = useState(false);
const [previewSize, setPreviewSize] = useState<PreviewSize>(loadPreviewSize);
function changePreviewSize(size: PreviewSize) {
setPreviewSize(size);
// Mémorisé côté navigateur : c'est un confort d'affichage propre à l'écran
// qu'on a sous les yeux, pas un réglage à imposer aux autres postes.
savePreviewSize(size);
}
// Tirées des profils plutôt que du serveur : la liste arrive déjà entière par
// le flux temps réel, donc toujours à jour, et une étiquette que personne ne
// porte n'aurait de toute façon rien à filtrer.
const tags = useMemo(() => usedTags(targets), [targets]);
function toggleTag(id: string) {
setSelectedTags((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
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 ; à statut égal les
* favoris devant, puis la priorité. L'ordre du statut prime : un direct en
* cours appelle une décision, un favori hors-ligne non.
*/
const ordered = useMemo(
() =>
[...targets].sort((a, b) => {
const rank = (target: WatchTarget) =>
target.state === 'public' ? 0 : target.state === 'private' ? 1 : 2;
return (
rank(a) - rank(b) ||
Number(b.favorite) - Number(a.favorite) ||
b.priority - a.priority ||
a.username.localeCompare(b.username)
);
}),
[targets],
);
const visible = ordered.filter((target) => {
// Les deux filtres se cumulent — « en direct » ET portant l'une des
// étiquettes retenues. Entre étiquettes en revanche, c'est une union : en
// cocher une seconde élargit la sélection, ce qui est le geste attendu
// quand on empile des critères de même nature.
if (selectedTags.size > 0 && !target.tags.some((tag) => selectedTags.has(tag.id))) {
return false;
}
if (filter === 'favorites') return target.favorite;
if (filter === 'live') return target.state === 'public';
return true;
});
// La fiche ouverte doit refléter les mises à jour temps réel, et se refermer
// d'elle-même si le profil disparaît de la liste.
const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null;
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>
{targets.length > 0 && (
<div className="row filter-row">
{FILTERS.map((entry) => (
<button
key={entry.id}
className={filter === entry.id ? 'chip active' : 'chip'}
onClick={() => setFilter(entry.id)}
>
{entry.label}
</button>
))}
<div className="spacer" />
<span className="muted small">Aperçus</span>
{PREVIEW_SIZES.map((size) => (
<button
key={size.id}
className={previewSize === size.id ? 'chip active' : 'chip'}
onClick={() => changePreviewSize(size.id)}
title={
size.id === 'off'
? "N'afficher aucune image du flux — et ne pas la charger"
: `Aperçu ${size.label.toLowerCase()}`
}
>
{size.label}
</button>
))}
<span className="muted small">{visible.length} profil(s)</span>
</div>
)}
{targets.length > 0 && (
<div className="row filter-row tag-filters">
{tags.map((tag) => (
<button
key={tag.id}
className={selectedTags.has(tag.id) ? 'tag tag-filter active' : 'tag tag-filter'}
style={tagStyle(tag.name)}
onClick={() => toggleTag(tag.id)}
title={`Ne garder que les profils portant « ${tag.name} »`}
>
{tag.name}
</button>
))}
{tags.length === 0 && (
<span className="muted small">
Aucune étiquette pose-en une depuis la fiche d'un streamer pour trier la liste
d'ici.
</span>
)}
{selectedTags.size > 0 && (
<button className="link-button" onClick={() => setSelectedTags(new Set())}>
tout afficher
</button>
)}
<div className="spacer" />
<button className="chip" onClick={() => setManagingTags(true)} title="Renommer, supprimer">
Étiquettes
</button>
</div>
)}
{targets.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>
) : visible.length === 0 ? (
<p className="muted small">Aucun profil pour ce filtre.</p>
) : (
<div className={`streamer-grid preview-${previewSize}`}>
{visible.map((target) => (
<StreamerCard
key={target.id}
target={target}
targets={targets}
agents={agents}
notify={notify}
previewSize={previewSize}
onOpen={() => setOpenId(target.id)}
/>
))}
</div>
)}
{openTarget && (
<StreamerDetail
target={openTarget}
targets={targets}
agents={agents}
notify={notify}
onClose={() => setOpenId(null)}
/>
)}
{managingTags && (
<TagManager
onClose={() => setManagingTags(false)}
// Une étiquette renommée ou supprimée touche tous les profils : le
// serveur les republie, et le filtre en cours peut désigner une
// étiquette qui n'existe plus.
onChanged={() => setSelectedTags(new Set())}
/>
)}
</div>
);
}
/**
* Vignette : identité, statut, et la seule action qui presse — lancer ou
* arrêter la capture. Tout le reste (agent assigné, automatisme, historique)
* vit dans la fiche détaillée, qu'un clic sur la vignette ouvre.
*/
function StreamerCard({
target,
targets,
agents,
notify,
previewSize,
onOpen,
}: {
target: WatchTarget;
targets: WatchTarget[];
agents: AgentView[];
notify: (message: string, tone?: 'info' | 'error') => void;
previewSize: PreviewSize;
onOpen: () => void;
}) {
const [busy, setBusy] = useState(false);
const [avatarBroken, setAvatarBroken] = useState(false);
// La valeur en échec, pas un simple booléen : `preview` change de contenu à
// chaque sonde (nouveau `snapshotAt`) sans changer d'identité de composant,
// un booléen figé resterait donc bloqué après le premier accroc réseau.
const [brokenPreview, setBrokenPreview] = useState<string | null>(null);
const state = stateLabel(target);
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const context = recordingContext(target, agent, targets);
const verdict = preemption(target, context);
const isLive = target.state === 'public';
// Rien n'est calculé — ni chargé — quand les aperçus sont coupés : ce sont des
// images distantes, autant ne pas les demander si personne ne les regarde.
const preview = isLive && previewSize !== 'off' ? previewSrc(target) : null;
const showPreview = preview !== null && preview !== brokenPreview;
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);
}
}
/** Les commandes ne doivent pas ouvrir la fiche en même temps qu'elles s'exécutent. */
const isolate = (handler: () => void) => (event: React.MouseEvent) => {
event.stopPropagation();
handler();
};
return (
<article
// La vignette entière est cliquable ; chaque commande arrête la propagation.
className={`streamer tone-${context.self ? 'rec' : state.tone} clickable`}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
role="button"
tabIndex={0}
title="Ouvrir la fiche"
>
{showPreview && (
// Un `<img>`, pas un flux : le serveur n'a que l'API REST de la
// plateforme, ce n'est pas l'agent qui pilote un navigateur ici. Le
// clic n'est pas intercepté : même agrandie, l'image reste celle de
// cette fiche, et l'ouvrir en cliquant dessus reste cohérent.
<div className="streamer-preview">
<img
src={preview}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setBrokenPreview(preview)}
/>
</div>
)}
<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">
<span className="streamer-name">{target.label ?? target.username}</span>
<span className="muted small">@{target.username}</span>
</div>
<button
className={target.favorite ? 'icon star on' : 'icon star'}
disabled={busy}
title={target.favorite ? 'Retirer des favoris' : 'Mettre en favori'}
onClick={isolate(() =>
void run(() => api.updateTarget(target.id, { favorite: !target.favorite })),
)}
>
{target.favorite ? '★' : '☆'}
</button>
<span className={`badge ${state.tone}`} title={`statut brut « ${target.rawStatus ?? '?'} »`}>
{state.text}
</span>
</header>
{/* Sans retrait possible ici : la vignette sert à voir, la fiche à régler. */}
{target.tags.length > 0 && (
<div className="tag-list">
{target.tags.map((tag) => (
<TagChip key={tag.id} name={tag.name} />
))}
</div>
)}
<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>
<div className="actions" onClick={(event) => event.stopPropagation()}>
{context.self ? (
<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 || verdict?.allowed === false}
title={recordTitle(target, agent, context, verdict)}
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
>
{verdict?.allowed ? '● Enregistrer ⚡' : '● Enregistrer'}
</button>
)}
<div className="spacer" />
<span className="muted small">
{agent ? agent.name : 'aucun agent'}
{/* Le rang par défaut resterait du bruit : il ne dit rien de plus que
l'absence de réglage. */}
{target.priority !== DEFAULT_PRIORITY && ` · ${priorityLabel(target.priority)}`}
{target.preempt && <span title="Peut interrompre une capture de rang inférieur"> </span>}
</span>
</div>
<footer className="card-foot muted small">
<span className={context.self ? 'session-rec' : undefined}>
{context.self
? '⏺ enregistrement en cours'
: context.busyWith
? `VM occupée : ${context.busyWith}`
: target.lastCheckedAt
? `sondé ${formatRelative(target.lastCheckedAt)}`
: 'pas encore sondé'}
</span>
{target.notify ? <span title="Notifications activées">🔔</span> : <span title="Notifications désactivées">🔕</span>}
</footer>
</article>
);
}
function recordTitle(
target: WatchTarget,
agent: AgentView | null,
context: RecordingContext,
verdict: PreemptionVerdict | null,
): string {
if (!target.agentId) return 'Assigne un agent à ce streamer (clique la fiche)';
if (!agent?.online) return 'Agent hors-ligne';
if (verdict?.allowed) {
return `Interrompt la capture de ${context.busyWith} sur ${agent.name} — priorité supérieure`;
}
if (verdict) return `${agent.name} déjà pris : ${verdict.reason}`;
if (target.state !== 'public') return "Le streamer n'est pas en direct";
return agent.browser.enabled
? "Ouvrir la page, passer en plein écran et lancer l'enregistrement"
: 'Lancer OBS seul — le pilotage du navigateur est désactivé sur cet agent, ' +
'la page ne sera pas ouverte';
}