Feat : tag system
All checks were successful
release / build (push) Successful in 26s
release / verify-windows (push) Successful in 1m26s

This commit is contained in:
jeanotx32
2026-08-12 22:19:16 +02:00
parent cbbf31cabf
commit d44a7c4fb6
10 changed files with 840 additions and 6 deletions

View File

@@ -3,6 +3,7 @@ import type {
AgentView,
LogEntry,
PushoverSettings,
Tag,
TimelineData,
WatchTarget,
} from '@stream-control/shared';
@@ -126,7 +127,10 @@ export const api = {
WatchTarget,
'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite' | 'priority' | 'preempt'
>
>,
> & {
/** Remplace en bloc les étiquettes ; omis, elles restent inchangées. */
tagIds?: string[];
},
) =>
request<{ target: WatchTarget }>(`/watchlist/${id}`, {
method: 'PATCH',
@@ -149,6 +153,20 @@ export const api = {
`/timeline?days=${days}${targetId ? `&targetId=${encodeURIComponent(targetId)}` : ''}`,
),
// --- Étiquettes -------------------------------------------------------------
tags: () => request<{ tags: Tag[] }>('/tags'),
/** Crée l'étiquette, ou renvoie celle qui porte déjà ce nom. */
createTag: (name: string) =>
request<{ tag: Tag }>('/tags', { method: 'POST', body: JSON.stringify({ name }) }),
renameTag: (id: string, name: string) =>
request<{ tag: Tag }>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify({ name }) }),
/** La retire aussi de tous les profils qui la portaient. */
deleteTag: (id: string) => request<{ ok: true }>(`/tags/${id}`, { method: 'DELETE' }),
// --- Notifications ----------------------------------------------------------
pushoverSettings: () => request<{ settings: PushoverSettings }>('/settings/pushover'),

View File

@@ -11,6 +11,7 @@ import {
stateLabel,
TIMELINE_RANGES,
} from '../streamers';
import { TagEditor } from './Tags';
import { Timeline } from './Timeline';
interface Props {
@@ -179,6 +180,22 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
{target.lastError && <p className="error small">Sonde en échec : {target.lastError}</p>}
<section className="group">
<h3>Étiquettes</h3>
<TagEditor
target={target}
disabled={busy}
// Pas de `notify` au succès : la vignette se met à jour d'elle-même
// par le flux temps réel, un bandeau à chaque étiquette posée
// deviendrait vite pénible.
onChange={(tagIds) => api.updateTarget(target.id, { tagIds })}
/>
<p className="muted small">
Sert à filtrer la liste des streamers. Une étiquette inconnue se crée en la
saisissant.
</p>
</section>
<section className="group">
<h3>Enregistrement</h3>

View File

@@ -8,9 +8,12 @@ import {
previewSrc,
recordingContext,
stateLabel,
tagStyle,
usedTags,
type RecordingContext,
} from '../streamers';
import { StreamerDetail } from './StreamerDetail';
import { TagChip, TagManager } from './Tags';
interface Props {
targets: WatchTarget[];
@@ -39,6 +42,22 @@ export function StreamersPage({
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);
// 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();
@@ -76,6 +95,13 @@ export function StreamersPage({
);
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;
@@ -120,6 +146,37 @@ export function StreamersPage({
</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>
@@ -154,6 +211,16 @@ export function StreamersPage({
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>
);
}
@@ -284,6 +351,15 @@ function StreamerCard({
</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>

View File

@@ -0,0 +1,319 @@
import { useEffect, useState, type FormEvent } from 'react';
import type { Tag, WatchTarget } from '@stream-control/shared';
import { TAG_MAX_LENGTH, normalizeTagName } from '@stream-control/shared';
import { api } from '../api';
import { tagStyle } from '../streamers';
/**
* Étiquette affichée. Sa couleur vient de son nom (voir {@link tagStyle}), pas
* d'un réglage : poser une étiquette doit rester une simple frappe.
*/
export function TagChip({
name,
onRemove,
title,
}: {
name: string;
onRemove?: () => void;
title?: string;
}) {
return (
<span className="tag" style={tagStyle(name)} title={title}>
{name}
{onRemove && (
<button
type="button"
className="tag-remove"
onClick={onRemove}
title={`Retirer « ${name} »`}
aria-label={`Retirer « ${name} »`}
>
</button>
)}
</span>
);
}
/**
* Pose et retire les étiquettes d'un profil.
*
* Saisir un nom inconnu crée l'étiquette au passage : rien ne distingue à la
* frappe un nom neuf d'un nom existant, et imposer un aller-retour par un écran
* de création pour une étiquette qu'on veut poser tout de suite serait un
* détour. Les étiquettes déjà connues sont proposées juste en dessous, pour
* éviter d'en créer une seconde à une lettre près.
*/
export function TagEditor({
target,
disabled,
onChange,
}: {
target: WatchTarget;
disabled?: boolean;
onChange: (tagIds: string[]) => Promise<unknown>;
}) {
const [known, setKnown] = useState<Tag[]>([]);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.tags()
.then(({ tags }) => {
if (!cancelled) setKnown(tags);
})
// Les suggestions ne sont qu'un confort : sans elles, la saisie libre
// fonctionne toujours. Inutile d'alerter l'opérateur pour ça.
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
const current = target.tags;
const currentIds = new Set(current.map((tag) => tag.id));
// Ce que l'on peut encore poser : les étiquettes connues que ce profil n'a pas.
const available = known.filter((tag) => !currentIds.has(tag.id));
async function attach(tag: Tag) {
setBusy(true);
setError(null);
try {
await onChange([...current.map((entry) => entry.id), tag.id]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Étiquette non posée');
} finally {
setBusy(false);
}
}
async function submit(event: FormEvent) {
event.preventDefault();
const name = normalizeTagName(draft);
if (!name) return;
setBusy(true);
setError(null);
try {
// `createTag` renvoie l'étiquette existante si le nom est déjà pris : la
// saisie d'un nom connu revient donc à le sélectionner, sans doublon.
const { tag } = await api.createTag(name);
setKnown((entries) =>
entries.some((entry) => entry.id === tag.id) ? entries : [...entries, tag],
);
if (!currentIds.has(tag.id)) {
await onChange([...current.map((entry) => entry.id), tag.id]);
}
setDraft('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Étiquette non créée');
} finally {
setBusy(false);
}
}
async function detach(tagId: string) {
setBusy(true);
setError(null);
try {
await onChange(current.filter((tag) => tag.id !== tagId).map((tag) => tag.id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Étiquette non retirée');
} finally {
setBusy(false);
}
}
return (
<div className="tag-editor">
<div className="tag-list">
{current.map((tag) => (
<TagChip
key={tag.id}
name={tag.name}
onRemove={disabled || busy ? undefined : () => void detach(tag.id)}
/>
))}
{current.length === 0 && <span className="muted small">Aucune étiquette</span>}
</div>
<form className="row tag-add" onSubmit={submit}>
<input
value={draft}
maxLength={TAG_MAX_LENGTH}
disabled={disabled || busy}
onChange={(event) => setDraft(event.target.value)}
placeholder="nouvelle étiquette…"
aria-label="Nom de l'étiquette à poser"
/>
<button type="submit" className="ghost" disabled={disabled || busy || !draft.trim()}>
Poser
</button>
</form>
{available.length > 0 && (
<div className="tag-list">
<span className="muted small">Déjà utilisées :</span>
{available.map((tag) => (
<button
key={tag.id}
type="button"
className="tag tag-suggest"
style={tagStyle(tag.name)}
disabled={disabled || busy}
onClick={() => void attach(tag)}
title={`Poser « ${tag.name} »`}
>
+ {tag.name}
</button>
))}
</div>
)}
{error && <p className="error small">{error}</p>}
</div>
);
}
/**
* Renomme et supprime les étiquettes, tous profils confondus.
*
* Séparé de la pose : corriger une faute de frappe ou se débarrasser d'une
* étiquette devenue inutile concerne toute la liste, pas le profil qu'on est en
* train de regarder. Sans cet écran, une étiquette mal nommée serait définitive.
*/
export function TagManager({
onClose,
onChanged,
}: {
onClose: () => void;
/** Une étiquette renommée ou supprimée change l'affichage de tous les profils. */
onChanged: () => void;
}) {
const [tags, setTags] = useState<Tag[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState<{ id: string; name: string } | null>(null);
function reload() {
api
.tags()
.then(({ tags }) => setTags(tags))
.catch((err: unknown) =>
setError(err instanceof Error ? err.message : 'Étiquettes indisponibles'),
);
}
useEffect(reload, []);
async function run(action: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await action();
reload();
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : 'Action impossible');
} finally {
setBusy(false);
}
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<header className="modal-head">
<h2>Étiquettes</h2>
<div className="spacer" />
<button className="icon" onClick={onClose} title="Fermer">
</button>
</header>
<div className="modal-body">
{error && <p className="error small">{error}</p>}
{tags === null && <p className="muted small">Chargement</p>}
{tags?.length === 0 && (
<p className="muted small">
Aucune étiquette. Elles se créent en les saisissant sur la fiche d'un streamer.
</p>
)}
{tags?.map((tag) =>
editing?.id === tag.id ? (
<form
key={tag.id}
className="row tag-row"
onSubmit={(event) => {
event.preventDefault();
const name = normalizeTagName(editing.name);
if (!name || name === tag.name) {
setEditing(null);
return;
}
void run(() => api.renameTag(tag.id, name)).then(() => setEditing(null));
}}
>
<input
value={editing.name}
maxLength={TAG_MAX_LENGTH}
autoFocus
disabled={busy}
onChange={(event) => setEditing({ id: tag.id, name: event.target.value })}
/>
<button type="submit" className="ghost" disabled={busy}>
Renommer
</button>
<button type="button" className="ghost" onClick={() => setEditing(null)}>
Annuler
</button>
</form>
) : (
<div key={tag.id} className="row tag-row">
<TagChip name={tag.name} />
<div className="spacer" />
<button
className="link-button"
disabled={busy}
onClick={() => setEditing({ id: tag.id, name: tag.name })}
>
Renommer
</button>
<button
className="link-button error"
disabled={busy}
onClick={() => {
if (
!confirm(
`Supprimer « ${tag.name} » ? Elle disparaîtra de tous les streamers qui la portent.`,
)
) {
return;
}
void run(() => api.deleteTag(tag.id));
}}
>
Supprimer
</button>
</div>
),
)}
</div>
<footer className="modal-foot">
<span className="muted small">
Supprimer une étiquette ne supprime aucun streamer.
</span>
<div className="spacer" />
<button className="ghost" onClick={onClose}>
Fermer
</button>
</footer>
</div>
</div>
);
}

View File

@@ -134,6 +134,38 @@ export function previewSrc(target: Pick<WatchTarget, 'previewUrl'>): string | nu
return target.previewUrl;
}
/**
* Couleur d'une étiquette, dérivée de son nom.
*
* Calculée plutôt que choisie : demander une couleur à chaque création
* alourdirait le geste — poser une étiquette doit rester une frappe au clavier.
* Le nom donnant toujours la même teinte, une même étiquette se reconnaît d'une
* fiche à l'autre sans que rien ne soit stocké pour ça.
*/
export function tagStyle(name: string): { background: string; color: string; borderColor: string } {
let hash = 0;
for (let index = 0; index < name.length; index += 1) {
hash = (hash * 31 + name.charCodeAt(index)) | 0;
}
const hue = Math.abs(hash) % 360;
// Saturation et luminosité fixes : seule la teinte varie, sans quoi certaines
// étiquettes passeraient inaperçues et d'autres crèveraient l'écran.
return {
background: `hsl(${hue} 42% 20%)`,
color: `hsl(${hue} 70% 76%)`,
borderColor: `hsl(${hue} 35% 32%)`,
};
}
/** Toutes les étiquettes posées, sans doublon, dans l'ordre alphabétique. */
export function usedTags(targets: WatchTarget[]): Array<{ id: string; name: string }> {
const byId = new Map<string, { id: string; name: string }>();
for (const target of targets) {
for (const tag of target.tags) byId.set(tag.id, tag);
}
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/** Fenêtres proposées sous les frises. */
export const TIMELINE_RANGES = [
{ days: 1, label: '24 h' },

View File

@@ -642,6 +642,75 @@ fieldset.group > legend {
flex-wrap: wrap;
}
/* --- Étiquettes ---
*
* Les couleurs sont posées en style inline, dérivées du nom (voir `tagStyle`) :
* on ne trouvera donc ici que la forme, jamais la teinte.
*/
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
}
.tag-list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
}
.tag-remove {
padding: 0;
border: none;
background: transparent;
color: inherit;
font-size: 10px;
line-height: 1;
cursor: pointer;
opacity: 0.6;
}
.tag-remove:hover {
opacity: 1;
}
/* Bouton, mais lu comme une étiquette : même forme, cliquable. */
.tag-suggest,
.tag-filter {
cursor: pointer;
transition: filter 0.12s ease;
}
.tag-suggest:hover:not(:disabled),
.tag-filter:hover {
filter: brightness(1.35);
}
/* L'étiquette retenue comme filtre se détache par un contour, pas par sa
couleur — celle-ci appartient déjà à son nom et doit rester reconnaissable. */
.tag-filter.active {
outline: 1px solid var(--text);
outline-offset: 1px;
}
.tag-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.tag-add {
max-width: 340px;
}
.tag-row {
align-items: center;
gap: 8px;
}
.link-button.error {
color: #fca5a5;
}
/*
* Vignette du flux en direct. `overflow: hidden` recadre l'image au repos ;
* au survol il passe à `visible` pour que l'agrandissement (sur le seul