style + warnings fix

This commit is contained in:
nvrl committed 2026-06-16 22:00:47 +02:00
1 parent 296ae167fb
commit a95a692e1c
35 files changed
+282 -215

No files matched your search

@@ -34,7 +34,7 @@ interface Banner {
slug: string;
description: string;
is_active: boolean;
settings: any;
settings: Record<string, unknown>;
schema: BannerSettingSchema[];
}
@@ -52,7 +52,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
onSave,
}) => {
const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState<any>({});
const [localSettings, setLocalSettings] = useState<Record<string, unknown>>({});
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
@@ -71,15 +71,18 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
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<BannerSettingsDialogProps> = ({
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<BannerSettingsDialogProps> = ({
<TextField
fullWidth
size="small"
value={value}
value={String(value)}
onChange={(e) => updateSetting(schema.key, e.target.value)}
/>
)}
+3 -9
View File
@@ -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<CookieBannerProps> = ({ 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');
+10 -3
View File
@@ -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<MentionTextFieldProps> = ({
useEffect(() => {
const fetchUsers = async () => {
try {
const res = await api.get('flavors/search/');
const res = await api.get<SearchResultItem[]>('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,
+2 -2
View File
@@ -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<Theme>;
}
const sizeMap = {
+2 -2
View File
@@ -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<Theme>;
}
const sizeMap = {
@@ -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', () => {
@@ -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<BannerPerfState>(defaultState);
import { BannerPerfContext, type BannerPerfState } from './banner-perf';
export interface BannerPerformanceWrapperProps {
children: ReactNode;
@@ -61,52 +45,3 @@ export const BannerPerformanceWrapper = ({
</BannerPerfContext.Provider>
);
};
/**
* 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],
);
};
@@ -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<BannerPerfState>(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],
);
};
+2 -1
View File
@@ -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';
@@ -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,
);
});
@@ -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;
+2 -2
View File
@@ -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'
+8 -1
View File
@@ -38,9 +38,16 @@ const Carousel: React.FC<CarouselProps> = ({ 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();
+3 -28
View File
@@ -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<void>;
refetchFollowing: () => Promise<void>;
logout: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
export type { AuthUser, FollowingEntry };
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const { i18n } = useTranslation();
+3 -14
View File
@@ -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<boolean>;
}
export const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export type { ConfirmOptions, ConfirmContextValue } from './confirm-context';
export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
const [open, setOpen] = useState(false);
+4 -12
View File
@@ -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<void>;
markAllRead: () => Promise<void>;
markRead: (id: number) => Promise<void>;
}
export const NotificationContext = createContext<NotificationContextValue | null>(null);
export type { Notification } from './notification-context';
export const NotificationProvider = ({ children }: { children: ReactNode }) => {
const { user, setUser, refetchUser } = useAuth();
+3 -8
View File
@@ -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<void>;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);
export type { ThemeContextValue } from './theme-context';
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [themeName, setThemeName] = useState<CatppuccinTheme>(
+4 -13
View File
@@ -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<ToastContextValue | null>(null);
export type { ToastOptions, ToastContextValue } from './toast-context';
interface ToastState extends ToastOptions {
key: number;
+29
View File
@@ -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<void>;
refetchFollowing: () => Promise<void>;
logout: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
+15
View File
@@ -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<boolean>;
}
export const ConfirmContext = createContext<ConfirmContextValue | null>(null);
@@ -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<void>;
markAllRead: () => Promise<void>;
markRead: (id: number) => Promise<void>;
}
export const NotificationContext = createContext<NotificationContextValue | null>(null);
+10
View File
@@ -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<void>;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);
+14
View File
@@ -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<ToastContextValue | null>(null);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+10 -4
View File
@@ -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(
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+8 -6
View File
@@ -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');
+7 -3
View File
@@ -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');
+8 -2
View File
@@ -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]);
+15 -12
View File
@@ -74,20 +74,18 @@ const Settings: React.FC<SettingsProps> = ({ 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<number | string>('');
// 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<SettingsProps> = ({ 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<typeof me>(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<HTMLInputElement>) => {
const file = e.target.files?.[0];
+7 -5
View File
@@ -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<any>(null);
const [removeFlavor, setRemoveFlavor] = useState<any>(null);
const [keepFlavor, setKeepFlavor] = useState<FlavorListItem | null>(null);
const [removeFlavor, setRemoveFlavor] = useState<FlavorListItem | null>(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);
+16 -2
View File
@@ -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 = {