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

@@ -40,9 +40,13 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
réglables VM par VM : de « Économe » à « Sans perte ».
- [Vignette du flux en direct](#vignette-du-flux-en-direct) sur la fiche compacte de chaque
profil en direct, agrandie au survol.
- [Étiquettes](#étiquettes) libres sur les streamers, créées à la volée, avec filtrage de la
liste par étiquette.
- [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil
suivi, avec en surimpression ce qui en a été capturé — par profil sur sa fiche, ou tous
ensemble dans l'onglet « Timeline ».
ensemble dans l'onglet « Timeline ». Une
[vignette est conservée par diffusion](#une-vignette-par-diffusion-conservée), visible au
survol des mois après.
## Prérequis
@@ -637,6 +641,40 @@ l'ordre de sondage qui décide qui tente sa chance en premier sur une VM libre :
hasard laisserait un profil mineur s'en emparer, pour se faire couper dans la foulée par son
aîné — même résultat, au prix d'un fichier de quelques secondes.
## Étiquettes
Des libellés libres posés sur les profils, pour trier une liste qui grandit. Ils se créent
**en les saisissant** sur la fiche d'un streamer (onglet Streamers → clic sur une vignette →
section « Étiquettes ») : rien ne distingue à la frappe un nom neuf d'un nom déjà connu, donc
un nom inconnu crée l'étiquette au passage, et un nom déjà pris la réutilise. Les étiquettes
existantes sont proposées juste en dessous du champ, pour éviter d'en créer une seconde à une
lettre près.
**La casse ne crée pas de doublon** : « Blonde » et « blonde » sont la même étiquette, garanti
par un index unique `COLLATE NOCASE`. Les espaces multiples sont réduits et les extrémités
coupées pour la même raison — deux entrées indiscernables à l'œil dans la liste des filtres
seraient pires qu'un refus.
La barre de filtres de l'onglet Streamers les reprend toutes. **Entre étiquettes le filtre 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. Le filtre de statut (Tous / Favoris / En direct), lui, se
**cumule** avec — « en direct » ET portant l'une des étiquettes retenues.
**La couleur d'une étiquette est calculée depuis son nom**, jamais choisie : demander une
couleur à chaque création alourdirait un geste qui doit rester une frappe au clavier. Le même
nom donnant toujours la même teinte, une étiquette se reconnaît d'une fiche à l'autre sans que
rien ne soit stocké pour ça.
Le bouton **⚙ Étiquettes** de la barre de filtres ouvre le seul écran qui les gère globalement :
renommer (répercuté partout) et supprimer (retirée de tous les profils qui la portaient, sans
en supprimer aucun). Sans lui, une étiquette mal nommée serait définitive. Le renommage
**refuse une collision** plutôt que de fusionner en silence : corriger une faute de frappe
n'est pas demander à faire disparaître l'autre étiquette des profils qui la portent.
Une étiquette survit à la suppression du dernier profil qui la portait — elle reste disponible
pour la suite. Inversement, ne plus suivre un profil n'emporte que ses rattachements
(`ON DELETE CASCADE` des deux côtés de la table de liaison).
## Vignette du flux en direct
Chaque fiche compacte d'un profil **en direct** (onglet Streamers) affiche une image du
@@ -812,7 +850,11 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
| `GET` | `/api/agents` | Liste des agents et de leur état |
| `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** |
| `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion, surveillance, navigateur, preset |
| `PATCH` | `/api/watchlist/:id` | Libellé, agent assigné, notifications, automatisme, favori, priorité, interruption |
| `PATCH` | `/api/watchlist/:id` | Libellé, agent assigné, notifications, automatisme, favori, priorité, interruption, étiquettes (`tagIds`, remplacement en bloc) |
| `GET` | `/api/tags` | Étiquettes existantes |
| `POST` | `/api/tags` | Crée l'étiquette, ou renvoie celle qui porte déjà ce nom |
| `PATCH` | `/api/tags/:id` | Renomme (409 si le nom est déjà pris) |
| `DELETE` | `/api/tags/:id` | Supprime, et la retire de tous les profils |
| `GET` | `/api/timeline?days=&targetId=` | Diffusions et captures ; sans `targetId`, tous les profils |
| `GET` | `/api/timeline/sessions/:id/preview` | Vignette conservée d'une diffusion (jeton accepté en query) |
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |

View File

@@ -8,12 +8,13 @@ import {
normalizeBrowserSettings,
normalizePushoverSettings,
normalizeRecordingSettings,
normalizeTagName,
normalizeWatchSettings,
parseStripchatUsername,
} from '@stream-control/shared';
import { config } from './config.ts';
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, targetsRepo } from './db.ts';
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, tagsRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts';
import { previewFileFor } from './previews.ts';
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
@@ -289,6 +290,16 @@ api.patch('/watchlist/:id', (req, res) => {
preempt: typeof req.body?.preempt === 'boolean' ? req.body.preempt : target.preempt,
});
// Remplacement en bloc, et seulement si le champ est présent : une requête qui
// ne parle pas d'étiquettes (basculer un favori, par exemple) ne doit pas les
// effacer au passage.
if (Array.isArray(req.body?.tagIds)) {
tagsRepo.setForTarget(
target.id,
(req.body.tagIds as unknown[]).filter((id): id is string => typeof id === 'string'),
);
}
const updated = targetsRepo.get(target.id);
if (!updated) {
res.status(500).json({ error: 'Mise à jour impossible' });
@@ -298,6 +309,70 @@ api.patch('/watchlist/:id', (req, res) => {
res.json({ target: updated });
});
// --- Étiquettes ---------------------------------------------------------------
api.get('/tags', (_req, res) => {
res.json({ tags: tagsRepo.list() });
});
/**
* Crée une étiquette, ou renvoie celle qui porte déjà ce nom.
*
* Pas de 409 sur un nom déjà pris : côté dashboard, poser une étiquette se fait
* en la saisissant, et rien ne distingue à la frappe un nom neuf d'un nom
* existant. Refuser le second obligerait l'interface à vérifier avant chaque
* ajout pour un résultat identique.
*/
api.post('/tags', (req, res) => {
const name = normalizeTagName(req.body?.name);
if (!name) {
res.status(400).json({ error: 'Nom d\'étiquette vide' });
return;
}
res.status(201).json({ tag: tagsRepo.ensure(randomUUID(), name) });
});
api.patch('/tags/:id', (req, res) => {
const tag = tagsRepo.get(req.params.id);
if (!tag) {
res.status(404).json({ error: 'Étiquette introuvable' });
return;
}
const name = normalizeTagName(req.body?.name);
if (!name) {
res.status(400).json({ error: 'Nom d\'étiquette vide' });
return;
}
// Le renommage, lui, refuse la collision : fusionner deux étiquettes n'est pas
// ce qu'on demande en corrigeant une faute de frappe, et le faire en silence
// ferait disparaître l'une des deux des profils qui la portaient.
const clash = tagsRepo.findByName(name);
if (clash && clash.id !== tag.id) {
res.status(409).json({ error: `« ${name} » existe déjà` });
return;
}
tagsRepo.rename(tag.id, name);
// Les profils portent leurs étiquettes en clair : sans republication, les
// fiches déjà à l'écran garderaient l'ancien nom jusqu'au prochain rechargement.
for (const target of targetsRepo.list()) hub.publishTarget(target);
res.json({ tag: { id: tag.id, name } });
});
api.delete('/tags/:id', (req, res) => {
const tag = tagsRepo.get(req.params.id);
if (!tag) {
res.status(404).json({ error: 'Étiquette introuvable' });
return;
}
// Les rattachements partent en cascade : l'étiquette disparaît de tous les
// profils qui la portaient, sans que ceux-ci soient touchés autrement.
tagsRepo.remove(tag.id);
for (const target of targetsRepo.list()) hub.publishTarget(target);
res.json({ ok: true });
});
api.delete('/watchlist/:id', (req, res) => {
const target = targetsRepo.get(req.params.id);
if (!target) {

View File

@@ -11,6 +11,7 @@ import type {
RecordingSpan,
StreamSession,
StreamState,
Tag,
BrowserSettings,
WatchSettings,
WatchTarget,
@@ -114,6 +115,31 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_spans_started ON recording_spans (started_at DESC);
CREATE INDEX IF NOT EXISTS idx_spans_open ON recording_spans (agent_id) WHERE ended_at IS NULL;
-- Étiquettes libres, posables sur autant de profils qu'on veut. Table à part
-- plutôt qu'un texte répété sur chaque profil : c'est ce qui rend le renommage
-- possible d'un geste, et garde une étiquette disponible même quand plus aucun
-- profil ne la porte.
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- NOCASE : « Blonde » et « blonde » sont la même étiquette. Sans cet index,
-- une majuscule d'inattention en créerait une seconde, indiscernable de la
-- première dans la liste des filtres.
CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name ON tags (name COLLATE NOCASE);
-- Le double CASCADE est voulu : ni un profil qu'on ne suit plus, ni une
-- étiquette supprimée ne doivent laisser de rattachement derrière eux.
CREATE TABLE IF NOT EXISTS target_tags (
target_id TEXT NOT NULL REFERENCES watch_targets (id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
PRIMARY KEY (target_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_target_tags_tag ON target_tags (tag_id);
-- Réglages globaux, hors agents et profils : une ligne par clé, en JSON.
-- Pensé pour grandir (un jour d'autres canaux que Pushover) sans nouvelle
-- table à chaque fois.
@@ -495,8 +521,126 @@ interface TargetRow {
const num = (value: number | null): number | null => (value === null ? null : Number(value));
function toTarget(row: TargetRow): WatchTarget {
// --- Étiquettes ---------------------------------------------------------------
const tagStmts = {
list: db.prepare('SELECT id, name FROM tags ORDER BY name COLLATE NOCASE'),
get: db.prepare('SELECT id, name FROM tags WHERE id = ?'),
getByName: db.prepare('SELECT id, name FROM tags WHERE name = ? COLLATE NOCASE'),
insert: db.prepare('INSERT INTO tags (id, name, created_at) VALUES (?, ?, ?)'),
rename: db.prepare('UPDATE tags SET name = ? WHERE id = ?'),
remove: db.prepare('DELETE FROM tags WHERE id = ?'),
forTarget: db.prepare(`
SELECT t.id, t.name FROM tags t
JOIN target_tags tt ON tt.tag_id = t.id
WHERE tt.target_id = ?
ORDER BY t.name COLLATE NOCASE
`),
all: db.prepare(`
SELECT tt.target_id, t.id, t.name FROM target_tags tt
JOIN tags t ON t.id = tt.tag_id
ORDER BY t.name COLLATE NOCASE
`),
clearForTarget: db.prepare('DELETE FROM target_tags WHERE target_id = ?'),
attach: db.prepare('INSERT OR IGNORE INTO target_tags (target_id, tag_id) VALUES (?, ?)'),
};
export const tagsRepo = {
list(): Tag[] {
return tagStmts.list.all() as unknown as Tag[];
},
get(id: string): Tag | null {
return (tagStmts.get.get(id) as unknown as Tag | undefined) ?? null;
},
findByName(name: string): Tag | null {
return (tagStmts.getByName.get(name) as unknown as Tag | undefined) ?? null;
},
/**
* Crée l'étiquette, ou renvoie celle qui porte déjà ce nom.
*
* Idempotent à dessein : côté dashboard, poser une étiquette se fait en la
* saisissant, et rien ne distingue à la frappe un nom neuf d'un nom existant.
* Échouer sur le second obligerait l'interface à vérifier avant chaque ajout.
*/
ensure(id: string, name: string): Tag {
const existing = tagsRepo.findByName(name);
if (existing) return existing;
tagStmts.insert.run(id, name, Date.now());
return { id, name };
},
rename(id: string, name: string): void {
tagStmts.rename.run(name, id);
},
remove(id: string): void {
tagStmts.remove.run(id);
},
forTarget(targetId: string): Tag[] {
return tagStmts.forTarget.all(targetId) as unknown as Tag[];
},
/** Toutes les affectations d'un coup, pour éviter une requête par profil. */
byTarget(): Map<string, Tag[]> {
const rows = tagStmts.all.all() as unknown as Array<{
target_id: string;
id: string;
name: string;
}>;
const map = new Map<string, Tag[]>();
for (const row of rows) {
const bucket = map.get(row.target_id);
const tag = { id: row.id, name: row.name };
if (bucket) bucket.push(tag);
else map.set(row.target_id, [tag]);
}
return map;
},
/**
* Remplace en bloc les étiquettes d'un profil.
*
* En transaction : l'opération efface avant de réécrire, et une interruption
* entre les deux laisserait le profil dépouillé de toutes ses étiquettes
* plutôt qu'inchangé.
*/
setForTarget(targetId: string, tagIds: string[]): void {
db.exec('BEGIN');
try {
tagStmts.clearForTarget.run(targetId);
for (const tagId of tagIds) {
// Un identifiant périmé (étiquette supprimée entre l'affichage et
// l'envoi) est écarté sans faire échouer les autres : la clé étrangère
// le refuse, on passe au suivant.
try {
tagStmts.attach.run(targetId, tagId);
} catch {
// Étiquette disparue : rien à rattacher.
}
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
},
};
/**
* Étiquettes d'un profil, ou celles fournies d'avance.
*
* Le paramètre existe pour `list()`, qui les charge toutes en une requête et les
* distribue ensuite : sans lui, afficher trente profils en déclencherait trente,
* une par ligne.
*/
function toTarget(row: TargetRow, tags?: Tag[]): WatchTarget {
return {
tags: tags ?? tagsRepo.forTarget(row.id),
id: row.id,
provider: (row.provider as WatchTarget['provider']) ?? 'stripchat',
username: row.username,
@@ -550,7 +694,10 @@ const targetStmts = {
export const targetsRepo = {
list(): WatchTarget[] {
return (targetStmts.list.all() as unknown as TargetRow[]).map(toTarget);
const tags = tagsRepo.byTarget();
return (targetStmts.list.all() as unknown as TargetRow[]).map((row) =>
toTarget(row, tags.get(row.id) ?? []),
);
},
get(id: string): WatchTarget | null {

View File

@@ -866,6 +866,35 @@ export interface LogEntry {
event: AgentEvent | null;
}
/**
* Étiquette librement créée par l'opérateur, posable sur autant de profils
* qu'on veut.
*
* Une entité à part entière plutôt qu'un simple texte répété sur chaque profil :
* c'est ce qui permet de la renommer d'un geste, et de la garder disponible
* même quand plus aucun profil ne la porte.
*/
export interface Tag {
id: string;
name: string;
}
/** Limite de saisie : au-delà, l'étiquette ne tient plus dans une vignette. */
export const TAG_MAX_LENGTH = 24;
/**
* Nettoie un nom d'étiquette saisi, ou renvoie `null` s'il ne reste rien.
*
* Les espaces intérieurs sont réduits et les extrémités coupées : « en soirée »
* et « en soirée » désignent la même étiquette, et les laisser cohabiter ferait
* deux entrées indiscernables à l'œil dans la liste des filtres.
*/
export function normalizeTagName(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const name = raw.replace(/\s+/g, ' ').trim().slice(0, TAG_MAX_LENGTH);
return name.length > 0 ? name : null;
}
/**
* Profil surveillé par le serveur : on colle l'URL d'un streamer, le serveur
* sonde son statut et signale les passages en direct.
@@ -914,6 +943,16 @@ export interface WatchTarget {
lastError: string | null;
createdAt: number;
/**
* Étiquettes libres posées par l'opérateur, pour trier une liste qui grandit.
*
* Embarquées dans le profil plutôt que référencées par identifiant : la liste
* part déjà en entier à chaque mise à jour temps réel, et une poignée de noms
* ne pèse rien à côté. Le dashboard n'a de ce fait aucune seconde liste à
* tenir à jour pour afficher une vignette.
*/
tags: Tag[];
/** Photo de profil. */
avatarUrl: string | null;
/**

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