diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aa39d4e..988ecc4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,20 +1,39 @@ -import "./index.css"; -// import DigitalClock from "./components/DigitalClock"; +import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom"; import Timer from "./components/Timer"; +import ShowsPage from "./pages/ShowsPage"; +import "./index.css"; +import { useState } from "react"; +import React from "react"; export default function App() { + const [open, setOpen] = useState(false); + const loc = useLocation(); + + // close drawer on route change + React.useEffect(() => { setOpen(false); }, [loc.pathname]); + return (
- - {/* */} +
+ + Watch Party + +
+ + + } /> + } /> + +
- Built by{" "} - - @nik4nao - {" "} - — contact for inquiries or requirements. -
+ Built by @nik4nao — contact for inquiries or requirements. +
); diff --git a/frontend/src/index.css b/frontend/src/index.css index b81c5eb..8d7fc2a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -23,7 +23,25 @@ html, body, #root { place-items: center; padding: 24px; } - +.appbar { + display:flex; align-items:center; justify-content:space-between; + margin-bottom: 12px; +} +.burger { + font-size:20px; line-height:1; padding:6px 10px; border-radius:8px; + background: rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); + color: var(--text); cursor:pointer; +} +.brand { color: var(--text); text-decoration:none; font-weight:800; } +.drawer { + position:absolute; left:16px; top:56px; display:flex; flex-direction:column; + gap:8px; padding:12px; border-radius:12px; background:rgba(15,21,32,0.98); + border:1px solid rgba(255,255,255,0.12); box-shadow:0 20px 40px rgba(0,0,0,0.35); + transform: translateY(-12px) scale(0.98); opacity:0; pointer-events:none; transition:0.16s ease; +} +.drawer.open { transform:none; opacity:1; pointer-events:auto; } +.navlink { color:var(--text); text-decoration:none; font-weight:700; } +.navlink.active { color:var(--accent); } .card { background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); border: 1px solid rgba(255,255,255,0.08); @@ -238,6 +256,32 @@ kbd { filter: blur(1px) brightness(0.85); } +.shows-page { display:grid; gap:12px; } +.shows-grid { + display:grid; gap:10px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + margin: 6px 0 8px; +} +.show-card { + text-align:left; padding:12px; border-radius:12px; cursor:pointer; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.12); + color: var(--text); +} +.show-card .title { font-weight:800; } +.show-card.selected { outline: 2px solid var(--accent); background: rgba(121,192,255,0.10); } + +.form-row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; justify-content:center; } +.input { + min-width: 220px; padding:10px 12px; border-radius:10px; + background: rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); + color: var(--text); +} +.primary { + padding:10px 14px; border-radius:10px; font-weight:800; cursor:pointer; + background: var(--accent); color:#0b0f14; border:none; +} +.primary:disabled { opacity:0.5; cursor:not-allowed; } + .footer a { text-decoration: underline; } @keyframes pop { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 97f20e9..ce7ad38 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; import App from "./App"; +import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + + + ); \ No newline at end of file diff --git a/frontend/src/pages/ShowsPage.tsx b/frontend/src/pages/ShowsPage.tsx new file mode 100644 index 0000000..0b086c5 --- /dev/null +++ b/frontend/src/pages/ShowsPage.tsx @@ -0,0 +1,118 @@ +import { useEffect, useMemo, useState } from "react"; + +type Show = { + id: number; + ep_num: number; + ep_title: string; + season_name: string; + start_time: string; // "HH:MM:SS" + playback_length: string; // "HH:MM:SS" or "MM:SS" + date_created: string; +}; + +const GET_URL = "/api/v1/shows"; +const POST_URL = "/api/v1/current"; + +const HHMMSS = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/; + +export default function ShowsPage() { + const [shows, setShows] = useState([]); + const [loading, setLoading] = useState(true); + const [posting, setPosting] = useState(false); + const [error, setError] = useState(null); + + // form state + const [selectedId, setSelectedId] = useState(null); + const [startTime, setStartTime] = useState(""); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + setLoading(true); + const res = await fetch(GET_URL, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as Show[]; + if (!cancelled) setShows(data); + } catch (e: any) { + if (!cancelled) setError(e.message || "Failed to load shows"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, []); + + const current = useMemo(() => shows.find(s => s.id === selectedId) || null, [shows, selectedId]); + + async function submit() { + setError(null); + if (!selectedId) { setError("Pick an episode first"); return; } + if (startTime && !HHMMSS.test(startTime)) { setError("Start time must be HH:MM:SS"); return; } + + try { + setPosting(true); + const body: any = { id: selectedId }; + if (startTime) body.start_time = startTime; + const res = await fetch(POST_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`POST failed (${res.status})`); + // success UX + setStartTime(""); + } catch (e: any) { + setError(e.message || "Failed to set current"); + } finally { + setPosting(false); + } + } + + return ( +
+

Shows

+

Pick an episode, set optional start time (HH:MM:SS), then “Set current”.

+ + {loading &&
Loading…
} + {error &&
{error}
} + + {!loading && shows.length === 0 &&
No shows.
} + + {shows.length > 0 && ( +
+ {shows.map(s => ( + + ))} +
+ )} + +
+ setStartTime(e.target.value.trim())} + maxLength={8} + /> + +
+ + {current && ( +
+ Selected: Ep {current.ep_num} — {current.ep_title} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bc02def..2bd5a82 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,18 +1,21 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -const base = process.env.PUBLIC_BASE_PATH || "/"; - -export default defineConfig({ - base, - plugins: [react()], - server: { - proxy: { - "/api": { - target: "http://localhost:8082", - changeOrigin: true, - rewrite: p => p.replace(/^\/api/, ""), +export default defineConfig(({ mode }) => { + const base = mode === "development" ? "/" : process.env.PUBLIC_BASE_PATH || "/"; + return { + base, + plugins: [react()], + server: { + port: 5173, + open: true, + proxy: { + "/api": { + target: "http://localhost:8082", + changeOrigin: true, + rewrite: p => p.replace(/^\/api/, ""), + }, }, }, - }, + }; }); \ No newline at end of file