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
+2 -34
View File
@@ -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
View File
@@ -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>
);
+2
View File
@@ -0,0 +1,2 @@
export const GrainOverlay = () => <div className="grain-overlay" aria-hidden="true" />;
export default GrainOverlay;
+78
View File
@@ -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;
+42
View File
@@ -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;
+102
View File
@@ -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;
+54
View File
@@ -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
View File
@@ -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); }
+33
View File
@@ -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;
};
+56
View File
@@ -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);
+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>
);
}