mirror of
https://github.com/myronblair/kino
synced 2026-07-28 04:23:03 -05:00
auto-commit for df4b0748-985b-4592-8c48-1e56102f3613
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { Trash2, Star } from "lucide-react";
|
||||
|
||||
export default function Admin() {
|
||||
const [movies, setMovies] = useState([]);
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/movies");
|
||||
setMovies(data);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!window.confirm("Remove this movie permanently?")) return;
|
||||
await api.delete(`/movies/${id}`);
|
||||
toast.success("Removed");
|
||||
load();
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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">
|
||||
Review Requests
|
||||
</a>
|
||||
</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-2">Source</span>
|
||||
<span className="col-span-1 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">
|
||||
<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-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span>
|
||||
<div className="col-span-1 flex justify-end gap-2">
|
||||
<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>
|
||||
<button onClick={() => remove(m.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors" aria-label="Delete" data-testid={`delete-${m.id}`}>
|
||||
<Trash2 size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { UploadCloud } 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 [form, setForm] = useState({
|
||||
title: "", description: "", year: 2024, duration_minutes: 0, rating: "NR",
|
||||
genres: "", cast: "", director: "", poster_url: "", backdrop_url: "", featured: false,
|
||||
});
|
||||
|
||||
const upd = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
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));
|
||||
fd.append("file", file);
|
||||
try {
|
||||
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));
|
||||
},
|
||||
});
|
||||
toast.success("Movie uploaded");
|
||||
nav("/admin");
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Upload failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<form onSubmit={submit} className="mt-12 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)}
|
||||
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"
|
||||
/>
|
||||
{file && <p className="mt-2 text-xs text-[#8A8A8A]">{file.name} · {(file.size / (1024*1024)).toFixed(1)} MB</p>}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-5">
|
||||
<Field label="Title" required value={form.title} onChange={(v) => upd("title", v)} testid="upload-title" />
|
||||
<Field label="Director" value={form.director} onChange={(v) => upd("director", v)} testid="upload-director" />
|
||||
<Field label="Year" type="number" value={form.year} onChange={(v) => upd("year", Number(v) || 0)} testid="upload-year" />
|
||||
<Field label="Duration (min)" type="number" value={form.duration_minutes} onChange={(v) => upd("duration_minutes", Number(v) || 0)} testid="upload-duration" />
|
||||
<Field label="Rating" value={form.rating} onChange={(v) => upd("rating", v)} testid="upload-rating" />
|
||||
<Field label="Genres (comma sep)" value={form.genres} onChange={(v) => upd("genres", v)} testid="upload-genres" />
|
||||
<Field label="Cast (comma sep)" value={form.cast} onChange={(v) => upd("cast", v)} testid="upload-cast" />
|
||||
<Field label="Poster URL" value={form.poster_url} onChange={(v) => upd("poster_url", v)} testid="upload-poster" />
|
||||
<Field label="Backdrop URL" value={form.backdrop_url} onChange={(v) => upd("backdrop_url", v)} testid="upload-backdrop" full />
|
||||
</div>
|
||||
|
||||
<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}
|
||||
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"
|
||||
/>
|
||||
</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" />
|
||||
<span className="text-sm text-[#C8C8C8]">Set as featured (hero banner)</span>
|
||||
</label>
|
||||
|
||||
{submitting && progress > 0 && (
|
||||
<div className="border border-[#222] p-4">
|
||||
<div className="flex items-center justify-between text-xs text-[#8A8A8A] mb-2">
|
||||
<span>Uploading…</span><span>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-1 bg-[#222]"><div className="h-full bg-[#D9381E] transition-all" style={{ width: `${progress}%` }} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
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"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)}
|
||||
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}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import Hero from "../components/Hero";
|
||||
import Row from "../components/Row";
|
||||
import MovieDetailModal from "../components/MovieDetailModal";
|
||||
|
||||
export default function Browse() {
|
||||
const nav = useNavigate();
|
||||
const [featured, setFeatured] = useState(null);
|
||||
const [movies, setMovies] = useState([]);
|
||||
const [genres, setGenres] = useState([]);
|
||||
const [continueWatching, setContinueWatching] = useState([]);
|
||||
const [watchlist, setWatchlist] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [progressMap, setProgressMap] = useState({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [{ data: f }, { data: ms }, { data: gs }, { data: cw }, { data: wl }] = await Promise.all([
|
||||
api.get("/movies/featured").catch(() => ({ data: null })),
|
||||
api.get("/movies"),
|
||||
api.get("/movies/genres"),
|
||||
api.get("/progress/continue").catch(() => ({ data: [] })),
|
||||
api.get("/watchlist").catch(() => ({ data: [] })),
|
||||
]);
|
||||
setFeatured(f);
|
||||
setMovies(ms);
|
||||
setGenres(gs);
|
||||
setContinueWatching(cw);
|
||||
setWatchlist(wl);
|
||||
const map = {};
|
||||
for (const m of cw) map[m.id] = m.progress;
|
||||
setProgressMap(map);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handlePlay = (m) => nav(`/watch/${m.id}`);
|
||||
const handleMore = (m) => setSelected(m);
|
||||
const handleAddList = async (m) => {
|
||||
try { await api.post(`/watchlist/${m.id}`); load(); } catch {}
|
||||
};
|
||||
|
||||
const byGenre = (g) => movies.filter((m) => m.genres?.includes(g));
|
||||
const trending = movies.slice(0, 10);
|
||||
const newReleases = [...movies].sort((a, b) => b.year - a.year).slice(0, 12);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505]" data-testid="browse-page">
|
||||
{featured && (
|
||||
<Hero
|
||||
movie={featured}
|
||||
onPlay={handlePlay}
|
||||
onMore={handleMore}
|
||||
onAddList={handleAddList}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="-mt-20 relative z-10 pb-24">
|
||||
{continueWatching.length > 0 && (
|
||||
<Row title="Continue Watching" movies={continueWatching} onCardClick={handleMore} progressMap={progressMap} />
|
||||
)}
|
||||
<Row title="Trending Now" movies={trending} onCardClick={handleMore} progressMap={progressMap} />
|
||||
<Row title="New Releases" movies={newReleases} onCardClick={handleMore} progressMap={progressMap} />
|
||||
{watchlist.length > 0 && (
|
||||
<Row title="My List" movies={watchlist} onCardClick={handleMore} progressMap={progressMap} />
|
||||
)}
|
||||
{genres.slice(0, 6).map((g) => {
|
||||
const list = byGenre(g);
|
||||
if (!list.length) return null;
|
||||
return <Row key={g} title={g} movies={list} onCardClick={handleMore} progressMap={progressMap} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<MovieDetailModal
|
||||
movie={selected}
|
||||
open={!!selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onWatchlistChange={load}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
toast.success("Welcome back");
|
||||
nav(loc.state?.from || "/browse", { replace: true });
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Login failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full grid md:grid-cols-2 bg-[#050505]" data-testid="login-page">
|
||||
<div className="hidden md:block relative overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1698159929270-c88bad6346fe?crop=entropy&cs=srgb&fm=jpg&ixid=M3w4NjA1ODR8MHwxfHNlYXJjaHwzfHxhYnN0cmFjdCUyMGRhcmslMjB0ZXh0dXJlJTIwZmlsbSUyMGdyYWlufGVufDB8fHx8MTc3NzQ3MzE2M3ww&ixlib=rb-4.1.0&q=85"
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#050505]/60 to-[#050505]" />
|
||||
<div className="absolute bottom-12 left-12 right-12 z-10">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Personal Cinema</span>
|
||||
<h1 className="font-display text-6xl font-black tracking-tighter text-white mt-4 leading-none">
|
||||
Your library,<br/>your way.
|
||||
</h1>
|
||||
<p className="text-[#8A8A8A] mt-6 max-w-md leading-relaxed">
|
||||
Stream the films you own, the way you remember them — without the noise.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center px-6 md:px-12 py-12">
|
||||
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="login-form">
|
||||
<Link to="/" className="block mb-12" data-testid="login-logo">
|
||||
<span className="font-display text-3xl font-black tracking-tighter text-white">Kino</span>
|
||||
<span className="text-[#D9381E] text-3xl">.</span>
|
||||
</Link>
|
||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Sign in</h2>
|
||||
<p className="text-sm text-[#8A8A8A] mt-2 mb-8">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/register" className="text-[#D9381E] hover:text-[#ED4B32] transition-colors" data-testid="login-to-register">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(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 transition-colors"
|
||||
data-testid="login-email-input"
|
||||
/>
|
||||
</label>
|
||||
<label className="block mt-5">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(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 transition-colors"
|
||||
data-testid="login-password-input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white py-3 text-sm uppercase tracking-[0.2em] font-medium transition-colors duration-300"
|
||||
data-testid="login-submit-button"
|
||||
>
|
||||
{submitting ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
|
||||
<div className="mt-8 p-4 border border-[#222] text-xs text-[#8A8A8A]" data-testid="login-demo-credentials">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E] block mb-2">Demo Admin</span>
|
||||
admin@kino.local / kino-admin-2026
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import MovieCard from "../components/MovieCard";
|
||||
import MovieDetailModal from "../components/MovieDetailModal";
|
||||
|
||||
export default function MyList() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/watchlist");
|
||||
setItems(data);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="my-list-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Your collection</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
|
||||
My List
|
||||
</h1>
|
||||
<p className="text-[#8A8A8A] mt-4 max-w-xl">
|
||||
Films you've curated for later. Click any to play or remove.
|
||||
</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-16 border border-[#222] p-12 text-center" data-testid="my-list-empty">
|
||||
<p className="text-[#8A8A8A]">Your list is empty. Add films from Browse.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-12 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
{items.map((m) => (
|
||||
<MovieCard key={m.id} movie={m} onClick={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MovieDetailModal movie={selected} open={!!selected} onClose={() => setSelected(null)} onWatchlistChange={load} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import api, { getStreamUrl } from "../lib/api";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function Player() {
|
||||
const { id } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [movie, setMovie] = useState(null);
|
||||
const videoRef = useRef(null);
|
||||
const lastSent = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const { data } = await api.get(`/movies/${id}`);
|
||||
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 {}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
const v = videoRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
const now = Date.now();
|
||||
if (now - lastSent.current < 5000) return;
|
||||
lastSent.current = now;
|
||||
api.post("/progress", {
|
||||
movie_id: id,
|
||||
position_seconds: v.currentTime,
|
||||
duration_seconds: v.duration,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
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="fixed inset-0 bg-black z-50 flex flex-col" data-testid="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 duration-300"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={getStreamUrl(movie)}
|
||||
controls
|
||||
autoPlay
|
||||
className="w-full h-full object-contain bg-black"
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
data-testid="player-video"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function Register() {
|
||||
const { register } = useAuth();
|
||||
const nav = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (password.length < 6) {
|
||||
toast.error("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await register(email, password, name);
|
||||
toast.success("Welcome to Kino");
|
||||
nav("/browse", { replace: true });
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not register");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full grid md:grid-cols-2 bg-[#050505]" data-testid="register-page">
|
||||
<div className="hidden md:block relative overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1705147651064-36aedc005020?crop=entropy&cs=srgb&fm=jpg&ixid=M3w3NTY2ODh8MHwxfHNlYXJjaHwxfHxjaW5lbWF0aWMlMjBsYW5kc2NhcGUlMjBkYXJrfGVufDB8fHx8MTc3NzQ3MzE2M3ww&ixlib=rb-4.1.0&q=85"
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#050505]/60 to-[#050505]" />
|
||||
<div className="absolute bottom-12 left-12 right-12 z-10">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Join Kino</span>
|
||||
<h1 className="font-display text-6xl font-black tracking-tighter text-white mt-4 leading-none">
|
||||
Curate.<br/>Stream.<br/>Repeat.
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center px-6 md:px-12 py-12">
|
||||
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="register-form">
|
||||
<Link to="/" className="block mb-12">
|
||||
<span className="font-display text-3xl font-black tracking-tighter text-white">Kino</span>
|
||||
<span className="text-[#D9381E] text-3xl">.</span>
|
||||
</Link>
|
||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Create account</h2>
|
||||
<p className="text-sm text-[#8A8A8A] mt-2 mb-8">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" className="text-[#D9381E] hover:text-[#ED4B32] transition-colors" data-testid="register-to-login">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
|
||||
<input
|
||||
required value={name}
|
||||
onChange={(e) => setName(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 transition-colors"
|
||||
data-testid="register-name-input"
|
||||
/>
|
||||
</label>
|
||||
<label className="block mt-5">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Email</span>
|
||||
<input
|
||||
type="email" required value={email}
|
||||
onChange={(e) => setEmail(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 transition-colors"
|
||||
data-testid="register-email-input"
|
||||
/>
|
||||
</label>
|
||||
<label className="block mt-5">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Password</span>
|
||||
<input
|
||||
type="password" required value={password}
|
||||
onChange={(e) => setPassword(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 transition-colors"
|
||||
data-testid="register-password-input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white py-3 text-sm uppercase tracking-[0.2em] font-medium transition-colors duration-300"
|
||||
data-testid="register-submit-button"
|
||||
>
|
||||
{submitting ? "Creating…" : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function Requests() {
|
||||
const { user } = useAuth();
|
||||
const [mine, setMine] = useState([]);
|
||||
const [all, setAll] = useState([]);
|
||||
const [title, setTitle] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/requests/mine");
|
||||
setMine(data);
|
||||
if (user?.is_admin) {
|
||||
const { data: a } = await api.get("/requests");
|
||||
setAll(a);
|
||||
}
|
||||
};
|
||||
useEffect(() => { load(); }, [user?.is_admin]);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
try {
|
||||
await api.post("/requests", { title, year: year ? Number(year) : null, notes });
|
||||
toast.success("Request submitted");
|
||||
setTitle(""); setYear(""); setNotes("");
|
||||
load();
|
||||
} catch {
|
||||
toast.error("Could not submit request");
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (id, status) => {
|
||||
try {
|
||||
await api.patch(`/requests/${id}`, { status });
|
||||
toast.success(`Marked ${status}`);
|
||||
load();
|
||||
} catch {
|
||||
toast.error("Could not update");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="requests-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Library wishlist</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
|
||||
Requests
|
||||
</h1>
|
||||
<p className="text-[#8A8A8A] mt-4 max-w-2xl leading-relaxed">
|
||||
Want a film added to the library? Submit a request and the admin will review it. Owned content only.
|
||||
</p>
|
||||
|
||||
<form onSubmit={submit} className="mt-12 grid md:grid-cols-3 gap-4 max-w-3xl" data-testid="request-form">
|
||||
<div className="md:col-span-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Title</span>
|
||||
<input
|
||||
value={title} onChange={(e) => setTitle(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="request-title-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Year</span>
|
||||
<input
|
||||
type="number" value={year} onChange={(e) => setYear(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="request-year-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-3">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Notes (optional)</span>
|
||||
<textarea
|
||||
value={notes} onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
|
||||
data-testid="request-notes-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#D9381E] hover:bg-[#ED4B32] text-white px-6 py-3 text-sm uppercase tracking-[0.2em]"
|
||||
data-testid="request-submit-button"
|
||||
>
|
||||
Submit Request
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-16">
|
||||
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">My Requests</h2>
|
||||
{mine.length === 0 ? (
|
||||
<p className="text-[#8A8A8A] text-sm" data-testid="my-requests-empty">No requests yet.</p>
|
||||
) : (
|
||||
<div className="border border-[#222]">
|
||||
{mine.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`my-request-${r.id}`}>
|
||||
<div>
|
||||
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p>
|
||||
{r.notes && <p className="text-xs text-[#8A8A8A] mt-1">{r.notes}</p>}
|
||||
</div>
|
||||
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
|
||||
r.status === "fulfilled" ? "text-[#86efac]" :
|
||||
r.status === "rejected" ? "text-[#fca5a5]" : "text-[#fcd34d]"
|
||||
}`}>
|
||||
{r.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user?.is_admin && (
|
||||
<div className="mt-16">
|
||||
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">All Requests (Admin)</h2>
|
||||
{all.length === 0 ? (
|
||||
<p className="text-[#8A8A8A] text-sm">No requests in queue.</p>
|
||||
) : (
|
||||
<div className="border border-[#222]">
|
||||
{all.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
|
||||
<div>
|
||||
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p>
|
||||
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}</p>
|
||||
{r.notes && <p className="text-xs text-[#666] mt-1">{r.notes}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => updateStatus(r.id, "fulfilled")}
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-3 py-2"
|
||||
data-testid={`fulfill-${r.id}`}>Fulfilled</button>
|
||||
<button onClick={() => updateStatus(r.id, "rejected")}
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-3 py-2"
|
||||
data-testid={`reject-${r.id}`}>Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import MovieCard from "../components/MovieCard";
|
||||
import MovieDetailModal from "../components/MovieDetailModal";
|
||||
import { Search as SearchIcon } from "lucide-react";
|
||||
|
||||
export default function Search() {
|
||||
const [q, setQ] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!q) { setResults([]); return; }
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/movies?q=${encodeURIComponent(q)}`);
|
||||
if (!cancelled) setResults(data);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="search-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Find a film</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
|
||||
Search
|
||||
</h1>
|
||||
|
||||
<div className="relative mt-10 max-w-2xl">
|
||||
<SearchIcon className="absolute left-4 top-1/2 -translate-y-1/2 text-[#8A8A8A]" size={18} strokeWidth={1.5} />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
autoFocus
|
||||
placeholder="Title, director, or cast…"
|
||||
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white pl-12 pr-4 py-4 transition-colors"
|
||||
data-testid="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
{loading && <p className="text-[#8A8A8A] text-sm">Searching…</p>}
|
||||
{!loading && q && results.length === 0 && (
|
||||
<p className="text-[#8A8A8A] text-sm" data-testid="search-no-results">
|
||||
No films match "{q}". Submit a request to add it.
|
||||
</p>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
{results.map((m) => (
|
||||
<MovieCard key={m.id} movie={m} onClick={setSelected} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MovieDetailModal movie={selected} open={!!selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user