initial commit
This commit is contained in:
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