Feat : QOL - better filter
This commit is contained in:
60
README.md
60
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.
|
profil en direct, agrandie au survol.
|
||||||
- [Étiquettes](#étiquettes) libres sur les streamers, créées à la volée, avec filtrage de la
|
- [Étiquettes](#étiquettes) libres sur les streamers, créées à la volée, avec filtrage de la
|
||||||
liste par étiquette.
|
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
|
- [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 ». Une
|
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
|
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).
|
(`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
|
## 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
|
||||||
|
|||||||
57
packages/web/src/components/SearchField.tsx
Normal file
57
packages/web/src/components/SearchField.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="search-field">
|
||||||
|
<span className="search-icon" aria-hidden="true">
|
||||||
|
⌕
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
// `search` plutôt que `text` : le clavier mobile propose « Rechercher »
|
||||||
|
// au lieu de « Entrée », et l'historique de saisie reste propre.
|
||||||
|
type="search"
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={(event) => 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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-clear"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
title="Effacer la recherche"
|
||||||
|
aria-label="Effacer la recherche"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,18 +6,21 @@ import { formatDateTime, formatDuration, formatRelative } from '../format';
|
|||||||
import {
|
import {
|
||||||
PREVIEW_SIZES,
|
PREVIEW_SIZES,
|
||||||
loadPreviewSize,
|
loadPreviewSize,
|
||||||
|
matchesSearch,
|
||||||
|
matchesTags,
|
||||||
preemption,
|
preemption,
|
||||||
previewSrc,
|
previewSrc,
|
||||||
recordingContext,
|
recordingContext,
|
||||||
savePreviewSize,
|
savePreviewSize,
|
||||||
stateLabel,
|
stateLabel,
|
||||||
tagStyle,
|
toggleId,
|
||||||
usedTags,
|
usedTags,
|
||||||
type PreviewSize,
|
type PreviewSize,
|
||||||
type RecordingContext,
|
type RecordingContext,
|
||||||
} from '../streamers';
|
} from '../streamers';
|
||||||
|
import { SearchField } from './SearchField';
|
||||||
import { StreamerDetail } from './StreamerDetail';
|
import { StreamerDetail } from './StreamerDetail';
|
||||||
import { TagChip, TagManager } from './Tags';
|
import { TagChip, TagFilter, TagManager } from './Tags';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
targets: WatchTarget[];
|
targets: WatchTarget[];
|
||||||
@@ -45,6 +48,7 @@ export function StreamersPage({
|
|||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [filter, setFilter] = useState<FilterId>('all');
|
const [filter, setFilter] = useState<FilterId>('all');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
||||||
const [managingTags, setManagingTags] = useState(false);
|
const [managingTags, setManagingTags] = useState(false);
|
||||||
@@ -63,12 +67,7 @@ export function StreamersPage({
|
|||||||
const tags = useMemo(() => usedTags(targets), [targets]);
|
const tags = useMemo(() => usedTags(targets), [targets]);
|
||||||
|
|
||||||
function toggleTag(id: string) {
|
function toggleTag(id: string) {
|
||||||
setSelectedTags((current) => {
|
setSelectedTags((current) => toggleId(current, id));
|
||||||
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) {
|
||||||
@@ -107,13 +106,12 @@ 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
|
// Les trois filtres se cumulent — la recherche ET « en direct » ET portant
|
||||||
// étiquettes retenues. Entre étiquettes en revanche, c'est une union : en
|
// l'une des étiquettes retenues. C'est une union entre étiquettes seulement
|
||||||
// cocher une seconde élargit la sélection, ce qui est le geste attendu
|
// (voir `matchesTags`) : des critères de nature différente qui s'élargiraient
|
||||||
// quand on empile des critères de même nature.
|
// les uns les autres ne filtreraient plus rien.
|
||||||
if (selectedTags.size > 0 && !target.tags.some((tag) => selectedTags.has(tag.id))) {
|
if (!matchesSearch(target, query)) return false;
|
||||||
return false;
|
if (!matchesTags(target, selectedTags)) 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;
|
||||||
@@ -144,6 +142,12 @@ export function StreamersPage({
|
|||||||
|
|
||||||
{targets.length > 0 && (
|
{targets.length > 0 && (
|
||||||
<div className="row filter-row">
|
<div className="row filter-row">
|
||||||
|
<SearchField
|
||||||
|
value={query}
|
||||||
|
onChange={setQuery}
|
||||||
|
placeholder="Chercher un streamer…"
|
||||||
|
label="Chercher un streamer par nom, pseudo ou étiquette"
|
||||||
|
/>
|
||||||
{FILTERS.map((entry) => (
|
{FILTERS.map((entry) => (
|
||||||
<button
|
<button
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
@@ -169,34 +173,29 @@ export function StreamersPage({
|
|||||||
{size.label}
|
{size.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="muted small">{visible.length} profil(s)</span>
|
<span className="muted small">
|
||||||
|
{visible.length} profil(s)
|
||||||
|
{/* Le total n'a d'intérêt qu'une fois la liste réduite : sans filtre,
|
||||||
|
il répète le nombre déjà affiché. */}
|
||||||
|
{visible.length !== targets.length && ` sur ${targets.length}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{targets.length > 0 && (
|
{targets.length > 0 && (
|
||||||
<div className="row filter-row tag-filters">
|
<div className="row filter-row tag-filters">
|
||||||
{tags.map((tag) => (
|
<TagFilter
|
||||||
<button
|
tags={tags}
|
||||||
key={tag.id}
|
selected={selectedTags}
|
||||||
className={selectedTags.has(tag.id) ? 'tag tag-filter active' : 'tag tag-filter'}
|
onToggle={toggleTag}
|
||||||
style={tagStyle(tag.name)}
|
onClear={() => setSelectedTags(new Set())}
|
||||||
onClick={() => toggleTag(tag.id)}
|
/>
|
||||||
title={`Ne garder que les profils portant « ${tag.name} »`}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{tags.length === 0 && (
|
{tags.length === 0 && (
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
Aucune étiquette — pose-en une depuis la fiche d'un streamer pour trier la liste
|
Aucune étiquette — pose-en une depuis la fiche d'un streamer pour trier la liste
|
||||||
d'ici.
|
d'ici.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{selectedTags.size > 0 && (
|
|
||||||
<button className="link-button" onClick={() => setSelectedTags(new Set())}>
|
|
||||||
tout afficher
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
<button className="chip" onClick={() => setManagingTags(true)} title="Renommer, supprimer">
|
<button className="chip" onClick={() => setManagingTags(true)} title="Renommer, supprimer">
|
||||||
⚙ Étiquettes
|
⚙ Étiquettes
|
||||||
@@ -213,7 +212,14 @@ export function StreamersPage({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : visible.length === 0 ? (
|
) : visible.length === 0 ? (
|
||||||
<p className="muted small">Aucun profil pour ce filtre.</p>
|
// 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.
|
||||||
|
<p className="muted small">
|
||||||
|
{query.trim()
|
||||||
|
? `Aucun profil ne correspond à « ${query.trim()} ».`
|
||||||
|
: 'Aucun profil pour ce filtre.'}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={`streamer-grid preview-${previewSize}`}>
|
<div className={`streamer-grid preview-${previewSize}`}>
|
||||||
{visible.map((target) => (
|
{visible.map((target) => (
|
||||||
|
|||||||
@@ -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<string>;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
className={selected.has(tag.id) ? 'tag tag-filter active' : 'tag tag-filter'}
|
||||||
|
style={tagStyle(tag.name)}
|
||||||
|
onClick={() => onToggle(tag.id)}
|
||||||
|
title={`Ne garder que les profils portant « ${tag.name} »`}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<button className="link-button" onClick={onClear}>
|
||||||
|
tout afficher
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pose et retire les étiquettes d'un profil.
|
* Pose et retire les étiquettes d'un profil.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,15 +2,11 @@ import { useMemo, useState } from 'react';
|
|||||||
import type { RecordingPause, RecordingSpan, StreamSession } from '@stream-control/shared';
|
import type { RecordingPause, RecordingSpan, StreamSession } from '@stream-control/shared';
|
||||||
import { sessionPreviewSrc } from '../api';
|
import { sessionPreviewSrc } from '../api';
|
||||||
import { formatDuration } from '../format';
|
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 {
|
export type { TimelineLane };
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
avatarUrl?: string | null;
|
|
||||||
sessions: StreamSession[];
|
|
||||||
spans: RecordingSpan[];
|
|
||||||
pauses: RecordingPause[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
from: number;
|
from: number;
|
||||||
|
|||||||
@@ -2,9 +2,20 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared';
|
import type { AgentView, TimelineData, WatchTarget } from '@stream-control/shared';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { formatDuration } from '../format';
|
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 { StreamerDetail } from './StreamerDetail';
|
||||||
import { Timeline, type TimelineLane } from './Timeline';
|
import { Timeline } from './Timeline';
|
||||||
|
import { TagFilter } from './Tags';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
targets: WatchTarget[];
|
targets: WatchTarget[];
|
||||||
@@ -27,6 +38,9 @@ export function TimelinePage({ targets, agents, notify }: Props) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [onlyRecorded, setOnlyRecorded] = useState(false);
|
const [onlyRecorded, setOnlyRecorded] = useState(false);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
||||||
|
const [sort, setSort] = useState<TimelineSort>('activity');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -51,49 +65,35 @@ export function TimelinePage({ targets, agents, notify }: Props) {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const from = now - days * 86_400_000;
|
const from = now - days * 86_400_000;
|
||||||
|
|
||||||
const lanes = useMemo<TimelineLane[]>(() => {
|
// Tirées des profils et non du serveur : ils arrivent entiers par le flux
|
||||||
const byTarget = new Map<string, TimelineLane>();
|
// temps réel, donc toujours à jour.
|
||||||
for (const target of targets) {
|
const tags = useMemo(() => usedTags(targets), [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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Un profil sans la moindre diffusion sur la période n'apporte qu'une ligne
|
const lanes = useMemo<TimelineLane[]>(
|
||||||
// vide : la frise sert à comparer des activités, pas à recenser les suivis.
|
() =>
|
||||||
return [...byTarget.values()]
|
buildTimelineLanes(targets, data, { query, tags: selectedTags, onlyRecorded, sort }),
|
||||||
.filter((lane) => lane.sessions.length > 0 || lane.spans.length > 0)
|
[data, targets, onlyRecorded, query, selectedTags, sort],
|
||||||
.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),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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;
|
const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="row filter-row">
|
<div className="row filter-row">
|
||||||
<h2>Frise des diffusions</h2>
|
<h2>Frise des diffusions</h2>
|
||||||
|
<SearchField
|
||||||
|
value={query}
|
||||||
|
onChange={setQuery}
|
||||||
|
placeholder="Chercher un streamer…"
|
||||||
|
label="Chercher un streamer par nom, pseudo ou étiquette"
|
||||||
|
/>
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
<button
|
<button
|
||||||
className={onlyRecorded ? 'chip active' : 'chip'}
|
className={onlyRecorded ? 'chip active' : 'chip'}
|
||||||
@@ -113,16 +113,50 @@ export function TimelinePage({ targets, agents, notify }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="row filter-row">
|
||||||
|
<label className="sort-field muted small">
|
||||||
|
Trier par
|
||||||
|
<select
|
||||||
|
className="sort-select"
|
||||||
|
value={sort}
|
||||||
|
onChange={(event) => setSort(event.target.value as TimelineSort)}
|
||||||
|
>
|
||||||
|
{TIMELINE_SORTS.map((entry) => (
|
||||||
|
<option key={entry.id} value={entry.id}>
|
||||||
|
{entry.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<TagFilter
|
||||||
|
tags={tags}
|
||||||
|
selected={selectedTags}
|
||||||
|
onToggle={(id) => setSelectedTags((current) => toggleId(current, id))}
|
||||||
|
onClear={() => setSelectedTags(new Set())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="muted small timeline-legend">
|
<p className="muted small timeline-legend">
|
||||||
<span className="legend-swatch live" /> diffusion publique
|
<span className="legend-swatch live" /> diffusion publique
|
||||||
<span className="legend-swatch rec" /> enregistré
|
<span className="legend-swatch rec" /> enregistré
|
||||||
<span className="legend-swatch paused" /> en pause
|
<span className="legend-swatch paused" /> en pause
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{data.sessions.length} diffusion(s) · {formatDuration(totalRecorded)} enregistré au total
|
{shownSessions} diffusion(s) · {formatDuration(totalRecorded)} enregistré
|
||||||
|
{filtered ? ' sur la sélection' : ' au total'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="muted small">Chargement…</p>
|
<p className="muted small">Chargement…</p>
|
||||||
|
) : lanes.length === 0 ? (
|
||||||
|
// Une frise sans voie n'affiche qu'une règle graduée : rien n'y
|
||||||
|
// distingue « personne n'a diffusé » de « le filtre a tout écarté ».
|
||||||
|
<p className="muted small">
|
||||||
|
{query.trim()
|
||||||
|
? `Aucun streamer ne correspond à « ${query.trim()} » sur cette période.`
|
||||||
|
: filtered
|
||||||
|
? 'Aucune diffusion pour ce filtre sur cette période.'
|
||||||
|
: 'Aucune diffusion observée sur cette période.'}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="timeline-scroll">
|
<div className="timeline-scroll">
|
||||||
<Timeline from={from} to={now} lanes={lanes} onSelectLane={setOpenId} />
|
<Timeline from={from} to={now} lanes={lanes} onSelectLane={setOpenId} />
|
||||||
@@ -142,10 +176,3 @@ export function TimelinePage({ targets, agents, notify }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dernier signe d'activité, diffusion ou capture confondues. */
|
|
||||||
function lastActivity(lane: TimelineLane): number {
|
|
||||||
let latest = 0;
|
|
||||||
for (const session of lane.sessions) latest = Math.max(latest, session.endedAt ?? Date.now());
|
|
||||||
for (const span of lane.spans) latest = Math.max(latest, span.endedAt ?? Date.now());
|
|
||||||
return latest;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentView,
|
AgentView,
|
||||||
PreemptionVerdict,
|
PreemptionVerdict,
|
||||||
|
RecordingPause,
|
||||||
|
RecordingSpan,
|
||||||
|
StreamSession,
|
||||||
StreamState,
|
StreamState,
|
||||||
WatchTarget,
|
WatchTarget,
|
||||||
} from '@stream-control/shared';
|
} from '@stream-control/shared';
|
||||||
@@ -157,6 +160,64 @@ export function tagStyle(name: string): { background: string; color: string; bor
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forme comparable d'un texte : sans casse, sans accent, sans espaces autour.
|
||||||
|
*
|
||||||
|
* Taper « lea » doit trouver « 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 qu'il
|
||||||
|
* faut sanctionner par une liste vide.
|
||||||
|
*/
|
||||||
|
export function normalizeSearch(text: string): string {
|
||||||
|
return text
|
||||||
|
.normalize('NFD')
|
||||||
|
// Les diacritiques isolés par NFD, qu'on retire pour comparer « e » à « é ».
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le profil répond-il à la recherche ?
|
||||||
|
*
|
||||||
|
* Cherche à la fois dans le nom affiché, le pseudo et les étiquettes : ce sont
|
||||||
|
* les trois façons dont on désigne un profil, et exiger de savoir laquelle
|
||||||
|
* s'applique reviendrait à demander de chercher ce qu'on a déjà trouvé.
|
||||||
|
*
|
||||||
|
* Chaque mot saisi doit correspondre, mais n'importe où et dans n'importe quel
|
||||||
|
* ordre — « blonde lea » retrouve « Léa » étiquetée « blonde ».
|
||||||
|
*/
|
||||||
|
export function matchesSearch(
|
||||||
|
target: Pick<WatchTarget, 'label' | 'username' | 'tags'>,
|
||||||
|
query: string,
|
||||||
|
): boolean {
|
||||||
|
const needle = normalizeSearch(query);
|
||||||
|
if (!needle) return true;
|
||||||
|
const haystack = normalizeSearch(
|
||||||
|
[target.label ?? '', target.username, ...target.tags.map((tag) => tag.name)].join(' '),
|
||||||
|
);
|
||||||
|
return needle.split(/\s+/).every((term) => haystack.includes(term));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ajoute ou retire un identifiant d'une sélection, sans muter l'original. */
|
||||||
|
export function toggleId(current: ReadonlySet<string>, id: string): Set<string> {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le profil porte-t-il au moins une des étiquettes retenues ? */
|
||||||
|
export function matchesTags(
|
||||||
|
target: Pick<WatchTarget, 'tags'>,
|
||||||
|
selected: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
// Aucune étiquette cochée : le filtre ne s'applique pas. Entre étiquettes
|
||||||
|
// 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 (selected.size === 0) return true;
|
||||||
|
return target.tags.some((tag) => selected.has(tag.id));
|
||||||
|
}
|
||||||
|
|
||||||
/** Toutes les étiquettes posées, sans doublon, dans l'ordre alphabétique. */
|
/** Toutes les étiquettes posées, sans doublon, dans l'ordre alphabétique. */
|
||||||
export function usedTags(targets: WatchTarget[]): Array<{ id: string; name: string }> {
|
export function usedTags(targets: WatchTarget[]): Array<{ id: string; name: string }> {
|
||||||
const byId = new Map<string, { id: string; name: string }>();
|
const byId = new Map<string, { id: string; name: string }>();
|
||||||
@@ -202,3 +263,158 @@ export const TIMELINE_RANGES = [
|
|||||||
{ days: 7, label: '7 jours' },
|
{ days: 7, label: '7 jours' },
|
||||||
{ days: 30, label: '30 jours' },
|
{ days: 30, label: '30 jours' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordres de tri des voies de la frise.
|
||||||
|
*
|
||||||
|
* L'activité récente reste la valeur par défaut — c'est ce qu'on vient voir
|
||||||
|
* neuf fois sur dix. Les deux tris par durée répondent à une autre question :
|
||||||
|
* qui occupe réellement les machines, et sur quels profils la capture décroche
|
||||||
|
* face au temps de diffusion.
|
||||||
|
*/
|
||||||
|
export const TIMELINE_SORTS = [
|
||||||
|
{ id: 'activity', label: 'Activité récente' },
|
||||||
|
{ id: 'recorded', label: 'Temps enregistré' },
|
||||||
|
{ id: 'live', label: 'Temps de diffusion' },
|
||||||
|
{ id: 'name', label: 'Nom (A→Z)' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TimelineSort = (typeof TIMELINE_SORTS)[number]['id'];
|
||||||
|
|
||||||
|
/** Intervalle daté, encore ouvert quand `endedAt` vaut `null`. */
|
||||||
|
interface Interval {
|
||||||
|
startedAt: number;
|
||||||
|
endedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ce qu'il faut d'une voie de frise pour la classer.
|
||||||
|
*
|
||||||
|
* Décrit par sa forme et non par la voie complète : le tri ne regarde que des
|
||||||
|
* durées et un nom, il n'a aucune raison de dépendre de ce qui dessine les
|
||||||
|
* barres.
|
||||||
|
*/
|
||||||
|
export interface SortableLane {
|
||||||
|
label: string;
|
||||||
|
sessions: Interval[];
|
||||||
|
spans: Interval[];
|
||||||
|
pauses: Interval[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une ligne de la frise : un profil et tout ce qui le concerne sur la période. */
|
||||||
|
export interface TimelineLane extends SortableLane {
|
||||||
|
id: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
sessions: StreamSession[];
|
||||||
|
spans: RecordingSpan[];
|
||||||
|
pauses: RecordingPause[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Critères appliqués aux voies de la frise. */
|
||||||
|
export interface TimelineFilters {
|
||||||
|
query: string;
|
||||||
|
tags: ReadonlySet<string>;
|
||||||
|
onlyRecorded: boolean;
|
||||||
|
sort: TimelineSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Répartit les diffusions, captures et pauses en voies, filtrées et triées.
|
||||||
|
*
|
||||||
|
* Le filtre s'applique aux profils, avant la répartition : les diffusions d'un
|
||||||
|
* profil écarté n'ont alors aucune voie où se ranger et disparaissent avec lui,
|
||||||
|
* sans traitement particulier. Une capture sans profil identifié (lancée à la
|
||||||
|
* main depuis l'onglet Agents) n'a de même nulle part où aller — la frise
|
||||||
|
* n'affiche que ce qu'elle sait rattacher à quelqu'un.
|
||||||
|
*/
|
||||||
|
export function buildTimelineLanes(
|
||||||
|
targets: WatchTarget[],
|
||||||
|
data: { sessions: StreamSession[]; spans: RecordingSpan[]; pauses: RecordingPause[] },
|
||||||
|
filters: TimelineFilters,
|
||||||
|
now = Date.now(),
|
||||||
|
): TimelineLane[] {
|
||||||
|
const byTarget = new Map<string, TimelineLane>();
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!matchesSearch(target, filters.query) || !matchesTags(target, filters.tags)) continue;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) => !filters.onlyRecorded || lane.spans.length > 0)
|
||||||
|
.sort(laneComparator(filters.sort, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durée cumulée d'intervalles, ceux encore ouverts comptés jusqu'à maintenant. */
|
||||||
|
export function totalMs(intervals: Interval[], now = Date.now()): number {
|
||||||
|
return intervals.reduce(
|
||||||
|
(sum, interval) => sum + ((interval.endedAt ?? now) - interval.startedAt),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temps réellement capturé sur une voie : les pauses retranchées.
|
||||||
|
*
|
||||||
|
* La capture tournait pendant une pause, mais n'écrivait rien. La compter
|
||||||
|
* surestimerait ce qu'on a effectivement gardé — et fausserait le classement
|
||||||
|
* par temps enregistré, où un profil souvent en show privé passerait devant un
|
||||||
|
* profil réellement capturé.
|
||||||
|
*/
|
||||||
|
export function recordedMs(lane: SortableLane, now = Date.now()): number {
|
||||||
|
return Math.max(0, totalMs(lane.spans, now) - totalMs(lane.pauses, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dernier signe d'activité, diffusion ou capture confondues. */
|
||||||
|
export function lastActivity(lane: SortableLane, now = Date.now()): number {
|
||||||
|
let latest = 0;
|
||||||
|
for (const session of lane.sessions) latest = Math.max(latest, session.endedAt ?? now);
|
||||||
|
for (const span of lane.spans) latest = Math.max(latest, span.endedAt ?? now);
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordre des voies pour le tri demandé.
|
||||||
|
*
|
||||||
|
* Les tris par durée retombent sur l'activité récente à égalité : deux profils
|
||||||
|
* à zéro seconde enregistrée sont fréquents, et les laisser dans un ordre
|
||||||
|
* arbitraire donnerait une liste qui se réorganise sans raison visible d'un
|
||||||
|
* rafraîchissement à l'autre.
|
||||||
|
*
|
||||||
|
* `now` est figé une fois pour toutes à la construction du comparateur : lu à
|
||||||
|
* chaque comparaison, il ferait bouger les durées des voies encore ouvertes en
|
||||||
|
* plein tri, et l'ordre obtenu pourrait alors se contredire lui-même.
|
||||||
|
*/
|
||||||
|
export function laneComparator(
|
||||||
|
sort: TimelineSort,
|
||||||
|
now = Date.now(),
|
||||||
|
): (a: SortableLane, b: SortableLane) => number {
|
||||||
|
const recent = (a: SortableLane, b: SortableLane) => lastActivity(b, now) - lastActivity(a, now);
|
||||||
|
switch (sort) {
|
||||||
|
case 'recorded':
|
||||||
|
return (a, b) => recordedMs(b, now) - recordedMs(a, now) || recent(a, b);
|
||||||
|
case 'live':
|
||||||
|
return (a, b) => totalMs(b.sessions, now) - totalMs(a.sessions, now) || recent(a, b);
|
||||||
|
case 'name':
|
||||||
|
return (a, b) => a.label.localeCompare(b.label);
|
||||||
|
default:
|
||||||
|
return recent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -642,6 +642,76 @@ fieldset.group > legend {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Recherche et tri ---
|
||||||
|
*
|
||||||
|
* Le champ est habillé par son conteneur et non par l'`input` lui-même : la
|
||||||
|
* loupe et la croix vivent dans la même bordure que le texte, et le liseré de
|
||||||
|
* mise au point entoure l'ensemble plutôt que le seul champ, qui paraîtrait
|
||||||
|
* alors flotter dans sa propre boîte.
|
||||||
|
*/
|
||||||
|
.search-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #0f141a;
|
||||||
|
}
|
||||||
|
.search-field:focus-within {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
.search-field input {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 6px 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.search-field input:focus {
|
||||||
|
/* Déjà porté par le conteneur : deux liserés imbriqués feraient du bruit. */
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
/* La croix native de WebKit doublonnerait avec la nôtre, sans en partager la
|
||||||
|
couleur ni la position. */
|
||||||
|
.search-field input[type='search']::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.search-icon {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.search-clear {
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.search-clear:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-field {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sort-select {
|
||||||
|
/* Les champs sont pleine largeur par défaut ; dans une rangée de filtres, un
|
||||||
|
sélecteur doit se contenter de ce que son libellé demande. */
|
||||||
|
width: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Étiquettes ---
|
/* --- Étiquettes ---
|
||||||
*
|
*
|
||||||
* Les couleurs sont posées en style inline, dérivées du nom (voir `tagStyle`) :
|
* Les couleurs sont posées en style inline, dérivées du nom (voir `tagStyle`) :
|
||||||
@@ -1291,6 +1361,13 @@ fieldset.group > legend {
|
|||||||
flex: 1 1 100px;
|
flex: 1 1 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Le champ de recherche prend sa propre ligne : coincé entre les puces de
|
||||||
|
filtre, il se réduirait à quelques caractères visibles. */
|
||||||
|
.search-field {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Trois colonnes fixes (heure, agent, message) laissent trop peu de place
|
/* Trois colonnes fixes (heure, agent, message) laissent trop peu de place
|
||||||
au message une fois l'écran étroit : chaque entrée devient une pile. */
|
au message une fois l'écran étroit : chaque entrée devient une pile. */
|
||||||
.log-line,
|
.log-line,
|
||||||
|
|||||||
Reference in New Issue
Block a user