Feat : tag system
All checks were successful
release / build (push) Successful in 26s
release / verify-windows (push) Successful in 1m26s

This commit is contained in:
jeanotx32
2026-08-12 22:19:16 +02:00
parent cbbf31cabf
commit d44a7c4fb6
10 changed files with 840 additions and 6 deletions

View File

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