Added set current

This commit is contained in:
Nik Afiq 2025-11-05 15:01:25 +09:00
parent 339db999b8
commit ca1ff16487
5 changed files with 212 additions and 24 deletions

View File

@ -1,19 +1,38 @@
import "./index.css"; import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom";
// import DigitalClock from "./components/DigitalClock";
import Timer from "./components/Timer"; 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() { export default function App() {
const [open, setOpen] = useState(false);
const loc = useLocation();
// close drawer on route change
React.useEffect(() => { setOpen(false); }, [loc.pathname]);
return ( return (
<div className="app"> <div className="app">
<main className="card"> <main className="card">
<Timer /> <header className="appbar">
{/* <DigitalClock /> */} <button className="burger" aria-label="Menu" onClick={() => setOpen(v => !v)}>
</button>
<Link to="/" className="brand">Watch Party</Link>
<nav className={`drawer ${open ? "open" : ""}`} onClick={() => setOpen(false)}>
<NavLink end to="/" className="navlink">Timer</NavLink>
<NavLink to="/shows" className="navlink">Shows</NavLink>
</nav>
</header>
<Routes>
<Route path="/" element={<Timer />} />
<Route path="/shows" element={<ShowsPage />} />
</Routes>
<div className="footer"> <div className="footer">
Built by{" "} Built by <a href="https://x.com/nik4nao" target="_blank" rel="noopener noreferrer">@nik4nao</a> contact for inquiries or requirements.
<a href="https://x.com/nik4nao" target="_blank" rel="noopener noreferrer">
@nik4nao
</a>{" "}
contact for inquiries or requirements.
</div> </div>
</main> </main>
</div> </div>

View File

@ -23,7 +23,25 @@ html, body, #root {
place-items: center; place-items: center;
padding: 24px; 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 { .card {
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); 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); border: 1px solid rgba(255,255,255,0.08);
@ -238,6 +256,32 @@ kbd {
filter: blur(1px) brightness(0.85); 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; } .footer a { text-decoration: underline; }
@keyframes pop { @keyframes pop {

View File

@ -1,9 +1,13 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<App /> <App />
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -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<Show[]>([]);
const [loading, setLoading] = useState(true);
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
// form state
const [selectedId, setSelectedId] = useState<number | null>(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 (
<div className="shows-page">
<h2 className="h1" style={{ marginBottom: 8 }}>Shows</h2>
<p className="subtle" style={{ marginTop: 0 }}>Pick an episode, set optional start time (HH:MM:SS), then Set current.</p>
{loading && <div className="subtle">Loading</div>}
{error && <div className="timer-status" style={{ color: "#f88" }}>{error}</div>}
{!loading && shows.length === 0 && <div className="subtle">No shows.</div>}
{shows.length > 0 && (
<div className="shows-grid">
{shows.map(s => (
<button
key={s.id}
className={`show-card ${selectedId === s.id ? "selected" : ""}`}
onClick={() => setSelectedId(s.id)}
>
<div className="title">Ep {s.ep_num}: {s.ep_title}</div>
<div className="season subtle">{s.season_name}</div>
<div className="meta subtle">Start {s.start_time} Length {s.playback_length}</div>
</button>
))}
</div>
)}
<div className="form-row">
<input
className="input"
placeholder="Start time (HH:MM:SS) — optional"
value={startTime}
onChange={e => setStartTime(e.target.value.trim())}
maxLength={8}
/>
<button className="primary" disabled={posting || !selectedId || (!!startTime && !HHMMSS.test(startTime))} onClick={submit}>
{posting ? "Setting…" : "Set current"}
</button>
</div>
{current && (
<div className="subtle" style={{ marginTop: 8 }}>
Selected: Ep {current.ep_num} {current.ep_title}
</div>
)}
</div>
);
}

View File

@ -1,12 +1,14 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
const base = process.env.PUBLIC_BASE_PATH || "/"; export default defineConfig(({ mode }) => {
const base = mode === "development" ? "/" : process.env.PUBLIC_BASE_PATH || "/";
export default defineConfig({ return {
base, base,
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173,
open: true,
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:8082", target: "http://localhost:8082",
@ -15,4 +17,5 @@ export default defineConfig({
}, },
}, },
}, },
};
}); });