auto-commit for 14921357-f5e2-4aba-b4c1-a07a52c800cc

This commit is contained in:
emergent-agent-e1
2026-04-29 16:01:20 +00:00
parent 1d4bd4f513
commit cdc8c8955f
19 changed files with 1933 additions and 339 deletions
+1
View File
@@ -38,6 +38,7 @@
"cra-template": "1.2.0",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"hls.js": "^1.6.16",
"input-otp": "^1.4.2",
"lucide-react": "^0.507.0",
"next-themes": "^0.4.6",
+55 -22
View File
@@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
import { Toaster } from "sonner";
import { AuthProvider, useAuth } from "./lib/auth";
import { ProfileProvider, useProfile } from "./lib/profile";
import ProtectedRoute from "./components/ProtectedRoute";
import Navbar from "./components/Navbar";
import GrainOverlay from "./components/GrainOverlay";
@@ -13,14 +14,38 @@ import Player from "./pages/Player";
import Requests from "./pages/Requests";
import Admin from "./pages/Admin";
import AdminUpload from "./pages/AdminUpload";
import Settings from "./pages/Settings";
import RadarrImport from "./pages/RadarrImport";
import ProfileSelect from "./pages/ProfileSelect";
const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth();
const { active, loading: profLoading } = useProfile();
const loc = useLocation();
if (authLoading || profLoading) {
return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
}
if (!user) return <Navigate to="/login" state={{ from: loc.pathname }} replace />;
if (!active) return <Navigate to="/profile" replace />;
return children;
};
const AdminGate = ({ children }) => {
const { user, loading } = useAuth();
if (loading) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
if (!user) return <Navigate to="/login" replace />;
if (!user.is_admin) return <Navigate to="/browse" replace />;
return children;
};
const Shell = ({ children }) => {
const { user } = useAuth();
const { active } = useProfile();
const loc = useLocation();
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register";
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register" || loc.pathname === "/profile";
return (
<>
{!hideChrome && user && <Navbar />}
{!hideChrome && user && active && <Navbar />}
{children}
<GrainOverlay />
</>
@@ -29,8 +54,11 @@ const Shell = ({ children }) => {
const RootRedirect = () => {
const { user, loading } = useAuth();
if (loading) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
return <Navigate to={user ? "/browse" : "/login"} replace />;
const { active, loading: pl } = useProfile();
if (loading || pl) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading</div>;
if (!user) return <Navigate to="/login" replace />;
if (!active) return <Navigate to="/profile" replace />;
return <Navigate to="/browse" replace />;
};
function App() {
@@ -38,24 +66,29 @@ function App() {
<div className="App min-h-screen bg-[#050505]">
<BrowserRouter>
<AuthProvider>
<Shell>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/browse" element={<ProtectedRoute><Browse /></ProtectedRoute>} />
<Route path="/my-list" element={<ProtectedRoute><MyList /></ProtectedRoute>} />
<Route path="/search" element={<ProtectedRoute><Search /></ProtectedRoute>} />
<Route path="/watch/:id" element={<ProtectedRoute><Player /></ProtectedRoute>} />
<Route path="/requests" element={<ProtectedRoute><Requests /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute adminOnly><Admin /></ProtectedRoute>} />
<Route path="/admin/upload" element={<ProtectedRoute adminOnly><AdminUpload /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Shell>
<Toaster position="bottom-right" theme="dark" toastOptions={{
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
}} />
<ProfileProvider>
<Shell>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
<Route path="/my-list" element={<ProfileGate><MyList /></ProfileGate>} />
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
<Route path="/requests" element={<ProfileGate><Requests /></ProfileGate>} />
<Route path="/admin" element={<AdminGate><Admin /></AdminGate>} />
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Shell>
<Toaster position="bottom-right" theme="dark" toastOptions={{
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
}} />
</ProfileProvider>
</AuthProvider>
</BrowserRouter>
</div>
+37 -51
View File
@@ -1,10 +1,12 @@
import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/auth";
import { Search, LogOut, Upload, ListVideo } from "lucide-react";
import { useProfile } from "../lib/profile";
import { Search, LogOut, Upload, Users, Settings as SettingsIcon } from "lucide-react";
import { useState, useEffect } from "react";
export const Navbar = () => {
const { user, logout } = useAuth();
const { active, clearActive } = useProfile();
const nav = useNavigate();
const [scrolled, setScrolled] = useState(false);
@@ -15,23 +17,19 @@ export const Navbar = () => {
}, []);
const linkClass = ({ isActive }) =>
`text-sm tracking-wide transition-colors duration-300 ${
isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"
}`;
`text-sm tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"}`;
const switchProfile = () => {
clearActive();
nav("/profile");
};
return (
<header
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled ? "glass border-b" : ""
}`}
data-testid="main-navbar"
>
<header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled ? "glass border-b" : ""}`} data-testid="main-navbar">
<div className="px-6 md:px-12 py-4 flex items-center justify-between">
<div className="flex items-center gap-10">
<Link to="/browse" className="flex items-center gap-2" data-testid="nav-logo">
<span className="font-display text-2xl font-black tracking-tighter text-white">
Kino
</span>
<span className="font-display text-2xl font-black tracking-tighter text-white">Kino</span>
<span className="text-[#D9381E] text-2xl leading-none">.</span>
</Link>
{user && (
@@ -39,60 +37,48 @@ export const Navbar = () => {
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Browse</NavLink>
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">My List</NavLink>
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Requests</NavLink>
{user.is_admin && (
<NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>
)}
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
</nav>
)}
</div>
{user ? (
<div className="flex items-center gap-4">
<button
onClick={() => nav("/search")}
className="text-[#8A8A8A] hover:text-white transition-colors duration-300"
data-testid="nav-search-button"
aria-label="Search"
>
<button onClick={() => nav("/search")} className="text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-search-button" aria-label="Search">
<Search size={18} strokeWidth={1.5} />
</button>
{user.is_admin && (
<button
onClick={() => nav("/admin/upload")}
className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300"
data-testid="nav-upload-button"
>
<Upload size={14} strokeWidth={1.5} /> Upload
</button>
<>
<button onClick={() => nav("/admin/upload")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-upload-button">
<Upload size={14} strokeWidth={1.5} /> Upload
</button>
<button onClick={() => nav("/admin/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
</button>
</>
)}
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
<div className="hidden sm:flex flex-col items-end leading-tight">
<span className="text-xs text-white" data-testid="nav-user-name">{user.name}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
{user.is_admin ? "Admin" : "Member"}
</span>
</div>
<div className="w-8 h-8 bg-[#D9381E] flex items-center justify-center text-white text-xs font-medium">
{user.name?.[0]?.toUpperCase() || "U"}
</div>
<button
onClick={() => { logout(); nav("/login"); }}
className="text-[#8A8A8A] hover:text-white transition-colors duration-300"
data-testid="nav-logout-button"
aria-label="Logout"
>
<button onClick={switchProfile} className="flex items-center gap-2 group" data-testid="nav-switch-profile" title="Switch profile">
{active && (
<>
<div className="hidden sm:flex flex-col items-end leading-tight">
<span className="text-xs text-white">{active.name}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] group-hover:text-white transition-colors">switch</span>
</div>
<div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
{active.name?.[0]?.toUpperCase() || "U"}
</div>
</>
)}
{!active && <Users size={18} strokeWidth={1.5} className="text-[#8A8A8A]" />}
</button>
<button onClick={() => { logout(); nav("/login"); }} className="text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-logout-button" aria-label="Logout">
<LogOut size={16} strokeWidth={1.5} />
</button>
</div>
</div>
) : (
<Link
to="/login"
className="text-sm tracking-wide bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 transition-colors duration-300"
data-testid="nav-sign-in"
>
Sign in
</Link>
<Link to="/login" className="text-sm tracking-wide bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 transition-colors duration-300" data-testid="nav-sign-in">Sign in</Link>
)}
</div>
</header>
+19 -13
View File
@@ -8,26 +8,32 @@ const instance = axios.create({ baseURL: API });
instance.interceptors.request.use((config) => {
const token = localStorage.getItem("kino_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
// Active profile id (set by ProfileProvider via localStorage)
const userId = JSON.parse(localStorage.getItem("kino_user_id") || "null");
if (userId) {
const pid = localStorage.getItem(`kino_profile_${userId}`);
if (pid) config.headers["X-Profile-Id"] = pid;
}
return config;
});
instance.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401) {
// soft handling — let pages decide
}
return Promise.reject(err);
}
);
export default instance;
export const getStreamUrl = (movie) => {
if (!movie) return "";
if (movie.storage_type === "local") {
const token = localStorage.getItem("kino_token");
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token || "")}`;
const token = localStorage.getItem("kino_token") || "";
if (movie.hls_status === "done" && movie.hls_path) {
return `${API}/movies/${movie.id}/hls/playlist.m3u8?auth=${encodeURIComponent(token)}`;
}
if (movie.storage_type === "local" || movie.storage_type === "radarr") {
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token)}`;
}
return movie.video_url;
};
export const isHls = (url) => url.includes(".m3u8");
export const getSubtitleUrl = (sub) => {
const token = localStorage.getItem("kino_token") || "";
return `${API}/subtitles/${sub.id}/file?auth=${encodeURIComponent(token)}`;
};
+6 -5
View File
@@ -9,16 +9,14 @@ export const AuthProvider = ({ children }) => {
const refresh = useCallback(async () => {
const token = localStorage.getItem("kino_token");
if (!token) {
setUser(null);
setLoading(false);
return;
}
if (!token) { setUser(null); setLoading(false); return; }
try {
const { data } = await api.get("/auth/me");
setUser(data);
localStorage.setItem("kino_user_id", JSON.stringify(data.id));
} catch {
localStorage.removeItem("kino_token");
localStorage.removeItem("kino_user_id");
setUser(null);
} finally {
setLoading(false);
@@ -30,6 +28,7 @@ export const AuthProvider = ({ children }) => {
const login = async (email, password) => {
const { data } = await api.post("/auth/login", { email, password });
localStorage.setItem("kino_token", data.access_token);
localStorage.setItem("kino_user_id", JSON.stringify(data.user.id));
setUser(data.user);
return data.user;
};
@@ -37,12 +36,14 @@ export const AuthProvider = ({ children }) => {
const register = async (email, password, name) => {
const { data } = await api.post("/auth/register", { email, password, name });
localStorage.setItem("kino_token", data.access_token);
localStorage.setItem("kino_user_id", JSON.stringify(data.user.id));
setUser(data.user);
return data.user;
};
const logout = () => {
localStorage.removeItem("kino_token");
localStorage.removeItem("kino_user_id");
setUser(null);
};
+48
View File
@@ -0,0 +1,48 @@
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import api from "./api";
import { useAuth } from "./auth";
const ProfileCtx = createContext(null);
export const ProfileProvider = ({ children }) => {
const { user } = useAuth();
const [profiles, setProfiles] = useState([]);
const [active, setActive] = useState(null);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
if (!user) { setProfiles([]); setActive(null); return; }
setLoading(true);
try {
const { data } = await api.get("/profiles");
setProfiles(data);
const stored = localStorage.getItem(`kino_profile_${user.id}`);
const found = data.find((p) => p.id === stored);
setActive(found || null);
} finally {
setLoading(false);
}
}, [user]);
useEffect(() => { refresh(); }, [refresh]);
const switchTo = (p) => {
if (!user) return;
localStorage.setItem(`kino_profile_${user.id}`, p.id);
setActive(p);
};
const clearActive = () => {
if (!user) return;
localStorage.removeItem(`kino_profile_${user.id}`);
setActive(null);
};
return (
<ProfileCtx.Provider value={{ profiles, active, loading, refresh, switchTo, clearActive }}>
{children}
</ProfileCtx.Provider>
);
};
export const useProfile = () => useContext(ProfileCtx);
+66 -27
View File
@@ -1,10 +1,13 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Trash2, Star } from "lucide-react";
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw } from "lucide-react";
import { useNavigate, Link } from "react-router-dom";
export default function Admin() {
const nav = useNavigate();
const [movies, setMovies] = useState([]);
const [transcoding, setTranscoding] = useState({});
const load = async () => {
const { data } = await api.get("/movies");
@@ -12,6 +15,14 @@ export default function Admin() {
};
useEffect(() => { load(); }, []);
// Poll for transcoding status
useEffect(() => {
const inProgress = movies.some((m) => m.hls_status === "running" || m.hls_status === "pending");
if (!inProgress) return;
const t = setInterval(load, 3000);
return () => clearInterval(t);
}, [movies]);
const remove = async (id) => {
if (!window.confirm("Remove this movie permanently?")) return;
await api.delete(`/movies/${id}`);
@@ -21,52 +32,80 @@ export default function Admin() {
const toggleFeatured = async (m) => {
await api.patch(`/movies/${m.id}`, { featured: !m.featured });
if (!m.featured) {
// unset others client-side? Keep simple: server allows multiple but featured endpoint picks first
toast.success("Featured updated");
}
load();
};
const startTranscode = async (m) => {
setTranscoding({ ...transcoding, [m.id]: true });
try {
await api.post(`/movies/${m.id}/transcode`);
toast.success("Transcoding started — refresh in a few minutes");
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not start transcode");
} finally {
setTranscoding({ ...transcoding, [m.id]: false });
}
};
const hlsStatusBadge = (m) => {
if (!m.hls_status) return null;
const colors = {
pending: "text-[#fcd34d]",
running: "text-[#fcd34d]",
done: "text-[#86efac]",
failed: "text-[#fca5a5]",
};
return <span className={`text-[9px] uppercase tracking-[0.2em] ${colors[m.hls_status] || "text-[#8A8A8A]"}`}>HLS {m.hls_status}</span>;
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="admin-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Library management</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
Admin
</h1>
<p className="text-[#8A8A8A] mt-4">
Manage your library and review pending requests.
</p>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Admin</h1>
<p className="text-[#8A8A8A] mt-4">Manage your library, requests, integrations.</p>
<div className="mt-10 flex gap-3">
<a href="/admin/upload" className="bg-[#D9381E] hover:bg-[#ED4B32] text-white px-6 py-3 text-sm uppercase tracking-[0.2em]" data-testid="admin-upload-link">
Upload New
</a>
<a href="/requests" className="bg-white/10 hover:bg-white/20 text-white px-6 py-3 text-sm uppercase tracking-[0.2em] border border-white/10">
<div className="mt-10 flex flex-wrap gap-3">
<Link to="/admin/upload" className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="admin-upload-link">
<Film size={14} strokeWidth={1.5} /> Upload Movie
</Link>
<Link to="/admin/radarr" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-radarr-link">
<Download size={14} strokeWidth={1.5} /> Radarr Import
</Link>
<Link to="/admin/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
</Link>
<Link to="/requests" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10">
Review Requests
</a>
</Link>
</div>
<div className="mt-12 border border-[#222]">
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
<span className="col-span-5">Title</span>
<span className="col-span-2">Year</span>
<span className="col-span-2">Rating</span>
<span className="col-span-4">Title</span>
<span className="col-span-1">Year</span>
<span className="col-span-1">Rating</span>
<span className="col-span-2">Source</span>
<span className="col-span-1 text-right">Actions</span>
<span className="col-span-2">HLS</span>
<span className="col-span-2 text-right">Actions</span>
</div>
{movies.map((m) => (
<div key={m.id} className="grid grid-cols-12 items-center px-5 py-4 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors"
data-testid={`admin-movie-row-${m.id}`}>
<div className="col-span-5 flex items-center gap-3">
<div key={m.id} className="grid grid-cols-12 items-center px-5 py-4 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`admin-movie-row-${m.id}`}>
<div className="col-span-4 flex items-center gap-3 min-w-0">
<img src={m.poster_url} alt="" className="w-10 h-14 object-cover" />
<span className="text-white truncate">{m.title}</span>
</div>
<span className="col-span-2 text-[#8A8A8A] text-sm">{m.year}</span>
<span className="col-span-2 text-[#8A8A8A] text-sm">{m.rating}</span>
<span className="col-span-1 text-[#8A8A8A] text-sm">{m.year}</span>
<span className="col-span-1 text-[#8A8A8A] text-sm">{m.rating}</span>
<span className="col-span-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span>
<div className="col-span-1 flex justify-end gap-2">
<span className="col-span-2">{hlsStatusBadge(m)}</span>
<div className="col-span-2 flex justify-end gap-2">
{(m.storage_type === "local" || m.storage_type === "radarr") && m.hls_status !== "done" && m.hls_status !== "running" && (
<button onClick={() => startTranscode(m)} disabled={transcoding[m.id]}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-3 py-1 disabled:opacity-50"
data-testid={`transcode-${m.id}`}>HLS</button>
)}
<button onClick={() => toggleFeatured(m)} className={`${m.featured ? "text-[#D9381E]" : "text-[#8A8A8A]"} hover:text-[#ED4B32] transition-colors`} aria-label="Feature" data-testid={`feature-${m.id}`}>
<Star size={16} strokeWidth={1.5} fill={m.featured ? "#D9381E" : "none"} />
</button>
+182 -38
View File
@@ -1,64 +1,175 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { useNavigate } from "react-router-dom";
import { UploadCloud } from "lucide-react";
import { UploadCloud, Search, Subtitles, Trash2 } from "lucide-react";
export default function AdminUpload() {
const nav = useNavigate();
const [file, setFile] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState(0);
const [tmdbConfigured, setTmdbConfigured] = useState(false);
const [tmdbQuery, setTmdbQuery] = useState("");
const [tmdbResults, setTmdbResults] = useState([]);
const [searchingTmdb, setSearchingTmdb] = useState(false);
const [createdMovieId, setCreatedMovieId] = useState(null);
const [subs, setSubs] = useState([]);
const [subFile, setSubFile] = useState(null);
const [subLang, setSubLang] = useState("en");
const [subLabel, setSubLabel] = useState("English");
const [form, setForm] = useState({
title: "", description: "", year: 2024, duration_minutes: 0, rating: "NR",
genres: "", cast: "", director: "", poster_url: "", backdrop_url: "", featured: false,
genres: "", cast: "", director: "", poster_url: "", backdrop_url: "", featured: false, tmdb_id: "",
});
const upd = (k, v) => setForm((f) => ({ ...f, [k]: v }));
useEffect(() => {
api.get("/settings").then(({ data }) => setTmdbConfigured(data.tmdb_configured)).catch(() => {});
}, []);
const searchTmdb = async () => {
if (!tmdbQuery.trim()) return;
setSearchingTmdb(true);
try {
const { data } = await api.get(`/tmdb/search?q=${encodeURIComponent(tmdbQuery)}`);
setTmdbResults(data);
} catch (err) {
toast.error(err.response?.data?.detail || "TMDB search failed");
} finally { setSearchingTmdb(false); }
};
const pickTmdb = async (r) => {
try {
const { data } = await api.get(`/tmdb/movie/${r.tmdb_id}`);
setForm({
title: data.title || "",
description: data.description || "",
year: data.year || 2024,
duration_minutes: data.duration_minutes || 0,
rating: data.rating || "NR",
genres: (data.genres || []).join(", "),
cast: (data.cast || []).join(", "),
director: data.director || "",
poster_url: data.poster_url || "",
backdrop_url: data.backdrop_url || "",
featured: false,
tmdb_id: data.tmdb_id || "",
});
setTmdbResults([]);
setTmdbQuery("");
toast.success("Metadata filled from TMDB");
} catch (err) {
toast.error("Could not fetch movie details");
}
};
const submit = async (e) => {
e.preventDefault();
if (!file) { toast.error("Choose a video file"); return; }
if (!form.title.trim()) { toast.error("Title is required"); return; }
setSubmitting(true);
const fd = new FormData();
Object.entries(form).forEach(([k, v]) => fd.append(k, v));
Object.entries(form).forEach(([k, v]) => {
if (k === "tmdb_id" && !v) return;
fd.append(k, v);
});
fd.append("file", file);
try {
await api.post("/upload/video", fd, {
const { data } = await api.post("/upload/video", fd, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (p) => {
if (p.total) setProgress(Math.round((p.loaded / p.total) * 100));
},
onUploadProgress: (p) => { if (p.total) setProgress(Math.round((p.loaded / p.total) * 100)); },
});
toast.success("Movie uploaded");
nav("/admin");
setCreatedMovieId(data.id);
loadSubs(data.id);
} catch (err) {
toast.error(err.response?.data?.detail || "Upload failed");
} finally {
setSubmitting(false);
} finally { setSubmitting(false); }
};
const loadSubs = async (movieId) => {
const { data } = await api.get(`/movies/${movieId}/subtitles`);
setSubs(data);
};
const uploadSub = async (e) => {
e.preventDefault();
if (!subFile || !createdMovieId) return;
const fd = new FormData();
fd.append("language", subLang);
fd.append("label", subLabel);
fd.append("is_default", subs.length === 0);
fd.append("file", subFile);
try {
await api.post(`/movies/${createdMovieId}/subtitles`, fd, { headers: { "Content-Type": "multipart/form-data" } });
toast.success("Subtitle added");
setSubFile(null);
loadSubs(createdMovieId);
} catch (err) {
toast.error(err.response?.data?.detail || "Subtitle upload failed");
}
};
const deleteSub = async (sid) => {
await api.delete(`/subtitles/${sid}`);
loadSubs(createdMovieId);
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="admin-upload-page">
<div className="px-6 md:px-12 max-w-3xl mx-auto">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E]">Library</span>
<h1 className="font-display text-5xl font-black tracking-tighter text-white mt-3">
Upload Film
</h1>
<h1 className="font-display text-5xl font-black tracking-tighter text-white mt-3">Upload Film</h1>
<form onSubmit={submit} className="mt-12 space-y-6" data-testid="upload-form">
{/* TMDB search box */}
{tmdbConfigured && !createdMovieId && (
<div className="mt-10 border border-[#222] p-5" data-testid="tmdb-search-section">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.3em] text-[#D9381E] mb-3">
<Search size={12} strokeWidth={1.5} /> Auto-fill from TMDB
</div>
<div className="flex gap-2">
<input value={tmdbQuery} onChange={(e) => setTmdbQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), searchTmdb())}
placeholder="Search by title…"
className="flex-1 bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="tmdb-query-input" />
<button type="button" onClick={searchTmdb} disabled={searchingTmdb}
className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-5 py-3 text-xs uppercase tracking-[0.2em]"
data-testid="tmdb-search-button">{searchingTmdb ? "…" : "Search"}</button>
</div>
{tmdbResults.length > 0 && (
<div className="mt-4 max-h-72 overflow-y-auto border border-[#222]">
{tmdbResults.map((r) => (
<button type="button" key={r.tmdb_id} onClick={() => pickTmdb(r)}
className="w-full flex items-center gap-3 p-3 hover:bg-[#0F0F0F] text-left border-b border-[#222] last:border-b-0 transition-colors"
data-testid={`tmdb-result-${r.tmdb_id}`}>
{r.poster_url ? <img src={r.poster_url} alt="" className="w-10 h-14 object-cover" /> : <div className="w-10 h-14 bg-[#1A1A1A]" />}
<div className="flex-1 min-w-0">
<p className="text-white truncate">{r.title} {r.year && <span className="text-[#8A8A8A]">({r.year})</span>}</p>
<p className="text-xs text-[#8A8A8A] line-clamp-1">{r.overview}</p>
</div>
</button>
))}
</div>
)}
</div>
)}
{!tmdbConfigured && (
<p className="mt-8 text-xs text-[#8A8A8A]">
<span className="text-[#fcd34d]"></span> Configure TMDB in <a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">Settings</a> for auto-fill.
</p>
)}
<form onSubmit={submit} className="mt-10 space-y-6" data-testid="upload-form">
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Video file (MP4)</span>
<div className="mt-2 border border-dashed border-[#333] hover:border-[#D9381E] transition-colors p-8 text-center">
<UploadCloud className="mx-auto text-[#8A8A8A]" size={28} strokeWidth={1.5} />
<input
type="file" accept="video/*"
onChange={(e) => setFile(e.target.files?.[0] || null)}
<input type="file" accept="video/*" onChange={(e) => setFile(e.target.files?.[0] || null)}
className="mt-3 block w-full text-sm text-[#C8C8C8] file:mr-4 file:py-2 file:px-4 file:border-0 file:bg-[#D9381E] file:text-white file:cursor-pointer"
data-testid="upload-file-input"
/>
data-testid="upload-file-input" />
{file && <p className="mt-2 text-xs text-[#8A8A8A]">{file.name} · {(file.size / (1024*1024)).toFixed(1)} MB</p>}
</div>
</label>
@@ -77,17 +188,13 @@ export default function AdminUpload() {
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Description</span>
<textarea
value={form.description} onChange={(e) => upd("description", e.target.value)}
rows={4}
<textarea value={form.description} onChange={(e) => upd("description", e.target.value)} rows={4}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="upload-description"
/>
data-testid="upload-description" />
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={form.featured} onChange={(e) => upd("featured", e.target.checked)}
className="accent-[#D9381E]" data-testid="upload-featured" />
<input type="checkbox" checked={form.featured} onChange={(e) => upd("featured", e.target.checked)} className="accent-[#D9381E]" data-testid="upload-featured" />
<span className="text-sm text-[#C8C8C8]">Set as featured (hero banner)</span>
</label>
@@ -100,15 +207,55 @@ export default function AdminUpload() {
</div>
)}
<button
type="submit"
disabled={submitting}
<button type="submit" disabled={submitting || createdMovieId}
className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]"
data-testid="upload-submit-button"
>
{submitting ? "Uploading…" : "Upload"}
data-testid="upload-submit-button">
{submitting ? "Uploading…" : createdMovieId ? "Uploaded" : "Upload"}
</button>
</form>
{/* Subtitles - only after movie created */}
{createdMovieId && (
<div className="mt-12 border border-[#222] p-6" data-testid="subtitles-section">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.3em] text-[#D9381E] mb-4">
<Subtitles size={12} strokeWidth={1.5} /> Subtitles
</div>
<p className="text-sm text-[#8A8A8A] mb-4">Upload .srt or .vtt files. SRT will be auto-converted.</p>
<form onSubmit={uploadSub} className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input value={subLang} onChange={(e) => setSubLang(e.target.value)} placeholder="Lang code (en)"
className="bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 text-sm"
data-testid="sub-lang-input" />
<input value={subLabel} onChange={(e) => setSubLabel(e.target.value)} placeholder="Label (English)"
className="bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 text-sm"
data-testid="sub-label-input" />
<input type="file" accept=".srt,.vtt,text/vtt" onChange={(e) => setSubFile(e.target.files?.[0] || null)}
className="text-sm text-[#C8C8C8] file:mr-2 file:py-2 file:px-4 file:border-0 file:bg-[#222] file:text-white"
data-testid="sub-file-input" />
<button type="submit" disabled={!subFile} className="sm:col-span-3 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white py-2 text-xs uppercase tracking-[0.2em]"
data-testid="sub-upload-button">Upload Subtitle</button>
</form>
{subs.length > 0 && (
<div className="mt-5 border border-[#222]">
{subs.map((s) => (
<div key={s.id} className="flex items-center justify-between p-3 border-b border-[#222] last:border-b-0" data-testid={`sub-row-${s.id}`}>
<div>
<p className="text-white text-sm">{s.label} <span className="text-[#8A8A8A] text-xs">({s.language})</span></p>
{s.is_default && <p className="text-[10px] uppercase tracking-[0.2em] text-[#86efac]">Default</p>}
</div>
<button onClick={() => deleteSub(s.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`sub-delete-${s.id}`}>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
))}
</div>
)}
<button onClick={() => nav("/admin")} className="mt-6 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 transition-colors"
data-testid="upload-done-button">Done back to Admin</button>
</div>
)}
</div>
</div>
);
@@ -117,11 +264,8 @@ export default function AdminUpload() {
const Field = ({ label, value, onChange, type = "text", required, testid, full }) => (
<label className={`block ${full ? "md:col-span-2" : ""}`}>
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{label}</span>
<input
type={type} value={value} required={required}
onChange={(e) => onChange(e.target.value)}
<input type={type} value={value} required={required} onChange={(e) => onChange(e.target.value)}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid={testid}
/>
data-testid={testid} />
</label>
);
+64 -25
View File
@@ -1,32 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api, { getStreamUrl } from "../lib/api";
import api, { getStreamUrl, getSubtitleUrl, isHls } from "../lib/api";
import { ArrowLeft } from "lucide-react";
import Hls from "hls.js";
export default function Player() {
const { id } = useParams();
const nav = useNavigate();
const [movie, setMovie] = useState(null);
const [subs, setSubs] = useState([]);
const videoRef = useRef(null);
const hlsRef = useRef(null);
const lastSent = useRef(0);
useEffect(() => {
let cancelled = false;
(async () => {
const { data } = await api.get(`/movies/${id}`);
const [{ data: m }, { data: subList }] = await Promise.all([
api.get(`/movies/${id}`),
api.get(`/movies/${id}/subtitles`).catch(() => ({ data: [] })),
]);
if (cancelled) return;
setMovie(data);
// Restore progress
try {
const { data: p } = await api.get(`/progress/${id}`);
if (videoRef.current && p?.position_seconds) {
videoRef.current.currentTime = p.position_seconds;
}
} catch {}
setMovie(m);
setSubs(subList);
})();
return () => { cancelled = true; };
}, [id]);
// Wire up the source — supports HLS via hls.js or native (Safari)
useEffect(() => {
if (!movie || !videoRef.current) return;
const v = videoRef.current;
const src = getStreamUrl(movie);
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
if (isHls(src) && Hls.isSupported() && !v.canPlayType("application/vnd.apple.mpegurl")) {
const hls = new Hls();
hls.loadSource(src);
hls.attachMedia(v);
hlsRef.current = hls;
} else {
v.src = src;
}
// Restore progress
api.get(`/progress/${id}`).then(({ data }) => {
if (data?.position_seconds && v) {
v.currentTime = data.position_seconds;
}
}).catch(() => {});
return () => {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
};
}, [movie, id]);
const onTimeUpdate = () => {
const v = videoRef.current;
if (!v || !v.duration) return;
@@ -41,40 +73,47 @@ export default function Player() {
};
if (!movie) {
return (
<div className="min-h-screen bg-black flex items-center justify-center text-[#8A8A8A]" data-testid="player-loading">
Loading
</div>
);
return <div className="min-h-screen bg-black flex items-center justify-center text-[#8A8A8A]" data-testid="player-loading">Loading</div>;
}
return (
<div className="fixed inset-0 bg-black z-50 flex flex-col" data-testid="player-page">
<button
onClick={() => nav(-1)}
<button onClick={() => nav(-1)}
className="absolute top-6 left-6 z-10 flex items-center gap-2 text-white/80 hover:text-white bg-black/60 hover:bg-black px-4 py-2 transition-colors duration-300"
data-testid="player-back-button"
>
data-testid="player-back-button">
<ArrowLeft size={16} strokeWidth={1.5} />
<span className="text-xs uppercase tracking-[0.2em]">Back</span>
</button>
<div className="absolute top-6 right-6 z-10 text-right">
<h2 className="font-display text-xl text-white tracking-tight" data-testid="player-title">
{movie.title}
</h2>
<p className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{movie.year} · {movie.rating}</p>
<h2 className="font-display text-xl text-white tracking-tight" data-testid="player-title">{movie.title}</h2>
<p className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
{movie.year} · {movie.rating}
{movie.hls_status === "done" && <span className="text-[#86efac]"> · HLS</span>}
</p>
</div>
<video
ref={videoRef}
src={getStreamUrl(movie)}
controls
autoPlay
crossOrigin="anonymous"
className="w-full h-full object-contain bg-black"
onTimeUpdate={onTimeUpdate}
data-testid="player-video"
/>
>
{subs.map((s) => (
<track
key={s.id}
kind="subtitles"
src={getSubtitleUrl(s)}
srcLang={s.language}
label={s.label}
default={s.is_default}
data-testid={`player-track-${s.id}`}
/>
))}
</video>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "../lib/api";
import { useProfile } from "../lib/profile";
import { useAuth } from "../lib/auth";
import { Plus, Pencil, Check, X, Trash2 } from "lucide-react";
import { toast } from "sonner";
const COLORS = ["#D9381E", "#EAB308", "#22C55E", "#3B82F6", "#A855F7", "#EC4899"];
export default function ProfileSelect() {
const { user, logout } = useAuth();
const { profiles, refresh, switchTo } = useProfile();
const nav = useNavigate();
const [editing, setEditing] = useState(false);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" });
useEffect(() => { refresh(); }, [refresh]);
const choose = (p) => {
switchTo(p);
nav("/browse", { replace: true });
};
const submitNew = async (e) => {
e.preventDefault();
if (!draft.name.trim()) return;
try {
await api.post("/profiles", draft);
toast.success("Profile created");
setCreating(false);
setDraft({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" });
refresh();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not create");
}
};
const remove = async (id) => {
if (!window.confirm("Delete this profile? Watchlist & history will be removed.")) return;
try { await api.delete(`/profiles/${id}`); refresh(); } catch (err) {
toast.error(err.response?.data?.detail || "Could not delete");
}
};
return (
<div className="min-h-screen bg-[#050505] flex flex-col items-center justify-center px-6 py-16" data-testid="profile-select-page">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-6">Welcome back, {user?.name}</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white text-center">
Who's watching?
</h1>
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
{profiles.map((p) => (
<div key={p.id} className="flex flex-col items-center gap-3 group" data-testid={`profile-${p.id}`}>
<button
onClick={() => editing ? null : choose(p)}
className="w-28 h-28 md:w-36 md:h-36 flex items-center justify-center text-3xl font-bold text-white transition-all duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
style={{ backgroundColor: p.avatar_color }}
data-testid={`select-profile-${p.id}`}
>
{p.name?.[0]?.toUpperCase() || "?"}
</button>
<span className="font-display text-lg text-white">{p.name}</span>
<div className="flex gap-2 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
{p.is_kids && <span>Kids</span>}
{p.max_rating !== "NR" && <span>· Up to {p.max_rating}</span>}
</div>
{editing && (
<button onClick={() => remove(p.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors mt-1" data-testid={`delete-profile-${p.id}`}>
<Trash2 size={14} strokeWidth={1.5} />
</button>
)}
</div>
))}
{profiles.length < 5 && (
<button
onClick={() => setCreating(true)}
className="w-28 h-28 md:w-36 md:h-36 flex flex-col items-center justify-center border border-dashed border-[#333] hover:border-[#D9381E] text-[#8A8A8A] hover:text-white transition-colors duration-300"
data-testid="add-profile-button"
>
<Plus size={32} strokeWidth={1.5} />
<span className="text-xs uppercase tracking-[0.2em] mt-2">Add</span>
</button>
)}
</div>
<div className="mt-16 flex gap-4">
<button onClick={() => setEditing(!editing)}
className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors duration-300"
data-testid="manage-profiles-button">
{editing ? <Check size={14} strokeWidth={1.5} /> : <Pencil size={14} strokeWidth={1.5} />}
{editing ? "Done" : "Manage Profiles"}
</button>
<button onClick={() => { logout(); nav("/login"); }}
className="text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em] transition-colors duration-300"
data-testid="profile-logout-button">
Sign out
</button>
</div>
{creating && (
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="new-profile-modal">
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={() => setCreating(false)} />
<form onSubmit={submitNew} className="relative bg-[#0F0F0F] border border-[#222] p-8 w-full max-w-md mx-4 fade-up">
<button type="button" onClick={() => setCreating(false)} className="absolute top-4 right-4 text-[#8A8A8A] hover:text-white"><X size={18} /></button>
<h2 className="font-display text-3xl font-bold tracking-tight text-white">New profile</h2>
<label className="block mt-6">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
<input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })}
required className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="new-profile-name" />
</label>
<div className="mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Color</span>
<div className="mt-2 flex gap-2">
{COLORS.map((c) => (
<button type="button" key={c} onClick={() => setDraft({ ...draft, avatar_color: c })}
className={`w-10 h-10 ${draft.avatar_color === c ? "ring-2 ring-white" : ""}`}
style={{ backgroundColor: c }} aria-label={`Color ${c}`}
data-testid={`color-${c.replace('#', '')}`} />
))}
</div>
</div>
<label className="flex items-center gap-3 mt-5 cursor-pointer">
<input type="checkbox" checked={draft.is_kids}
onChange={(e) => setDraft({ ...draft, is_kids: e.target.checked, max_rating: e.target.checked ? "PG" : draft.max_rating })}
className="accent-[#D9381E]" data-testid="new-profile-kids" />
<span className="text-sm text-[#C8C8C8]">Kids profile (limits content to PG)</span>
</label>
<label className="block mt-5">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Max rating</span>
<select value={draft.max_rating} onChange={(e) => setDraft({ ...draft, max_rating: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] text-white px-4 py-3 focus:border-[#D9381E] focus:outline-none"
data-testid="new-profile-rating">
<option value="G">G</option>
<option value="PG">PG</option>
<option value="PG-13">PG-13</option>
<option value="R">R</option>
<option value="NR">No restriction</option>
</select>
</label>
<button type="submit" className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] text-white py-3 text-sm uppercase tracking-[0.2em]"
data-testid="new-profile-submit">Create profile</button>
</form>
</div>
)}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Download, RefreshCcw, Check } from "lucide-react";
export default function RadarrImport() {
const [movies, setMovies] = useState([]);
const [selected, setSelected] = useState(new Set());
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const load = async () => {
setLoading(true);
try {
const { data } = await api.get("/radarr/movies");
setMovies(data);
} catch (err) {
toast.error(err.response?.data?.detail || "Could not load Radarr movies");
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const toggle = (id) => {
const s = new Set(selected);
s.has(id) ? s.delete(id) : s.add(id);
setSelected(s);
};
const importSelected = async () => {
if (selected.size === 0) return;
setImporting(true);
try {
const { data } = await api.post("/radarr/import", { radarr_ids: Array.from(selected) });
toast.success(`Imported ${data.imported} movie(s)`);
setSelected(new Set());
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Import failed");
} finally { setImporting(false); }
};
const withFile = movies.filter((m) => m.has_file);
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="radarr-import-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Radarr</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Import Library</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
{movies.length === 0 && !loading ? "No movies found, or Radarr not configured." :
`${withFile.length} movie(s) with files available to import.`}
</p>
<div className="mt-8 flex gap-3">
<button onClick={load} disabled={loading}
className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors duration-300 disabled:opacity-50"
data-testid="radarr-refresh-button">
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} />
Refresh
</button>
<button onClick={importSelected} disabled={selected.size === 0 || importing}
className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors"
data-testid="radarr-import-button">
<Download size={14} strokeWidth={1.5} />
Import {selected.size > 0 ? `(${selected.size})` : ""}
</button>
</div>
<div className="mt-10 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{withFile.map((m) => (
<button key={m.radarr_id} onClick={() => toggle(m.radarr_id)}
className={`relative aspect-[2/3] overflow-hidden bg-[#0F0F0F] border-2 transition-colors ${selected.has(m.radarr_id) ? "border-[#D9381E]" : "border-transparent hover:border-white/20"}`}
data-testid={`radarr-card-${m.radarr_id}`}>
{m.poster_url
? <img src={m.poster_url} alt={m.title} className="w-full h-full object-cover" />
: <div className="w-full h-full flex items-center justify-center text-[#8A8A8A] text-xs px-3 text-center">{m.title}</div>}
<div className="absolute inset-x-0 bottom-0 bg-black/70 p-2">
<p className="text-xs text-white truncate">{m.title}</p>
<p className="text-[10px] text-[#8A8A8A]">{m.year}</p>
</div>
{selected.has(m.radarr_id) && (
<div className="absolute top-2 right-2 w-7 h-7 bg-[#D9381E] flex items-center justify-center">
<Check size={14} strokeWidth={2} color="white" />
</div>
)}
</button>
))}
</div>
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Eye, EyeOff } from "lucide-react";
export default function Settings() {
const [s, setS] = useState({ tmdb_api_key: "", radarr_url: "", radarr_api_key: "" });
const [info, setInfo] = useState({ tmdb_configured: false, radarr_configured: false });
const [showTmdb, setShowTmdb] = useState(false);
const [showRadarr, setShowRadarr] = useState(false);
const [saving, setSaving] = useState(false);
const load = async () => {
const { data } = await api.get("/settings");
setS({ tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "" });
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
};
useEffect(() => { load(); }, []);
const save = async (e) => {
e.preventDefault();
setSaving(true);
try {
const { data } = await api.put("/settings", s);
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
toast.success("Settings saved");
} catch {
toast.error("Could not save");
} finally { setSaving(false); }
};
const testRadarr = async () => {
try {
const { data } = await api.post("/radarr/test");
data.ok ? toast.success("Radarr connected") : toast.error("Radarr unreachable");
} catch (err) {
toast.error(err.response?.data?.detail || "Test failed");
}
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
<div className="px-6 md:px-12 max-w-3xl mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Settings</h1>
<p className="text-[#8A8A8A] mt-4 max-w-xl">Connect TMDB for metadata auto-fill and Radarr for library import.</p>
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white">TMDB</h2>
<span className={`text-[10px] uppercase tracking-[0.3em] ${info.tmdb_configured ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
{info.tmdb_configured ? "● Connected" : "○ Not configured"}
</span>
</div>
<p className="text-sm text-[#8A8A8A] mb-4">
Get a free API key at <a className="text-[#D9381E] hover:text-[#ED4B32]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org/settings/api</a>
</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">API key (v3)</span>
<div className="relative mt-2">
<input type={showTmdb ? "text" : "password"}
value={s.tmdb_api_key} onChange={(e) => setS({ ...s, tmdb_api_key: e.target.value })}
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12"
data-testid="tmdb-key-input" />
<button type="button" onClick={() => setShowTmdb(!showTmdb)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
{showTmdb ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
</div>
</label>
</section>
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white">Radarr</h2>
<span className={`text-[10px] uppercase tracking-[0.3em] ${info.radarr_configured ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
{info.radarr_configured ? "● Configured" : "○ Not configured"}
</span>
</div>
<p className="text-sm text-[#8A8A8A] mb-4">
Import your existing Radarr-managed library. Kino must be able to read Radarr's media paths on disk.
</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Base URL</span>
<input value={s.radarr_url} placeholder="http://192.168.1.10:7878"
onChange={(e) => setS({ ...s, radarr_url: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="radarr-url-input" />
</label>
<label className="block mt-4">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">API key</span>
<div className="relative mt-2">
<input type={showRadarr ? "text" : "password"}
value={s.radarr_api_key} onChange={(e) => setS({ ...s, radarr_api_key: e.target.value })}
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12"
data-testid="radarr-key-input" />
<button type="button" onClick={() => setShowRadarr(!showRadarr)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
{showRadarr ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
</div>
</label>
<button type="button" onClick={testRadarr}
className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 transition-colors"
data-testid="radarr-test-button">
Test Connection
</button>
</section>
<button type="submit" disabled={saving}
className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]"
data-testid="settings-save-button">
{saving ? "Saving…" : "Save Settings"}
</button>
</form>
</div>
</div>
);
}