188 lines
5.0 KiB
TypeScript
188 lines
5.0 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
import { pauseMutation, pauseQuery } from "@/lib/queries";
|
|
import InlineError from "@/lib/InlineError";
|
|
import { styles as shared } from "@/ui/styles";
|
|
import { colors } from "@/ui/tokens.stylex";
|
|
|
|
const DURATIONS = [
|
|
{ label: "60 seconds", seconds: 60 },
|
|
{ label: "5 minutes", seconds: 300 },
|
|
{ label: "30 minutes", seconds: 1800 },
|
|
{ label: "Indefinitely", seconds: null },
|
|
] as const;
|
|
|
|
const styles = stylex.create({
|
|
/**
|
|
* Disabled text darkens in light scheme and lightens in dark, the opposite
|
|
* direction from `textMuted`, so the token cannot express it.
|
|
*/
|
|
trigger: {
|
|
color: {
|
|
default: null,
|
|
":disabled": "oklch(70.5% 0.015 286.067)",
|
|
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
|
},
|
|
},
|
|
pausedRow: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "flex-end",
|
|
},
|
|
pausedControls: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.5rem",
|
|
},
|
|
/**
|
|
* Amber as standalone text on the app ground, not inside a warning banner, so
|
|
* the `warn*` tokens — tuned against `warnSurface` — do not apply here.
|
|
*/
|
|
pausedLabel: {
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: {
|
|
default: "oklch(55.5% 0.163 48.998)",
|
|
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
|
|
},
|
|
},
|
|
anchor: {
|
|
position: "relative",
|
|
},
|
|
menu: {
|
|
position: "absolute",
|
|
right: 0,
|
|
top: "100%",
|
|
zIndex: 10,
|
|
marginTop: "0.25rem",
|
|
display: "flex",
|
|
width: "9rem",
|
|
flexDirection: "column",
|
|
borderRadius: "0.25rem",
|
|
borderWidth: 1,
|
|
borderStyle: "solid",
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.surfaceRaised,
|
|
paddingBlock: "0.25rem",
|
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
|
},
|
|
menuItem: {
|
|
borderStyle: "none",
|
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
|
color: "inherit",
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.375rem",
|
|
textAlign: "left",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
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 {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
|
|
Pause
|
|
</button>
|
|
);
|
|
}
|
|
|
|
if (data.paused) {
|
|
return (
|
|
<div {...stylex.props(styles.pausedRow)}>
|
|
<div {...stylex.props(styles.pausedControls)}>
|
|
<span {...stylex.props(styles.pausedLabel)}>
|
|
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => mutation.mutate({ paused: false })}
|
|
disabled={mutation.isPending}
|
|
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
|
>
|
|
Resume
|
|
</button>
|
|
</div>
|
|
<InlineError error={mutation.error} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
{...stylex.props(styles.anchor)}
|
|
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}
|
|
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
|
>
|
|
Pause
|
|
</button>
|
|
{menuOpen && (
|
|
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
|
{DURATIONS.map(({ label, seconds }) => (
|
|
<button
|
|
key={label}
|
|
type="button"
|
|
onClick={() => {
|
|
setMenuOpen(false);
|
|
mutation.mutate(
|
|
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
|
);
|
|
}}
|
|
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<InlineError error={mutation.error} />
|
|
</div>
|
|
);
|
|
}
|