Feat : tag system
This commit is contained in:
@@ -3,6 +3,7 @@ import type {
|
||||
AgentView,
|
||||
LogEntry,
|
||||
PushoverSettings,
|
||||
Tag,
|
||||
TimelineData,
|
||||
WatchTarget,
|
||||
} from '@stream-control/shared';
|
||||
@@ -126,7 +127,10 @@ export const api = {
|
||||
WatchTarget,
|
||||
'label' | 'agentId' | 'notify' | 'autoRecord' | 'favorite' | 'priority' | 'preempt'
|
||||
>
|
||||
>,
|
||||
> & {
|
||||
/** Remplace en bloc les étiquettes ; omis, elles restent inchangées. */
|
||||
tagIds?: string[];
|
||||
},
|
||||
) =>
|
||||
request<{ target: WatchTarget }>(`/watchlist/${id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -149,6 +153,20 @@ export const api = {
|
||||
`/timeline?days=${days}${targetId ? `&targetId=${encodeURIComponent(targetId)}` : ''}`,
|
||||
),
|
||||
|
||||
// --- Étiquettes -------------------------------------------------------------
|
||||
|
||||
tags: () => request<{ tags: Tag[] }>('/tags'),
|
||||
|
||||
/** Crée l'étiquette, ou renvoie celle qui porte déjà ce nom. */
|
||||
createTag: (name: string) =>
|
||||
request<{ tag: Tag }>('/tags', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
|
||||
renameTag: (id: string, name: string) =>
|
||||
request<{ tag: Tag }>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify({ name }) }),
|
||||
|
||||
/** La retire aussi de tous les profils qui la portaient. */
|
||||
deleteTag: (id: string) => request<{ ok: true }>(`/tags/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// --- Notifications ----------------------------------------------------------
|
||||
|
||||
pushoverSettings: () => request<{ settings: PushoverSettings }>('/settings/pushover'),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
stateLabel,
|
||||
TIMELINE_RANGES,
|
||||
} from '../streamers';
|
||||
import { TagEditor } from './Tags';
|
||||
import { Timeline } from './Timeline';
|
||||
|
||||
interface Props {
|
||||
@@ -179,6 +180,22 @@ export function StreamerDetail({ target, targets, agents, notify, onClose }: Pro
|
||||
|
||||
{target.lastError && <p className="error small">Sonde en échec : {target.lastError}</p>}
|
||||
|
||||
<section className="group">
|
||||
<h3>Étiquettes</h3>
|
||||
<TagEditor
|
||||
target={target}
|
||||
disabled={busy}
|
||||
// Pas de `notify` au succès : la vignette se met à jour d'elle-même
|
||||
// par le flux temps réel, un bandeau à chaque étiquette posée
|
||||
// deviendrait vite pénible.
|
||||
onChange={(tagIds) => api.updateTarget(target.id, { tagIds })}
|
||||
/>
|
||||
<p className="muted small">
|
||||
Sert à filtrer la liste des streamers. Une étiquette inconnue se crée en la
|
||||
saisissant.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="group">
|
||||
<h3>Enregistrement</h3>
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
previewSrc,
|
||||
recordingContext,
|
||||
stateLabel,
|
||||
tagStyle,
|
||||
usedTags,
|
||||
type RecordingContext,
|
||||
} from '../streamers';
|
||||
import { StreamerDetail } from './StreamerDetail';
|
||||
import { TagChip, TagManager } from './Tags';
|
||||
|
||||
interface Props {
|
||||
targets: WatchTarget[];
|
||||
@@ -39,6 +42,22 @@ export function StreamersPage({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [filter, setFilter] = useState<FilterId>('all');
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
||||
const [managingTags, setManagingTags] = useState(false);
|
||||
|
||||
// Tirées des profils plutôt que du serveur : la liste arrive déjà entière par
|
||||
// le flux temps réel, donc toujours à jour, et une étiquette que personne ne
|
||||
// porte n'aurait de toute façon rien à filtrer.
|
||||
const tags = useMemo(() => usedTags(targets), [targets]);
|
||||
|
||||
function toggleTag(id: string) {
|
||||
setSelectedTags((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function add(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -76,6 +95,13 @@ export function StreamersPage({
|
||||
);
|
||||
|
||||
const visible = ordered.filter((target) => {
|
||||
// Les deux filtres se cumulent — « en direct » ET portant l'une des
|
||||
// étiquettes retenues. Entre étiquettes en revanche, c'est une union : en
|
||||
// cocher une seconde élargit la sélection, ce qui est le geste attendu
|
||||
// quand on empile des critères de même nature.
|
||||
if (selectedTags.size > 0 && !target.tags.some((tag) => selectedTags.has(tag.id))) {
|
||||
return false;
|
||||
}
|
||||
if (filter === 'favorites') return target.favorite;
|
||||
if (filter === 'live') return target.state === 'public';
|
||||
return true;
|
||||
@@ -120,6 +146,37 @@ export function StreamersPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targets.length > 0 && (
|
||||
<div className="row filter-row tag-filters">
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
className={selectedTags.has(tag.id) ? 'tag tag-filter active' : 'tag tag-filter'}
|
||||
style={tagStyle(tag.name)}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
title={`Ne garder que les profils portant « ${tag.name} »`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
{tags.length === 0 && (
|
||||
<span className="muted small">
|
||||
Aucune étiquette — pose-en une depuis la fiche d'un streamer pour trier la liste
|
||||
d'ici.
|
||||
</span>
|
||||
)}
|
||||
{selectedTags.size > 0 && (
|
||||
<button className="link-button" onClick={() => setSelectedTags(new Set())}>
|
||||
tout afficher
|
||||
</button>
|
||||
)}
|
||||
<div className="spacer" />
|
||||
<button className="chip" onClick={() => setManagingTags(true)} title="Renommer, supprimer">
|
||||
⚙ Étiquettes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targets.length === 0 ? (
|
||||
<div className="empty">
|
||||
<h2>Aucun streamer suivi</h2>
|
||||
@@ -154,6 +211,16 @@ export function StreamersPage({
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{managingTags && (
|
||||
<TagManager
|
||||
onClose={() => setManagingTags(false)}
|
||||
// Une étiquette renommée ou supprimée touche tous les profils : le
|
||||
// serveur les republie, et le filtre en cours peut désigner une
|
||||
// étiquette qui n'existe plus.
|
||||
onChanged={() => setSelectedTags(new Set())}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -284,6 +351,15 @@ function StreamerCard({
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* Sans retrait possible ici : la vignette sert à voir, la fiche à régler. */}
|
||||
{target.tags.length > 0 && (
|
||||
<div className="tag-list">
|
||||
{target.tags.map((tag) => (
|
||||
<TagChip key={tag.id} name={tag.name} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="streamer-facts">
|
||||
<div>
|
||||
<dt>{isLive ? 'En direct depuis' : 'Statut depuis'}</dt>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleur d'une étiquette, dérivée de son nom.
|
||||
*
|
||||
* Calculée plutôt que choisie : demander une couleur à chaque création
|
||||
* alourdirait le geste — poser une étiquette doit rester une frappe au clavier.
|
||||
* Le nom donnant toujours la même teinte, une même étiquette se reconnaît d'une
|
||||
* fiche à l'autre sans que rien ne soit stocké pour ça.
|
||||
*/
|
||||
export function tagStyle(name: string): { background: string; color: string; borderColor: string } {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < name.length; index += 1) {
|
||||
hash = (hash * 31 + name.charCodeAt(index)) | 0;
|
||||
}
|
||||
const hue = Math.abs(hash) % 360;
|
||||
// Saturation et luminosité fixes : seule la teinte varie, sans quoi certaines
|
||||
// étiquettes passeraient inaperçues et d'autres crèveraient l'écran.
|
||||
return {
|
||||
background: `hsl(${hue} 42% 20%)`,
|
||||
color: `hsl(${hue} 70% 76%)`,
|
||||
borderColor: `hsl(${hue} 35% 32%)`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Toutes les étiquettes posées, sans doublon, dans l'ordre alphabétique. */
|
||||
export function usedTags(targets: WatchTarget[]): Array<{ id: string; name: string }> {
|
||||
const byId = new Map<string, { id: string; name: string }>();
|
||||
for (const target of targets) {
|
||||
for (const tag of target.tags) byId.set(tag.id, tag);
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Fenêtres proposées sous les frises. */
|
||||
export const TIMELINE_RANGES = [
|
||||
{ days: 1, label: '24 h' },
|
||||
|
||||
@@ -642,6 +642,75 @@ fieldset.group > legend {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* --- Étiquettes ---
|
||||
*
|
||||
* Les couleurs sont posées en style inline, dérivées du nom (voir `tagStyle`) :
|
||||
* on ne trouvera donc ici que la forme, jamais la teinte.
|
||||
*/
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.tag-remove {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.tag-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Bouton, mais lu comme une étiquette : même forme, cliquable. */
|
||||
.tag-suggest,
|
||||
.tag-filter {
|
||||
cursor: pointer;
|
||||
transition: filter 0.12s ease;
|
||||
}
|
||||
.tag-suggest:hover:not(:disabled),
|
||||
.tag-filter:hover {
|
||||
filter: brightness(1.35);
|
||||
}
|
||||
/* L'étiquette retenue comme filtre se détache par un contour, pas par sa
|
||||
couleur — celle-ci appartient déjà à son nom et doit rester reconnaissable. */
|
||||
.tag-filter.active {
|
||||
outline: 1px solid var(--text);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.tag-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.tag-add {
|
||||
max-width: 340px;
|
||||
}
|
||||
.tag-row {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.link-button.error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/*
|
||||
* Vignette du flux en direct. `overflow: hidden` recadre l'image au repos ;
|
||||
* au survol il passe à `visible` pour que l'agrandissement (sur le seul
|
||||
|
||||
Reference in New Issue
Block a user