Feat : page streamer
This commit is contained in:
@@ -6,7 +6,8 @@ import { Login } from './components/Login';
|
||||
import { AgentCard } from './components/AgentCard';
|
||||
import { AgentSettings } from './components/AgentSettings';
|
||||
import { LogPanel } from './components/LogPanel';
|
||||
import { WatchlistPanel } from './components/WatchlistPanel';
|
||||
import { StreamersPage } from './components/StreamersPage';
|
||||
import { useHashRoute } from './useHashRoute';
|
||||
|
||||
interface Toast {
|
||||
message: string;
|
||||
@@ -20,6 +21,7 @@ export function App() {
|
||||
const [toast, setToast] = useState<Toast | null>(null);
|
||||
const [newAgentToken, setNewAgentToken] = useState<{ name: string; token: string } | null>(null);
|
||||
|
||||
const [route, setRoute] = useHashRoute();
|
||||
const [notificationsEnabled, setNotificationsEnabled] = useState(
|
||||
() => typeof Notification !== 'undefined' && Notification.permission === 'granted',
|
||||
);
|
||||
@@ -79,6 +81,7 @@ export function App() {
|
||||
|
||||
const online = agents.filter((agent) => agent.online);
|
||||
const recording = agents.filter((agent) => agent.status.recording);
|
||||
const liveTargets = targets.filter((target) => target.state === 'public').length;
|
||||
|
||||
const runCommand = useCallback(
|
||||
async (id: string, action: AgentAction, params?: Record<string, unknown>) => {
|
||||
@@ -149,24 +152,46 @@ export function App() {
|
||||
<div className="brand">
|
||||
<span className={`dot ${connected ? 'ok' : 'offline'}`} />
|
||||
<h1>Stream Control</h1>
|
||||
<span className="muted small">
|
||||
{online.length}/{agents.length} en ligne · {recording.length} en enregistrement
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="tabs">
|
||||
<button
|
||||
className={route === 'agents' ? 'tab active' : 'tab'}
|
||||
onClick={() => setRoute('agents')}
|
||||
>
|
||||
Agents
|
||||
<span className="muted small">
|
||||
{' · '}
|
||||
{online.length}/{agents.length}
|
||||
{recording.length > 0 ? ` · ${recording.length} ⏺` : ''}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={route === 'streamers' ? 'tab active' : 'tab'}
|
||||
onClick={() => setRoute('streamers')}
|
||||
>
|
||||
Streamers
|
||||
{liveTargets > 0 && <span className="pill">{liveTargets}</span>}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="topbar-actions">
|
||||
<button className="primary" onClick={() => void runBulk('record.start')}>
|
||||
● Enregistrer{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
||||
</button>
|
||||
<button className="danger" onClick={() => void runBulk('record.stop')}>
|
||||
■ Arrêter{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
||||
</button>
|
||||
<button className="ghost" onClick={() => void runBulk('obs.connect')}>
|
||||
Reconnecter OBS
|
||||
</button>
|
||||
<button className="ghost" onClick={() => void addAgent()}>
|
||||
+ Agent
|
||||
</button>
|
||||
{route === 'agents' && (
|
||||
<>
|
||||
<button className="primary" onClick={() => void runBulk('record.start')}>
|
||||
● Enregistrer{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
||||
</button>
|
||||
<button className="danger" onClick={() => void runBulk('record.stop')}>
|
||||
■ Arrêter{selection.size > 0 ? ` (${selection.size})` : ' tout'}
|
||||
</button>
|
||||
<button className="ghost" onClick={() => void runBulk('obs.connect')}>
|
||||
Reconnecter OBS
|
||||
</button>
|
||||
<button className="ghost" onClick={() => void addAgent()}>
|
||||
+ Agent
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="ghost" onClick={logout}>
|
||||
Quitter
|
||||
</button>
|
||||
@@ -189,35 +214,37 @@ export function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WatchlistPanel
|
||||
targets={targets}
|
||||
agents={agents}
|
||||
notify={notify}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
onEnableNotifications={() => void enableNotifications()}
|
||||
/>
|
||||
|
||||
<main className="grid">
|
||||
{agents.length === 0 && (
|
||||
<div className="empty">
|
||||
<h2>Aucun agent enregistré</h2>
|
||||
<p className="muted">
|
||||
Crée un agent ici pour obtenir un jeton, ou démarre un agent avec le jeton
|
||||
d'enrôlement : il apparaîtra automatiquement.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
selected={selection.has(agent.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onCommand={runCommand}
|
||||
onOpenSettings={setSettingsFor}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
{route === 'streamers' ? (
|
||||
<StreamersPage
|
||||
targets={targets}
|
||||
agents={agents}
|
||||
notify={notify}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
onEnableNotifications={() => void enableNotifications()}
|
||||
/>
|
||||
) : (
|
||||
<main className="grid">
|
||||
{agents.length === 0 && (
|
||||
<div className="empty">
|
||||
<h2>Aucun agent enregistré</h2>
|
||||
<p className="muted">
|
||||
Crée un agent ici pour obtenir un jeton, ou démarre un agent avec le jeton
|
||||
d'enrôlement : il apparaîtra automatiquement.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
selected={selection.has(agent.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onCommand={runCommand}
|
||||
onOpenSettings={setSettingsFor}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
)}
|
||||
|
||||
<LogPanel logs={logs} />
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import type { AgentView, StreamState, WatchTarget } from '@stream-control/shared';
|
||||
import { api } from '../api';
|
||||
import { 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 WatchlistPanel({
|
||||
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é à la veille`);
|
||||
setUrl('');
|
||||
} catch (err) {
|
||||
notify(err instanceof Error ? err.message : 'Ajout impossible', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const live = targets.filter((target) => target.state === 'public').length;
|
||||
|
||||
return (
|
||||
<section className="watchlist">
|
||||
<header className="watchlist-head">
|
||||
<h3>Veille</h3>
|
||||
<span className="muted small">
|
||||
{targets.length} profil(s) · {live} en direct
|
||||
</span>
|
||||
<div className="spacer" />
|
||||
{!notificationsEnabled && (
|
||||
<button className="ghost" onClick={onEnableNotifications}>
|
||||
🔔 Activer les notifications
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<form className="row watchlist-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 à surveiller"
|
||||
/>
|
||||
<button className="primary" type="submit" disabled={busy || !url.trim()}>
|
||||
Surveiller
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{targets.length === 0 ? (
|
||||
<p className="muted small">
|
||||
Colle le lien d'un profil : tu seras prévenu dès qu'il passe en direct, et tu
|
||||
pourras lancer l'enregistrement d'un clic.
|
||||
</p>
|
||||
) : (
|
||||
<div className="target-list">
|
||||
{targets.map((target) => (
|
||||
<TargetRow key={target.id} target={target} agents={agents} notify={notify} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetRow({
|
||||
target,
|
||||
agents,
|
||||
notify,
|
||||
}: {
|
||||
target: WatchTarget;
|
||||
agents: AgentView[];
|
||||
notify: (message: string, tone?: 'info' | 'error') => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const state = STATE_LABELS[target.state];
|
||||
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
|
||||
const recording = agent?.status.recording ?? false;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`target tone-${state.tone}`}>
|
||||
<span className={`badge ${state.tone}`}>{state.text}</span>
|
||||
|
||||
<a className="target-name" href={target.url} target="_blank" rel="noreferrer">
|
||||
{target.label ?? target.username}
|
||||
</a>
|
||||
|
||||
<select
|
||||
className="target-agent"
|
||||
value={target.agentId ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
void run(() => api.updateTarget(target.id, { agentId: event.target.value || null }))
|
||||
}
|
||||
>
|
||||
<option value="">— aucun agent —</option>
|
||||
{agents.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="small muted target-meta">
|
||||
{target.lastError
|
||||
? `sonde en échec : ${target.lastError}`
|
||||
: target.lastCheckedAt
|
||||
? `sondé ${formatRelative(target.lastCheckedAt)}`
|
||||
: 'pas encore sondé'}
|
||||
</span>
|
||||
|
||||
<div className="target-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 || target.state !== 'public'}
|
||||
title={
|
||||
!target.agentId
|
||||
? 'Assigne un agent'
|
||||
: !agent?.online
|
||||
? 'Agent hors-ligne'
|
||||
: target.state !== 'public'
|
||||
? 'Le streamer n\'est pas en direct'
|
||||
: 'Lancer l\'enregistrement'
|
||||
}
|
||||
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
|
||||
>
|
||||
● Enregistrer
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="icon"
|
||||
disabled={busy}
|
||||
title="Sonder maintenant"
|
||||
onClick={() => void run(() => api.checkTarget(target.id))}
|
||||
>
|
||||
⟳
|
||||
</button>
|
||||
<button
|
||||
className="icon"
|
||||
disabled={busy}
|
||||
title="Retirer de la veille"
|
||||
onClick={() => {
|
||||
if (confirm(`Retirer « ${target.username} » de la veille ?`)) {
|
||||
void run(() => api.removeTarget(target.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,3 +36,29 @@ export function formatPercent(value: number | undefined): string {
|
||||
export function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('fr-FR');
|
||||
}
|
||||
|
||||
/** Durée lisible : « 2 h 14 », « 45 min », « 30 s ». */
|
||||
export function formatDuration(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) return '—';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds} s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours} h ${String(rest).padStart(2, '0')}` : `${hours} h`;
|
||||
}
|
||||
|
||||
/** Date courte : « hier à 21:30 », « le 03/08 à 14:02 ». */
|
||||
export function formatDateTime(ts: number | null | undefined): string {
|
||||
if (!ts) return '—';
|
||||
const date = new Date(ts);
|
||||
const today = new Date();
|
||||
const sameDay = date.toDateString() === today.toDateString();
|
||||
const yesterday = new Date(today.getTime() - 86_400_000).toDateString() === date.toDateString();
|
||||
const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
if (sameDay) return `aujourd'hui à ${time}`;
|
||||
if (yesterday) return `hier à ${time}`;
|
||||
return `le ${date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} à ${time}`;
|
||||
}
|
||||
|
||||
@@ -401,84 +401,142 @@ fieldset.group > legend {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Veille --- */
|
||||
/* --- Onglets --- */
|
||||
|
||||
.watchlist {
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 4px;
|
||||
}
|
||||
.watchlist-head {
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.watchlist-add {
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.target-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.target {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(120px, 1fr) 160px minmax(0, 1.2fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
.tab.active {
|
||||
background: var(--panel-2);
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.target.tone-rec {
|
||||
.pill {
|
||||
min-width: 18px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--rec);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- Page Streamers --- */
|
||||
|
||||
.page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
align-content: start;
|
||||
}
|
||||
.streamer-add {
|
||||
max-width: 720px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.streamer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.streamer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.streamer.tone-rec {
|
||||
border-left-color: var(--rec);
|
||||
}
|
||||
.target.tone-warn {
|
||||
.streamer.tone-warn {
|
||||
border-left-color: var(--warn);
|
||||
}
|
||||
.streamer.tone-offline {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.target-name {
|
||||
.streamer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.avatar-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.streamer-identity {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.streamer-name {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.target-name:hover {
|
||||
.streamer-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.target-agent {
|
||||
width: 100%;
|
||||
|
||||
.streamer-facts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background: var(--panel-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.target-meta {
|
||||
.streamer-facts > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.streamer-facts dt {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.streamer-facts dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.target-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.target {
|
||||
grid-template-columns: 96px 1fr;
|
||||
row-gap: 6px;
|
||||
}
|
||||
.target-meta {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.target-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Journal --- */
|
||||
|
||||
|
||||
25
packages/web/src/useHashRoute.ts
Normal file
25
packages/web/src/useHashRoute.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const ROUTES = ['agents', 'streamers'] as const;
|
||||
export type Route = (typeof ROUTES)[number];
|
||||
|
||||
function currentRoute(): Route {
|
||||
const raw = window.location.hash.replace(/^#\/?/, '');
|
||||
return (ROUTES as readonly string[]).includes(raw) ? (raw as Route) : 'agents';
|
||||
}
|
||||
|
||||
/**
|
||||
* Routage minimal par ancre — l'application n'a que deux vues, et l'ancre suffit
|
||||
* à les rendre partageables et à survivre à un rechargement.
|
||||
*/
|
||||
export function useHashRoute(): [Route, (route: Route) => void] {
|
||||
const [route, setRoute] = useState<Route>(currentRoute);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setRoute(currentRoute());
|
||||
window.addEventListener('hashchange', onChange);
|
||||
return () => window.removeEventListener('hashchange', onChange);
|
||||
}, []);
|
||||
|
||||
return [route, (next: Route) => { window.location.hash = `#/${next}`; }];
|
||||
}
|
||||
Reference in New Issue
Block a user