Feat : Streamers page revamp
This commit is contained in:
@@ -1,26 +1,9 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared';
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import type { AgentView, 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' },
|
||||
};
|
||||
|
||||
/**
|
||||
* `idle` et `private` partagent le même état normalisé — les deux mettent
|
||||
* l'enregistrement en pause sans le clore — mais ne veulent pas dire la même
|
||||
* chose à l'écran : le premier est une absence, le second un show payant.
|
||||
*/
|
||||
function stateLabel(target: WatchTarget): { text: string; tone: string } {
|
||||
if (target.state === 'private' && target.rawStatus === 'idle') {
|
||||
return { text: 'revient bientôt', tone: 'warn' };
|
||||
}
|
||||
return STATE_LABELS[target.state];
|
||||
}
|
||||
import { recordingContext, stateLabel, type RecordingContext } from '../streamers';
|
||||
import { StreamerDetail } from './StreamerDetail';
|
||||
|
||||
interface Props {
|
||||
targets: WatchTarget[];
|
||||
@@ -30,6 +13,14 @@ interface Props {
|
||||
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,
|
||||
@@ -39,6 +30,8 @@ export function StreamersPage({
|
||||
}: Props) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [filter, setFilter] = useState<FilterId>('all');
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
async function add(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -55,13 +48,35 @@ export function StreamersPage({
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
/**
|
||||
* En direct d'abord, puis show privé, puis le reste ; à statut égal, les
|
||||
* favoris devant. 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) ||
|
||||
a.username.localeCompare(b.username)
|
||||
);
|
||||
}),
|
||||
[targets],
|
||||
);
|
||||
|
||||
const visible = ordered.filter((target) => {
|
||||
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}>
|
||||
@@ -81,7 +96,23 @@ export function StreamersPage({
|
||||
)}
|
||||
</form>
|
||||
|
||||
{ordered.length === 0 ? (
|
||||
{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">{visible.length} profil(s)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targets.length === 0 ? (
|
||||
<div className="empty">
|
||||
<h2>Aucun streamer suivi</h2>
|
||||
<p className="muted">
|
||||
@@ -89,46 +120,60 @@ export function StreamersPage({
|
||||
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">
|
||||
{ordered.map((target) => (
|
||||
<StreamerCard key={target.id} target={target} agents={agents} notify={notify} />
|
||||
{visible.map((target) => (
|
||||
<StreamerCard
|
||||
key={target.id}
|
||||
target={target}
|
||||
targets={targets}
|
||||
agents={agents}
|
||||
notify={notify}
|
||||
onOpen={() => setOpenId(target.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openTarget && (
|
||||
<StreamerDetail
|
||||
target={openTarget}
|
||||
targets={targets}
|
||||
agents={agents}
|
||||
notify={notify}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explique, en une ligne, ce que l'automatisme fera ou pourquoi il ne fera rien.
|
||||
* Un interrupteur coché qui reste sans effet est plus déroutant qu'un
|
||||
* interrupteur absent.
|
||||
* 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 autoRecordHint(target: WatchTarget, agent: AgentView | null): string {
|
||||
if (!target.autoRecord) return ' — dès une minute de direct';
|
||||
if (!agent) return ' — agent introuvable';
|
||||
if (!agent.online) return ` — en attente : ${agent.name} est hors-ligne`;
|
||||
if (agent.status.recording) return ` — en attente : ${agent.name} enregistre déjà`;
|
||||
if (target.state === 'public') return ' — armé, direct en cours';
|
||||
return ' — armé, en attente du prochain direct';
|
||||
}
|
||||
|
||||
function StreamerCard({
|
||||
target,
|
||||
targets,
|
||||
agents,
|
||||
notify,
|
||||
onOpen,
|
||||
}: {
|
||||
target: WatchTarget;
|
||||
targets: WatchTarget[];
|
||||
agents: AgentView[];
|
||||
notify: (message: string, tone?: 'info' | 'error') => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [avatarBroken, setAvatarBroken] = useState(false);
|
||||
|
||||
const state = stateLabel(target);
|
||||
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
|
||||
const recording = agent?.status.recording ?? false;
|
||||
const context = recordingContext(target, agent, targets);
|
||||
const isLive = target.state === 'public';
|
||||
|
||||
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
|
||||
@@ -149,19 +194,27 @@ function StreamerCard({
|
||||
}
|
||||
}
|
||||
|
||||
const recordTitle = !target.agentId
|
||||
? 'Assigne un agent à ce streamer'
|
||||
: !agent?.online
|
||||
? 'Agent hors-ligne'
|
||||
: !isLive
|
||||
? "Le streamer n'est pas en direct"
|
||||
: 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";
|
||||
/** 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 className={`streamer tone-${state.tone}`}>
|
||||
<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"
|
||||
>
|
||||
<header className="streamer-head">
|
||||
{target.avatarUrl && !avatarBroken ? (
|
||||
<img
|
||||
@@ -180,12 +233,21 @@ function StreamerCard({
|
||||
)}
|
||||
|
||||
<div className="streamer-identity">
|
||||
<a href={target.url} target="_blank" rel="noreferrer" className="streamer-name">
|
||||
{target.label ?? target.username}
|
||||
</a>
|
||||
<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>
|
||||
@@ -219,48 +281,8 @@ function StreamerCard({
|
||||
</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>
|
||||
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={target.autoRecord}
|
||||
disabled={busy || !target.agentId}
|
||||
onChange={(event) =>
|
||||
void run(() => api.updateTarget(target.id, { autoRecord: event.target.checked }))
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
Enregistrer automatiquement
|
||||
<span className="muted small">
|
||||
{!target.agentId
|
||||
? ' — assigne d\'abord un agent'
|
||||
: autoRecordHint(target, agent)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="actions">
|
||||
{recording ? (
|
||||
<div className="actions" onClick={(event) => event.stopPropagation()}>
|
||||
{context.self ? (
|
||||
<button
|
||||
className="danger"
|
||||
disabled={busy || !agent?.online}
|
||||
@@ -271,8 +293,8 @@ function StreamerCard({
|
||||
) : (
|
||||
<button
|
||||
className="primary"
|
||||
disabled={busy || !agent?.online || !isLive}
|
||||
title={recordTitle}
|
||||
disabled={busy || !agent?.online || !isLive || Boolean(context.busyWith)}
|
||||
title={recordTitle(target, agent, context)}
|
||||
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
|
||||
>
|
||||
● Enregistrer
|
||||
@@ -281,34 +303,38 @@ function StreamerCard({
|
||||
|
||||
<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>
|
||||
<span className="muted small">{agent ? agent.name : 'aucun agent'}</span>
|
||||
</div>
|
||||
|
||||
<footer className="card-foot muted small">
|
||||
<span>
|
||||
{target.lastCheckedAt ? `sondé ${formatRelative(target.lastCheckedAt)}` : 'pas encore sondé'}
|
||||
<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>🔔</span> : <span title="Notifications désactivées">🔕</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,
|
||||
): string {
|
||||
if (!target.agentId) return 'Assigne un agent à ce streamer (clique la fiche)';
|
||||
if (!agent?.online) return 'Agent hors-ligne';
|
||||
if (context.busyWith) {
|
||||
return `${agent.name} enregistre déjà ${context.busyWith} — une VM ne traite qu'un flux à la fois`;
|
||||
}
|
||||
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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user