mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
style + warnings fix
This commit is contained in:
1 parent
296ae167fb
commit
a95a692e1c
35 files changed
+278
-211
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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,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,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);
|
||||
|
||||
@@ -22,11 +22,17 @@ export const useDrawerAnchor = (): UseDrawerAnchorResult => {
|
||||
(user?.drawer_anchor as DrawerAnchor) ?? readLocal(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.drawer_anchor) {
|
||||
// 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);
|
||||
localStorage.setItem(STORAGE_KEY, user.drawer_anchor);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.drawer_anchor) localStorage.setItem(STORAGE_KEY, user.drawer_anchor);
|
||||
}, [user?.drawer_anchor]);
|
||||
|
||||
const setAnchor = useCallback(
|
||||
|
||||
@@ -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,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,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);
|
||||
|
||||
@@ -25,12 +25,14 @@ const AdminRatingDetail: React.FC = () => {
|
||||
const { notify } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
if (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 || '');
|
||||
}
|
||||
}, [rating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) navigate('/admin-panel');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user