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
This commit is contained in:
2026-03-17 02:40:32 +01:00
parent 8ed8da2d90
commit c4d2e48f73
11 changed files with 50 additions and 32 deletions

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect, useLayoutEffect } from "react";
import type { ProgrammingBlock } from "@/lib/types"; import type { ProgrammingBlock } from "@/lib/types";
const SNAP_MINS = 15; const SNAP_MINS = 15;
@@ -63,12 +63,13 @@ export function BlockTimeline({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
const blocksRef = useRef(blocks); const blocksRef = useRef(blocks);
blocksRef.current = blocks;
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onCreateRef = useRef(onCreateBlock); const onCreateRef = useRef(onCreateBlock);
onCreateRef.current = onCreateBlock; useLayoutEffect(() => {
blocksRef.current = blocks;
onChangeRef.current = onChange;
onCreateRef.current = onCreateBlock;
});
const [draft, setDraft] = useState<Draft | null>(null); const [draft, setDraft] = useState<Draft | null>(null);

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
Pencil, Pencil,
@@ -33,10 +33,13 @@ interface ChannelCardProps {
function useScheduleStatus(channelId: string) { function useScheduleStatus(channelId: string) {
const { data: schedule } = useActiveSchedule(channelId); 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 }; if (!schedule) return { status: "none" as const, label: null };
const expiresAt = new Date(schedule.valid_until); 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) { if (hoursLeft < 0) {
return { status: "expired" as const, label: "Schedule expired" }; return { status: "expired" as const, label: "Schedule expired" };

View File

@@ -9,7 +9,7 @@ import {
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button"; 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 { AlgorithmicFilterEditor } from "./algorithmic-filter-editor";
import { RecyclePolicyEditor } from "./recycle-policy-editor"; import { RecyclePolicyEditor } from "./recycle-policy-editor";
import { WebhookEditor } from "./webhook-editor"; import { WebhookEditor } from "./webhook-editor";
@@ -23,7 +23,6 @@ import type {
ChannelResponse, ChannelResponse,
LogoPosition, LogoPosition,
ProgrammingBlock, ProgrammingBlock,
BlockContent,
FillStrategy, FillStrategy,
MediaFilter, MediaFilter,
ProviderInfo, ProviderInfo,

View File

@@ -43,6 +43,8 @@ export function TranscodeSettingsDialog({ open, onOpenChange }: Props) {
const [confirmClear, setConfirmClear] = useState(false); const [confirmClear, setConfirmClear] = useState(false);
useEffect(() => { 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); if (settings) setTtl(settings.cleanup_ttl_hours);
}, [settings]); }, [settings]);

View File

@@ -1135,8 +1135,9 @@ Output only valid JSON matching this structure:
<H3>Up next banner</H3> <H3>Up next banner</H3>
<P> <P>
When the current item is more than 80% complete, an "Up next" banner When the current item is more than 80% complete, an &ldquo;Up
appears at the bottom showing the next item's title and start time. next&rdquo; banner appears at the bottom showing the next item&apos;s
title and start time.
</P> </P>
<H3>Autoplay after page refresh</H3> <H3>Autoplay after page refresh</H3>
@@ -1186,7 +1187,7 @@ Output only valid JSON matching this structure:
the backend. the backend.
</P> </P>
<H3>Video won't play / stream error</H3> <H3>Video won&apos;t play / stream error</H3>
<P> <P>
Click <strong className="text-zinc-300">Retry</strong> on the error Click <strong className="text-zinc-300">Retry</strong> on the error
screen. If it keeps failing, check that Jellyfin is online and the screen. If it keeps failing, check that Jellyfin is online and the

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Tv } from "lucide-react"; import { Tv } from "lucide-react";
import { api, ApiRequestError } from "@/lib/api"; import { api, ApiRequestError } from "@/lib/api";
@@ -69,7 +70,8 @@ function ChannelRow({ channel }: { channel: ChannelResponse }) {
retry: false, 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( const current = slots?.find(
(s) => (s) =>

View File

@@ -20,6 +20,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
const tokenRef = useRef(token); const tokenRef = useRef(token);
useEffect(() => { tokenRef.current = token; }, [token]); useEffect(() => { tokenRef.current = token; }, [token]);
/* eslint-disable react-hooks/refs -- tokenRef is only read in onError callbacks, not during render */
const [queryClient] = useState(() => { const [queryClient] = useState(() => {
return new QueryClient({ return new QueryClient({
queryCache: new QueryCache({ queryCache: new QueryCache({
@@ -46,6 +47,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
defaultOptions: { queries: { staleTime: 60 * 1000 } }, defaultOptions: { queries: { staleTime: 60 * 1000 } },
}); });
}); });
/* eslint-enable react-hooks/refs */
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>

View File

@@ -4,7 +4,6 @@ import {
createContext, createContext,
useContext, useContext,
useState, useState,
useEffect,
type ReactNode, type ReactNode,
} from "react"; } from "react";
@@ -20,14 +19,15 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setTokenState] = useState<string | null>(null); const [token, setTokenState] = useState<string | null>(() => {
const [isLoaded, setIsLoaded] = useState(false); try {
return localStorage.getItem(TOKEN_KEY);
useEffect(() => { } catch {
const stored = localStorage.getItem(TOKEN_KEY); return null;
if (stored) setTokenState(stored); }
setIsLoaded(true); });
}, []); // isLoaded is always true: lazy init above reads localStorage synchronously
const [isLoaded] = useState(true);
const setToken = (t: string | null) => { const setToken = (t: string | null) => {
setTokenState(t); setTokenState(t);

View File

@@ -1,17 +1,17 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState } from "react";
import type { ChannelResponse } from "@/lib/types"; import type { ChannelResponse } from "@/lib/types";
export function useChannelOrder(channels: ChannelResponse[] | undefined) { export function useChannelOrder(channels: ChannelResponse[] | undefined) {
const [channelOrder, setChannelOrder] = useState<string[]>([]); const [channelOrder, setChannelOrder] = useState<string[]>(() => {
useEffect(() => {
try { try {
const stored = localStorage.getItem("k-tv-channel-order"); const stored = localStorage.getItem("k-tv-channel-order");
if (stored) setChannelOrder(JSON.parse(stored)); return stored ? JSON.parse(stored) : [];
} catch {} } catch {
}, []); return [];
}
});
const saveOrder = (order: string[]) => { const saveOrder = (order: string[]) => {
setChannelOrder(order); setChannelOrder(order);

View File

@@ -27,12 +27,18 @@ export function useIdle(
videoRef.current?.play().catch(() => {}); videoRef.current?.play().catch(() => {});
}, [timeoutMs, videoRef]); }, [timeoutMs, videoRef]);
// Start the idle timer on mount without calling setState
// (default state values already set by useState above)
useEffect(() => { useEffect(() => {
resetIdle(); idleTimer.current = setTimeout(() => {
setShowOverlays(false);
onIdleRef.current?.();
}, timeoutMs);
return () => { return () => {
if (idleTimer.current) clearTimeout(idleTimer.current); if (idleTimer.current) clearTimeout(idleTimer.current);
}; };
}, [resetIdle]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { showOverlays, needsInteraction, setNeedsInteraction, resetIdle }; return { showOverlays, needsInteraction, setNeedsInteraction, resetIdle };
} }

View File

@@ -8,8 +8,10 @@ export function useSubtitlePicker(channelIdx: number, slotId?: string) {
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1); const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
const [showSubtitlePicker, setShowSubtitlePicker] = useState(false); 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(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSubtitleTracks([]); setSubtitleTracks([]);
setActiveSubtitleTrack(-1); setActiveSubtitleTrack(-1);
setShowSubtitlePicker(false); setShowSubtitlePicker(false);