125 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|