initial commit
This commit is contained in:
76
frontend/src/App.tsx
Normal file
76
frontend/src/App.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||||
import { api, Library } from "./api/client";
|
||||
import BrowserPage from "./pages/BrowserPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import SearchPage from "./pages/SearchPage";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function Sidebar() {
|
||||
const { data: libraries = [] } = useQuery<Library[]>({
|
||||
queryKey: ["libraries"],
|
||||
queryFn: api.libraries.list,
|
||||
});
|
||||
|
||||
const linkStyle = ({ isActive }: { isActive: boolean }) => ({
|
||||
display: "block",
|
||||
padding: "6px 12px",
|
||||
textDecoration: "none",
|
||||
borderRadius: 4,
|
||||
color: isActive ? "#3b82f6" : "inherit",
|
||||
background: isActive ? "#eff6ff" : "transparent",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
});
|
||||
|
||||
return (
|
||||
<nav style={{ width: 220, borderRight: "1px solid #e5e7eb", padding: "1rem", display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 12 }}>MediaLore</div>
|
||||
|
||||
<NavLink to="/search" style={linkStyle}>Search</NavLink>
|
||||
|
||||
{libraries.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#9ca3af", margin: "12px 0 4px", padding: "0 12px" }}>
|
||||
Libraries
|
||||
</div>
|
||||
{libraries.map((lib) => (
|
||||
<NavLink key={lib.id} to={`/library/${lib.id}`} style={linkStyle}>
|
||||
{lib.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "auto" }}>
|
||||
<NavLink to="/settings" style={linkStyle}>Settings</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
||||
<Sidebar />
|
||||
<main style={{ flex: 1, overflow: "auto" }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<SearchPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/library/:libraryId" element={<BrowserPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AppShell />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
101
frontend/src/api/client.ts
Normal file
101
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
const BASE = "/api";
|
||||
|
||||
export interface Library {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface TagsByCategory {
|
||||
category: string;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: number;
|
||||
library_id: number;
|
||||
rel_path: string;
|
||||
filename: string;
|
||||
media_type: "image" | "video";
|
||||
size_bytes: number | null;
|
||||
missing: boolean;
|
||||
tags: Tag[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BrowseEntry {
|
||||
name: string;
|
||||
type: "dir" | "image" | "video";
|
||||
rel_path: string;
|
||||
media_item_id: number | null;
|
||||
}
|
||||
|
||||
export interface BrowseResult {
|
||||
path: string;
|
||||
entries: BrowseEntry[];
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
libraries: {
|
||||
list: () => request<Library[]>("/libraries"),
|
||||
create: (name: string, path: string) =>
|
||||
request<Library>("/libraries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, path }),
|
||||
}),
|
||||
delete: (id: number) =>
|
||||
request<void>(`/libraries/${id}`, { method: "DELETE" }),
|
||||
browse: (id: number, path = "") =>
|
||||
request<BrowseResult>(`/libraries/${id}/browse?path=${encodeURIComponent(path)}`),
|
||||
},
|
||||
|
||||
media: {
|
||||
get: (id: number) => request<MediaItem>(`/media/${id}`),
|
||||
fileUrl: (id: number) => `${BASE}/media/${id}/file`,
|
||||
thumbnailUrl: (id: number) => `${BASE}/media/${id}/thumbnail`,
|
||||
setTags: (id: number, tagIds: number[]) =>
|
||||
request<MediaItem>(`/media/${id}/tags`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ tag_ids: tagIds }),
|
||||
}),
|
||||
},
|
||||
|
||||
tags: {
|
||||
list: () => request<TagsByCategory[]>("/tags"),
|
||||
create: (name: string, category: string) =>
|
||||
request<Tag>("/tags", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, category }),
|
||||
}),
|
||||
delete: (id: number) =>
|
||||
request<void>(`/tags/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
|
||||
search: (params: { q?: string; tags?: number[]; library_id?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (params.q) p.set("q", params.q);
|
||||
if (params.tags?.length) p.set("tags", params.tags.join(","));
|
||||
if (params.library_id != null) p.set("library_id", String(params.library_id));
|
||||
return request<MediaItem[]>(`/search?${p}`);
|
||||
},
|
||||
};
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
96
frontend/src/components/FileBrowser/FileBrowser.tsx
Normal file
96
frontend/src/components/FileBrowser/FileBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
frontend/src/components/MediaViewer/MediaViewer.tsx
Normal file
105
frontend/src/components/MediaViewer/MediaViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
frontend/src/components/TagPanel/TagPanel.tsx
Normal file
102
frontend/src/components/TagPanel/TagPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
28
frontend/src/pages/BrowserPage.tsx
Normal file
28
frontend/src/pages/BrowserPage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Library } from "../api/client";
|
||||
import FileBrowser from "../components/FileBrowser/FileBrowser";
|
||||
|
||||
export default function BrowserPage() {
|
||||
const { libraryId } = useParams<{ libraryId: string }>();
|
||||
const id = Number(libraryId);
|
||||
|
||||
const { data: libraries = [] } = useQuery<Library[]>({
|
||||
queryKey: ["libraries"],
|
||||
queryFn: api.libraries.list,
|
||||
});
|
||||
|
||||
const library = libraries.find((l) => l.id === id);
|
||||
|
||||
if (!library) return <p style={{ padding: "2rem" }}>Library not found.</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: "1rem 1rem 0", borderBottom: "1px solid #eee" }}>
|
||||
<h2 style={{ margin: 0 }}>{library.name}</h2>
|
||||
<div style={{ fontSize: 12, color: "#888" }}>{library.path}</div>
|
||||
</div>
|
||||
<FileBrowser libraryId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/SearchPage.tsx
Normal file
110
frontend/src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, MediaItem, TagsByCategory } from "../api/client";
|
||||
|
||||
export default function SearchPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<number[]>([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
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>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, padding: "6px 10px" }}
|
||||
/>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
{/* Tag filter */}
|
||||
{grouped.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 13, color: "#666", 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: "#888", 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) ? "#3b82f6" : "transparent",
|
||||
color: selectedTags.includes(tag.id) ? "#fff" : "inherit",
|
||||
borderColor: selectedTags.includes(tag.id) ? "#3b82f6" : "#ccc",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFetching && <p>Searching…</p>}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
|
||||
{results.map((item) => (
|
||||
<div key={item.id} style={{ border: "1px solid #ddd", borderRadius: 6, overflow: "hidden" }}>
|
||||
<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" }}>
|
||||
{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: "#e0edff", color: "#3b82f6", borderRadius: 8, padding: "1px 6px", fontSize: 11 }}>
|
||||
{t.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{submitted && !isFetching && results.length === 0 && (
|
||||
<p style={{ color: "#888" }}>No results found.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/SettingsPage.tsx
Normal file
81
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, Library } from "../api/client";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: libraries = [] } = useQuery<Library[]>({
|
||||
queryKey: ["libraries"],
|
||||
queryFn: api.libraries.list,
|
||||
});
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [path, setPath] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => api.libraries.create(name, path),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["libraries"] });
|
||||
setName("");
|
||||
setPath("");
|
||||
setError("");
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.libraries.delete(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["libraries"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: "2rem", maxWidth: 600 }}>
|
||||
<h1>Settings</h1>
|
||||
<h2>Libraries</h2>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addMutation.mutate(); }}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }}
|
||||
>
|
||||
<input
|
||||
placeholder="Library name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
placeholder="/path/to/directory"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <p style={{ color: "red", margin: 0 }}>{error}</p>}
|
||||
<button type="submit" disabled={addMutation.isPending}>
|
||||
{addMutation.isPending ? "Adding…" : "Add Library"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{libraries.map((lib) => (
|
||||
<li
|
||||
key={lib.id}
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid #eee" }}
|
||||
>
|
||||
<div>
|
||||
<strong>{lib.name}</strong>
|
||||
<div style={{ fontSize: 12, color: "#666" }}>{lib.path}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(lib.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
style={{ color: "red" }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user