refactor(api): literal-union the closed-value string fields (R7 S3a·1)

Video.status, Playlist.kind/source (+ PlaylistDetail), DownloadJob.job_kind
were bare `string`; type them as named unions verified against the backend
(VALID_STATES={new,watched,hidden}; kind user|watch_later; source local|
youtube; job_kind download|edit). VideoStatus then propagates through the
optimistic-override map, onState, and the VideoCard/VirtualFeed/PlayerModal
props — tsc now rejects a status typo (e.g. a stale "in_progress", which is a
derived filter, never a stored status) at every call site.
This commit is contained in:
2026-07-27 22:40:11 +02:00
parent ca0312d1a1
commit 5de6be8fde
5 changed files with 28 additions and 18 deletions
+4 -4
View File
@@ -22,7 +22,7 @@ import {
Youtube,
type LucideIcon,
} from "lucide-react";
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
@@ -70,7 +70,7 @@ const VIEW_ICON: Record<FeedView, LucideIcon> = {
// (which encode both). One entry per concept; a single arrow flips the direction.
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
function matchesView(status: string, show: string): boolean {
function matchesView(status: VideoStatus, show: string): boolean {
switch (show) {
case "hidden":
return status === "hidden";
@@ -126,7 +126,7 @@ export default function Feed({
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
const [overrides, setOverrides] = useState<Record<string, string>>({});
const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
@@ -241,7 +241,7 @@ export default function Feed({
const loadedRef = useRef<Video[]>([]);
const onState = useCallback(
(id: string, status: string) => {
(id: string, status: VideoStatus) => {
setOverrides((o) => ({ ...o, [id]: status }));
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
+2 -2
View File
@@ -29,7 +29,7 @@ import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { modalCount } from "./Modal";
import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api";
import { api, type Video, type VideoStatus } from "../lib/api";
import {
channelYouTubeUrl,
formatDate,
@@ -118,7 +118,7 @@ export default function PlayerModal({
queue?: Video[];
startIndex?: number;
onClose: () => void;
onState: (id: string, status: string) => void;
onState: (id: string, status: VideoStatus) => void;
// Open the active video's channel page in-app (closes the player first). The small external
// icon next to the name keeps the open-on-YouTube behaviour.
}) {
+4 -4
View File
@@ -18,7 +18,7 @@ import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import clsx from "clsx";
import type { Video } from "../lib/api";
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
@@ -30,14 +30,14 @@ function Actions({
dense = false,
}: {
video: Video;
onState: (id: string, status: string) => void;
onState: (id: string, status: VideoStatus) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
/** Tighter buttons for the small card, whose column bottoms out at 180px. */
dense?: boolean;
}) {
const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => {
const act = (status: VideoStatus) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onState(video.id, video.status === status ? "new" : status);
@@ -375,7 +375,7 @@ function VideoCard({
view: FeedView;
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number;
onState: (id: string, status: string) => void;
onState: (id: string, status: VideoStatus) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Video } from "../lib/api";
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import VideoCard from "./VideoCard";
import VirtualGrid from "./VirtualGrid";
@@ -21,7 +21,7 @@ const ROW_EST: Record<FeedView, number> = {
export interface VirtualFeedProps {
items: Video[];
view: FeedView;
onState: (id: string, status: string) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void;