100 lines
3.7 KiB
TypeScript
100 lines
3.7 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { api, type BrowseResult } from "../../api/client";
|
|
import MediaViewer from "../MediaViewer/MediaViewer";
|
|
|
|
interface Props {
|
|
libraryId: number;
|
|
}
|
|
|
|
export default function FileBrowser({ libraryId }: Props) {
|
|
const [libraryPaths, setLibraryPaths] = useState<Record<number, string>>({});
|
|
const [viewingId, setViewingId] = useState<number | null>(null);
|
|
|
|
const currentPath = libraryPaths[libraryId] ?? "";
|
|
|
|
const { data, isLoading } = useQuery<BrowseResult>({
|
|
queryKey: ["browse", libraryId, currentPath],
|
|
queryFn: () => api.libraries.browse(libraryId, currentPath),
|
|
});
|
|
|
|
const pathParts = currentPath ? currentPath.split("/").filter(Boolean) : [];
|
|
|
|
function navigate(relPath: string) {
|
|
setLibraryPaths((prev) => ({ ...prev, [libraryId]: relPath }));
|
|
setViewingId(null);
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: "1rem" }}>
|
|
{/* Breadcrumb */}
|
|
<nav style={{ marginBottom: 16, display: "flex", gap: 4, alignItems: "center", flexWrap: "wrap" }}>
|
|
<button onClick={() => navigate("")} style={{ background: "none", border: "none", cursor: "pointer", fontWeight: 600, color: "var(--text)", padding: "2px 4px" }}>
|
|
Root
|
|
</button>
|
|
{pathParts.map((part, i) => {
|
|
const partPath = pathParts.slice(0, i + 1).join("/");
|
|
return (
|
|
<span key={partPath} style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
<span style={{ color: "var(--text-muted)" }}>/</span>
|
|
<button onClick={() => navigate(partPath)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text)", padding: "2px 4px" }}>
|
|
{part}
|
|
</button>
|
|
</span>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{isLoading && <p style={{ color: "var(--text-secondary)" }}>Loading…</p>}
|
|
|
|
{/* Grid */}
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
|
|
{data?.entries.map((entry) => (
|
|
<div
|
|
key={entry.rel_path}
|
|
onClick={() => {
|
|
if (entry.type === "dir") navigate(entry.rel_path);
|
|
else if (entry.media_item_id) setViewingId(entry.media_item_id);
|
|
}}
|
|
style={{
|
|
cursor: entry.type === "dir" || entry.media_item_id ? "pointer" : "default",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: 6,
|
|
overflow: "hidden",
|
|
background: "var(--bg-card)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
opacity: entry.type !== "dir" && !entry.media_item_id ? 0.5 : 1,
|
|
}}
|
|
>
|
|
{entry.type === "dir" ? (
|
|
<div style={{ fontSize: 40, padding: "20px 0" }}>📁</div>
|
|
) : (
|
|
<img
|
|
src={entry.media_item_id ? api.media.thumbnailUrl(entry.media_item_id) : ""}
|
|
alt={entry.name}
|
|
loading="lazy"
|
|
style={{ width: "100%", height: 110, objectFit: "cover" }}
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
/>
|
|
)}
|
|
<div style={{ padding: "4px 6px", fontSize: 12, width: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--text)" }}>
|
|
{entry.name}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{viewingId && data && (
|
|
<MediaViewer
|
|
mediaId={viewingId}
|
|
siblings={data.entries}
|
|
onClose={() => setViewingId(null)}
|
|
onNavigate={setViewingId}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|