diff --git a/README.md b/README.md index e5d117f..aa4b1ed 100644 --- a/README.md +++ b/README.md @@ -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 { 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) { diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index c206233..096b735 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -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 { + const rows = tagStmts.all.all() as unknown as Array<{ + target_id: string; + id: string; + name: string; + }>; + const map = new Map(); + 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 { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0758d2c..98c9922 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; /** diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index 4d2bbf4..f982b9f 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -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'), diff --git a/packages/web/src/components/StreamerDetail.tsx b/packages/web/src/components/StreamerDetail.tsx index 6ec7392..7f298e9 100644 --- a/packages/web/src/components/StreamerDetail.tsx +++ b/packages/web/src/components/StreamerDetail.tsx @@ -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 &&

Sonde en échec : {target.lastError}

} +
+

Étiquettes

+ api.updateTarget(target.id, { tagIds })} + /> +

+ Sert à filtrer la liste des streamers. Une étiquette inconnue se crée en la + saisissant. +

+
+

Enregistrement

diff --git a/packages/web/src/components/StreamersPage.tsx b/packages/web/src/components/StreamersPage.tsx index a26a926..2f7ce78 100644 --- a/packages/web/src/components/StreamersPage.tsx +++ b/packages/web/src/components/StreamersPage.tsx @@ -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('all'); const [openId, setOpenId] = useState(null); + const [selectedTags, setSelectedTags] = useState>(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({ )} + {targets.length > 0 && ( +
+ {tags.map((tag) => ( + + ))} + {tags.length === 0 && ( + + Aucune étiquette — pose-en une depuis la fiche d'un streamer pour trier la liste + d'ici. + + )} + {selectedTags.size > 0 && ( + + )} +
+ +
+ )} + {targets.length === 0 ? (

Aucun streamer suivi

@@ -154,6 +211,16 @@ export function StreamersPage({ onClose={() => setOpenId(null)} /> )} + + {managingTags && ( + 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())} + /> + )}
); } @@ -284,6 +351,15 @@ function StreamerCard({ + {/* Sans retrait possible ici : la vignette sert à voir, la fiche à régler. */} + {target.tags.length > 0 && ( +
+ {target.tags.map((tag) => ( + + ))} +
+ )} +
{isLive ? 'En direct depuis' : 'Statut depuis'}
diff --git a/packages/web/src/components/Tags.tsx b/packages/web/src/components/Tags.tsx new file mode 100644 index 0000000..e2c770c --- /dev/null +++ b/packages/web/src/components/Tags.tsx @@ -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 ( + + {name} + {onRemove && ( + + )} + + ); +} + +/** + * 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; +}) { + const [known, setKnown] = useState([]); + const [draft, setDraft] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+
+ {current.map((tag) => ( + void detach(tag.id)} + /> + ))} + {current.length === 0 && Aucune étiquette} +
+ +
+ setDraft(event.target.value)} + placeholder="nouvelle étiquette…" + aria-label="Nom de l'étiquette à poser" + /> + +
+ + {available.length > 0 && ( +
+ Déjà utilisées : + {available.map((tag) => ( + + ))} +
+ )} + + {error &&

{error}

} +
+ ); +} + +/** + * 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(null); + const [error, setError] = useState(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) { + setBusy(true); + setError(null); + try { + await action(); + reload(); + onChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Action impossible'); + } finally { + setBusy(false); + } + } + + return ( +
+
event.stopPropagation()}> +
+

Étiquettes

+
+ +
+ +
+ {error &&

{error}

} + {tags === null &&

Chargement…

} + {tags?.length === 0 && ( +

+ Aucune étiquette. Elles se créent en les saisissant sur la fiche d'un streamer. +

+ )} + + {tags?.map((tag) => + editing?.id === tag.id ? ( +
{ + 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)); + }} + > + setEditing({ id: tag.id, name: event.target.value })} + /> + + +
+ ) : ( +
+ +
+ + +
+ ), + )} +
+ +
+ + Supprimer une étiquette ne supprime aucun streamer. + +
+ +
+
+
+ ); +} diff --git a/packages/web/src/streamers.ts b/packages/web/src/streamers.ts index 8ead28f..8453a62 100644 --- a/packages/web/src/streamers.ts +++ b/packages/web/src/streamers.ts @@ -134,6 +134,38 @@ export function previewSrc(target: Pick): 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(); + 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' }, diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 33f4b59..95303cc 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -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