milestone 9: react spa admin ui, frontend ci and embedded dist
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { pauseMutation, pauseQuery } from "@/lib/queries";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
|
||||
const DURATIONS = [
|
||||
{ label: "60 seconds", seconds: 60 },
|
||||
{ label: "5 minutes", seconds: 300 },
|
||||
{ label: "30 minutes", seconds: 1800 },
|
||||
{ label: "Indefinitely", seconds: null },
|
||||
] as const;
|
||||
|
||||
export function formatRemaining(totalSeconds: number): string {
|
||||
const clamped = Math.max(0, totalSeconds);
|
||||
const hours = Math.floor(clamped / 3600);
|
||||
const minutes = Math.floor((clamped % 3600) / 60);
|
||||
const seconds = clamped % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
function nowSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function useNowSeconds(active: boolean): number {
|
||||
const [now, setNow] = useState(nowSeconds);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setNow(nowSeconds());
|
||||
const id = setInterval(() => setNow(nowSeconds()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [active]);
|
||||
return now;
|
||||
}
|
||||
|
||||
const BUTTON_CLASS =
|
||||
"rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:text-zinc-400 dark:border-zinc-700 dark:disabled:text-zinc-600";
|
||||
|
||||
export default function PauseWidget() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useQuery({
|
||||
...pauseQuery(),
|
||||
refetchInterval: (query) => (query.state.data?.paused === true ? 5000 : false),
|
||||
});
|
||||
const mutation = useMutation(pauseMutation(queryClient));
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const now = useNowSeconds(data?.paused === true && data.until !== null);
|
||||
const paused = data?.paused === true;
|
||||
const { reset } = mutation;
|
||||
useEffect(() => reset(), [paused, reset]);
|
||||
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<button type="button" disabled className={BUTTON_CLASS}>
|
||||
Pause
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.paused) {
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-amber-700 dark:text-amber-400">
|
||||
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mutation.mutate({ paused: false })}
|
||||
disabled={mutation.isPending}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="pause-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
disabled={mutation.isPending}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
id="pause-menu"
|
||||
className="absolute right-0 top-full z-10 mt-1 flex w-36 flex-col rounded border border-zinc-200 bg-white py-1 shadow dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
{DURATIONS.map(({ label, seconds }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
mutation.mutate(
|
||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||
);
|
||||
}}
|
||||
className="px-3 py-1.5 text-left text-sm hover:bg-zinc-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user