diff --git a/frontend/src/components/BannerSettingsDialog.tsx b/frontend/src/components/BannerSettingsDialog.tsx index 43040a0..c93540e 100644 --- a/frontend/src/components/BannerSettingsDialog.tsx +++ b/frontend/src/components/BannerSettingsDialog.tsx @@ -34,7 +34,7 @@ interface Banner { slug: string; description: string; is_active: boolean; - settings: any; + settings: Record; schema: BannerSettingSchema[]; } @@ -52,7 +52,7 @@ const BannerSettingsDialog: React.FC = ({ onSave, }) => { const { t } = useTranslation(); - const [localSettings, setLocalSettings] = useState({}); + const [localSettings, setLocalSettings] = useState>({}); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); @@ -71,15 +71,18 @@ const BannerSettingsDialog: React.FC = ({ await api.patch(`banners/${banner.id}/`, { settings: localSettings }); onSave(); onClose(); - } catch (err: any) { - setError(err.response?.data?.error || 'Failed to save settings'); + } catch (err) { + setError( + (err as { response?: { data?: { error?: string } } }).response?.data?.error || + 'Failed to save settings', + ); } finally { setSaving(false); } }; - const updateSetting = (key: string, value: any) => { - setLocalSettings((prev: any) => ({ + const updateSetting = (key: string, value: unknown) => { + setLocalSettings((prev) => ({ ...prev, [key]: value, })); @@ -119,7 +122,7 @@ const BannerSettingsDialog: React.FC = ({ type="number" fullWidth size="small" - value={value} + value={String(value)} inputProps={{ min: schema.min, max: schema.max, step: schema.step }} onChange={(e) => updateSetting(schema.key, Number(e.target.value))} /> @@ -127,7 +130,7 @@ const BannerSettingsDialog: React.FC = ({ updateSetting(schema.key, e.target.value)} /> )} diff --git a/frontend/src/components/CookieBanner.tsx b/frontend/src/components/CookieBanner.tsx index 99d4988..23b0810 100644 --- a/frontend/src/components/CookieBanner.tsx +++ b/frontend/src/components/CookieBanner.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Typography, Button, @@ -19,14 +19,8 @@ interface CookieBannerProps { const CookieBanner: React.FC = ({ onThemeChange, currentTheme }) => { const { t } = useTranslation(); - const [open, setOpen] = useState(false); - - useEffect(() => { - const consent = localStorage.getItem('cookie-consent'); - if (!consent) { - setOpen(true); - } - }, []); + // Open on first render only if consent hasn't been recorded yet. + const [open, setOpen] = useState(() => !localStorage.getItem('cookie-consent')); const handleAccept = () => { localStorage.setItem('cookie-consent', 'true'); diff --git a/frontend/src/components/MentionTextField.tsx b/frontend/src/components/MentionTextField.tsx index 392e33e..9b1a8e6 100644 --- a/frontend/src/components/MentionTextField.tsx +++ b/frontend/src/components/MentionTextField.tsx @@ -19,6 +19,13 @@ interface UserSuggestion { avatar: string | null; } +interface SearchResultItem { + type: string; + id: number; + name: string; + image_url: string | null; +} + interface MentionTextFieldProps { value: string; onChange: (value: string) => void; @@ -52,10 +59,10 @@ const MentionTextField: React.FC = ({ useEffect(() => { const fetchUsers = async () => { try { - const res = await api.get('flavors/search/'); + const res = await api.get('flavors/search/'); const users = (res.data || []) - .filter((item: any) => item.type === 'user') - .map((item: any) => ({ + .filter((item) => item.type === 'user') + .map((item) => ({ id: item.id, username: item.name, avatar: item.image_url, diff --git a/frontend/src/components/RatingBadge.tsx b/frontend/src/components/RatingBadge.tsx index 094b9cf..cad9713 100644 --- a/frontend/src/components/RatingBadge.tsx +++ b/frontend/src/components/RatingBadge.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { Box, alpha, useTheme } from '@mui/material'; +import { Box, alpha, useTheme, type SxProps, type Theme } from '@mui/material'; interface RatingBadgeProps { score: number; size?: 'small' | 'medium' | 'large'; - sx?: any; + sx?: SxProps; } const sizeMap = { diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index 8840863..cf58f10 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { Box, Typography, alpha, useTheme } from '@mui/material'; +import { Box, Typography, alpha, useTheme, type SxProps, type Theme } from '@mui/material'; import { useTranslation } from 'react-i18next'; interface StatusBadgeProps { isLegacy?: boolean; isAvailable?: boolean; size?: 'small' | 'medium'; - sx?: any; + sx?: SxProps; } const sizeMap = { diff --git a/frontend/src/components/banners/BannerPerformanceWrapper.test.tsx b/frontend/src/components/banners/BannerPerformanceWrapper.test.tsx index 59a4388..5471e5e 100644 --- a/frontend/src/components/banners/BannerPerformanceWrapper.test.tsx +++ b/frontend/src/components/banners/BannerPerformanceWrapper.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { useContext } from 'react'; import { act, render } from '@testing-library/react'; -import { BannerPerfContext, BannerPerformanceWrapper } from './BannerPerformanceWrapper'; +import { BannerPerformanceWrapper } from './BannerPerformanceWrapper'; +import { BannerPerfContext } from './banner-perf'; type IOEntryLike = { isIntersecting: boolean }; type IOCallback = (entries: IOEntryLike[]) => void; @@ -50,12 +51,13 @@ describe('BannerPerformanceWrapper', () => { ioCallback = null; observeMock = vi.fn(); disconnectMock = vi.fn(); - (globalThis as any).IntersectionObserver = FakeIntersectionObserver; + (globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = + FakeIntersectionObserver; mockMatchMedia(false); }); afterEach(() => { - delete (globalThis as any).IntersectionObserver; + delete (globalThis as unknown as { IntersectionObserver?: unknown }).IntersectionObserver; }); it('defaults to isActive=true with configured targetFps', () => { diff --git a/frontend/src/components/banners/BannerPerformanceWrapper.tsx b/frontend/src/components/banners/BannerPerformanceWrapper.tsx index a6f0a81..e6a5058 100644 --- a/frontend/src/components/banners/BannerPerformanceWrapper.tsx +++ b/frontend/src/components/banners/BannerPerformanceWrapper.tsx @@ -1,23 +1,7 @@ -import { - createContext, - useContext, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import useMediaQuery from '@mui/material/useMediaQuery'; import Box from '@mui/material/Box'; - -export interface BannerPerfState { - isActive: boolean; - targetFps: number; -} - -const defaultState: BannerPerfState = { isActive: true, targetFps: 60 }; - -export const BannerPerfContext = createContext(defaultState); +import { BannerPerfContext, type BannerPerfState } from './banner-perf'; export interface BannerPerformanceWrapperProps { children: ReactNode; @@ -61,52 +45,3 @@ export const BannerPerformanceWrapper = ({ ); }; - -/** - * Returns a ref whose .current is the live perf state. Banner rAF loops - * read the ref synchronously inside their draw function to gate/throttle - * without re-triggering the useEffect that sets up the canvas. - */ -export const useBannerPerfRef = () => { - const state = useContext(BannerPerfContext); - const ref = useRef(state); - useEffect(() => { - ref.current = state; - }, [state]); - return ref; -}; - -export type GateDecision = 'draw' | 'skip' | 'halt'; - -/** - * Returns a stable gate function. Call it at the top of your rAF draw - * callback with the current timestamp; act on the returned decision: - * - 'draw': proceed with the draw body and request the next frame - * - 'skip': do not draw this frame but still request the next one - * - 'halt': user prefers reduced motion; stop rAF entirely - * The first frame always returns 'draw' so the canvas shows content even - * when reduced-motion is set. - */ -export const useBannerFrameGate = () => { - const perfRef = useBannerPerfRef(); - const stateRef = useRef({ lastTs: 0, drawnOnce: false }); - return useMemo( - () => - (now: number): GateDecision => { - const { isActive, targetFps } = perfRef.current; - if (!stateRef.current.drawnOnce) { - stateRef.current.drawnOnce = true; - stateRef.current.lastTs = now; - return 'draw'; - } - if (!isActive) return targetFps === 0 ? 'halt' : 'skip'; - if (targetFps > 0) { - const minDelta = 1000 / targetFps; - if (now - stateRef.current.lastTs < minDelta) return 'skip'; - } - stateRef.current.lastTs = now; - return 'draw'; - }, - [perfRef], - ); -}; diff --git a/frontend/src/components/banners/banner-perf.ts b/frontend/src/components/banners/banner-perf.ts new file mode 100644 index 0000000..e469e3d --- /dev/null +++ b/frontend/src/components/banners/banner-perf.ts @@ -0,0 +1,59 @@ +import { createContext, useContext, useEffect, useMemo, useRef } from 'react'; + +export interface BannerPerfState { + isActive: boolean; + targetFps: number; +} + +export const defaultBannerPerfState: BannerPerfState = { isActive: true, targetFps: 60 }; + +export const BannerPerfContext = createContext(defaultBannerPerfState); + +/** + * Returns a ref whose .current is the live perf state. Banner rAF loops + * read the ref synchronously inside their draw function to gate/throttle + * without re-triggering the useEffect that sets up the canvas. + */ +export const useBannerPerfRef = () => { + const state = useContext(BannerPerfContext); + const ref = useRef(state); + useEffect(() => { + ref.current = state; + }, [state]); + return ref; +}; + +export type GateDecision = 'draw' | 'skip' | 'halt'; + +/** + * Returns a stable gate function. Call it at the top of your rAF draw + * callback with the current timestamp; act on the returned decision: + * - 'draw': proceed with the draw body and request the next frame + * - 'skip': do not draw this frame but still request the next one + * - 'halt': user prefers reduced motion; stop rAF entirely + * The first frame always returns 'draw' so the canvas shows content even + * when reduced-motion is set. + */ +export const useBannerFrameGate = () => { + const perfRef = useBannerPerfRef(); + const stateRef = useRef({ lastTs: 0, drawnOnce: false }); + return useMemo( + () => + (now: number): GateDecision => { + const { isActive, targetFps } = perfRef.current; + if (!stateRef.current.drawnOnce) { + stateRef.current.drawnOnce = true; + stateRef.current.lastTs = now; + return 'draw'; + } + if (!isActive) return targetFps === 0 ? 'halt' : 'skip'; + if (targetFps > 0) { + const minDelta = 1000 / targetFps; + if (now - stateRef.current.lastTs < minDelta) return 'skip'; + } + stateRef.current.lastTs = now; + return 'draw'; + }, + [perfRef], + ); +}; diff --git a/frontend/src/components/banners/index.ts b/frontend/src/components/banners/index.ts index af56e3a..7959e1a 100644 --- a/frontend/src/components/banners/index.ts +++ b/frontend/src/components/banners/index.ts @@ -5,6 +5,7 @@ export { default as HextechBanner } from './HextechBanner'; export { default as HextechCorruptionBanner } from './HextechCorruptionBanner'; export { default as MatrixBanner } from './MatrixBanner'; export { default as NebulaBanner } from './NebulaBanner'; -export { BannerPerformanceWrapper, useBannerFrameGate } from './BannerPerformanceWrapper'; +export { BannerPerformanceWrapper } from './BannerPerformanceWrapper'; +export { useBannerFrameGate } from './banner-perf'; export { useCanvasBanner, CANVAS_STYLE } from './useCanvasBanner'; export type { CanvasMouse, DrawArgs, UseCanvasBannerOptions } from './useCanvasBanner'; diff --git a/frontend/src/components/banners/useCanvasBanner.test.tsx b/frontend/src/components/banners/useCanvasBanner.test.tsx index 9027439..c55cc67 100644 --- a/frontend/src/components/banners/useCanvasBanner.test.tsx +++ b/frontend/src/components/banners/useCanvasBanner.test.tsx @@ -77,12 +77,11 @@ beforeEach(() => { }); // jsdom doesn't implement Canvas 2D — stub it. vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-explicit-any () => ({ scale: vi.fn(), setTransform: vi.fn(), - }) as any, + }) as unknown as CanvasRenderingContext2D, ); }); diff --git a/frontend/src/components/banners/useCanvasBanner.ts b/frontend/src/components/banners/useCanvasBanner.ts index 2485b7d..c03a0a6 100644 --- a/frontend/src/components/banners/useCanvasBanner.ts +++ b/frontend/src/components/banners/useCanvasBanner.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, type CSSProperties, type DependencyList, type RefObject } from 'react'; -import { useBannerFrameGate } from './BannerPerformanceWrapper'; +import { useBannerFrameGate } from './banner-perf'; export interface CanvasMouse { x: number; diff --git a/frontend/src/components/ui/Glass.tsx b/frontend/src/components/ui/Glass.tsx index 7524898..c25f091 100644 --- a/frontend/src/components/ui/Glass.tsx +++ b/frontend/src/components/ui/Glass.tsx @@ -1,4 +1,4 @@ -import { styled } from '@mui/material/styles'; +import { styled, type Theme } from '@mui/material/styles'; import MuiCard from '@mui/material/Card'; import MuiPaper from '@mui/material/Paper'; import Box from '@mui/material/Box'; @@ -7,7 +7,7 @@ type Intensity = 'subtle' | 'default' | 'strong'; const intensityStyles = (intensity: Intensity) => - ({ theme }: { theme: any }) => { + ({ theme }: { theme: Theme }) => { const t = theme.tokens; const tint = intensity === 'strong' diff --git a/frontend/src/components/ui/HeroGallery.tsx b/frontend/src/components/ui/HeroGallery.tsx index 4e2e6f2..478ff19 100644 --- a/frontend/src/components/ui/HeroGallery.tsx +++ b/frontend/src/components/ui/HeroGallery.tsx @@ -38,9 +38,16 @@ const Carousel: React.FC = ({ images, alt, onMainClick, variant } useEffect(() => { if (!emblaApi) return; - updateState(); emblaApi.on('select', updateState); emblaApi.on('reInit', updateState); + // Defer the initial sync off the effect body so it isn't a synchronous + // setState (avoids the cascading-render lint and matches the event path). + const raf = requestAnimationFrame(updateState); + return () => { + cancelAnimationFrame(raf); + emblaApi.off('select', updateState); + emblaApi.off('reInit', updateState); + }; }, [emblaApi, updateState]); const scrollPrev = () => emblaApi?.scrollPrev(); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 31fc186..de1c256 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,34 +1,9 @@ -import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import api, { type RetriableConfig } from '../lib/api'; +import { AuthContext, type AuthUser, type FollowingEntry } from './auth-context'; -export interface AuthUser { - username: string; - avatar: string | null; - unread_notifications_count: number; - is_superuser: boolean; - theme?: string; - language?: string; - drawer_anchor?: 'left' | 'right'; -} - -export interface FollowingEntry { - id: number; - username: string; - avatar: string | null; -} - -export interface AuthContextValue { - user: AuthUser | null; - setUser: (user: AuthUser | null) => void; - following: FollowingEntry[]; - loadingUser: boolean; - refetchUser: () => Promise; - refetchFollowing: () => Promise; - logout: () => Promise; -} - -export const AuthContext = createContext(null); +export type { AuthUser, FollowingEntry }; export const AuthProvider = ({ children }: { children: ReactNode }) => { const { i18n } = useTranslation(); diff --git a/frontend/src/contexts/ConfirmContext.tsx b/frontend/src/contexts/ConfirmContext.tsx index b2b29ad..4a1ebd4 100644 --- a/frontend/src/contexts/ConfirmContext.tsx +++ b/frontend/src/contexts/ConfirmContext.tsx @@ -1,24 +1,13 @@ -import { createContext, useCallback, useRef, useState, type ReactNode } from 'react'; +import { useCallback, useRef, useState, type ReactNode } from 'react'; import Dialog from '@mui/material/Dialog'; import DialogTitle from '@mui/material/DialogTitle'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogActions from '@mui/material/DialogActions'; import Button from '@mui/material/Button'; +import { ConfirmContext, type ConfirmOptions } from './confirm-context'; -export interface ConfirmOptions { - title?: string; - message: string; - confirmLabel?: string; - cancelLabel?: string; - danger?: boolean; -} - -export interface ConfirmContextValue { - confirm: (opts: ConfirmOptions | string) => Promise; -} - -export const ConfirmContext = createContext(null); +export type { ConfirmOptions, ConfirmContextValue } from './confirm-context'; export const ConfirmProvider = ({ children }: { children: ReactNode }) => { const [open, setOpen] = useState(false); diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx index 83d8049..1b7eab8 100644 --- a/frontend/src/contexts/NotificationContext.tsx +++ b/frontend/src/contexts/NotificationContext.tsx @@ -1,25 +1,17 @@ -import { createContext, useCallback, useEffect, type ReactNode } from 'react'; +import { useCallback, useEffect, type ReactNode } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../hooks/useAuth'; import { useToast } from '../hooks/useToast'; import { queryKeys } from '../api/keys'; -import { useNotificationsQuery, type Notification } from '../api/queries/useNotifications'; +import { useNotificationsQuery } from '../api/queries/useNotifications'; import { useMarkAllNotificationsRead, useMarkNotificationRead, } from '../api/mutations/useMarkNotifications'; +import { NotificationContext } from './notification-context'; -export type { Notification }; - -export interface NotificationContextValue { - notifications: Notification[]; - fetchNotifications: () => Promise; - markAllRead: () => Promise; - markRead: (id: number) => Promise; -} - -export const NotificationContext = createContext(null); +export type { Notification } from './notification-context'; export const NotificationProvider = ({ children }: { children: ReactNode }) => { const { user, setUser, refetchUser } = useAuth(); diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx index facff6b..1373a4b 100644 --- a/frontend/src/contexts/ThemeContext.tsx +++ b/frontend/src/contexts/ThemeContext.tsx @@ -1,15 +1,10 @@ -import { createContext, useCallback, useMemo, useState, type ReactNode } from 'react'; +import { useCallback, useMemo, useState, type ReactNode } from 'react'; import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material'; import { getTheme, type CatppuccinTheme } from '../theme'; import api from '../lib/api'; +import { ThemeContext } from './theme-context'; -export interface ThemeContextValue { - themeName: CatppuccinTheme; - setThemeName: (name: CatppuccinTheme) => void; - handleThemeChange: (name: CatppuccinTheme, isLoggedIn: boolean) => Promise; -} - -export const ThemeContext = createContext(null); +export type { ThemeContextValue } from './theme-context'; export const ThemeProvider = ({ children }: { children: ReactNode }) => { const [themeName, setThemeName] = useState( diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx index 227e668..7b5907d 100644 --- a/frontend/src/contexts/ToastContext.tsx +++ b/frontend/src/contexts/ToastContext.tsx @@ -1,18 +1,9 @@ -import { createContext, useCallback, useState, type ReactNode } from 'react'; +import { useCallback, useState, type ReactNode } from 'react'; import Snackbar from '@mui/material/Snackbar'; -import Alert, { type AlertColor } from '@mui/material/Alert'; +import Alert from '@mui/material/Alert'; +import { ToastContext, type ToastOptions } from './toast-context'; -export interface ToastOptions { - message: string; - severity?: AlertColor; - duration?: number; -} - -export interface ToastContextValue { - notify: (opts: ToastOptions | string) => void; -} - -export const ToastContext = createContext(null); +export type { ToastOptions, ToastContextValue } from './toast-context'; interface ToastState extends ToastOptions { key: number; diff --git a/frontend/src/contexts/auth-context.ts b/frontend/src/contexts/auth-context.ts new file mode 100644 index 0000000..5044e20 --- /dev/null +++ b/frontend/src/contexts/auth-context.ts @@ -0,0 +1,29 @@ +import { createContext } from 'react'; + +export interface AuthUser { + username: string; + avatar: string | null; + unread_notifications_count: number; + is_superuser: boolean; + theme?: string; + language?: string; + drawer_anchor?: 'left' | 'right'; +} + +export interface FollowingEntry { + id: number; + username: string; + avatar: string | null; +} + +export interface AuthContextValue { + user: AuthUser | null; + setUser: (user: AuthUser | null) => void; + following: FollowingEntry[]; + loadingUser: boolean; + refetchUser: () => Promise; + refetchFollowing: () => Promise; + logout: () => Promise; +} + +export const AuthContext = createContext(null); diff --git a/frontend/src/contexts/confirm-context.ts b/frontend/src/contexts/confirm-context.ts new file mode 100644 index 0000000..1e90357 --- /dev/null +++ b/frontend/src/contexts/confirm-context.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react'; + +export interface ConfirmOptions { + title?: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; +} + +export interface ConfirmContextValue { + confirm: (opts: ConfirmOptions | string) => Promise; +} + +export const ConfirmContext = createContext(null); diff --git a/frontend/src/contexts/notification-context.ts b/frontend/src/contexts/notification-context.ts new file mode 100644 index 0000000..2998e2e --- /dev/null +++ b/frontend/src/contexts/notification-context.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react'; +import type { Notification } from '../api/queries/useNotifications'; + +export type { Notification }; + +export interface NotificationContextValue { + notifications: Notification[]; + fetchNotifications: () => Promise; + markAllRead: () => Promise; + markRead: (id: number) => Promise; +} + +export const NotificationContext = createContext(null); diff --git a/frontend/src/contexts/theme-context.ts b/frontend/src/contexts/theme-context.ts new file mode 100644 index 0000000..9994ff9 --- /dev/null +++ b/frontend/src/contexts/theme-context.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; +import type { CatppuccinTheme } from '../theme'; + +export interface ThemeContextValue { + themeName: CatppuccinTheme; + setThemeName: (name: CatppuccinTheme) => void; + handleThemeChange: (name: CatppuccinTheme, isLoggedIn: boolean) => Promise; +} + +export const ThemeContext = createContext(null); diff --git a/frontend/src/contexts/toast-context.ts b/frontend/src/contexts/toast-context.ts new file mode 100644 index 0000000..7c6c4bf --- /dev/null +++ b/frontend/src/contexts/toast-context.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react'; +import type { AlertColor } from '@mui/material/Alert'; + +export interface ToastOptions { + message: string; + severity?: AlertColor; + duration?: number; +} + +export interface ToastContextValue { + notify: (opts: ToastOptions | string) => void; +} + +export const ToastContext = createContext(null); diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 2e04dab..07d95ec 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -1,5 +1,5 @@ import { useContext } from 'react'; -import { AuthContext } from '../contexts/AuthContext'; +import { AuthContext } from '../contexts/auth-context'; export const useAuth = () => { const ctx = useContext(AuthContext); diff --git a/frontend/src/hooks/useConfirm.ts b/frontend/src/hooks/useConfirm.ts index 35fe949..d1add16 100644 --- a/frontend/src/hooks/useConfirm.ts +++ b/frontend/src/hooks/useConfirm.ts @@ -1,5 +1,5 @@ import { useContext } from 'react'; -import { ConfirmContext } from '../contexts/ConfirmContext'; +import { ConfirmContext } from '../contexts/confirm-context'; export const useConfirm = () => { const ctx = useContext(ConfirmContext); diff --git a/frontend/src/hooks/useDrawerAnchor.ts b/frontend/src/hooks/useDrawerAnchor.ts index bc72fe9..f8fd95e 100644 --- a/frontend/src/hooks/useDrawerAnchor.ts +++ b/frontend/src/hooks/useDrawerAnchor.ts @@ -22,11 +22,17 @@ export const useDrawerAnchor = (): UseDrawerAnchorResult => { (user?.drawer_anchor as DrawerAnchor) ?? readLocal(), ); + // Adopt the server-side preference when it loads/changes. Derived during + // render (tracking the previously-applied value) so it doesn't cost an extra + // effect pass; the localStorage mirror is written in the effect below. + const [appliedAnchor, setAppliedAnchor] = useState(user?.drawer_anchor); + if (user?.drawer_anchor && user.drawer_anchor !== appliedAnchor) { + setAppliedAnchor(user.drawer_anchor); + setAnchorState(user.drawer_anchor); + } + useEffect(() => { - if (user?.drawer_anchor) { - setAnchorState(user.drawer_anchor); - localStorage.setItem(STORAGE_KEY, user.drawer_anchor); - } + if (user?.drawer_anchor) localStorage.setItem(STORAGE_KEY, user.drawer_anchor); }, [user?.drawer_anchor]); const setAnchor = useCallback( diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index 155195e..aecef9c 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -1,5 +1,5 @@ import { useContext } from 'react'; -import { NotificationContext } from '../contexts/NotificationContext'; +import { NotificationContext } from '../contexts/notification-context'; export const useNotifications = () => { const ctx = useContext(NotificationContext); diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts index 246a24e..b34e973 100644 --- a/frontend/src/hooks/useTheme.ts +++ b/frontend/src/hooks/useTheme.ts @@ -1,5 +1,5 @@ import { useContext } from 'react'; -import { ThemeContext } from '../contexts/ThemeContext'; +import { ThemeContext } from '../contexts/theme-context'; export const useTheme = () => { const ctx = useContext(ThemeContext); diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts index 265fee9..f6619cd 100644 --- a/frontend/src/hooks/useToast.ts +++ b/frontend/src/hooks/useToast.ts @@ -1,5 +1,5 @@ import { useContext } from 'react'; -import { ToastContext } from '../contexts/ToastContext'; +import { ToastContext } from '../contexts/toast-context'; export const useToast = () => { const ctx = useContext(ToastContext); diff --git a/frontend/src/pages/AdminRatingDetail.tsx b/frontend/src/pages/AdminRatingDetail.tsx index 642e975..c0ba51b 100644 --- a/frontend/src/pages/AdminRatingDetail.tsx +++ b/frontend/src/pages/AdminRatingDetail.tsx @@ -25,12 +25,14 @@ const AdminRatingDetail: React.FC = () => { const { notify } = useToast(); const { confirm } = useConfirm(); - useEffect(() => { - if (rating) { - setScore(rating.score); - setComment(rating.comment || ''); - } - }, [rating]); + // Seed the form from the loaded rating during render (tracking the + // previously-synced row) so it re-fills only when the data changes. + const [syncedRating, setSyncedRating] = useState(rating); + if (rating && rating !== syncedRating) { + setSyncedRating(rating); + setScore(rating.score); + setComment(rating.comment || ''); + } useEffect(() => { if (error) navigate('/admin-panel'); diff --git a/frontend/src/pages/AdminReplyDetail.tsx b/frontend/src/pages/AdminReplyDetail.tsx index 6502a25..903439d 100644 --- a/frontend/src/pages/AdminReplyDetail.tsx +++ b/frontend/src/pages/AdminReplyDetail.tsx @@ -25,9 +25,13 @@ const AdminReplyDetail: React.FC = () => { const { notify } = useToast(); const { confirm } = useConfirm(); - useEffect(() => { - if (reply) setText(reply.text); - }, [reply]); + // Seed the form from the loaded reply during render (tracking the + // previously-synced row) so it re-fills only when the data changes. + const [syncedReply, setSyncedReply] = useState(reply); + if (reply && reply !== syncedReply) { + setSyncedReply(reply); + setText(reply.text); + } useEffect(() => { if (error) navigate('/admin-panel'); diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index 5505d45..bfec5ef 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -66,6 +66,14 @@ const PublicProfile: React.FC = () => { const { notify } = useToast(); const { confirm } = useConfirm(); + // No avatar → no palette. Cleared during render (tracking the previous + // avatar) so the reset isn't a synchronous setState inside the effect. + const [paletteAvatar, setPaletteAvatar] = useState(data?.avatar); + if (data?.avatar !== paletteAvatar) { + setPaletteAvatar(data?.avatar); + if (!data?.avatar) setPalette([]); + } + useEffect(() => { if (data?.avatar) { const img = new Image(); @@ -96,8 +104,6 @@ const PublicProfile: React.FC = () => { setPalette([]); } }; - } else { - setPalette([]); } }, [data?.avatar]); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 69c65e7..7e98334 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -74,20 +74,18 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { const [pendingConfirmation, setPendingConfirmation] = useState(false); const [oldPassword, setOldPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); - const [deletionCode, setDeletionCode] = useState(''); - const [isDeleting, setIsDeleting] = useState(false); + const [deletionCode, setDeletionCode] = useState(() => searchParams.get('deletion_code') ?? ''); + const [isDeleting, setIsDeleting] = useState(() => searchParams.has('deletion_code')); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [selectedBannerId, setSelectedBannerId] = useState(''); // Deep link from the deletion email: ?deletion_code=NNNNNN pre-fills the code - // and opens the confirm form. The user still has to click "Delete" — the link - // is a convenience, not a one-click destruct. Strip the param after reading so - // it doesn't linger in the URL / browser history. + // and opens the confirm form (see the lazy useState initializers above). The + // user still has to click "Delete" — the link is a convenience, not a + // one-click destruct. Strip the param after reading so it doesn't linger in + // the URL / browser history. useEffect(() => { - const code = searchParams.get('deletion_code'); - if (!code) return; - setDeletionCode(code); - setIsDeleting(true); + if (!searchParams.has('deletion_code')) return; searchParams.delete('deletion_code'); setSearchParams(searchParams, { replace: true }); }, [searchParams, setSearchParams]); @@ -97,15 +95,20 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { else navigate('/'); }; - useEffect(() => { - if (!me) return; + // Seed the editable form fields from the loaded profile. Done during render + // (tracking the previously-synced `me`) rather than in an effect so the + // inputs reflect server data without an extra commit/paint, and re-sync only + // when the query data actually changes. + const [syncedMe, setSyncedMe] = useState(undefined); + if (me && me !== syncedMe) { + setSyncedMe(me); setUsername(me.username); setEmail(me.email); setCurrentEmail(me.email); setAvatar(me.avatar); setSelectedBannerId(me.selected_banner || ''); if (me.language) setLanguage(me.language); - }, [me]); + } const handleAvatarUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; diff --git a/frontend/src/pages/admin/AdminFlavors.tsx b/frontend/src/pages/admin/AdminFlavors.tsx index f310ffb..dbcb5ad 100644 --- a/frontend/src/pages/admin/AdminFlavors.tsx +++ b/frontend/src/pages/admin/AdminFlavors.tsx @@ -17,7 +17,7 @@ import { } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useToast } from '../../hooks/useToast'; -import { useFlavorsList } from '../../api/queries/useFlavorsList'; +import { useFlavorsList, type FlavorListItem } from '../../api/queries/useFlavorsList'; import api from '../../lib/api'; import { useQueryClient } from '@tanstack/react-query'; import { queryKeys } from '../../api/keys'; @@ -28,8 +28,8 @@ const AdminFlavors: React.FC = () => { const queryClient = useQueryClient(); const { data: flavors = [], isLoading } = useFlavorsList(); - const [keepFlavor, setKeepFlavor] = useState(null); - const [removeFlavor, setRemoveFlavor] = useState(null); + const [keepFlavor, setKeepFlavor] = useState(null); + const [removeFlavor, setRemoveFlavor] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [isMerging, setIsMerging] = useState(false); @@ -55,8 +55,10 @@ const AdminFlavors: React.FC = () => { setKeepFlavor(null); setRemoveFlavor(null); setConfirmOpen(false); - } catch (err: any) { - const errorMsg = err.response?.data?.error || err.message; + } catch (err) { + const errorMsg = + (err as { response?: { data?: { error?: string } } }).response?.data?.error || + (err as Error).message; notify({ message: t('admin.errorPrefix', { message: errorMsg }), severity: 'error' }); } finally { setIsMerging(false); diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index 49208cf..a3aaba2 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -77,8 +77,22 @@ export const isLightTheme = (mode: CatppuccinTheme): boolean => { ].includes(mode); }; +interface PaletteColor { + hex: string; +} + +interface ThemePalette { + base: PaletteColor; + surface0: PaletteColor; + crust: PaletteColor; + text: PaletteColor; + subtext0: PaletteColor; + primary: PaletteColor; + secondary: PaletteColor; +} + export const getTheme = (mode: CatppuccinTheme) => { - let palette: any; + let palette: ThemePalette; // Define Holy branding colors const holyPurple = { light: '#7c3aed', dark: '#a78bfa' }; @@ -216,7 +230,7 @@ export const getTheme = (mode: CatppuccinTheme) => { }; } else { // Handle Catppuccin flavors - const flavor = (flavors as any)[mode]; + const flavor = flavors[mode as keyof typeof flavors]; if (flavor) { const colors = flavor.colors; palette = {