mirror of
https://github.com/myronblair/kino-app
synced 2026-06-30 17:50:16 -05:00
auto-commit for 14921357-f5e2-4aba-b4c1-a07a52c800cc
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user