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:
1 parent
c78df092c1
commit
c1ea1e84a7
13 files changed
+880
-33
No files matched your search
+116
-9
@@ -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}
|
||||
|
||||
@@ -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
@@ -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)}`);
|
||||
|
||||
@@ -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" />
|
||||
</>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user