Tags et favoris, filtres dans l'explorateur

- DATA_DIR/.meta.json : tags et favoris par chemin, suivis aux renommages,
  déplacements, copies (tags seuls) et suppressions.
- API : GET /api/tags, POST /api/meta/favorite, POST /api/meta/tags ; Entry
  porte tags et favorite ; /api/search accepte tag= et fav=true (q facultatif).
- Tags comparés sans casse ni accents, écriture existante conservée ; ils
  participent à la recherche texte.
- Explorateur : ligne de filtres (Favoris, tags avec leur nombre), étoile et
  tags sur les éléments, menu Favori / Tags…, actions sur la sélection,
  dialogue Tags (ajout, retrait, suggestions).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6J7vKwXx7uopxKhWfFJpm
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-07 18:06:45 +02:00
1 parent c78df092c1
commit c1ea1e84a7
13 files changed
+880 -33

No files matched your search

+116 -9
View File
@@ -11,6 +11,7 @@ import {
withExt,
type Entry,
type Hit,
type TagCount,
} from "./api";
import {
CloseIcon,
@@ -26,12 +27,16 @@ import {
RefreshIcon,
SearchIcon,
SelectAllIcon,
StarFilledIcon,
StarIcon,
TagIcon,
TrashIcon,
UpIcon,
UploadIcon,
} from "./icons";
import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer";
import { MoveDialog } from "./MoveDialog";
import { TagsDialog } from "./TagsDialog";
import { Thumb } from "./Thumb";
export const EMPTY_SCENE = JSON.stringify({
@@ -96,10 +101,16 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
const [notice, setNotice] = useState<{ text: string; detail?: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null); // progression d'un import
const [fileDrag, setFileDrag] = useState(false); // fichiers du système survolant l'explorateur
const [tags, setTags] = useState<TagCount[]>([]);
const [tagFilter, setTagFilter] = useState<string | null>(null);
const [favOnly, setFavOnly] = useState(false);
const [tagging, setTagging] = useState<Entry[] | null>(null); // éléments du dialogue « Tags »
const fileInput = useRef<HTMLInputElement>(null);
const dirInput = useRef<HTMLInputElement>(null);
const list = results ?? entries;
const filter = { tag: tagFilter, fav: favOnly };
const filtering = query.trim() !== "" || tagFilter !== null || favOnly;
const guard = useCallback(async (fn: () => Promise<unknown>) => {
setError(null);
@@ -113,8 +124,11 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
const refresh = useCallback(
() =>
guard(async () => {
const next = await api.list(dir);
const [next, tagList] = await Promise.all([api.list(dir), api.tags()]);
setEntries(next);
setTags(tagList);
// Tag filtré qui n'existe plus : filtre retiré.
setTagFilter((t) => (t && tagList.some((x) => x.name === t) ? t : null));
setMenuFor(null);
// Oublie les éléments sélectionnés qui ont disparu.
setSelected((s) => {
@@ -141,15 +155,22 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
if (current) setDir(dirname(current));
}, [current]);
// Recherche avec anti-rebond.
// Recherche (texte, tag, favoris) avec anti-rebond.
useEffect(() => {
if (!query.trim()) {
if (!filtering) {
setResults(null);
return;
}
const t = setTimeout(() => guard(async () => setResults(await api.search(query))), 250);
const t = setTimeout(() => guard(async () => setResults(await api.search(query, filter))), 250);
return () => clearTimeout(t);
}, [query, guard, refreshKey]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, tagFilter, favOnly, guard, refreshKey]);
/** Recharge le dossier et, si une recherche est affichée, ses résultats. */
const reload = async () => {
await refresh();
if (filtering) setResults(await api.search(query, filter));
};
useEffect(() => {
try {
@@ -254,13 +275,24 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
}
clearSelection();
setMoving(null);
await refresh();
if (results) setResults(await api.search(query));
await reload();
if (failed.length) {
setError(`${plural(failed.length, "élément")} non déplacé${failed.length > 1 ? "s" : ""} — ${failed.join(" ; ")}`);
}
});
/** Favoris : la sélection devient favorite, sauf si elle l'est déjà entièrement. */
const toggleFavorite = (items: Entry[]) =>
guard(async () => {
const fav = !items.every((e) => e.favorite);
await api.setFavorite(
items.map((e) => e.path),
fav,
);
setMenuFor(null);
await reload();
});
const duplicate = (e: Entry) =>
guard(async () => {
const base = join(dirname(e.path), stripExt(e.name));
@@ -291,8 +323,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
onDeleted(p);
}
clearSelection();
await refresh();
if (results) setResults(await api.search(query));
await reload();
});
/**
@@ -425,6 +456,14 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
<button onClick={() => setMoving([e.path])}>Déplacer…</button>
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
{!e.isDir && <button onClick={() => onHistory(e.path)}>Historique…</button>}
<button onClick={() => toggleFavorite([e])}>
{e.favorite ? <StarFilledIcon /> : <StarIcon />}
{e.favorite ? "Retirer des favoris" : "Favori"}
</button>
<button onClick={() => setTagging([e])}>
<TagIcon />
Tags…
</button>
{!e.isDir && (
<a href={api.downloadUrl(e.path)} download={e.name}>
Télécharger
@@ -471,11 +510,30 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
<button className="name" onClick={(ev) => onItemClick(ev, e)} title={e.path}>
{icon(e)}
<span className="label">
{e.favorite && (
<span className="fav" title="Favori">
<StarFilledIcon size={12} />
</span>
)}
{e.isDir ? e.name : stripExt(e.name)}
{results && <small className="muted where"> {dirname(e.path) || "/"}</small>}
{e.snippet && <small className="snippet">{e.snippet}</small>}
</span>
</button>
{!!e.tags?.length && (
<span className="tags">
{e.tags.map((t) => (
<button
key={t}
className={`chip ${t === tagFilter ? "on" : ""}`}
title={`Filtrer sur « ${t} »`}
onClick={() => setTagFilter(t === tagFilter ? null : t)}
>
{t}
</button>
))}
</span>
)}
{!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
<button
className="icon-btn more"
@@ -583,6 +641,30 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
</button>
</div>
<div className="filters" role="group" aria-label="Filtres">
<button
className={`chip ${favOnly ? "on" : ""}`}
aria-pressed={favOnly}
onClick={() => setFavOnly((f) => !f)}
title="Favoris de toute l'arborescence"
>
{favOnly ? <StarFilledIcon size={12} /> : <StarIcon size={12} />}
Favoris
</button>
{tags.map((t) => (
<button
key={t.name}
className={`chip ${t.name === tagFilter ? "on" : ""}`}
aria-pressed={t.name === tagFilter}
onClick={() => setTagFilter(t.name === tagFilter ? null : t.name)}
title={`${plural(t.count, "élément")} avec ce tag`}
>
{t.name}
<small>{t.count}</small>
</button>
))}
</div>
{!results && (
<nav className="crumbs">
<button className={dropTarget === "" ? "drop" : ""} onClick={() => setDir("")} {...dropProps("")}>
@@ -616,6 +698,22 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
>
{selected.size === list.length ? <DeselectIcon /> : <SelectAllIcon />}
</button>
<button
className="icon-btn"
onClick={() => toggleFavorite(list.filter((e) => selected.has(e.path)))}
title="Favoris (ajouter ou retirer)"
aria-label="Favoris"
>
<StarIcon />
</button>
<button
className="icon-btn"
onClick={() => setTagging(list.filter((e) => selected.has(e.path)))}
title="Tags…"
aria-label="Tags"
>
<TagIcon />
</button>
<button className="ex-btn" onClick={() => setMoving([...selected])}>
<MoveIcon />
<span>Déplacer…</span>
@@ -663,6 +761,15 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
</div>
)}
{tagging && (
<TagsDialog
items={tagging.map((t) => list.find((e) => e.path === t.path) ?? t)}
known={tags}
onChanged={reload}
onClose={() => setTagging(null)}
/>
)}
{moving && (
<MoveDialog
paths={moving}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useState } from "react";
import { api, basename, stripExt, type Entry, type TagCount } from "./api";
import { CloseIcon, TagIcon } from "./icons";
interface Props {
/** Éléments concernés, avec leurs tags actuels. */
items: Entry[];
/** Tags déjà utilisés (suggestions). */
known: TagCount[];
/** Appelé après chaque modification pour rafraîchir l'explorateur. */
onChanged: () => Promise<void>;
onClose: () => void;
}
const key = (t: string) => t.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
/** Ajout et retrait de tags sur un ou plusieurs éléments. */
export function TagsDialog({ items, known, onChanged, onClose }: Props) {
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const paths = items.map((e) => e.path);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Tags présents et nombre d'éléments qui les portent.
const present = new Map<string, { name: string; count: number }>();
for (const e of items)
for (const t of e.tags ?? []) {
const p = present.get(key(t));
if (p) p.count++;
else present.set(key(t), { name: t, count: 1 });
}
const suggestions = known.filter((t) => !present.has(key(t.name)) || present.get(key(t.name))!.count < items.length);
const run = async (add: string[], remove: string[]) => {
setBusy(true);
setError(null);
try {
await api.editTags(paths, add, remove);
await onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const add = (raw: string) => {
const tags = raw
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (tags.length === 0) return;
setInput("");
run(tags, []);
};
const title =
items.length === 1
? `Tags · ${items[0].isDir ? items[0].name : stripExt(basename(items[0].path))}`
: `Tags · ${items.length} éléments`;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal tags-dialog" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
<h3>{title}</h3>
<div className="chips">
{[...present.values()].map((t) => (
<span key={t.name} className="chip">
{t.name}
{items.length > 1 && t.count < items.length && (
<small className="muted">
{" "}
{t.count}/{items.length}
</small>
)}
<button
className="icon-btn"
disabled={busy}
onClick={() => run([], [t.name])}
title={`Retirer « ${t.name} »`}
aria-label={`Retirer ${t.name}`}
>
<CloseIcon size={12} />
</button>
</span>
))}
{present.size === 0 && <span className="muted">Aucun tag.</span>}
</div>
<form
className="tag-add"
onSubmit={(e) => {
e.preventDefault();
add(input);
}}
>
<label className="search">
<TagIcon />
<input
autoFocus
value={input}
list="known-tags"
maxLength={200}
placeholder="Ajouter des tags (séparés par des virgules)"
onChange={(e) => setInput(e.target.value)}
/>
</label>
<datalist id="known-tags">
{known.map((t) => (
<option key={t.name} value={t.name} />
))}
</datalist>
<button type="submit" className="primary" disabled={busy || !input.trim()}>
Ajouter
</button>
</form>
{suggestions.length > 0 && (
<div className="chips suggestions">
{suggestions.slice(0, 30).map((t) => (
<button key={t.name} className="chip" disabled={busy} onClick={() => run([t.name], [])} title={`Ajouter « ${t.name} »`}>
+ {t.name}
</button>
))}
</div>
)}
{error && <div className="error">{error}</div>}
<div className="modal-actions">
<span className="spacer" />
<button onClick={onClose}>Fermer</button>
</div>
</div>
</div>
);
}
+27 -2
View File
@@ -6,6 +6,19 @@ export interface Entry {
isDir: boolean;
size: number;
modTime: string;
tags?: string[];
favorite?: boolean;
}
export interface TagCount {
name: string;
count: number;
}
/** Filtres de recherche : tag exact et/ou favoris seulement. */
export interface SearchFilter {
tag?: string | null;
fav?: boolean;
}
/** Résultat de recherche ; snippet = extrait du contenu qui correspond. */
@@ -77,8 +90,20 @@ export const api = {
list: async (dir: string): Promise<Entry[]> =>
(await request(`/api/list?path=${q(dir)}`)).json(),
search: async (text: string): Promise<Hit[]> =>
(await request(`/api/search?q=${q(text)}`)).json(),
search: async (text: string, f: SearchFilter = {}): Promise<Hit[]> =>
(
await request(`/api/search?q=${q(text)}${f.tag ? `&tag=${q(f.tag)}` : ""}${f.fav ? "&fav=true" : ""}`)
).json(),
tags: async (): Promise<TagCount[]> => (await request("/api/tags")).json(),
setFavorite: async (paths: string[], favorite: boolean): Promise<void> => {
await request("/api/meta/favorite", json({ paths, favorite }));
},
editTags: async (paths: string[], add: string[], remove: string[]): Promise<void> => {
await request("/api/meta/tags", json({ paths, add, remove }));
},
read: async (path: string): Promise<{ text: string; etag: string }> => {
const res = await request(`/api/file?path=${q(path)}`);
+20
View File
@@ -163,3 +163,23 @@ export const DownloadIcon = icon(
<path d="M7 11l5 5 5-5M12 4v12" />
</>,
);
export const StarIcon = icon(
<path d="M12 17.75l-6.17 3.24 1.18-6.87-5-4.86 6.9-1 3.09-6.26 3.09 6.26 6.9 1-5 4.86 1.18 6.87z" />,
);
/** Étoile pleine (favori). */
export function StarFilledIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinejoin="round" aria-hidden>
<path d="M12 17.75l-6.17 3.24 1.18-6.87-5-4.86 6.9-1 3.09-6.26 3.09 6.26 6.9 1-5 4.86 1.18 6.87z" />
</svg>
);
}
export const TagIcon = icon(
<>
<circle cx="8.5" cy="8.5" r="1" />
<path d="M4 7v3.86a2 2 0 0 0 .59 1.41l7.71 7.71a2.4 2.4 0 0 0 3.4 0l4.27-4.27a2.4 2.4 0 0 0 0-3.4L12.26 4.59A2 2 0 0 0 10.85 4H7a3 3 0 0 0-3 3z" />
</>,
);
+87
View File
@@ -1140,3 +1140,90 @@ li.active > .row {
.entries.grid .snippet {
text-align: center;
}
/* --- Tags et favoris -------------------------------------------------------- */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 8px;
border-radius: 11px;
background: var(--ex-surface-low);
box-shadow: none;
font-size: 11.5px;
letter-spacing: normal;
white-space: nowrap;
}
button.chip.on,
.chip.on {
background: var(--ex-primary);
color: var(--ex-surface-lowest);
}
.chip small {
opacity: 0.7;
font-size: 10.5px;
}
.filters {
display: flex;
gap: 4px;
overflow-x: auto;
flex: none;
padding-bottom: 2px;
}
.row .fav {
display: inline-flex;
margin-right: 4px;
vertical-align: -1px;
color: #f59f00;
}
.row .tags {
display: flex;
flex-wrap: wrap;
gap: 3px;
}
/* En liste : tags sur une ligne sous le nom. */
.entries.list .row {
flex-wrap: wrap;
}
.entries.list .row .tags {
order: 10;
flex-basis: 100%;
margin-top: -4px;
padding: 0 6px 5px 82px;
}
.row .tags .chip {
height: 18px;
padding: 0 6px;
font-size: 10.5px;
}
.entries.grid .row .tags {
justify-content: center;
padding: 0 4px 6px;
}
.tags-dialog .chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 13px;
}
.tags-dialog .chip {
height: 26px;
padding: 0 4px 0 10px;
border-radius: 13px;
font-size: 12.5px;
}
.tags-dialog .chip .icon-btn {
width: 20px;
height: 20px;
border-radius: 10px;
}
.tags-dialog .suggestions .chip {
padding: 0 10px;
color: var(--muted);
}
.tag-add {
display: flex;
gap: 6px;
}