mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 13:33:49 -05:00
auto-commit for b082c1c8-f6e3-4cfd-82ee-0396c70d1a00
This commit is contained in:
@@ -18,6 +18,10 @@ import Settings from "./pages/Settings";
|
||||
import RadarrImport from "./pages/RadarrImport";
|
||||
import ProfileSelect from "./pages/ProfileSelect";
|
||||
import TranscodeQueue from "./pages/TranscodeQueue";
|
||||
import Shows from "./pages/Shows";
|
||||
import ShowDetail from "./pages/ShowDetail";
|
||||
import EpisodePlayer from "./pages/EpisodePlayer";
|
||||
import SonarrImport from "./pages/SonarrImport";
|
||||
|
||||
const ProfileGate = ({ children }) => {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
@@ -75,6 +79,9 @@ function App() {
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
|
||||
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
|
||||
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
|
||||
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
|
||||
<Route path="/watch/episode/:id" element={<ProfileGate><EpisodePlayer /></ProfileGate>} />
|
||||
<Route path="/my-list" element={<ProfileGate><MyList /></ProfileGate>} />
|
||||
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
|
||||
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
|
||||
@@ -83,6 +90,7 @@ function App() {
|
||||
<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="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
|
||||
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -35,6 +35,7 @@ export const Navbar = () => {
|
||||
{user && (
|
||||
<nav className="hidden md:flex items-center gap-7">
|
||||
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Browse</NavLink>
|
||||
<NavLink to="/shows" className={linkClass} data-testid="nav-shows">Shows</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>}
|
||||
|
||||
@@ -220,6 +220,11 @@ export default function AdminUpload() {
|
||||
<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>
|
||||
|
||||
{form.tmdb_id && (
|
||||
<OpenSubsSearchBox movieId={createdMovieId} tmdbId={form.tmdb_id} onAdded={() => loadSubs(createdMovieId)} />
|
||||
)}
|
||||
|
||||
<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">
|
||||
@@ -269,3 +274,59 @@ const Field = ({ label, value, onChange, type = "text", required, testid, full }
|
||||
data-testid={testid} />
|
||||
</label>
|
||||
);
|
||||
|
||||
const OpenSubsSearchBox = ({ movieId, tmdbId, onAdded }) => {
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const search = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/opensubs/search?tmdb_id=${tmdbId}&languages=en`);
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "OpenSubs not configured or search failed");
|
||||
setResults([]);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const pick = async (r) => {
|
||||
try {
|
||||
await api.post("/opensubs/download", {
|
||||
file_id: r.file_id, movie_id: movieId,
|
||||
language: r.language, label: r.label || r.language.toUpperCase(),
|
||||
});
|
||||
toast.success("Subtitle attached");
|
||||
onAdded?.();
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Download failed"); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-5 border border-dashed border-[#222] p-4" data-testid="opensubs-box">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">Search OpenSubtitles for this movie</p>
|
||||
<button type="button" onClick={search} disabled={loading}
|
||||
className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors disabled:opacity-50"
|
||||
data-testid="opensubs-search-button">
|
||||
{loading ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{results && results.length === 0 && <p className="text-xs text-[#8A8A8A] mt-3">No results found.</p>}
|
||||
{results && results.length > 0 && (
|
||||
<div className="mt-3 max-h-64 overflow-y-auto border border-[#222]">
|
||||
{results.map((r) => (
|
||||
<button type="button" key={r.file_id} onClick={() => pick(r)}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-[#0F0F0F] text-left border-b border-[#222] last:border-b-0"
|
||||
data-testid={`opensubs-result-${r.file_id}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm truncate">{r.label || r.release || `${r.language.toUpperCase()} subtitle`}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{r.language} · {r.downloads} downloads</p>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-[#D9381E]">Add →</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import api, { API, isHls } from "../lib/api";
|
||||
import { ArrowLeft, SkipForward } from "lucide-react";
|
||||
import Hls from "hls.js";
|
||||
|
||||
export default function EpisodePlayer() {
|
||||
const { id } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [ep, setEp] = useState(null);
|
||||
const [show, setShow] = useState(null);
|
||||
const [nextEp, setNextEp] = useState(null);
|
||||
const [showNextCta, setShowNextCta] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
const hlsRef = useRef(null);
|
||||
const lastSent = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const { data: e } = await api.get(`/episodes/${id}`);
|
||||
if (cancelled) return;
|
||||
setEp(e);
|
||||
const [{ data: s }, { data: siblings }] = await Promise.all([
|
||||
api.get(`/shows/${e.show_id}`),
|
||||
api.get(`/shows/${e.show_id}/episodes`),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setShow(s);
|
||||
const idx = siblings.findIndex((x) => x.id === e.id);
|
||||
setNextEp(idx >= 0 && idx < siblings.length - 1 ? siblings[idx + 1] : null);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ep || !videoRef.current) return;
|
||||
const v = videoRef.current;
|
||||
const token = localStorage.getItem("kino_token") || "";
|
||||
const src = ep.storage_type === "external" && ep.video_url
|
||||
? ep.video_url
|
||||
: `${API}/stream/episode/${ep.id}?auth=${encodeURIComponent(token)}`;
|
||||
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; }
|
||||
api.get(`/progress/episode/${id}`).then(({ data }) => {
|
||||
if (data?.position_seconds && v) v.currentTime = data.position_seconds;
|
||||
}).catch(() => {});
|
||||
return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } };
|
||||
}, [ep, id]);
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
const v = videoRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
const now = Date.now();
|
||||
// Show "Next episode" CTA in last 20s if available
|
||||
if (nextEp && v.duration - v.currentTime <= 20 && !showNextCta) setShowNextCta(true);
|
||||
if (now - lastSent.current < 5000) return;
|
||||
lastSent.current = now;
|
||||
api.post("/progress/episode", {
|
||||
episode_id: id, position_seconds: v.currentTime, duration_seconds: v.duration,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const onEnded = () => { if (nextEp) nav(`/watch/episode/${nextEp.id}`); };
|
||||
|
||||
if (!ep || !show) return <div className="min-h-screen bg-black flex items-center justify-center text-[#8A8A8A]">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-50 flex flex-col" data-testid="episode-player-page">
|
||||
<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" data-testid="ep-player-back">
|
||||
<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-lg text-white">{show.title}</h2>
|
||||
<p className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">S{ep.season_number}·E{ep.episode_number} · {ep.title}</p>
|
||||
</div>
|
||||
<video ref={videoRef} controls autoPlay className="w-full h-full object-contain bg-black"
|
||||
onTimeUpdate={onTimeUpdate} onEnded={onEnded} data-testid="ep-player-video" />
|
||||
|
||||
{showNextCta && nextEp && (
|
||||
<button onClick={() => nav(`/watch/episode/${nextEp.id}`)}
|
||||
className="absolute bottom-24 right-8 z-20 flex items-center gap-3 bg-white/10 hover:bg-[#D9381E] text-white px-6 py-4 backdrop-blur-md border border-white/20 transition-colors duration-300"
|
||||
data-testid="next-episode-cta">
|
||||
<SkipForward size={18} strokeWidth={1.5} />
|
||||
<div className="text-left">
|
||||
<div className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Up next</div>
|
||||
<div className="text-sm">S{nextEp.season_number}·E{nextEp.episode_number} · {nextEp.title}</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+173
-87
@@ -1,117 +1,190 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, createContext, useContext } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, EyeOff, ListTodo } from "lucide-react";
|
||||
import { Eye, EyeOff, ListTodo, Link2, Unlink } from "lucide-react";
|
||||
|
||||
const FieldCtx = createContext(null);
|
||||
|
||||
const Badge = ({ on }) => (
|
||||
<span className={`text-[10px] uppercase tracking-[0.3em] ${on ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
|
||||
{on ? "● Configured" : "○ Not configured"}
|
||||
</span>
|
||||
);
|
||||
|
||||
const Field = ({ label, k, type = "text", placeholder, mask }) => {
|
||||
const { s, setS, show, setShow } = useContext(FieldCtx);
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{label}</span>
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
type={mask && !show[k] ? "password" : type}
|
||||
value={s[k]}
|
||||
placeholder={placeholder || ""}
|
||||
onChange={(e) => setS({ ...s, [k]: 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={`settings-${k}`}
|
||||
/>
|
||||
{mask && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow({ ...show, [k]: !show[k] })}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white"
|
||||
>
|
||||
{show[k] ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Settings() {
|
||||
const [s, setS] = useState({ tmdb_api_key: "", radarr_url: "", radarr_api_key: "", auto_transcode: "off", queue_paused: false });
|
||||
const [info, setInfo] = useState({ tmdb_configured: false, radarr_configured: false });
|
||||
const [showTmdb, setShowTmdb] = useState(false);
|
||||
const [showRadarr, setShowRadarr] = useState(false);
|
||||
const [s, setS] = useState({
|
||||
tmdb_api_key: "", radarr_url: "", radarr_api_key: "",
|
||||
sonarr_url: "", sonarr_api_key: "",
|
||||
opensubs_api_key: "", trakt_client_id: "", trakt_client_secret: "",
|
||||
auto_transcode: "off", queue_paused: false,
|
||||
});
|
||||
const [info, setInfo] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" });
|
||||
const [traktModal, setTraktModal] = useState(null);
|
||||
const [show, setShow] = useState({});
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/settings");
|
||||
const [{ data }, { data: ts }] = await Promise.all([api.get("/settings"), api.get("/trakt/status").catch(() => ({ data: { connected: false } }))]);
|
||||
setS({
|
||||
tmdb_api_key: data.tmdb_api_key || "",
|
||||
radarr_url: data.radarr_url || "",
|
||||
radarr_api_key: data.radarr_api_key || "",
|
||||
auto_transcode: data.auto_transcode || "off",
|
||||
queue_paused: !!data.queue_paused,
|
||||
tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "",
|
||||
sonarr_url: data.sonarr_url || "", sonarr_api_key: data.sonarr_api_key || "",
|
||||
opensubs_api_key: data.opensubs_api_key || "",
|
||||
trakt_client_id: data.trakt_client_id || "", trakt_client_secret: data.trakt_client_secret || "",
|
||||
auto_transcode: data.auto_transcode || "off", queue_paused: !!data.queue_paused,
|
||||
});
|
||||
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
|
||||
setInfo(data); setTraktStatus(ts);
|
||||
};
|
||||
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); }
|
||||
e.preventDefault(); setSaving(true);
|
||||
try { await api.put("/settings", s); toast.success("Settings saved"); load(); }
|
||||
catch { toast.error("Could not save"); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const testRadarr = async () => {
|
||||
const testConn = async (kind) => {
|
||||
try { const { data } = await api.post(`/${kind}/test`); data.ok ? toast.success(`${kind} connected`) : toast.error(`${kind} unreachable`); }
|
||||
catch (err) { toast.error(err.response?.data?.detail || "Test failed"); }
|
||||
};
|
||||
|
||||
const connectTrakt = 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");
|
||||
}
|
||||
const { data } = await api.post("/trakt/device-code");
|
||||
setTraktModal(data);
|
||||
// Poll
|
||||
const startedAt = Date.now();
|
||||
const interval = setInterval(async () => {
|
||||
if (Date.now() - startedAt > data.expires_in * 1000) { clearInterval(interval); setTraktModal(null); toast.error("Trakt code expired"); return; }
|
||||
try {
|
||||
const { data: r } = await api.post("/trakt/poll", { device_code: data.device_code });
|
||||
if (r.ok) { clearInterval(interval); setTraktModal(null); toast.success(`Trakt connected as ${r.username}`); load(); }
|
||||
} catch { clearInterval(interval); setTraktModal(null); toast.error("Trakt polling failed"); }
|
||||
}, (data.interval || 5) * 1000);
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Could not start Trakt flow"); }
|
||||
};
|
||||
|
||||
const disconnectTrakt = async () => {
|
||||
await api.delete("/trakt/disconnect"); toast.success("Trakt disconnected"); load();
|
||||
};
|
||||
|
||||
const enrichAll = async () => {
|
||||
if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return;
|
||||
try { const { data } = await api.post("/tmdb/enrich-all"); toast.success(`Enriched ${data.enriched}, skipped ${data.skipped}, failed ${data.failed}`); }
|
||||
catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
|
||||
<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">
|
||||
{/* TMDB */}
|
||||
<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>
|
||||
<h2 className="font-display text-2xl font-bold text-white">TMDB</h2><Badge on={info.tmdb_configured} />
|
||||
</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>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">Free key at <a className="text-[#D9381E]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org</a></p>
|
||||
<Field label="API key (v3)" k="tmdb_api_key" mask />
|
||||
{info.tmdb_configured && (
|
||||
<button type="button" onClick={enrichAll} className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors" data-testid="enrich-all-button">
|
||||
Bulk enrich all movies →
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Radarr */}
|
||||
<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>
|
||||
<h2 className="font-display text-2xl font-bold text-white">Radarr</h2><Badge on={info.radarr_configured} />
|
||||
</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>
|
||||
<Field label="Base URL" k="radarr_url" placeholder="http://192.168.1.10:7878" />
|
||||
<div className="mt-4"><Field label="API key" k="radarr_api_key" mask /></div>
|
||||
<button type="button" onClick={() => testConn("radarr")} 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">Test</button>
|
||||
</section>
|
||||
|
||||
{/* Sonarr */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white">Sonarr</h2><Badge on={info.sonarr_configured} />
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">Import your TV shows library.</p>
|
||||
<Field label="Base URL" k="sonarr_url" placeholder="http://192.168.1.10:8989" />
|
||||
<div className="mt-4"><Field label="API key" k="sonarr_api_key" mask /></div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button type="button" onClick={() => testConn("sonarr")} className="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="sonarr-test">Test</button>
|
||||
{info.sonarr_configured && <a href="/admin/sonarr" className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors">Import shows →</a>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* OpenSubtitles */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white">OpenSubtitles</h2><Badge on={info.opensubs_configured} />
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">One-click subtitle search per movie/episode. Free key at <a className="text-[#D9381E]" href="https://www.opensubtitles.com/consumer/apps" target="_blank" rel="noreferrer">opensubtitles.com</a></p>
|
||||
<Field label="API key" k="opensubs_api_key" mask />
|
||||
</section>
|
||||
|
||||
{/* Trakt */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white">Trakt.tv</h2><Badge on={info.trakt_configured} />
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">Mirror watch history to Trakt. Register an app at <a className="text-[#D9381E]" href="https://trakt.tv/oauth/applications" target="_blank" rel="noreferrer">trakt.tv/oauth/applications</a> (use urn:ietf:wg:oauth:2.0:oob as redirect).</p>
|
||||
<Field label="Client ID" k="trakt_client_id" mask />
|
||||
<div className="mt-4"><Field label="Client secret" k="trakt_client_secret" mask /></div>
|
||||
{info.trakt_configured && (
|
||||
<div className="mt-4">
|
||||
{traktStatus.connected ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#86efac]">● Connected as {traktStatus.username}</span>
|
||||
<button type="button" onClick={disconnectTrakt} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2" data-testid="trakt-disconnect">
|
||||
<Unlink size={12} /> Disconnect
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={connectTrakt} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] bg-[#D9381E] hover:bg-[#ED4B32] text-white px-4 py-2" data-testid="trakt-connect">
|
||||
<Link2 size={12} /> Connect Trakt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Transcode Queue */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white flex items-center gap-2">
|
||||
@@ -119,28 +192,41 @@ export default function Settings() {
|
||||
</h2>
|
||||
<a href="/admin/queue" className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E] hover:text-[#ED4B32]" data-testid="settings-queue-link">View queue →</a>
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">
|
||||
Auto-transcode every newly uploaded or imported movie. Runs at low CPU priority, one at a time.
|
||||
</p>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Auto-transcode mode</span>
|
||||
<select value={s.auto_transcode} onChange={(e) => setS({ ...s, auto_transcode: 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="auto-transcode-select">
|
||||
<option value="off">Off — manual only</option>
|
||||
<option value="quick">Quick (stream-copy, instant, single-rate)</option>
|
||||
<option value="abr">ABR (re-encoded multi-bitrate, slow but adaptive)</option>
|
||||
<option value="quick">Quick (stream-copy, instant)</option>
|
||||
<option value="abr">ABR (multi-bitrate, slow but adaptive)</option>
|
||||
</select>
|
||||
</label>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
{/* Trakt device-code modal */}
|
||||
{traktModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="trakt-modal">
|
||||
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={() => setTraktModal(null)} />
|
||||
<div className="relative bg-[#0F0F0F] border border-[#222] p-8 max-w-md w-full mx-4">
|
||||
<h2 className="font-display text-3xl font-bold text-white">Connect Trakt</h2>
|
||||
<p className="text-sm text-[#8A8A8A] mt-2">Visit the URL below, sign in to Trakt, and enter the code:</p>
|
||||
<a href={traktModal.verification_url} target="_blank" rel="noreferrer" className="block mt-6 text-[#D9381E] hover:text-[#ED4B32] break-all">{traktModal.verification_url}</a>
|
||||
<div className="mt-6 bg-black border border-[#D9381E] px-6 py-4 text-center">
|
||||
<div className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Enter this code</div>
|
||||
<div className="font-display text-4xl font-black tracking-widest text-white mt-2" data-testid="trakt-user-code">{traktModal.user_code}</div>
|
||||
</div>
|
||||
<p className="text-xs text-[#8A8A8A] mt-4">Waiting for approval… this window will close automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FieldCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { Play, ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function ShowDetail() {
|
||||
const { id } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [show, setShow] = useState(null);
|
||||
const [episodes, setEpisodes] = useState([]);
|
||||
const [season, setSeason] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [s, e] = await Promise.all([api.get(`/shows/${id}`), api.get(`/shows/${id}/episodes`)]);
|
||||
setShow(s.data); setEpisodes(e.data);
|
||||
const firstS = e.data[0]?.season_number || 1;
|
||||
setSeason(firstS);
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
const seasons = useMemo(() => [...new Set(episodes.map((e) => e.season_number))].sort((a, b) => a - b), [episodes]);
|
||||
const inSeason = useMemo(() => episodes.filter((e) => e.season_number === season), [episodes, season]);
|
||||
|
||||
if (!show) return <div className="min-h-screen bg-[#050505] flex items-center justify-center text-[#8A8A8A]">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505]" data-testid={`show-detail-${show.id}`}>
|
||||
<div className="relative h-[60vh] min-h-[400px] overflow-hidden">
|
||||
<img src={show.backdrop_url || show.poster_url} alt="" className="absolute inset-0 w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 hero-fade" />
|
||||
<button onClick={() => nav(-1)} className="absolute top-24 left-6 md:left-12 flex items-center gap-2 text-white/80 hover:text-white bg-black/40 hover:bg-black/60 px-4 py-2 backdrop-blur transition-colors" data-testid="show-back">
|
||||
<ArrowLeft size={16} strokeWidth={1.5} /> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
|
||||
</button>
|
||||
<div className="absolute bottom-12 left-6 md:left-12 max-w-2xl">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Series</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-2" data-testid="show-title">{show.title}</h1>
|
||||
<div className="flex items-center gap-3 mt-3 text-xs uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||
<span>{show.year}</span><span>·</span><span>{show.rating}</span><span>·</span>
|
||||
<span>{show.season_count} seasons · {show.episode_count} episodes</span>
|
||||
</div>
|
||||
<p className="text-[#C8C8C8] mt-5 max-w-xl leading-relaxed">{show.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto py-12">
|
||||
<div className="flex items-center gap-3 flex-wrap mb-6">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Seasons</span>
|
||||
{seasons.map((n) => (
|
||||
<button key={n} onClick={() => setSeason(n)}
|
||||
className={`text-sm px-4 py-2 border transition-colors ${season === n ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`}
|
||||
data-testid={`season-${n}`}>
|
||||
Season {n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{inSeason.map((ep) => (
|
||||
<button key={ep.id} onClick={() => nav(`/watch/episode/${ep.id}`)}
|
||||
className="w-full flex items-center gap-4 p-4 border border-[#222] hover:border-[#D9381E] hover:bg-[#0F0F0F] transition-colors text-left group"
|
||||
data-testid={`episode-${ep.id}`}>
|
||||
<div className="w-12 h-12 flex items-center justify-center bg-[#0F0F0F] group-hover:bg-[#D9381E] transition-colors shrink-0">
|
||||
<Play size={16} strokeWidth={1.5} fill="currentColor" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">E{ep.episode_number}</span>
|
||||
<h3 className="font-display text-lg text-white truncate">{ep.title || `Episode ${ep.episode_number}`}</h3>
|
||||
{ep.duration_minutes > 0 && <span className="text-xs text-[#8A8A8A] ml-auto">{ep.duration_minutes}m</span>}
|
||||
</div>
|
||||
{ep.description && <p className="text-sm text-[#8A8A8A] mt-1 line-clamp-2">{ep.description}</p>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { Tv } from "lucide-react";
|
||||
|
||||
export default function Shows() {
|
||||
const [shows, setShows] = useState([]);
|
||||
const [featured, setFeatured] = useState(null);
|
||||
const [continueEps, setContinueEps] = useState([]);
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [s, f, c] = await Promise.all([
|
||||
api.get("/shows"),
|
||||
api.get("/shows/featured").catch(() => ({ data: null })),
|
||||
api.get("/progress/episodes/continue").catch(() => ({ data: [] })),
|
||||
]);
|
||||
setShows(s.data); setFeatured(f.data); setContinueEps(c.data);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (shows.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="shows-empty-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto text-center">
|
||||
<Tv size={48} strokeWidth={1.5} className="mx-auto text-[#8A8A8A]" />
|
||||
<h1 className="mt-6 font-display text-4xl font-bold tracking-tight text-white">No TV shows yet</h1>
|
||||
<p className="text-[#8A8A8A] mt-3 max-w-md mx-auto">Configure Sonarr in Settings and import your library.</p>
|
||||
<a href="/admin/sonarr" className="inline-block mt-6 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-6 py-3 text-sm uppercase tracking-[0.2em]" data-testid="go-to-sonarr-import">
|
||||
Sonarr Import
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="shows-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Series</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">TV Shows</h1>
|
||||
|
||||
{continueEps.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">Continue Watching</h2>
|
||||
<div className="flex gap-4 overflow-x-auto no-scrollbar pb-4">
|
||||
{continueEps.map(({ episode, show, progress }) => {
|
||||
const pct = progress.duration_seconds ? Math.min(100, (progress.position_seconds / progress.duration_seconds) * 100) : 0;
|
||||
return (
|
||||
<button key={episode.id} onClick={() => nav(`/watch/episode/${episode.id}`)}
|
||||
className="relative shrink-0 w-[260px] group focus:outline-none" data-testid={`continue-ep-${episode.id}`}>
|
||||
<div className="aspect-video overflow-hidden bg-[#0F0F0F] transition-transform duration-300 group-hover:scale-105">
|
||||
<img src={show.backdrop_url || show.poster_url} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-white/10">
|
||||
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-white text-sm truncate">{show.title}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">S{episode.season_number}·E{episode.episode_number} · {episode.title}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-16 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
{shows.map((s) => (
|
||||
<button key={s.id} onClick={() => nav(`/show/${s.id}`)}
|
||||
className="group shrink-0 aspect-[2/3] overflow-hidden bg-[#0F0F0F] transition-transform duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
data-testid={`show-card-${s.id}`}>
|
||||
<img src={s.poster_url || s.backdrop_url} alt={s.title} loading="lazy" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<p className="text-white text-sm truncate">{s.title}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{s.season_count} seasons · {s.episode_count} eps</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { Download, RefreshCcw, Check } from "lucide-react";
|
||||
|
||||
export default function SonarrImport() {
|
||||
const [series, setSeries] = 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("/sonarr/series"); setSeries(data); }
|
||||
catch (err) { toast.error(err.response?.data?.detail || "Could not load Sonarr series"); }
|
||||
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("/sonarr/import", { sonarr_ids: Array.from(selected) });
|
||||
toast.success(`Imported ${data.shows_imported} show(s), ${data.episodes_imported} episode(s)`);
|
||||
setSelected(new Set()); load();
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Import failed"); }
|
||||
finally { setImporting(false); }
|
||||
};
|
||||
|
||||
const withEpisodes = series.filter((s) => s.episode_file_count > 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="sonarr-import-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Sonarr</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Import TV Series</h1>
|
||||
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
|
||||
{series.length === 0 && !loading ? "No series found — configure Sonarr in Settings first." : `${withEpisodes.length} series with episode files available.`}
|
||||
</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 disabled:opacity-50" data-testid="sonarr-refresh">
|
||||
<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]" data-testid="sonarr-import-btn">
|
||||
<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">
|
||||
{withEpisodes.map((m) => (
|
||||
<button key={m.sonarr_id} onClick={() => toggle(m.sonarr_id)}
|
||||
className={`relative aspect-[2/3] overflow-hidden bg-[#0F0F0F] border-2 transition-colors ${selected.has(m.sonarr_id) ? "border-[#D9381E]" : "border-transparent hover:border-white/20"}`}
|
||||
data-testid={`sonarr-card-${m.sonarr_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} · {m.episode_file_count} eps</p>
|
||||
</div>
|
||||
{selected.has(m.sonarr_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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user