155 lines
5.8 KiB
TypeScript
155 lines
5.8 KiB
TypeScript
import { useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { api, type MediaItem, type TagsByCategory, type BrowseEntry } from "../api/client";
|
|
import MediaViewer from "../components/MediaViewer/MediaViewer";
|
|
import DoomScrollViewer from "../components/DoomScrollViewer/DoomScrollViewer";
|
|
|
|
export default function SearchPage() {
|
|
const routerNavigate = useNavigate();
|
|
const [q, setQ] = useState("");
|
|
const [selectedTags, setSelectedTags] = useState<number[]>([]);
|
|
const [submitted, setSubmitted] = useState(false);
|
|
const [viewingId, setViewingId] = useState<number | null>(null);
|
|
const [doomScrollItems, setDoomScrollItems] = useState<MediaItem[] | null>(null);
|
|
|
|
function handleViewInLibrary(item: MediaItem) {
|
|
const dirPath = item.rel_path.split("/").slice(0, -1).join("/");
|
|
routerNavigate(`/library/${item.library_id}?path=${encodeURIComponent(dirPath)}`);
|
|
}
|
|
|
|
const { data: grouped = [] } = useQuery<TagsByCategory[]>({
|
|
queryKey: ["tags"],
|
|
queryFn: api.tags.list,
|
|
});
|
|
|
|
const { data: results = [], isFetching } = useQuery<MediaItem[]>({
|
|
queryKey: ["search", q, selectedTags],
|
|
queryFn: () => api.search({ q, tags: selectedTags }),
|
|
enabled: submitted,
|
|
});
|
|
|
|
function toggleTag(id: number) {
|
|
setSelectedTags((prev) =>
|
|
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
|
|
);
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSubmitted(true);
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: "2rem", maxWidth: 900 }}>
|
|
<h1 style={{ color: "var(--text)" }}>Search</h1>
|
|
<form onSubmit={handleSubmit} style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
|
|
<input
|
|
placeholder="Search by filename…"
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
style={{ flex: 1, minWidth: 200 }}
|
|
/>
|
|
<button type="submit" style={{ background: "var(--accent)", color: "#fff", border: "none" }}>Search</button>
|
|
</form>
|
|
|
|
{grouped.length > 0 && (
|
|
<div style={{ marginBottom: 24 }}>
|
|
<div style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 8 }}>Filter by tag:</div>
|
|
{grouped.map((group) => (
|
|
<div key={group.category} style={{ marginBottom: 8 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "var(--text-muted)", marginRight: 8 }}>
|
|
{group.category}
|
|
</span>
|
|
{group.tags.map((tag) => (
|
|
<button
|
|
key={tag.id}
|
|
onClick={() => toggleTag(tag.id)}
|
|
style={{
|
|
margin: "2px",
|
|
padding: "2px 10px",
|
|
borderRadius: 12,
|
|
border: "1px solid",
|
|
cursor: "pointer",
|
|
background: selectedTags.includes(tag.id) ? "var(--accent)" : "transparent",
|
|
color: selectedTags.includes(tag.id) ? "#fff" : "var(--text)",
|
|
borderColor: selectedTags.includes(tag.id) ? "var(--accent)" : "var(--border)",
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
{tag.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isFetching && <p style={{ color: "var(--text-secondary)" }}>Searching…</p>}
|
|
|
|
{results.length > 0 && (
|
|
<button
|
|
onClick={() => setDoomScrollItems([...results].sort(() => Math.random() - 0.5))}
|
|
style={{ background: "var(--accent)", color: "#fff", border: "none", borderRadius: 4, padding: "4px 10px", cursor: "pointer", fontSize: 13, marginBottom: 16 }}
|
|
>
|
|
Doom Scroll
|
|
</button>
|
|
)}
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
|
|
{results.map((item) => (
|
|
<div key={item.id} onClick={() => setViewingId(item.id)} style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", background: "var(--bg-card)", cursor: "pointer" }}>
|
|
<img
|
|
src={api.media.thumbnailUrl(item.id)}
|
|
alt={item.filename}
|
|
loading="lazy"
|
|
style={{ width: "100%", height: 110, objectFit: "cover" }}
|
|
/>
|
|
<div style={{ padding: "4px 6px", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--text)" }}>
|
|
{item.filename}
|
|
</div>
|
|
{item.tags.length > 0 && (
|
|
<div style={{ padding: "0 6px 4px", display: "flex", flexWrap: "wrap", gap: 4 }}>
|
|
{item.tags.map((t) => (
|
|
<span key={t.id} style={{ background: "var(--tag-bg)", color: "var(--accent-text)", borderRadius: 8, padding: "1px 6px", fontSize: 11 }}>
|
|
{t.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{submitted && !isFetching && results.length === 0 && (
|
|
<p style={{ color: "var(--text-secondary)" }}>No results found.</p>
|
|
)}
|
|
|
|
{doomScrollItems && (
|
|
<DoomScrollViewer
|
|
items={doomScrollItems}
|
|
onClose={() => setDoomScrollItems(null)}
|
|
onViewInLibrary={handleViewInLibrary}
|
|
/>
|
|
)}
|
|
|
|
{viewingId !== null && (() => {
|
|
const siblings: BrowseEntry[] = results.map((item) => ({
|
|
name: item.filename,
|
|
type: item.media_type,
|
|
rel_path: item.rel_path,
|
|
media_item_id: item.id,
|
|
}));
|
|
return (
|
|
<MediaViewer
|
|
mediaId={viewingId}
|
|
siblings={siblings}
|
|
onClose={() => setViewingId(null)}
|
|
onNavigate={(id) => setViewingId(id)}
|
|
/>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
}
|