initial commit

This commit is contained in:
2026-05-09 12:34:45 -04:00
commit 97fabc2c17
49 changed files with 4856 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, BrowseResult } from "../../api/client";
import MediaViewer from "../MediaViewer/MediaViewer";
interface Props {
libraryId: number;
}
export default function FileBrowser({ libraryId }: Props) {
const [currentPath, setCurrentPath] = useState("");
const [viewingId, setViewingId] = useState<number | null>(null);
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) {
setCurrentPath(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 }}>
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: "#888" }}>/</span>
<button onClick={() => navigate(partPath)} style={{ background: "none", border: "none", cursor: "pointer" }}>
{part}
</button>
</span>
);
})}
</nav>
{isLoading && <p>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: "pointer",
border: "1px solid #ddd",
borderRadius: 6,
overflow: "hidden",
background: "#fafafa",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{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" }}>
{entry.name}
</div>
</div>
))}
</div>
{viewingId && data && (
<MediaViewer
mediaId={viewingId}
siblings={data.entries}
onClose={() => setViewingId(null)}
onNavigate={setViewingId}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, BrowseEntry, MediaItem } from "../../api/client";
import TagPanel from "../TagPanel/TagPanel";
interface Props {
mediaId: number;
siblings: BrowseEntry[];
onClose: () => void;
onNavigate: (id: number) => void;
}
export default function MediaViewer({ mediaId, siblings, onClose, onNavigate }: Props) {
const { data: item } = useQuery<MediaItem>({
queryKey: ["media", mediaId],
queryFn: () => api.media.get(mediaId),
});
const mediaSiblings = siblings.filter((e) => e.media_item_id != null);
const currentIndex = mediaSiblings.findIndex((e) => e.media_item_id === mediaId);
const prevId = currentIndex > 0 ? mediaSiblings[currentIndex - 1].media_item_id : null;
const nextId = currentIndex < mediaSiblings.length - 1 ? mediaSiblings[currentIndex + 1].media_item_id : null;
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft" && prevId) onNavigate(prevId);
if (e.key === "ArrowRight" && nextId) onNavigate(nextId);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [prevId, nextId, onClose, onNavigate]);
return (
<div
onClick={onClose}
style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)",
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
display: "flex", gap: 16, background: "#1a1a1a", borderRadius: 8,
padding: 16, maxWidth: "95vw", maxHeight: "95vh", overflow: "auto",
}}
>
{/* Prev */}
<button
onClick={() => prevId && onNavigate(prevId)}
disabled={!prevId}
style={{ alignSelf: "center", fontSize: 24, background: "none", border: "none", color: prevId ? "#fff" : "#444", cursor: prevId ? "pointer" : "default" }}
>
</button>
{/* Media */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}>
{item?.filename && (
<div style={{ color: "#ccc", fontSize: 13 }}>{item.filename}</div>
)}
{item?.media_type === "image" && (
<img
src={api.media.fileUrl(mediaId)}
alt={item.filename}
style={{ maxWidth: "70vw", maxHeight: "80vh", objectFit: "contain" }}
/>
)}
{item?.media_type === "video" && (
<video
src={api.media.fileUrl(mediaId)}
controls
style={{ maxWidth: "70vw", maxHeight: "80vh" }}
/>
)}
</div>
{/* Next */}
<button
onClick={() => nextId && onNavigate(nextId)}
disabled={!nextId}
style={{ alignSelf: "center", fontSize: 24, background: "none", border: "none", color: nextId ? "#fff" : "#444", cursor: nextId ? "pointer" : "default" }}
>
</button>
{/* Tag panel */}
{item && (
<div style={{ color: "#fff", borderLeft: "1px solid #333", paddingLeft: 16, minWidth: 200 }}>
<TagPanel item={item} />
</div>
)}
{/* Close */}
<button
onClick={onClose}
style={{ position: "absolute", top: 16, right: 16, background: "none", border: "none", color: "#fff", fontSize: 20, cursor: "pointer" }}
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, Tag, TagsByCategory, MediaItem } from "../../api/client";
interface Props {
item: MediaItem;
}
export default function TagPanel({ item }: Props) {
const qc = useQueryClient();
const { data: grouped = [] } = useQuery<TagsByCategory[]>({
queryKey: ["tags"],
queryFn: api.tags.list,
});
const allTags = grouped.flatMap((g) => g.tags);
const assignedIds = new Set(item.tags.map((t) => t.id));
const [newName, setNewName] = useState("");
const [newCategory, setNewCategory] = useState("");
const setTagsMutation = useMutation({
mutationFn: (ids: number[]) => api.media.setTags(item.id, ids),
onSuccess: () => qc.invalidateQueries({ queryKey: ["media", item.id] }),
});
const createTagMutation = useMutation({
mutationFn: () => api.tags.create(newName.trim(), newCategory.trim()),
onSuccess: (tag: Tag) => {
qc.invalidateQueries({ queryKey: ["tags"] });
const newIds = [...assignedIds, tag.id];
setTagsMutation.mutate(newIds);
setNewName("");
setNewCategory("");
},
});
function toggle(tagId: number) {
const next = assignedIds.has(tagId)
? [...assignedIds].filter((id) => id !== tagId)
: [...assignedIds, tagId];
setTagsMutation.mutate(next);
}
return (
<div style={{ minWidth: 220 }}>
<h3 style={{ margin: "0 0 12px" }}>Tags</h3>
{grouped.map((group) => (
<div key={group.category} style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#888", marginBottom: 4 }}>
{group.category}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{group.tags.map((tag) => (
<button
key={tag.id}
onClick={() => toggle(tag.id)}
style={{
padding: "3px 10px",
borderRadius: 12,
border: "1px solid",
cursor: "pointer",
background: assignedIds.has(tag.id) ? "#3b82f6" : "transparent",
color: assignedIds.has(tag.id) ? "#fff" : "inherit",
borderColor: assignedIds.has(tag.id) ? "#3b82f6" : "#ccc",
fontSize: 13,
}}
>
{tag.name}
</button>
))}
</div>
</div>
))}
<details style={{ marginTop: 16 }}>
<summary style={{ cursor: "pointer", fontSize: 13, color: "#3b82f6" }}>+ New tag</summary>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
<input
placeholder="Tag name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
style={{ padding: "4px 8px" }}
/>
<input
placeholder="Category"
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
style={{ padding: "4px 8px" }}
/>
<button
onClick={() => createTagMutation.mutate()}
disabled={!newName.trim() || !newCategory.trim() || createTagMutation.isPending}
>
Create & assign
</button>
</div>
</details>
</div>
);
}