mirror of
https://github.com/myronblair/kino
synced 2026-06-30 17:50:29 -05:00
auto-commit for df4b0748-985b-4592-8c48-1e56102f3613
This commit is contained in:
+2
-34
@@ -1,34 +1,2 @@
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #0f0f10;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* Reset previous template styles. Most styling now lives in index.css and Tailwind. */
|
||||
.App { background-color: #050505; }
|
||||
|
||||
+51
-40
@@ -1,51 +1,62 @@
|
||||
import { useEffect } from "react";
|
||||
import "@/App.css";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
|
||||
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL;
|
||||
const API = `${BACKEND_URL}/api`;
|
||||
|
||||
const Home = () => {
|
||||
const helloWorldApi = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API}/`);
|
||||
console.log(response.data.message);
|
||||
} catch (e) {
|
||||
console.error(e, `errored out requesting / api`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
helloWorldApi();
|
||||
}, []);
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
import { AuthProvider, useAuth } from "./lib/auth";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import Navbar from "./components/Navbar";
|
||||
import GrainOverlay from "./components/GrainOverlay";
|
||||
import Login from "./pages/Login";
|
||||
import Register from "./pages/Register";
|
||||
import Browse from "./pages/Browse";
|
||||
import MyList from "./pages/MyList";
|
||||
import Search from "./pages/Search";
|
||||
import Player from "./pages/Player";
|
||||
import Requests from "./pages/Requests";
|
||||
import Admin from "./pages/Admin";
|
||||
import AdminUpload from "./pages/AdminUpload";
|
||||
|
||||
const Shell = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const loc = useLocation();
|
||||
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register";
|
||||
return (
|
||||
<div>
|
||||
<header className="App-header">
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://emergent.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img src="https://avatars.githubusercontent.com/in/1201222?s=120&u=2686cf91179bbafbc7a71bfbc43004cf9ae1acea&v=4" />
|
||||
</a>
|
||||
<p className="mt-5">Building something incredible ~!</p>
|
||||
</header>
|
||||
</div>
|
||||
<>
|
||||
{!hideChrome && user && <Navbar />}
|
||||
{children}
|
||||
<GrainOverlay />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RootRedirect = () => {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading…</div>;
|
||||
return <Navigate to={user ? "/browse" : "/login"} replace />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<div className="App min-h-screen bg-[#050505]">
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />}>
|
||||
<Route index element={<Home />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<AuthProvider>
|
||||
<Shell>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/browse" element={<ProtectedRoute><Browse /></ProtectedRoute>} />
|
||||
<Route path="/my-list" element={<ProtectedRoute><MyList /></ProtectedRoute>} />
|
||||
<Route path="/search" element={<ProtectedRoute><Search /></ProtectedRoute>} />
|
||||
<Route path="/watch/:id" element={<ProtectedRoute><Player /></ProtectedRoute>} />
|
||||
<Route path="/requests" element={<ProtectedRoute><Requests /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute adminOnly><Admin /></ProtectedRoute>} />
|
||||
<Route path="/admin/upload" element={<ProtectedRoute adminOnly><AdminUpload /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Shell>
|
||||
<Toaster position="bottom-right" theme="dark" toastOptions={{
|
||||
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
|
||||
}} />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const GrainOverlay = () => <div className="grain-overlay" aria-hidden="true" />;
|
||||
export default GrainOverlay;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Play, Plus, Info } from "lucide-react";
|
||||
|
||||
export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
|
||||
if (!movie) return null;
|
||||
const bg = movie.backdrop_url || movie.poster_url;
|
||||
return (
|
||||
<section
|
||||
className="relative w-full h-[92vh] min-h-[640px] overflow-hidden"
|
||||
data-testid="hero-banner"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-center bg-cover"
|
||||
style={{ backgroundImage: `url(${bg})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 hero-fade" />
|
||||
<div className="absolute inset-0 hero-side-fade" />
|
||||
|
||||
<div className="relative h-full max-w-[1500px] mx-auto px-6 md:px-12 flex flex-col justify-end pb-24 md:pb-32 fade-up">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-4" data-testid="hero-eyebrow">
|
||||
Featured
|
||||
</span>
|
||||
<h1 className="font-display text-5xl md:text-7xl font-black tracking-tighter leading-none text-white max-w-3xl"
|
||||
data-testid="hero-title">
|
||||
{movie.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-4 mt-5 text-xs uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||
<span>{movie.year}</span>
|
||||
<span className="w-1 h-1 bg-[#444] rounded-full" />
|
||||
<span>{movie.rating}</span>
|
||||
{movie.duration_minutes ? (
|
||||
<>
|
||||
<span className="w-1 h-1 bg-[#444] rounded-full" />
|
||||
<span>{movie.duration_minutes} min</span>
|
||||
</>
|
||||
) : null}
|
||||
{movie.genres?.length ? (
|
||||
<>
|
||||
<span className="w-1 h-1 bg-[#444] rounded-full" />
|
||||
<span>{movie.genres.slice(0, 3).join(" · ")}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-6 text-base md:text-lg text-[#C8C8C8] max-w-2xl leading-relaxed line-clamp-3">
|
||||
{movie.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 mt-8">
|
||||
<button
|
||||
onClick={() => onPlay?.(movie)}
|
||||
className="flex items-center gap-3 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-7 py-3 transition-colors duration-300"
|
||||
data-testid="hero-play-button"
|
||||
>
|
||||
<Play size={18} fill="white" strokeWidth={0} />
|
||||
<span className="text-sm uppercase tracking-[0.2em] font-medium">Play</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMore?.(movie)}
|
||||
className="flex items-center gap-3 bg-white/10 hover:bg-white/20 text-white px-7 py-3 transition-colors duration-300 backdrop-blur-md border border-white/10"
|
||||
data-testid="hero-more-info-button"
|
||||
>
|
||||
<Info size={18} strokeWidth={1.5} />
|
||||
<span className="text-sm uppercase tracking-[0.2em] font-medium">More Info</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAddList?.(movie)}
|
||||
className="hidden sm:flex items-center gap-3 text-white/70 hover:text-white px-3 py-3 transition-colors duration-300"
|
||||
data-testid="hero-add-list-button"
|
||||
>
|
||||
<Plus size={18} strokeWidth={1.5} />
|
||||
<span className="text-xs uppercase tracking-[0.2em]">My List</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
export const MovieCard = ({ movie, onClick, progress }) => {
|
||||
const pct = progress?.duration_seconds
|
||||
? Math.min(100, (progress.position_seconds / progress.duration_seconds) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
onClick={() => onClick?.(movie)}
|
||||
className="group relative shrink-0 w-[180px] md:w-[220px] aspect-[2/3] overflow-hidden bg-[#0F0F0F] transition-all duration-300 hover:scale-105 hover:z-10 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
data-testid={`movie-card-${movie.id}`}
|
||||
>
|
||||
<img
|
||||
src={movie.poster_url || movie.backdrop_url}
|
||||
alt={movie.title}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.currentTarget.style.opacity = 0.3; }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/30 to-transparent opacity-90 md:opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-2 md:translate-y-0 md:opacity-0 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-300">
|
||||
<h3 className="font-display text-lg font-bold tracking-tight text-white leading-none line-clamp-2">
|
||||
{movie.title}
|
||||
</h3>
|
||||
<div className="mt-2 flex items-center gap-2 text-[10px] uppercase tracking-[0.2em] text-[#C8C8C8]">
|
||||
<span>{movie.year}</span>
|
||||
{movie.rating ? <span>· {movie.rating}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 w-9 h-9 bg-[#D9381E] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<Play size={14} fill="white" strokeWidth={0} />
|
||||
</div>
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-white/10">
|
||||
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieCard;
|
||||
@@ -0,0 +1,136 @@
|
||||
import { X, Play, Plus, Check } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) => {
|
||||
const nav = useNavigate();
|
||||
const [inList, setInList] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!movie || !open) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [movie, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!movie) return;
|
||||
let cancelled = false;
|
||||
api.get("/watchlist").then(({ data }) => {
|
||||
if (!cancelled) setInList(data.some((m) => m.id === movie.id));
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [movie]);
|
||||
|
||||
if (!open || !movie) return null;
|
||||
|
||||
const toggle = async () => {
|
||||
try {
|
||||
if (inList) {
|
||||
await api.delete(`/watchlist/${movie.id}`);
|
||||
setInList(false);
|
||||
toast.success("Removed from My List");
|
||||
} else {
|
||||
await api.post(`/watchlist/${movie.id}`);
|
||||
setInList(true);
|
||||
toast.success("Added to My List");
|
||||
}
|
||||
onWatchlistChange?.();
|
||||
} catch {
|
||||
toast.error("Could not update list");
|
||||
}
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
onClose?.();
|
||||
nav(`/watch/${movie.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-start md:items-center justify-center overflow-y-auto"
|
||||
data-testid="movie-detail-modal"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={onClose} />
|
||||
<div className="relative w-full max-w-4xl mx-4 my-8 bg-[#0A0A0A] border border-[#222] overflow-hidden fade-up">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 w-10 h-10 flex items-center justify-center bg-black/60 hover:bg-black text-white transition-colors duration-300"
|
||||
data-testid="modal-close-button"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
<div className="relative h-[280px] md:h-[440px]">
|
||||
<img
|
||||
src={movie.backdrop_url || movie.poster_url}
|
||||
alt={movie.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0A] via-[#0A0A0A]/40 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-10">
|
||||
<h2 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white" data-testid="modal-movie-title">
|
||||
{movie.title}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-4 text-xs uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||
<span>{movie.year}</span>
|
||||
<span>·</span>
|
||||
<span>{movie.rating}</span>
|
||||
{movie.duration_minutes ? (<><span>·</span><span>{movie.duration_minutes} min</span></>) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-6">
|
||||
<button
|
||||
onClick={play}
|
||||
className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-6 py-3 transition-colors duration-300"
|
||||
data-testid="modal-play-button"
|
||||
>
|
||||
<Play size={16} fill="white" strokeWidth={0} />
|
||||
<span className="text-sm uppercase tracking-[0.2em] font-medium">Play</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-3 transition-colors duration-300 backdrop-blur border border-white/10"
|
||||
data-testid="modal-watchlist-button"
|
||||
>
|
||||
{inList ? <Check size={16} strokeWidth={1.5} /> : <Plus size={16} strokeWidth={1.5} />}
|
||||
<span className="text-xs uppercase tracking-[0.2em]">{inList ? "In My List" : "My List"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 md:p-10 grid md:grid-cols-3 gap-8">
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-base leading-relaxed text-[#C8C8C8]" data-testid="modal-movie-description">
|
||||
{movie.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4 text-sm">
|
||||
{movie.cast?.length ? (
|
||||
<div>
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Cast</span>
|
||||
<p className="mt-1 text-[#C8C8C8]">{movie.cast.join(", ")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{movie.director ? (
|
||||
<div>
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Director</span>
|
||||
<p className="mt-1 text-[#C8C8C8]">{movie.director}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{movie.genres?.length ? (
|
||||
<div>
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Genres</span>
|
||||
<p className="mt-1 text-[#C8C8C8]">{movie.genres.join(", ")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieDetailModal;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { Search, LogOut, Upload, ListVideo } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export const Navbar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const nav = useNavigate();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20);
|
||||
window.addEventListener("scroll", onScroll);
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const linkClass = ({ isActive }) =>
|
||||
`text-sm tracking-wide transition-colors duration-300 ${
|
||||
isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
scrolled ? "glass border-b" : ""
|
||||
}`}
|
||||
data-testid="main-navbar"
|
||||
>
|
||||
<div className="px-6 md:px-12 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-10">
|
||||
<Link to="/browse" className="flex items-center gap-2" data-testid="nav-logo">
|
||||
<span className="font-display text-2xl font-black tracking-tighter text-white">
|
||||
Kino
|
||||
</span>
|
||||
<span className="text-[#D9381E] text-2xl leading-none">.</span>
|
||||
</Link>
|
||||
{user && (
|
||||
<nav className="hidden md:flex items-center gap-7">
|
||||
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Browse</NavLink>
|
||||
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">My List</NavLink>
|
||||
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Requests</NavLink>
|
||||
{user.is_admin && (
|
||||
<NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => nav("/search")}
|
||||
className="text-[#8A8A8A] hover:text-white transition-colors duration-300"
|
||||
data-testid="nav-search-button"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
{user.is_admin && (
|
||||
<button
|
||||
onClick={() => nav("/admin/upload")}
|
||||
className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300"
|
||||
data-testid="nav-upload-button"
|
||||
>
|
||||
<Upload size={14} strokeWidth={1.5} /> Upload
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
|
||||
<div className="hidden sm:flex flex-col items-end leading-tight">
|
||||
<span className="text-xs text-white" data-testid="nav-user-name">{user.name}</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||
{user.is_admin ? "Admin" : "Member"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-[#D9381E] flex items-center justify-center text-white text-xs font-medium">
|
||||
{user.name?.[0]?.toUpperCase() || "U"}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { logout(); nav("/login"); }}
|
||||
className="text-[#8A8A8A] hover:text-white transition-colors duration-300"
|
||||
data-testid="nav-logout-button"
|
||||
aria-label="Logout"
|
||||
>
|
||||
<LogOut size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm tracking-wide bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 transition-colors duration-300"
|
||||
data-testid="nav-sign-in"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth";
|
||||
|
||||
export const ProtectedRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-[#8A8A8A]" data-testid="auth-loading">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (adminOnly && !user.is_admin) return <Navigate to="/browse" replace />;
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useRef } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import MovieCard from "./MovieCard";
|
||||
|
||||
export const Row = ({ title, movies, onCardClick, progressMap }) => {
|
||||
const ref = useRef(null);
|
||||
|
||||
if (!movies?.length) return null;
|
||||
|
||||
const scroll = (dir) => {
|
||||
if (!ref.current) return;
|
||||
ref.current.scrollBy({ left: dir * (ref.current.clientWidth * 0.8), behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="px-6 md:px-12 mt-12 md:mt-16 group/row" data-testid={`row-${title}`}>
|
||||
<div className="flex items-end justify-between mb-5">
|
||||
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="hidden md:flex items-center gap-2 opacity-0 group-hover/row:opacity-100 transition-opacity duration-300">
|
||||
<button
|
||||
onClick={() => scroll(-1)}
|
||||
className="w-9 h-9 flex items-center justify-center bg-white/5 hover:bg-white/10 text-white transition-colors duration-300"
|
||||
aria-label="Scroll left"
|
||||
data-testid={`row-scroll-left-${title}`}
|
||||
>
|
||||
<ChevronLeft size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scroll(1)}
|
||||
className="w-9 h-9 flex items-center justify-center bg-white/5 hover:bg-white/10 text-white transition-colors duration-300"
|
||||
aria-label="Scroll right"
|
||||
data-testid={`row-scroll-right-${title}`}
|
||||
>
|
||||
<ChevronRight size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={ref} className="flex items-stretch gap-3 md:gap-4 overflow-x-auto no-scrollbar pb-4 -mx-2 px-2">
|
||||
{movies.map((m) => (
|
||||
<MovieCard
|
||||
key={m.id}
|
||||
movie={m}
|
||||
progress={progressMap?.[m.id]}
|
||||
onClick={onCardClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Row;
|
||||
+93
-103
@@ -1,115 +1,105 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,700;9..144,900&family=Geist:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
:root {
|
||||
--kino-bg: #050505;
|
||||
--kino-surface: #0F0F0F;
|
||||
--kino-surface-2: #1A1A1A;
|
||||
--kino-accent: #D9381E;
|
||||
--kino-accent-hover: #ED4B32;
|
||||
--kino-text: #F2F2F2;
|
||||
--kino-muted: #8A8A8A;
|
||||
--kino-border: #222222;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
:root {
|
||||
--background: 0 0% 2%;
|
||||
--foreground: 0 0% 95%;
|
||||
--card: 0 0% 6%;
|
||||
--card-foreground: 0 0% 95%;
|
||||
--popover: 0 0% 6%;
|
||||
--popover-foreground: 0 0% 95%;
|
||||
--primary: 9 75% 48%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 10%;
|
||||
--secondary-foreground: 0 0% 95%;
|
||||
--muted: 0 0% 10%;
|
||||
--muted-foreground: 0 0% 54%;
|
||||
--accent: 0 0% 13%;
|
||||
--accent-foreground: 0 0% 95%;
|
||||
--destructive: 0 75% 50%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 13%;
|
||||
--input: 0 0% 13%;
|
||||
--ring: 9 75% 48%;
|
||||
--radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
@layer base {
|
||||
[data-debug-wrapper="true"] {
|
||||
display: contents !important;
|
||||
}
|
||||
|
||||
[data-debug-wrapper="true"] > * {
|
||||
margin-left: inherit;
|
||||
margin-right: inherit;
|
||||
margin-top: inherit;
|
||||
margin-bottom: inherit;
|
||||
padding-left: inherit;
|
||||
padding-right: inherit;
|
||||
padding-top: inherit;
|
||||
padding-bottom: inherit;
|
||||
column-gap: inherit;
|
||||
row-gap: inherit;
|
||||
gap: inherit;
|
||||
border-left-width: inherit;
|
||||
border-right-width: inherit;
|
||||
border-top-width: inherit;
|
||||
border-bottom-width: inherit;
|
||||
border-left-style: inherit;
|
||||
border-right-style: inherit;
|
||||
border-top-style: inherit;
|
||||
border-bottom-style: inherit;
|
||||
border-left-color: inherit;
|
||||
border-right-color: inherit;
|
||||
border-top-color: inherit;
|
||||
border-bottom-color: inherit;
|
||||
}
|
||||
html, body, #root {
|
||||
background-color: var(--kino-bg);
|
||||
color: var(--kino-text);
|
||||
font-family: 'Geist', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: 'Fraunces', serif;
|
||||
font-feature-settings: "ss01", "ss02";
|
||||
}
|
||||
|
||||
/* Hide scrollbars on row carousels but keep scrolling */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: var(--kino-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Grain overlay */
|
||||
.grain-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.06;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.9'/></svg>");
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background-color: rgba(15, 15, 15, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-color: rgba(242, 242, 242, 0.08);
|
||||
}
|
||||
|
||||
/* Smooth fade for hero gradient */
|
||||
.hero-fade {
|
||||
background: linear-gradient(180deg, rgba(5,5,5,0) 0%, rgba(5,5,5,0.4) 50%, rgba(5,5,5,1) 100%);
|
||||
}
|
||||
.hero-side-fade {
|
||||
background: linear-gradient(90deg, rgba(5,5,5,0.95) 0%, rgba(5,5,5,0.6) 40%, rgba(5,5,5,0) 100%);
|
||||
}
|
||||
|
||||
/* Page enter animation */
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fade-up { animation: fade-up 600ms ease-out both; }
|
||||
|
||||
/* Custom video controls */
|
||||
video::-webkit-media-controls-panel { background-color: rgba(0,0,0,0.7); }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import axios from "axios";
|
||||
|
||||
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL;
|
||||
export const API = `${BACKEND_URL}/api`;
|
||||
|
||||
const instance = axios.create({ baseURL: API });
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("kino_token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
// soft handling — let pages decide
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default instance;
|
||||
|
||||
export const getStreamUrl = (movie) => {
|
||||
if (!movie) return "";
|
||||
if (movie.storage_type === "local") {
|
||||
const token = localStorage.getItem("kino_token");
|
||||
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token || "")}`;
|
||||
}
|
||||
return movie.video_url;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
import api from "./api";
|
||||
|
||||
const AuthCtx = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const token = localStorage.getItem("kino_token");
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get("/auth/me");
|
||||
setUser(data);
|
||||
} catch {
|
||||
localStorage.removeItem("kino_token");
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const login = async (email, password) => {
|
||||
const { data } = await api.post("/auth/login", { email, password });
|
||||
localStorage.setItem("kino_token", data.access_token);
|
||||
setUser(data.user);
|
||||
return data.user;
|
||||
};
|
||||
|
||||
const register = async (email, password, name) => {
|
||||
const { data } = await api.post("/auth/register", { email, password, name });
|
||||
localStorage.setItem("kino_token", data.access_token);
|
||||
setUser(data.user);
|
||||
return data.user;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("kino_token");
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ user, loading, login, register, logout, refresh }}>
|
||||
{children}
|
||||
</AuthCtx.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => useContext(AuthCtx);
|
||||
@@ -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