mirror of
https://github.com/myronblair/kino-app
synced 2026-07-27 21:18:43 -05:00
auto-commit for 296e06eb-544f-4710-845e-c5a611b15f0e
This commit is contained in:
@@ -22,6 +22,7 @@ import Shows from "./pages/Shows";
|
||||
import ShowDetail from "./pages/ShowDetail";
|
||||
import EpisodePlayer from "./pages/EpisodePlayer";
|
||||
import SonarrImport from "./pages/SonarrImport";
|
||||
import AdminShowEpisodes from "./pages/AdminShowEpisodes";
|
||||
|
||||
const ProfileGate = ({ children }) => {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
@@ -91,6 +92,7 @@ function App() {
|
||||
<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/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
|
||||
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -7,11 +7,15 @@ import { useNavigate, Link } from "react-router-dom";
|
||||
export default function Admin() {
|
||||
const nav = useNavigate();
|
||||
const [movies, setMovies] = useState([]);
|
||||
const [shows, setShows] = useState([]);
|
||||
const [transcoding, setTranscoding] = useState({});
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/movies");
|
||||
setMovies(data);
|
||||
const [{ data: m }, { data: s }] = await Promise.all([
|
||||
api.get("/movies"),
|
||||
api.get("/shows").catch(() => ({ data: [] })),
|
||||
]);
|
||||
setMovies(m); setShows(s);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
@@ -134,6 +138,35 @@ export default function Admin() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shows.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">Series</h2>
|
||||
<div className="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-6">Title</span>
|
||||
<span className="col-span-2">Year</span>
|
||||
<span className="col-span-2">Episodes</span>
|
||||
<span className="col-span-2 text-right">Actions</span>
|
||||
</div>
|
||||
{shows.map((s) => (
|
||||
<div key={s.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-show-row-${s.id}`}>
|
||||
<div className="col-span-6 flex items-center gap-3 min-w-0">
|
||||
<img src={s.poster_url} alt="" className="w-10 h-14 object-cover" />
|
||||
<span className="text-white truncate">{s.title}</span>
|
||||
</div>
|
||||
<span className="col-span-2 text-[#8A8A8A] text-sm">{s.year}</span>
|
||||
<span className="col-span-2 text-[#8A8A8A] text-sm">{s.season_count}s · {s.episode_count}e</span>
|
||||
<div className="col-span-2 flex justify-end">
|
||||
<a href={`/admin/shows/${s.id}/episodes`} className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-3 py-1 transition-colors" data-testid={`manage-episodes-${s.id}`}>
|
||||
Manage Episodes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, EyeOff, Eye, Trash2, Zap, CheckSquare, Square } from "lucide-react";
|
||||
|
||||
export default function AdminShowEpisodes() {
|
||||
const { id } = useParams();
|
||||
const [show, setShow] = useState(null);
|
||||
const [episodes, setEpisodes] = useState([]);
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [season, setSeason] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [{ data: s }, { data: e }] = await Promise.all([
|
||||
api.get(`/shows/${id}`),
|
||||
api.get(`/shows/${id}/episodes?include_hidden=true`),
|
||||
]);
|
||||
setShow(s); setEpisodes(e);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, [id]);
|
||||
|
||||
const seasons = useMemo(() => [...new Set(episodes.map((e) => e.season_number))].sort((a, b) => a - b), [episodes]);
|
||||
const visible = useMemo(() => season === "all" ? episodes : episodes.filter((e) => e.season_number === Number(season)), [episodes, season]);
|
||||
|
||||
const toggle = (eid) => {
|
||||
const s = new Set(selected); s.has(eid) ? s.delete(eid) : s.add(eid); setSelected(s);
|
||||
};
|
||||
const toggleAll = () => {
|
||||
if (visible.every((e) => selected.has(e.id))) {
|
||||
const s = new Set(selected); visible.forEach((e) => s.delete(e.id)); setSelected(s);
|
||||
} else {
|
||||
const s = new Set(selected); visible.forEach((e) => s.add(e.id)); setSelected(s);
|
||||
}
|
||||
};
|
||||
const selectSeason = (n) => {
|
||||
const s = new Set(selected);
|
||||
episodes.filter((e) => e.season_number === n).forEach((e) => s.add(e.id));
|
||||
setSelected(s);
|
||||
};
|
||||
|
||||
const bulk = async (action) => {
|
||||
if (selected.size === 0) return;
|
||||
const ids = Array.from(selected);
|
||||
try {
|
||||
let msg = "";
|
||||
if (action === "transcode-abr") {
|
||||
const { data } = await api.post("/episodes/bulk-transcode", { episode_ids: ids, quality: "abr" });
|
||||
msg = `${data.queued} queued for ABR transcode`;
|
||||
} else if (action === "transcode-quick") {
|
||||
const { data } = await api.post("/episodes/bulk-transcode", { episode_ids: ids, quality: "quick" });
|
||||
msg = `${data.queued} queued for Quick transcode`;
|
||||
} else if (action === "hide") {
|
||||
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: true });
|
||||
msg = `${data.modified} hidden`;
|
||||
} else if (action === "unhide") {
|
||||
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: false });
|
||||
msg = `${data.modified} unhidden`;
|
||||
} else if (action === "delete") {
|
||||
if (!window.confirm(`Delete ${ids.length} episode(s) permanently? Files on disk are kept, but Kino entries and HLS output are removed.`)) return;
|
||||
const { data } = await api.post("/episodes/bulk-delete", { episode_ids: ids });
|
||||
msg = `${data.deleted} deleted`;
|
||||
}
|
||||
toast.success(msg);
|
||||
setSelected(new Set()); load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Bulk action failed");
|
||||
}
|
||||
};
|
||||
|
||||
const statusChip = (ep) => {
|
||||
const s = ep.hls_status;
|
||||
const map = { pending: "text-[#fcd34d]", running: "text-[#fcd34d]", done: "text-[#86efac]", failed: "text-[#fca5a5]" };
|
||||
if (!s) return null;
|
||||
return <span className={`text-[9px] uppercase tracking-[0.2em] ${map[s] || "text-[#8A8A8A]"}`}>HLS {s}</span>;
|
||||
};
|
||||
|
||||
if (!show) return <div className="min-h-screen bg-[#050505] flex items-center justify-center text-[#8A8A8A]">Loading…</div>;
|
||||
|
||||
const allSelected = visible.length > 0 && visible.every((e) => selected.has(e.id));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-32" data-testid={`show-episodes-admin-${show.id}`}>
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<Link to="/admin" className="inline-flex items-center gap-2 text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em] mb-6" data-testid="back-to-admin">
|
||||
<ArrowLeft size={14} strokeWidth={1.5} /> Back to Admin
|
||||
</Link>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Season Packs</span>
|
||||
<h1 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white mt-2" data-testid="admin-show-title">{show.title}</h1>
|
||||
<p className="text-[#8A8A8A] mt-3">{episodes.length} episodes across {seasons.length} season(s). Select any to bulk transcode, hide, or delete.</p>
|
||||
|
||||
{/* Season filter chips */}
|
||||
<div className="mt-8 flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A] mr-2">View</span>
|
||||
<button onClick={() => setSeason("all")} className={`text-xs px-4 py-2 border transition-colors ${season === "all" ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`} data-testid="season-all">All</button>
|
||||
{seasons.map((n) => (
|
||||
<div key={n} className="flex items-center gap-1">
|
||||
<button onClick={() => setSeason(n)} className={`text-xs 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={`filter-season-${n}`}>Season {n}</button>
|
||||
<button onClick={() => selectSeason(n)} className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-[#D9381E] transition-colors px-1" title={`Select all S${n} episodes`} data-testid={`select-season-${n}`}>+all</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table header */}
|
||||
<div className="mt-8 border border-[#222]">
|
||||
<div className="grid grid-cols-12 items-center px-4 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
|
||||
<button onClick={toggleAll} className="col-span-1 flex items-center gap-2 text-[#8A8A8A] hover:text-white" data-testid="toggle-all-visible">
|
||||
{allSelected ? <CheckSquare size={16} strokeWidth={1.5} /> : <Square size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
<span className="col-span-1">S·E</span>
|
||||
<span className="col-span-5">Title</span>
|
||||
<span className="col-span-2">Source</span>
|
||||
<span className="col-span-2">HLS</span>
|
||||
<span className="col-span-1 text-right">State</span>
|
||||
</div>
|
||||
{visible.length === 0 && <div className="p-8 text-center text-[#8A8A8A] text-sm">No episodes.</div>}
|
||||
{visible.map((ep) => (
|
||||
<div key={ep.id} className={`grid grid-cols-12 items-center px-4 py-3 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors ${ep.hidden ? "opacity-50" : ""}`} data-testid={`admin-ep-row-${ep.id}`}>
|
||||
<button onClick={() => toggle(ep.id)} className="col-span-1 flex items-center text-[#8A8A8A] hover:text-white" data-testid={`select-ep-${ep.id}`}>
|
||||
{selected.has(ep.id) ? <CheckSquare size={16} strokeWidth={1.5} className="text-[#D9381E]" /> : <Square size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
<span className="col-span-1 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">S{ep.season_number}·E{ep.episode_number}</span>
|
||||
<div className="col-span-5 min-w-0"><p className="text-white truncate">{ep.title || `Episode ${ep.episode_number}`}</p></div>
|
||||
<span className="col-span-2 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{ep.storage_type}</span>
|
||||
<span className="col-span-2">{statusChip(ep)}</span>
|
||||
<span className="col-span-1 text-right text-[10px] uppercase tracking-[0.2em]">{ep.hidden ? <span className="text-[#fca5a5]">hidden</span> : <span className="text-[#86efac]">live</span>}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky bulk action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 bg-[#0F0F0F] border-t border-[#D9381E] px-6 md:px-12 py-4 flex flex-wrap items-center gap-3" data-testid="bulk-action-bar">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">{selected.size} selected</span>
|
||||
<div className="flex-1 flex flex-wrap gap-2 justify-end">
|
||||
<button onClick={() => bulk("transcode-quick")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2" data-testid="bulk-transcode-quick">
|
||||
<Zap size={14} strokeWidth={1.5} /> Quick HLS
|
||||
</button>
|
||||
<button onClick={() => bulk("transcode-abr")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2" data-testid="bulk-transcode-abr">
|
||||
<Zap size={14} strokeWidth={1.5} /> ABR HLS
|
||||
</button>
|
||||
<button onClick={() => bulk("hide")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2" data-testid="bulk-hide">
|
||||
<EyeOff size={14} strokeWidth={1.5} /> Hide
|
||||
</button>
|
||||
<button onClick={() => bulk("unhide")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2" data-testid="bulk-unhide">
|
||||
<Eye size={14} strokeWidth={1.5} /> Unhide
|
||||
</button>
|
||||
<button onClick={() => bulk("delete")} 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="bulk-delete">
|
||||
<Trash2 size={14} strokeWidth={1.5} /> Delete
|
||||
</button>
|
||||
<button onClick={() => setSelected(new Set())} className="text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white px-4 py-2" data-testid="clear-selection">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user