Feat : page streamer
This commit is contained in:
264
packages/web/src/components/StreamersPage.tsx
Normal file
264
packages/web/src/components/StreamersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user