Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`, `media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held by theme.contract.test.ts. Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor, Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are 153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
76 lines
2.4 KiB
TypeScript
76 lines
2.4 KiB
TypeScript
import { lazy, Suspense, useState } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Download } from "lucide-react";
|
|
import clsx from "clsx";
|
|
// Lazy: the download dialog (+ its profile editor) loads only when a card's download button is
|
|
// clicked, so it never ships in the feed chunk for the majority who just browse.
|
|
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
|
import { api, type Me } from "../lib/api";
|
|
import { IconButton } from "./ui/IconButton";
|
|
|
|
// Self-contained download affordance for a video card / the player. Hidden for the demo account
|
|
// (downloads spend server disk + need a real identity). Reflects the video's current state from
|
|
// the shared per-user download index (one polled query shared across every card).
|
|
export default function DownloadButton({
|
|
videoId,
|
|
title,
|
|
variant = "ghost",
|
|
size = "sm",
|
|
}: {
|
|
videoId: string;
|
|
title?: string | null;
|
|
variant?: "ghost" | "outline" | "overlay";
|
|
size?: "sm" | "md";
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const me = qc.getQueryData<Me>(qk.me());
|
|
const isDemo = !!me?.is_demo;
|
|
|
|
const indexQ = useQuery({
|
|
queryKey: qk.downloadIndex(),
|
|
queryFn: api.downloadIndex,
|
|
enabled: !isDemo,
|
|
staleTime: 10000,
|
|
});
|
|
const [open, setOpen] = useState(false);
|
|
|
|
if (isDemo) return null;
|
|
|
|
const status = indexQ.data?.[videoId];
|
|
const downloaded = status === "done";
|
|
const inQueue = status === "queued" || status === "running" || status === "paused";
|
|
const label = downloaded
|
|
? t("downloads.button.downloaded")
|
|
: inQueue
|
|
? t("downloads.button.queued")
|
|
: t("downloads.button.label");
|
|
|
|
return (
|
|
<>
|
|
<IconButton
|
|
variant={variant}
|
|
size={size}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setOpen(true);
|
|
}}
|
|
label={label}
|
|
>
|
|
{/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */}
|
|
<Download
|
|
className={clsx((downloaded || inQueue) && "text-accent", inQueue && "animate-pulse")}
|
|
/>
|
|
</IconButton>
|
|
{open && (
|
|
<Suspense fallback={null}>
|
|
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
|
|
</Suspense>
|
|
)}
|
|
</>
|
|
);
|
|
}
|