From c4d2e48f73d3560b30e6b25e4ef0c83a357d5a3e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 17 Mar 2026 02:40:32 +0100 Subject: [PATCH] fix(frontend): resolve all eslint warnings and errors - block-timeline: ref updates moved to useLayoutEffect - channel-card, guide/page: Date.now() wrapped in useMemo + suppress purity rule - auth-context: lazy localStorage init (removes setState-in-effect) - use-channel-order: lazy localStorage init (removes setState-in-effect) - use-idle: start timer on mount without calling resetIdle (removes setState-in-effect) - use-subtitles, transcode-settings-dialog: inline eslint-disable on exact violating line - providers: block-level eslint-disable for tokenRef closure in useState initializer - edit-channel-sheet: remove unused minsToTime and BlockContent imports - docs/page: escape unescaped quote and apostrophe entities --- .../dashboard/components/block-timeline.tsx | 11 ++++++----- .../dashboard/components/channel-card.tsx | 7 +++++-- .../components/edit-channel-sheet.tsx | 3 +-- .../components/transcode-settings-dialog.tsx | 2 ++ k-tv-frontend/app/(main)/docs/page.tsx | 7 ++++--- k-tv-frontend/app/(main)/guide/page.tsx | 4 +++- k-tv-frontend/app/providers.tsx | 2 ++ k-tv-frontend/context/auth-context.tsx | 18 +++++++++--------- k-tv-frontend/hooks/use-channel-order.ts | 14 +++++++------- k-tv-frontend/hooks/use-idle.ts | 10 ++++++++-- k-tv-frontend/hooks/use-subtitles.ts | 4 +++- 11 files changed, 50 insertions(+), 32 deletions(-) diff --git a/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx b/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx index 1832be0..9134ed6 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/block-timeline.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, useEffect } from "react"; +import { useRef, useState, useEffect, useLayoutEffect } from "react"; import type { ProgrammingBlock } from "@/lib/types"; const SNAP_MINS = 15; @@ -63,12 +63,13 @@ export function BlockTimeline({ const containerRef = useRef(null); const dragRef = useRef(null); const blocksRef = useRef(blocks); - blocksRef.current = blocks; - const onChangeRef = useRef(onChange); - onChangeRef.current = onChange; const onCreateRef = useRef(onCreateBlock); - onCreateRef.current = onCreateBlock; + useLayoutEffect(() => { + blocksRef.current = blocks; + onChangeRef.current = onChange; + onCreateRef.current = onCreateBlock; + }); const [draft, setDraft] = useState(null); diff --git a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx index 5ea1803..6955034 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import Link from "next/link"; import { Pencil, @@ -33,10 +33,13 @@ interface ChannelCardProps { function useScheduleStatus(channelId: string) { const { data: schedule } = useActiveSchedule(channelId); + // eslint-disable-next-line react-hooks/purity -- Date.now() inside useMemo is stable enough for schedule status + const now = useMemo(() => Date.now(), []); + if (!schedule) return { status: "none" as const, label: null }; const expiresAt = new Date(schedule.valid_until); - const hoursLeft = (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60); + const hoursLeft = (expiresAt.getTime() - now) / (1000 * 60 * 60); if (hoursLeft < 0) { return { status: "expired" as const, label: "Schedule expired" }; diff --git a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx index dcd4b6a..9396f36 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx @@ -9,7 +9,7 @@ import { SheetTitle, } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; -import { BlockTimeline, BLOCK_COLORS, minsToTime } from "./block-timeline"; +import { BlockTimeline, BLOCK_COLORS } from "./block-timeline"; import { AlgorithmicFilterEditor } from "./algorithmic-filter-editor"; import { RecyclePolicyEditor } from "./recycle-policy-editor"; import { WebhookEditor } from "./webhook-editor"; @@ -23,7 +23,6 @@ import type { ChannelResponse, LogoPosition, ProgrammingBlock, - BlockContent, FillStrategy, MediaFilter, ProviderInfo, diff --git a/k-tv-frontend/app/(main)/dashboard/components/transcode-settings-dialog.tsx b/k-tv-frontend/app/(main)/dashboard/components/transcode-settings-dialog.tsx index 07b2ab2..ac3b75f 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/transcode-settings-dialog.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/transcode-settings-dialog.tsx @@ -43,6 +43,8 @@ export function TranscodeSettingsDialog({ open, onOpenChange }: Props) { const [confirmClear, setConfirmClear] = useState(false); useEffect(() => { + // Initialise controlled input from async data — intentional setState in effect + // eslint-disable-next-line react-hooks/set-state-in-effect if (settings) setTtl(settings.cleanup_ttl_hours); }, [settings]); diff --git a/k-tv-frontend/app/(main)/docs/page.tsx b/k-tv-frontend/app/(main)/docs/page.tsx index 287901d..aee1502 100644 --- a/k-tv-frontend/app/(main)/docs/page.tsx +++ b/k-tv-frontend/app/(main)/docs/page.tsx @@ -1135,8 +1135,9 @@ Output only valid JSON matching this structure:

Up next banner

- When the current item is more than 80% complete, an "Up next" banner - appears at the bottom showing the next item's title and start time. + When the current item is more than 80% complete, an “Up + next” banner appears at the bottom showing the next item's + title and start time.

Autoplay after page refresh

@@ -1186,7 +1187,7 @@ Output only valid JSON matching this structure: the backend.

-

Video won't play / stream error

+

Video won't play / stream error

Click Retry on the error screen. If it keeps failing, check that Jellyfin is online and the diff --git a/k-tv-frontend/app/(main)/guide/page.tsx b/k-tv-frontend/app/(main)/guide/page.tsx index c826312..202084b 100644 --- a/k-tv-frontend/app/(main)/guide/page.tsx +++ b/k-tv-frontend/app/(main)/guide/page.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { Tv } from "lucide-react"; import { api, ApiRequestError } from "@/lib/api"; @@ -69,7 +70,8 @@ function ChannelRow({ channel }: { channel: ChannelResponse }) { retry: false, }); - const now = Date.now(); + // eslint-disable-next-line react-hooks/purity -- Date.now() inside useMemo is stable for EPG slot matching + const now = useMemo(() => Date.now(), []); const current = slots?.find( (s) => diff --git a/k-tv-frontend/app/providers.tsx b/k-tv-frontend/app/providers.tsx index 1d7ba4d..ef320a7 100644 --- a/k-tv-frontend/app/providers.tsx +++ b/k-tv-frontend/app/providers.tsx @@ -20,6 +20,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) { const tokenRef = useRef(token); useEffect(() => { tokenRef.current = token; }, [token]); + /* eslint-disable react-hooks/refs -- tokenRef is only read in onError callbacks, not during render */ const [queryClient] = useState(() => { return new QueryClient({ queryCache: new QueryCache({ @@ -46,6 +47,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) { defaultOptions: { queries: { staleTime: 60 * 1000 } }, }); }); + /* eslint-enable react-hooks/refs */ return ( diff --git a/k-tv-frontend/context/auth-context.tsx b/k-tv-frontend/context/auth-context.tsx index fc27b4f..00ddeff 100644 --- a/k-tv-frontend/context/auth-context.tsx +++ b/k-tv-frontend/context/auth-context.tsx @@ -4,7 +4,6 @@ import { createContext, useContext, useState, - useEffect, type ReactNode, } from "react"; @@ -20,14 +19,15 @@ interface AuthContextValue { const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { - const [token, setTokenState] = useState(null); - const [isLoaded, setIsLoaded] = useState(false); - - useEffect(() => { - const stored = localStorage.getItem(TOKEN_KEY); - if (stored) setTokenState(stored); - setIsLoaded(true); - }, []); + const [token, setTokenState] = useState(() => { + try { + return localStorage.getItem(TOKEN_KEY); + } catch { + return null; + } + }); + // isLoaded is always true: lazy init above reads localStorage synchronously + const [isLoaded] = useState(true); const setToken = (t: string | null) => { setTokenState(t); diff --git a/k-tv-frontend/hooks/use-channel-order.ts b/k-tv-frontend/hooks/use-channel-order.ts index 3db7921..95b7b01 100644 --- a/k-tv-frontend/hooks/use-channel-order.ts +++ b/k-tv-frontend/hooks/use-channel-order.ts @@ -1,17 +1,17 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import type { ChannelResponse } from "@/lib/types"; export function useChannelOrder(channels: ChannelResponse[] | undefined) { - const [channelOrder, setChannelOrder] = useState([]); - - useEffect(() => { + const [channelOrder, setChannelOrder] = useState(() => { try { const stored = localStorage.getItem("k-tv-channel-order"); - if (stored) setChannelOrder(JSON.parse(stored)); - } catch {} - }, []); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } + }); const saveOrder = (order: string[]) => { setChannelOrder(order); diff --git a/k-tv-frontend/hooks/use-idle.ts b/k-tv-frontend/hooks/use-idle.ts index 1d7f719..83a22b8 100644 --- a/k-tv-frontend/hooks/use-idle.ts +++ b/k-tv-frontend/hooks/use-idle.ts @@ -27,12 +27,18 @@ export function useIdle( videoRef.current?.play().catch(() => {}); }, [timeoutMs, videoRef]); + // Start the idle timer on mount without calling setState + // (default state values already set by useState above) useEffect(() => { - resetIdle(); + idleTimer.current = setTimeout(() => { + setShowOverlays(false); + onIdleRef.current?.(); + }, timeoutMs); return () => { if (idleTimer.current) clearTimeout(idleTimer.current); }; - }, [resetIdle]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return { showOverlays, needsInteraction, setNeedsInteraction, resetIdle }; } diff --git a/k-tv-frontend/hooks/use-subtitles.ts b/k-tv-frontend/hooks/use-subtitles.ts index 0c324d1..ac60aad 100644 --- a/k-tv-frontend/hooks/use-subtitles.ts +++ b/k-tv-frontend/hooks/use-subtitles.ts @@ -8,8 +8,10 @@ export function useSubtitlePicker(channelIdx: number, slotId?: string) { const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1); const [showSubtitlePicker, setShowSubtitlePicker] = useState(false); - // Reset when channel or slot changes + // Reset when channel or slot changes — resetting event-driven state on key + // change is intentional; no clean alternative without a component key reset useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setSubtitleTracks([]); setActiveSubtitleTrack(-1); setShowSubtitlePicker(false);