Feat : tag system
This commit is contained in:
46
README.md
46
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 ».
|
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
|
- [Vignette du flux en direct](#vignette-du-flux-en-direct) sur la fiche compacte de chaque
|
||||||
profil en direct, agrandie au survol.
|
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
|
- [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
|
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
|
## 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
|
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.
|
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
|
## Vignette du flux en direct
|
||||||
|
|
||||||
Chaque fiche compacte d'un profil **en direct** (onglet Streamers) affiche une image du
|
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 |
|
| `GET` | `/api/agents` | Liste des agents et de leur état |
|
||||||
| `POST` | `/api/agents` | Provisionne un agent, renvoie son jeton **une seule fois** |
|
| `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/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?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) |
|
| `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) |
|
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import {
|
|||||||
normalizeBrowserSettings,
|
normalizeBrowserSettings,
|
||||||
normalizePushoverSettings,
|
normalizePushoverSettings,
|
||||||
normalizeRecordingSettings,
|
normalizeRecordingSettings,
|
||||||
|
normalizeTagName,
|
||||||
normalizeWatchSettings,
|
normalizeWatchSettings,
|
||||||
parseStripchatUsername,
|
parseStripchatUsername,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
import { config } from './config.ts';
|
import { config } from './config.ts';
|
||||||
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.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 { hub } from './hub.ts';
|
||||||
import { previewFileFor } from './previews.ts';
|
import { previewFileFor } from './previews.ts';
|
||||||
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.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,
|
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);
|
const updated = targetsRepo.get(target.id);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
res.status(500).json({ error: 'Mise à jour impossible' });
|
res.status(500).json({ error: 'Mise à jour impossible' });
|
||||||
@@ -298,6 +309,70 @@ api.patch('/watchlist/:id', (req, res) => {
|
|||||||
res.json({ target: updated });
|
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) => {
|
api.delete('/watchlist/:id', (req, res) => {
|
||||||
const target = targetsRepo.get(req.params.id);
|
const target = targetsRepo.get(req.params.id);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
RecordingSpan,
|
RecordingSpan,
|
||||||
StreamSession,
|
StreamSession,
|
||||||
StreamState,
|
StreamState,
|
||||||
|
Tag,
|
||||||
BrowserSettings,
|
BrowserSettings,
|
||||||
WatchSettings,
|
WatchSettings,
|
||||||
WatchTarget,
|
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_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;
|
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.
|
-- 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
|
-- Pensé pour grandir (un jour d'autres canaux que Pushover) sans nouvelle
|
||||||
-- table à chaque fois.
|
-- table à chaque fois.
|
||||||
@@ -495,8 +521,126 @@ interface TargetRow {
|
|||||||
|
|
||||||
const num = (value: number | null): number | null => (value === null ? null : Number(value));
|
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 {
|
return {
|
||||||
|
tags: tags ?? tagsRepo.forTarget(row.id),
|
||||||
id: row.id,
|
id: row.id,
|
||||||
provider: (row.provider as WatchTarget['provider']) ?? 'stripchat',
|
provider: (row.provider as WatchTarget['provider']) ?? 'stripchat',
|
||||||
username: row.username,
|
username: row.username,
|
||||||
@@ -550,7 +694,10 @@ const targetStmts = {
|
|||||||
|
|
||||||
export const targetsRepo = {
|
export const targetsRepo = {
|
||||||
list(): WatchTarget[] {
|
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 {
|
get(id: string): WatchTarget | null {
|
||||||
|
|||||||
@@ -866,6 +866,35 @@ export interface LogEntry {
|
|||||||
event: AgentEvent | null;
|
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
|
* Profil surveillé par le serveur : on colle l'URL d'un streamer, le serveur
|
||||||
* sonde son statut et signale les passages en direct.
|
* sonde son statut et signale les passages en direct.
|
||||||
@@ -914,6 +943,16 @@ export interface WatchTarget {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
createdAt: number;
|
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. */
|
/** Photo de profil. */
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
AgentView,
|
AgentView,
|
||||||
LogEntry,
|
LogEntry,
|
||||||
PushoverSettings,
|
PushoverSettings,
|
||||||
|
Tag,
|
||||||
TimelineData,
|
TimelineData,
|
||||||
WatchTarget,
|
WatchTarget,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
@@ -126,7 +127,10 @@ export const api = {
|
|||||||
WatchTarget,
|
WatchTarget,
|
||||||
'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite' | 'priority' | 'preempt'
|
'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite' | 'priority' | 'preempt'
|
||||||
>
|
>
|
||||||
>,
|
> & {
|
||||||
|
/** Remplace en bloc les étiquettes ; omis, elles restent inchangées. */
|
||||||
|
tagIds?: string[];
|
||||||
|
},
|
||||||
) =>
|
) =>
|
||||||
request<{ target: WatchTarget }>(`/watchlist/${id}`, {
|
request<{ target: WatchTarget }>(`/watchlist/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -149,6 +153,20 @@ export const api = {
|
|||||||
`/timeline?days=${days}${targetId ? `&targetId=${encodeURIComponent(targetId)}` : ''}`,
|
`/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 ----------------------------------------------------------
|
// --- Notifications ----------------------------------------------------------
|
||||||
|
|
||||||
pushoverSettings: () => request<{ settings: PushoverSettings }>('/settings/pushover'),
|
pushoverSettings: () => request<{ settings: PushoverSettings }>('/settings/pushover'),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
stateLabel,
|
stateLabel,
|
||||||
TIMELINE_RANGES,
|
TIMELINE_RANGES,
|
||||||
} from '../streamers';
|
} from '../streamers';
|
||||||
|
import { TagEditor } from './Tags';
|
||||||
import { Timeline } from './Timeline';
|
import { Timeline } from './Timeline';
|
||||||
|
|
||||||
interface Props {
|
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>}
|
{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">
|
<section className="group">
|
||||||
<h3>Enregistrement</h3>
|
<h3>Enregistrement</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import {
|
|||||||
previewSrc,
|
previewSrc,
|
||||||
recordingContext,
|
recordingContext,
|
||||||
stateLabel,
|
stateLabel,
|
||||||
|
tagStyle,
|
||||||
|
usedTags,
|
||||||
type RecordingContext,
|
type RecordingContext,
|
||||||
} from '../streamers';
|
} from '../streamers';
|
||||||
import { StreamerDetail } from './StreamerDetail';
|
import { StreamerDetail } from './StreamerDetail';
|
||||||
|
import { TagChip, TagManager } from './Tags';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
targets: WatchTarget[];
|
targets: WatchTarget[];
|
||||||
@@ -39,6 +42,22 @@ export function StreamersPage({
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [filter, setFilter] = useState<FilterId>('all');
|
const [filter, setFilter] = useState<FilterId>('all');
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
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) {
|
async function add(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -76,6 +95,13 @@ export function StreamersPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const visible = ordered.filter((target) => {
|
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 === 'favorites') return target.favorite;
|
||||||
if (filter === 'live') return target.state === 'public';
|
if (filter === 'live') return target.state === 'public';
|
||||||
return true;
|
return true;
|
||||||
@@ -120,6 +146,37 @@ export function StreamersPage({
|
|||||||
</div>
|
</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 ? (
|
{targets.length === 0 ? (
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
<h2>Aucun streamer suivi</h2>
|
<h2>Aucun streamer suivi</h2>
|
||||||
@@ -154,6 +211,16 @@ export function StreamersPage({
|
|||||||
onClose={() => setOpenId(null)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -284,6 +351,15 @@ function StreamerCard({
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</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">
|
<dl className="streamer-facts">
|
||||||
<div>
|
<div>
|
||||||
<dt>{isLive ? 'En direct depuis' : 'Statut depuis'}</dt>
|
<dt>{isLive ? 'En direct depuis' : 'Statut depuis'}</dt>
|
||||||
|
|||||||
319
packages/web/src/components/Tags.tsx
Normal file
319
packages/web/src/components/Tags.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -134,6 +134,38 @@ export function previewSrc(target: Pick<WatchTarget, 'previewUrl'>): string | nu
|
|||||||
return target.previewUrl;
|
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. */
|
/** Fenêtres proposées sous les frises. */
|
||||||
export const TIMELINE_RANGES = [
|
export const TIMELINE_RANGES = [
|
||||||
{ days: 1, label: '24 h' },
|
{ days: 1, label: '24 h' },
|
||||||
|
|||||||
@@ -642,6 +642,75 @@ fieldset.group > legend {
|
|||||||
flex-wrap: wrap;
|
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 ;
|
* Vignette du flux en direct. `overflow: hidden` recadre l'image au repos ;
|
||||||
* au survol il passe à `visible` pour que l'agrandissement (sur le seul
|
* au survol il passe à `visible` pour que l'agrandissement (sur le seul
|
||||||
|
|||||||
Reference in New Issue
Block a user