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:
@@ -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);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
blocksRef.current = blocks;
|
||||||
|
onChangeRef.current = onChange;
|
||||||
onCreateRef.current = onCreateBlock;
|
onCreateRef.current = onCreateBlock;
|
||||||
|
});
|
||||||
|
|
||||||
const [draft, setDraft] = useState<Draft | null>(null);
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 “Up
|
||||||
appears at the bottom showing the next item's title and start time.
|
next” banner appears at the bottom showing the next item'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'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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user