Library search upgrades: tag search, tag cloud, status filter
Backend: - MediaItem gains search_tags (custom + e621 tags, lowercase) and has_custom_data, maintained on save with a data migration backfill - File list search accepts search_type=filename|tags|both (tag search is word-AND across the flattened tag text) and status=matched|custom| unknown filters - New /api/tags/cloud/ endpoint (cached 2 min per guest/auth, invalidated on item changes and deletions) returning the most-used tags, honouring guest visibility Frontend: - Library sidebar: Filename/Tags/Both selector, status pill toggles (persisted), and a clickable tag cloud that runs a tag search - Roadmap updated
This commit is contained in:
@@ -41,13 +41,34 @@ const ratingFilters = [
|
||||
},
|
||||
];
|
||||
|
||||
const statusFilters = [
|
||||
{
|
||||
value: "matched",
|
||||
label: "Matched",
|
||||
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "Custom",
|
||||
active: "border-ctp-mauve/40 bg-ctp-mauve/15 text-ctp-mauve",
|
||||
},
|
||||
{
|
||||
value: "unknown",
|
||||
label: "Unknown",
|
||||
active: "border-ctp-surface2 bg-ctp-surface0 text-ctp-subtext1",
|
||||
},
|
||||
];
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchType, setSearchType] = useState("filename");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const ratings = useUi((state) => state.ratings);
|
||||
const setRatings = useUi((state) => state.setRatings);
|
||||
const statuses = useUi((state) => state.statuses);
|
||||
const setStatuses = useUi((state) => state.setStatuses);
|
||||
const perPage = useUi((state) => state.perPage);
|
||||
const setPerPage = useUi((state) => state.setPerPage);
|
||||
const ordering = useUi((state) => state.ordering);
|
||||
@@ -68,19 +89,35 @@ export default function LibraryPage() {
|
||||
}, []);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["files", { search, ratings, ordering, perPage, page }],
|
||||
queryKey: [
|
||||
"files",
|
||||
{ search, searchType, ratings, statuses, ordering, perPage, page },
|
||||
],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
ordering,
|
||||
});
|
||||
if (search) params.set("search", search);
|
||||
if (search) {
|
||||
params.set("search", search);
|
||||
params.set("search_type", searchType);
|
||||
}
|
||||
if (ratings.length) params.set("rating", ratings.join(","));
|
||||
if (statuses.length) params.set("status", statuses.join(","));
|
||||
return api<Paginated<MediaItem>>(`/api/files/?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
const tagCloudQuery = useQuery({
|
||||
queryKey: ["tag-cloud"],
|
||||
queryFn: () =>
|
||||
api<{ count: number; tags: { tag: string; count: number }[] }>(
|
||||
"/api/tags/cloud/",
|
||||
),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const pageCount = query.data
|
||||
? Math.max(1, Math.ceil(query.data.count / perPage))
|
||||
: 1;
|
||||
@@ -100,10 +137,28 @@ export default function LibraryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function toggleStatus(value: string) {
|
||||
setPage(1);
|
||||
setStatuses(
|
||||
statuses.includes(value)
|
||||
? statuses.filter((status) => status !== value)
|
||||
: [...statuses, value],
|
||||
);
|
||||
}
|
||||
|
||||
function searchTag(tag: string) {
|
||||
setSearchInput(tag);
|
||||
setSearch(tag);
|
||||
setSearchType("tags");
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setSearchInput("");
|
||||
setSearch("");
|
||||
setSearchType("filename");
|
||||
setRatings([]);
|
||||
setStatuses([]);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
@@ -118,11 +173,22 @@ export default function LibraryPage() {
|
||||
<input
|
||||
id="library-search"
|
||||
className={cn(inputClass, "pl-8")}
|
||||
placeholder="Filename…"
|
||||
placeholder={
|
||||
searchType === "tags" ? "Tags…" : "Filename…"
|
||||
}
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={searchType}
|
||||
onChange={(event) => setSearchType(event.target.value)}
|
||||
>
|
||||
<option value="filename">Filename</option>
|
||||
<option value="tags">Tags</option>
|
||||
<option value="both">Filename or tags</option>
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
Search
|
||||
@@ -133,6 +199,29 @@ export default function LibraryPage() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Status
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{statusFilters.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => toggleStatus(option.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
|
||||
statuses.includes(option.value)
|
||||
? option.active
|
||||
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Rating
|
||||
@@ -156,6 +245,31 @@ export default function LibraryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Tag cloud
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(tagCloudQuery.data?.tags ?? []).map((entry) => (
|
||||
<button
|
||||
key={entry.tag}
|
||||
type="button"
|
||||
onClick={() => searchTag(entry.tag)}
|
||||
title={`Search ${entry.tag}`}
|
||||
className="rounded border border-ctp-blue/30 bg-ctp-blue/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-blue transition hover:bg-ctp-blue/20"
|
||||
>
|
||||
{entry.tag} ({entry.count})
|
||||
</button>
|
||||
))}
|
||||
{tagCloudQuery.isPending ? (
|
||||
<Spinner className="h-3.5 w-3.5" />
|
||||
) : null}
|
||||
{tagCloudQuery.data && tagCloudQuery.data.tags.length === 0 ? (
|
||||
<p className="text-xs text-ctp-overlay0">No tags yet.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Sort
|
||||
|
||||
Reference in New Issue
Block a user