Added set current
This commit is contained in:
parent
339db999b8
commit
ca1ff16487
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
118
frontend/src/pages/ShowsPage.tsx
Normal file
118
frontend/src/pages/ShowsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user