auto-commit for df4b0748-985b-4592-8c48-1e56102f3613

This commit is contained in:
emergent-agent-e1
2026-04-29 14:49:07 +00:00
parent 7673090279
commit 356aa13063
30 changed files with 2809 additions and 244 deletions
+83
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
+83
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+151
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}