Files
nxdns/web/src/features/pause/PauseWidget.tsx
T
mokhtar 6f67940995
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 5m6s
CI / frontend (push) Successful in 45s
CI / cross (push) Successful in 7m53s
CI / docker (push) Failing after 1h10m57s
milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
2026-08-07 18:20:30 +02:00

125 lines
3.5 KiB
TypeScript

import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { buttonClass, insetFocusRing } from "@/ui/classes";
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 triggerButtonClass = `${buttonClass} disabled:text-zinc-400 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={triggerButtonClass}>
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={triggerButtonClass}
>
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={triggerButtonClass}
>
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 ${insetFocusRing} dark:hover:bg-zinc-800`}
>
{label}
</button>
))}
</div>
)}
<InlineError error={mutation.error} />
</div>
);
}