From 9a019447e794bfc85ad221212d2b415f3f7a1f40 Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Fri, 14 Aug 2026 21:17:02 -0400 Subject: [PATCH] Feat : QOL - better filter --- README.md | 60 +++++ packages/web/src/components/SearchField.tsx | 57 +++++ packages/web/src/components/StreamersPage.tsx | 72 +++--- packages/web/src/components/Tags.tsx | 40 ++++ packages/web/src/components/Timeline.tsx | 12 +- packages/web/src/components/TimelinePage.tsx | 117 ++++++---- packages/web/src/streamers.ts | 216 ++++++++++++++++++ packages/web/src/styles.css | 77 +++++++ 8 files changed, 565 insertions(+), 86 deletions(-) create mode 100644 packages/web/src/components/SearchField.tsx diff --git a/README.md b/README.md index 9bdc99a..3fb3b62 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`. profil en direct, agrandie au survol. - [Étiquettes](#étiquettes) libres sur les streamers, créées à la volée, avec filtrage de la liste par étiquette. +- [Recherche et tri](#recherche-et-tri) : un champ de recherche sur les deux listes (nom, + pseudo ou étiquette, accents et casse ignorés) et quatre ordres de tri sur la frise. - [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 ». Une @@ -794,6 +796,64 @@ Une étiquette survit à la suppression du dernier profil qui la portait — ell 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). +## Recherche et tri + +### Chercher un streamer + +Un champ de recherche ouvre la barre de filtres des onglets **Streamers** et **Timeline**. Il +filtre **à la frappe**, sans bouton ni validation : les profils sont déjà chargés côté +navigateur, il n'y a rien à attendre d'un aller-retour serveur — et en imposer un +transformerait un tri visuel en formulaire. + +Il cherche **à la fois dans le nom affiché, le pseudo et les étiquettes**. Ce sont les trois +façons dont on désigne un profil ; exiger de savoir laquelle s'applique reviendrait à demander +de chercher ce qu'on a déjà trouvé. + +**Ni la casse ni les accents ne comptent** : taper `lea` trouve « Léa ». On saisit le pseudo +qu'on a en tête, pas celui qu'on a sous les yeux, et un accent oublié n'est pas une faute qui +mérite une liste vide. Plusieurs mots se cumulent, **dans n'importe quel ordre** — `blonde lea` +retrouve « Léa » étiquetée « blonde ». + +La recherche **se cumule** avec les autres filtres (statut, étiquettes, « Enregistrés ») : ce +sont des critères de nature différente, les laisser s'élargir les uns les autres ne filtrerait +plus rien. Seules les étiquettes entre elles forment une union (voir +[Étiquettes](#étiquettes)). + +Une **croix** apparaît dès que le champ n'est plus vide, et la touche **Échap** fait la même +chose : un filtre qu'on ne sait pas défaire finit par mentir sur ce qu'on croit voir. Pour la +même raison, quand une liste est vide **le message nomme la recherche en cours** plutôt que de +parler d'un « filtre » indéterminé — c'est le critère qu'on oublie le plus facilement, les +autres se voyant à l'écran. + +### Trier la frise + +L'onglet **Timeline** propose quatre ordres, sélecteur *Trier par* : + +| Ordre | Ce qu'il classe | À quoi il répond | +| --- | --- | --- | +| **Activité récente** (défaut) | Dernier signe de vie, diffusion ou capture | « Que s'est-il passé récemment ? » | +| **Temps enregistré** | Durée capturée, **pauses déduites** | « Sur quoi ai-je réellement du fichier ? » | +| **Temps de diffusion** | Durée en public, capturée ou non | « Qui diffuse le plus ? » | +| **Nom (A→Z)** | Libellé, ou pseudo à défaut | Retrouver une voie précise | + +Les deux tris par durée **retombent sur l'activité récente à égalité** : des voies à zéro +seconde enregistrée sont fréquentes, et les laisser dans un ordre arbitraire donnerait une +liste qui se réorganise sans raison visible d'un rafraîchissement à l'autre. L'instant de +référence est figé une fois pour toutes au début du tri — relu à chaque comparaison, il ferait +bouger la durée des voies encore ouvertes en plein classement, et l'ordre obtenu pourrait alors +se contredire lui-même. + +**Le tri par temps enregistré déduit les pauses**, comme les totaux. Sans ça, un profil souvent +en show privé passerait devant un profil réellement capturé, sur la foi d'un intervalle qui +n'écrivait rien. + +**Les totaux de la légende suivent les filtres** : le résumé est posé sous la barre, il décrit +la frise qu'on a sous les yeux et non les données brutes — annoncer un total qui ne correspond +à rien de visible ferait douter du filtre autant que du chiffre. Le libellé le dit (« sur la +sélection » plutôt que « au total »). Une capture jamais rattachée à un profil suivi (lancée à +la main depuis l'onglet **Agents**) n'entre dans aucun total : elle n'a pas de voie où +s'afficher. + ## Vignette du flux en direct Chaque fiche compacte d'un profil **en direct** (onglet Streamers) affiche une image du diff --git a/packages/web/src/components/SearchField.tsx b/packages/web/src/components/SearchField.tsx new file mode 100644 index 0000000..d557e42 --- /dev/null +++ b/packages/web/src/components/SearchField.tsx @@ -0,0 +1,57 @@ +interface Props { + value: string; + onChange: (value: string) => void; + placeholder: string; + /** Libellé lu par les lecteurs d'écran : le champ n'a pas d'étiquette visible. */ + label: string; +} + +/** + * Champ de recherche à filtrage immédiat. + * + * Pas de bouton « Chercher » ni de validation : la liste se réduit à la frappe. + * Sur une poignée de profils déjà chargés côté navigateur, il n'y a rien à + * attendre — et un aller-retour serveur transformerait un tri visuel en + * formulaire. + * + * La croix et la touche Échap font la même chose : revenir à la liste entière. + * Un filtre qu'on ne sait pas défaire est un filtre qui finit par mentir sur ce + * qu'on croit voir. + */ +export function SearchField({ value, onChange, placeholder, label }: Props) { + return ( +
+ + onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape' && value) { + // Sans ça, Firefox vide le champ sans que React le sache : l'écran + // afficherait un champ vide et une liste toujours filtrée. + event.preventDefault(); + onChange(''); + } + }} + /> + {value && ( + + )} +
+ ); +} diff --git a/packages/web/src/components/StreamersPage.tsx b/packages/web/src/components/StreamersPage.tsx index 38b4820..b4849c4 100644 --- a/packages/web/src/components/StreamersPage.tsx +++ b/packages/web/src/components/StreamersPage.tsx @@ -6,18 +6,21 @@ import { formatDateTime, formatDuration, formatRelative } from '../format'; import { PREVIEW_SIZES, loadPreviewSize, + matchesSearch, + matchesTags, preemption, previewSrc, recordingContext, savePreviewSize, stateLabel, - tagStyle, + toggleId, usedTags, type PreviewSize, type RecordingContext, } from '../streamers'; +import { SearchField } from './SearchField'; import { StreamerDetail } from './StreamerDetail'; -import { TagChip, TagManager } from './Tags'; +import { TagChip, TagFilter, TagManager } from './Tags'; interface Props { targets: WatchTarget[]; @@ -45,6 +48,7 @@ export function StreamersPage({ const [url, setUrl] = useState(''); const [busy, setBusy] = useState(false); const [filter, setFilter] = useState('all'); + const [query, setQuery] = useState(''); const [openId, setOpenId] = useState(null); const [selectedTags, setSelectedTags] = useState>(new Set()); const [managingTags, setManagingTags] = useState(false); @@ -63,12 +67,7 @@ export function StreamersPage({ 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; - }); + setSelectedTags((current) => toggleId(current, id)); } async function add(event: FormEvent) { @@ -107,13 +106,12 @@ 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; - } + // Les trois filtres se cumulent — la recherche ET « en direct » ET portant + // l'une des étiquettes retenues. C'est une union entre étiquettes seulement + // (voir `matchesTags`) : des critères de nature différente qui s'élargiraient + // les uns les autres ne filtreraient plus rien. + if (!matchesSearch(target, query)) return false; + if (!matchesTags(target, selectedTags)) return false; if (filter === 'favorites') return target.favorite; if (filter === 'live') return target.state === 'public'; return true; @@ -144,6 +142,12 @@ export function StreamersPage({ {targets.length > 0 && (
+ {FILTERS.map((entry) => (
)} {targets.length > 0 && (
- {tags.map((tag) => ( - - ))} + setSelectedTags(new Set())} + /> {tags.length === 0 && ( Aucune étiquette — pose-en une depuis la fiche d'un streamer pour trier la liste d'ici. )} - {selectedTags.size > 0 && ( - - )}
) : visible.length === 0 ? ( -

Aucun profil pour ce filtre.

+ // La recherche est nommée dans le message : c'est le filtre qu'on oublie + // le plus facilement — les autres se voient, une chaîne tapée puis + // laissée dans le champ passe inaperçue. +

+ {query.trim() + ? `Aucun profil ne correspond à « ${query.trim()} ».` + : 'Aucun profil pour ce filtre.'} +

) : (
{visible.map((target) => ( diff --git a/packages/web/src/components/Tags.tsx b/packages/web/src/components/Tags.tsx index e2c770c..8670fbc 100644 --- a/packages/web/src/components/Tags.tsx +++ b/packages/web/src/components/Tags.tsx @@ -35,6 +35,46 @@ export function TagChip({ ); } +/** + * Rangée d'étiquettes servant de filtre, suivie de quoi tout réafficher. + * + * Rendue en fragment plutôt qu'en bloc : chaque page compose sa propre barre + * autour — la liste des streamers y ajoute la gestion des étiquettes, la frise + * son sélecteur de tri. + */ +export function TagFilter({ + tags, + selected, + onToggle, + onClear, +}: { + tags: Array<{ id: string; name: string }>; + selected: ReadonlySet; + onToggle: (id: string) => void; + onClear: () => void; +}) { + return ( + <> + {tags.map((tag) => ( + + ))} + {selected.size > 0 && ( + + )} + + ); +} + /** * Pose et retire les étiquettes d'un profil. * diff --git a/packages/web/src/components/Timeline.tsx b/packages/web/src/components/Timeline.tsx index 7b48812..5ec5581 100644 --- a/packages/web/src/components/Timeline.tsx +++ b/packages/web/src/components/Timeline.tsx @@ -2,15 +2,11 @@ import { useMemo, useState } from 'react'; import type { RecordingPause, RecordingSpan, StreamSession } from '@stream-control/shared'; import { sessionPreviewSrc } from '../api'; import { formatDuration } from '../format'; +// Décrite là où elle est construite et filtrée, plutôt qu'ici où elle n'est que +// dessinée : le composant reçoit ses voies, il ne les compose pas. +import type { TimelineLane } from '../streamers'; -export interface TimelineLane { - id: string; - label: string; - avatarUrl?: string | null; - sessions: StreamSession[]; - spans: RecordingSpan[]; - pauses: RecordingPause[]; -} +export type { TimelineLane }; interface Props { from: number; diff --git a/packages/web/src/components/TimelinePage.tsx b/packages/web/src/components/TimelinePage.tsx index 059a4cc..679ba0b 100644 --- a/packages/web/src/components/TimelinePage.tsx +++ b/packages/web/src/components/TimelinePage.tsx @@ -2,9 +2,20 @@ import { useEffect, useMemo, useState } from 'react'; import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared'; import { api } from '../api'; import { formatDuration } from '../format'; -import { TIMELINE_RANGES } from '../streamers'; +import { + TIMELINE_RANGES, + TIMELINE_SORTS, + buildTimelineLanes, + recordedMs, + toggleId, + usedTags, + type TimelineLane, + type TimelineSort, +} from '../streamers'; +import { SearchField } from './SearchField'; import { StreamerDetail } from './StreamerDetail'; -import { Timeline, type TimelineLane } from './Timeline'; +import { Timeline } from './Timeline'; +import { TagFilter } from './Tags'; interface Props { targets: WatchTarget[]; @@ -27,6 +38,9 @@ export function TimelinePage({ targets, agents, notify }: Props) { const [loading, setLoading] = useState(true); const [onlyRecorded, setOnlyRecorded] = useState(false); const [openId, setOpenId] = useState(null); + const [query, setQuery] = useState(''); + const [selectedTags, setSelectedTags] = useState>(new Set()); + const [sort, setSort] = useState('activity'); useEffect(() => { let cancelled = false; @@ -51,49 +65,35 @@ export function TimelinePage({ targets, agents, notify }: Props) { const now = Date.now(); const from = now - days * 86_400_000; - const lanes = useMemo(() => { - const byTarget = new Map(); - for (const target of targets) { - byTarget.set(target.id, { - id: target.id, - label: target.label ?? target.username, - avatarUrl: target.avatarUrl, - sessions: [], - spans: [], - pauses: [], - }); - } - for (const session of data.sessions) byTarget.get(session.targetId)?.sessions.push(session); - for (const span of data.spans) { - if (span.targetId) byTarget.get(span.targetId)?.spans.push(span); - } - for (const pause of data.pauses) { - if (pause.targetId) byTarget.get(pause.targetId)?.pauses.push(pause); - } + // Tirées des profils et non du serveur : ils arrivent entiers par le flux + // temps réel, donc toujours à jour. + const tags = useMemo(() => usedTags(targets), [targets]); - // Un profil sans la moindre diffusion sur la période n'apporte qu'une ligne - // vide : la frise sert à comparer des activités, pas à recenser les suivis. - return [...byTarget.values()] - .filter((lane) => lane.sessions.length > 0 || lane.spans.length > 0) - .filter((lane) => !onlyRecorded || lane.spans.length > 0) - .sort((a, b) => lastActivity(b) - lastActivity(a)); - }, [data, targets, onlyRecorded]); - - // Pauses déduites : la capture tournait, mais n'écrivait rien pendant ce - // temps-là. L'annoncer comme enregistré surestimerait ce qu'on a réellement. - const elapsed = (interval: { startedAt: number; endedAt: number | null }) => - (interval.endedAt ?? now) - interval.startedAt; - const totalRecorded = Math.max( - 0, - data.spans.reduce((sum, span) => sum + elapsed(span), 0) - - data.pauses.reduce((sum, pause) => sum + elapsed(pause), 0), + const lanes = useMemo( + () => + buildTimelineLanes(targets, data, { query, tags: selectedTags, onlyRecorded, sort }), + [data, targets, onlyRecorded, query, selectedTags, sort], ); + + // Comptés sur les voies affichées et non sur les données brutes : le résumé + // est posé sous les filtres, il doit décrire la frise qu'on a sous les yeux. + // Annoncer un total qui ne correspond à rien de visible ferait douter du + // filtre autant que du chiffre. + const shownSessions = lanes.reduce((sum, lane) => sum + lane.sessions.length, 0); + const totalRecorded = lanes.reduce((sum, lane) => sum + recordedMs(lane), 0); + const filtered = query.trim() !== '' || selectedTags.size > 0 || onlyRecorded; const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null; return (

Frise des diffusions

+