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

@@ -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