This commit is contained in:
Garret Patti
2026-06-29 10:41:29 -04:00
parent d48c1e973e
commit 2b0b19eb91
23 changed files with 969 additions and 44 deletions

View File

@@ -1,13 +1,34 @@
import { useEffect, useState } from "react";
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import { BrowserRouter, Routes, Route, NavLink, Navigate, useNavigate } from "react-router-dom";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { api, type Library } from "./api/client";
import { AuthProvider } from "./auth/AuthContext";
import { useAuth } from "./auth/useAuth";
import BrowserPage from "./pages/BrowserPage";
import SettingsPage from "./pages/SettingsPage";
import SearchPage from "./pages/SearchPage";
import LoginPage from "./pages/LoginPage";
const queryClient = new QueryClient();
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
<span style={{ color: "var(--text-secondary)" }}>Loading</span>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function useTheme() {
const [dark, setDark] = useState(
() => document.documentElement.getAttribute("data-theme") === "dark"
@@ -22,9 +43,12 @@ function useTheme() {
}
function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void; dark: boolean; onClose?: () => void }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { data: libraries = [] } = useQuery<Library[]>({
queryKey: ["libraries"],
queryFn: api.libraries.list,
enabled: !!user,
});
const linkStyle = ({ isActive }: { isActive: boolean }) => ({
@@ -37,6 +61,11 @@ function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void;
fontWeight: isActive ? 600 : 400,
});
function handleLogout() {
logout();
navigate("/login");
}
return (
<nav style={{
width: 220,
@@ -76,12 +105,24 @@ function Sidebar({ onToggleTheme, dark, onClose }: { onToggleTheme: () => void;
>
{dark ? "☀ Light mode" : "☾ Dark mode"}
</button>
{user && (
<div style={{ fontSize: 11, color: "var(--text-muted)", padding: "4px 12px 0" }}>
{user.username}
</div>
)}
<button
onClick={handleLogout}
style={{ textAlign: "left", border: "none", background: "transparent", padding: "6px 12px", color: "var(--danger)", borderRadius: 4 }}
>
Sign out
</button>
</div>
</nav>
);
}
function AppShell() {
const { isAuthenticated } = useAuth();
const { dark, toggle } = useTheme();
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -97,6 +138,15 @@ function AppShell() {
return () => mq.removeEventListener("change", handler);
}, []);
if (!isAuthenticated) {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);
}
return (
<div style={{ display: "flex", height: "100vh", background: "var(--bg)", color: "var(--text)" }}>
{/* Mobile hamburger button */}
@@ -129,10 +179,11 @@ function AppShell() {
<main style={{ flex: 1, overflow: "auto", background: "var(--bg)", paddingTop: isMobile ? 48 : 0 }}>
<Routes>
<Route path="/" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/library/:libraryId" element={<BrowserPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
<Route path="/search" element={<ProtectedRoute><SearchPage /></ProtectedRoute>} />
<Route path="/library/:libraryId" element={<ProtectedRoute><BrowserPage /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
<Route path="/login" element={<LoginPage />} />
</Routes>
</main>
</div>
@@ -142,9 +193,11 @@ function AppShell() {
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppShell />
</BrowserRouter>
<AuthProvider>
<BrowserRouter>
<AppShell />
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@@ -1,5 +1,9 @@
const BASE = "/api";
function getToken(): string | null {
return localStorage.getItem("token");
}
export interface Library {
id: number;
name: string;
@@ -42,11 +46,28 @@ export interface BrowseResult {
entries: BrowseEntry[];
}
export interface AuthUser {
id: number;
username: string;
is_admin: boolean;
created_at: string;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getToken();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json" },
headers,
...init,
});
if (res.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
throw new Error("Session expired");
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${res.status}: ${text}`);
@@ -56,6 +77,23 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
export const api = {
auth: {
login: (username: string, password: string) =>
request<{ access_token: string; token_type: string }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
me: () => request<AuthUser>("/auth/me"),
listUsers: () => request<AuthUser[]>("/auth/users"),
createUser: (username: string, password: string) =>
request<AuthUser>("/auth/users", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
deleteUser: (id: number) =>
request<void>(`/auth/users/${id}`, { method: "DELETE" }),
},
libraries: {
list: () => request<Library[]>("/libraries"),
create: (name: string, path: string) =>
@@ -77,8 +115,16 @@ export const api = {
media: {
get: (id: number) => request<MediaItem>(`/media/${id}`),
fileUrl: (id: number) => `${BASE}/media/${id}/file`,
thumbnailUrl: (id: number) => `${BASE}/media/${id}/thumbnail`,
fileUrl: (id: number) => {
const token = getToken();
const base = `${BASE}/media/${id}/file`;
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
},
thumbnailUrl: (id: number) => {
const token = getToken();
const base = `${BASE}/media/${id}/thumbnail`;
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
},
setTags: (id: number, tagIds: number[]) =>
request<MediaItem>(`/media/${id}/tags`, {
method: "PUT",

View File

@@ -0,0 +1,86 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { AuthContext, type AuthUser } from "./AuthTypes";
const BASE = "/api";
async function fetchMe(token: string) {
try {
const res = await fetch(`${BASE}/auth/me`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem("token"));
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function validateToken() {
if (!token) return;
const u = await fetchMe(token);
if (cancelled) return;
if (u) {
setUser(u);
} else {
localStorage.removeItem("token");
setToken(null);
}
}
validateToken().finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [token]);
const login = useCallback(async (username: string, password: string) => {
const res = await fetch(`${BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${res.status}: ${text}`);
}
const data = await res.json();
const newToken = data.access_token;
localStorage.setItem("token", newToken);
setToken(newToken);
}, []);
const logout = useCallback(() => {
localStorage.removeItem("token");
setToken(null);
setUser(null);
}, []);
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!user,
isLoading,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,19 @@
import { createContext } from "react";
export interface AuthUser {
id: number;
username: string;
is_admin: boolean;
created_at: string;
}
export interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
export const AuthContext = createContext<AuthState | null>(null);

View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { AuthContext, type AuthState } from "./AuthTypes";
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}

View File

@@ -0,0 +1,100 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/useAuth";
export default function LoginPage() {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
if (isAuthenticated) {
navigate("/", { replace: true });
return null;
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(username, password);
navigate("/", { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
}
return (
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
background: "var(--bg)",
}}>
<form
onSubmit={handleSubmit}
style={{
width: 320,
display: "flex",
flexDirection: "column",
gap: 16,
padding: 32,
borderRadius: 8,
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
}}
>
<h1 style={{ margin: 0, fontSize: 24, color: "var(--text)", textAlign: "center" }}>
MediaLore
</h1>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Username</span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
autoComplete="username"
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Password</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</label>
{error && (
<p style={{ color: "var(--danger)", margin: 0, fontSize: 13 }}>{error}</p>
)}
<button
type="submit"
disabled={loading}
style={{
background: "var(--accent)",
color: "#fff",
border: "none",
padding: "10px 0",
fontWeight: 600,
}}
>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, type Library } from "../api/client";
import { api, type Library, type AuthUser } from "../api/client";
import { useAuth } from "../auth/useAuth";
function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) => void }) {
const qc = useQueryClient();
@@ -48,6 +49,91 @@ function LibraryRow({ lib, onRemove }: { lib: Library; onRemove: (id: number) =>
);
}
function UserManagement() {
const qc = useQueryClient();
const { user: currentUser } = useAuth();
const { data: users = [] } = useQuery<AuthUser[]>({
queryKey: ["users"],
queryFn: api.auth.listUsers,
});
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [userError, setUserError] = useState("");
const createMutation = useMutation({
mutationFn: () => api.auth.createUser(newUsername, newPassword),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["users"] });
setNewUsername("");
setNewPassword("");
setUserError("");
},
onError: (e: Error) => setUserError(e.message),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.auth.deleteUser(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
onError: (e: Error) => setUserError(e.message),
});
return (
<div>
<h2 style={{ color: "var(--text)", marginTop: 32 }}>Users</h2>
<form
onSubmit={(e) => { e.preventDefault(); createMutation.mutate(); }}
style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 24 }}
>
<input
placeholder="Username"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
autoComplete="off"
/>
<input
type="password"
placeholder="Password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
autoComplete="new-password"
/>
{userError && <p style={{ color: "var(--danger)", margin: 0, fontSize: 13 }}>{userError}</p>}
<button type="submit" disabled={createMutation.isPending} style={{ background: "var(--accent)", color: "#fff", border: "none" }}>
{createMutation.isPending ? "Creating…" : "Add User"}
</button>
</form>
<ul style={{ listStyle: "none", padding: 0 }}>
{users.map((u) => (
<li key={u.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--border-subtle)" }}>
<div>
<strong style={{ color: "var(--text)" }}>{u.username}</strong>
{u.is_admin && (
<span style={{ marginLeft: 8, fontSize: 11, color: "var(--accent)", fontWeight: 600 }}>
ADMIN
</span>
)}
</div>
{u.id !== currentUser?.id && (
<button
onClick={() => deleteMutation.mutate(u.id)}
disabled={deleteMutation.isPending}
style={{ color: "var(--danger)", background: "transparent", border: "none" }}
>
Remove
</button>
)}
</li>
))}
</ul>
</div>
);
}
export default function SettingsPage() {
const qc = useQueryClient();
const { data: libraries = [] } = useQuery<Library[]>({
@@ -97,6 +183,8 @@ export default function SettingsPage() {
<LibraryRow key={lib.id} lib={lib} onRemove={(id) => deleteMutation.mutate(id)} />
))}
</ul>
<UserManagement />
</div>
);
}