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
+282
-215
No files matched your search
@@ -34,7 +34,7 @@ interface Banner {
|
|||||||
slug: string;
|
slug: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
settings: any;
|
settings: Record<string, unknown>;
|
||||||
schema: BannerSettingSchema[];
|
schema: BannerSettingSchema[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [localSettings, setLocalSettings] = useState<any>({});
|
const [localSettings, setLocalSettings] = useState<Record<string, unknown>>({});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -71,15 +71,18 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
|
|||||||
await api.patch(`banners/${banner.id}/`, { settings: localSettings });
|
await api.patch(`banners/${banner.id}/`, { settings: localSettings });
|
||||||
onSave();
|
onSave();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to save settings');
|
setError(
|
||||||
|
(err as { response?: { data?: { error?: string } } }).response?.data?.error ||
|
||||||
|
'Failed to save settings',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSetting = (key: string, value: any) => {
|
const updateSetting = (key: string, value: unknown) => {
|
||||||
setLocalSettings((prev: any) => ({
|
setLocalSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
}));
|
}));
|
||||||
@@ -119,7 +122,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
|
|||||||
type="number"
|
type="number"
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
value={value}
|
value={String(value)}
|
||||||
inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
|
inputProps={{ min: schema.min, max: schema.max, step: schema.step }}
|
||||||
onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
|
onChange={(e) => updateSetting(schema.key, Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
@@ -127,7 +130,7 @@ const BannerSettingsDialog: React.FC<BannerSettingsDialogProps> = ({
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
value={value}
|
value={String(value)}
|
||||||
onChange={(e) => updateSetting(schema.key, e.target.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 {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
Button,
|
||||||
@@ -19,14 +19,8 @@ interface CookieBannerProps {
|
|||||||
|
|
||||||
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => {
|
const CookieBanner: React.FC<CookieBannerProps> = ({ onThemeChange, currentTheme }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
// Open on first render only if consent hasn't been recorded yet.
|
||||||
|
const [open, setOpen] = useState(() => !localStorage.getItem('cookie-consent'));
|
||||||
useEffect(() => {
|
|
||||||
const consent = localStorage.getItem('cookie-consent');
|
|
||||||
if (!consent) {
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAccept = () => {
|
const handleAccept = () => {
|
||||||
localStorage.setItem('cookie-consent', 'true');
|
localStorage.setItem('cookie-consent', 'true');
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ interface UserSuggestion {
|
|||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SearchResultItem {
|
||||||
|
type: string;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
image_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface MentionTextFieldProps {
|
interface MentionTextFieldProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -52,10 +59,10 @@ const MentionTextField: React.FC<MentionTextFieldProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('flavors/search/');
|
const res = await api.get<SearchResultItem[]>('flavors/search/');
|
||||||
const users = (res.data || [])
|
const users = (res.data || [])
|
||||||
.filter((item: any) => item.type === 'user')
|
.filter((item) => item.type === 'user')
|
||||||
.map((item: any) => ({
|
.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
username: item.name,
|
username: item.name,
|
||||||
avatar: item.image_url,
|
avatar: item.image_url,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, alpha, useTheme } from '@mui/material';
|
import { Box, alpha, useTheme, type SxProps, type Theme } from '@mui/material';
|
||||||
|
|
||||||
interface RatingBadgeProps {
|
interface RatingBadgeProps {
|
||||||
score: number;
|
score: number;
|
||||||
size?: 'small' | 'medium' | 'large';
|
size?: 'small' | 'medium' | 'large';
|
||||||
sx?: any;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
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';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
interface StatusBadgeProps {
|
||||||
isLegacy?: boolean;
|
isLegacy?: boolean;
|
||||||
isAvailable?: boolean;
|
isAvailable?: boolean;
|
||||||
size?: 'small' | 'medium';
|
size?: 'small' | 'medium';
|
||||||
sx?: any;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { act, render } from '@testing-library/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 IOEntryLike = { isIntersecting: boolean };
|
||||||
type IOCallback = (entries: IOEntryLike[]) => void;
|
type IOCallback = (entries: IOEntryLike[]) => void;
|
||||||
@@ -50,12 +51,13 @@ describe('BannerPerformanceWrapper', () => {
|
|||||||
ioCallback = null;
|
ioCallback = null;
|
||||||
observeMock = vi.fn();
|
observeMock = vi.fn();
|
||||||
disconnectMock = vi.fn();
|
disconnectMock = vi.fn();
|
||||||
(globalThis as any).IntersectionObserver = FakeIntersectionObserver;
|
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver =
|
||||||
|
FakeIntersectionObserver;
|
||||||
mockMatchMedia(false);
|
mockMatchMedia(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete (globalThis as any).IntersectionObserver;
|
delete (globalThis as unknown as { IntersectionObserver?: unknown }).IntersectionObserver;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults to isActive=true with configured targetFps', () => {
|
it('defaults to isActive=true with configured targetFps', () => {
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
import {
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from 'react';
|
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
import { BannerPerfContext, type BannerPerfState } from './banner-perf';
|
||||||
export interface BannerPerfState {
|
|
||||||
isActive: boolean;
|
|
||||||
targetFps: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultState: BannerPerfState = { isActive: true, targetFps: 60 };
|
|
||||||
|
|
||||||
export const BannerPerfContext = createContext<BannerPerfState>(defaultState);
|
|
||||||
|
|
||||||
export interface BannerPerformanceWrapperProps {
|
export interface BannerPerformanceWrapperProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -61,52 +45,3 @@ export const BannerPerformanceWrapper = ({
|
|||||||
</BannerPerfContext.Provider>
|
</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 HextechCorruptionBanner } from './HextechCorruptionBanner';
|
||||||
export { default as MatrixBanner } from './MatrixBanner';
|
export { default as MatrixBanner } from './MatrixBanner';
|
||||||
export { default as NebulaBanner } from './NebulaBanner';
|
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 { useCanvasBanner, CANVAS_STYLE } from './useCanvasBanner';
|
||||||
export type { CanvasMouse, DrawArgs, UseCanvasBannerOptions } from './useCanvasBanner';
|
export type { CanvasMouse, DrawArgs, UseCanvasBannerOptions } from './useCanvasBanner';
|
||||||
@@ -77,12 +77,11 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
// jsdom doesn't implement Canvas 2D — stub it.
|
// jsdom doesn't implement Canvas 2D — stub it.
|
||||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
scale: vi.fn(),
|
scale: vi.fn(),
|
||||||
setTransform: 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 { useEffect, useRef, type CSSProperties, type DependencyList, type RefObject } from 'react';
|
||||||
import { useBannerFrameGate } from './BannerPerformanceWrapper';
|
import { useBannerFrameGate } from './banner-perf';
|
||||||
|
|
||||||
export interface CanvasMouse {
|
export interface CanvasMouse {
|
||||||
x: number;
|
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 MuiCard from '@mui/material/Card';
|
||||||
import MuiPaper from '@mui/material/Paper';
|
import MuiPaper from '@mui/material/Paper';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
@@ -7,7 +7,7 @@ type Intensity = 'subtle' | 'default' | 'strong';
|
|||||||
|
|
||||||
const intensityStyles =
|
const intensityStyles =
|
||||||
(intensity: Intensity) =>
|
(intensity: Intensity) =>
|
||||||
({ theme }: { theme: any }) => {
|
({ theme }: { theme: Theme }) => {
|
||||||
const t = theme.tokens;
|
const t = theme.tokens;
|
||||||
const tint =
|
const tint =
|
||||||
intensity === 'strong'
|
intensity === 'strong'
|
||||||
|
|||||||
@@ -38,9 +38,16 @@ const Carousel: React.FC<CarouselProps> = ({ images, alt, onMainClick, variant }
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!emblaApi) return;
|
if (!emblaApi) return;
|
||||||
updateState();
|
|
||||||
emblaApi.on('select', updateState);
|
emblaApi.on('select', updateState);
|
||||||
emblaApi.on('reInit', 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]);
|
}, [emblaApi, updateState]);
|
||||||
|
|
||||||
const scrollPrev = () => emblaApi?.scrollPrev();
|
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 { useTranslation } from 'react-i18next';
|
||||||
import api, { type RetriableConfig } from '../lib/api';
|
import api, { type RetriableConfig } from '../lib/api';
|
||||||
|
import { AuthContext, type AuthUser, type FollowingEntry } from './auth-context';
|
||||||
|
|
||||||
export interface AuthUser {
|
export type { AuthUser, FollowingEntry };
|
||||||
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 const AuthProvider = ({ children }: { children: ReactNode }) => {
|
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const { i18n } = useTranslation();
|
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 Dialog from '@mui/material/Dialog';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogContentText from '@mui/material/DialogContentText';
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
import { ConfirmContext, type ConfirmOptions } from './confirm-context';
|
||||||
|
|
||||||
export interface ConfirmOptions {
|
export type { ConfirmOptions, ConfirmContextValue } from './confirm-context';
|
||||||
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 const ConfirmProvider = ({ children }: { children: ReactNode }) => {
|
export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [open, setOpen] = useState(false);
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { useToast } from '../hooks/useToast';
|
import { useToast } from '../hooks/useToast';
|
||||||
import { queryKeys } from '../api/keys';
|
import { queryKeys } from '../api/keys';
|
||||||
import { useNotificationsQuery, type Notification } from '../api/queries/useNotifications';
|
import { useNotificationsQuery } from '../api/queries/useNotifications';
|
||||||
import {
|
import {
|
||||||
useMarkAllNotificationsRead,
|
useMarkAllNotificationsRead,
|
||||||
useMarkNotificationRead,
|
useMarkNotificationRead,
|
||||||
} from '../api/mutations/useMarkNotifications';
|
} from '../api/mutations/useMarkNotifications';
|
||||||
|
import { NotificationContext } from './notification-context';
|
||||||
|
|
||||||
export type { Notification };
|
export type { Notification } from './notification-context';
|
||||||
|
|
||||||
export interface NotificationContextValue {
|
|
||||||
notifications: Notification[];
|
|
||||||
fetchNotifications: () => Promise<void>;
|
|
||||||
markAllRead: () => Promise<void>;
|
|
||||||
markRead: (id: number) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
|
||||||
|
|
||||||
export const NotificationProvider = ({ children }: { children: ReactNode }) => {
|
export const NotificationProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const { user, setUser, refetchUser } = useAuth();
|
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 { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material';
|
||||||
import { getTheme, type CatppuccinTheme } from '../theme';
|
import { getTheme, type CatppuccinTheme } from '../theme';
|
||||||
import api from '../lib/api';
|
import api from '../lib/api';
|
||||||
|
import { ThemeContext } from './theme-context';
|
||||||
|
|
||||||
export interface ThemeContextValue {
|
export type { ThemeContextValue } from './theme-context';
|
||||||
themeName: CatppuccinTheme;
|
|
||||||
setThemeName: (name: CatppuccinTheme) => void;
|
|
||||||
handleThemeChange: (name: CatppuccinTheme, isLoggedIn: boolean) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
||||||
|
|
||||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [themeName, setThemeName] = useState<CatppuccinTheme>(
|
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 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 {
|
export type { ToastOptions, ToastContextValue } from './toast-context';
|
||||||
message: string;
|
|
||||||
severity?: AlertColor;
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToastContextValue {
|
|
||||||
notify: (opts: ToastOptions | string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ToastContext = createContext<ToastContextValue | null>(null);
|
|
||||||
|
|
||||||
interface ToastState extends ToastOptions {
|
interface ToastState extends ToastOptions {
|
||||||
key: number;
|
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 { useContext } from 'react';
|
||||||
import { AuthContext } from '../contexts/AuthContext';
|
import { AuthContext } from '../contexts/auth-context';
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const ctx = useContext(AuthContext);
|
const ctx = useContext(AuthContext);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { ConfirmContext } from '../contexts/ConfirmContext';
|
import { ConfirmContext } from '../contexts/confirm-context';
|
||||||
|
|
||||||
export const useConfirm = () => {
|
export const useConfirm = () => {
|
||||||
const ctx = useContext(ConfirmContext);
|
const ctx = useContext(ConfirmContext);
|
||||||
|
|||||||
@@ -22,11 +22,17 @@ export const useDrawerAnchor = (): UseDrawerAnchorResult => {
|
|||||||
(user?.drawer_anchor as DrawerAnchor) ?? readLocal(),
|
(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(() => {
|
useEffect(() => {
|
||||||
if (user?.drawer_anchor) {
|
if (user?.drawer_anchor) localStorage.setItem(STORAGE_KEY, user.drawer_anchor);
|
||||||
setAnchorState(user.drawer_anchor);
|
|
||||||
localStorage.setItem(STORAGE_KEY, user.drawer_anchor);
|
|
||||||
}
|
|
||||||
}, [user?.drawer_anchor]);
|
}, [user?.drawer_anchor]);
|
||||||
|
|
||||||
const setAnchor = useCallback(
|
const setAnchor = useCallback(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { NotificationContext } from '../contexts/NotificationContext';
|
import { NotificationContext } from '../contexts/notification-context';
|
||||||
|
|
||||||
export const useNotifications = () => {
|
export const useNotifications = () => {
|
||||||
const ctx = useContext(NotificationContext);
|
const ctx = useContext(NotificationContext);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { ThemeContext } from '../contexts/ThemeContext';
|
import { ThemeContext } from '../contexts/theme-context';
|
||||||
|
|
||||||
export const useTheme = () => {
|
export const useTheme = () => {
|
||||||
const ctx = useContext(ThemeContext);
|
const ctx = useContext(ThemeContext);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { ToastContext } from '../contexts/ToastContext';
|
import { ToastContext } from '../contexts/toast-context';
|
||||||
|
|
||||||
export const useToast = () => {
|
export const useToast = () => {
|
||||||
const ctx = useContext(ToastContext);
|
const ctx = useContext(ToastContext);
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ const AdminRatingDetail: React.FC = () => {
|
|||||||
const { notify } = useToast();
|
const { notify } = useToast();
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
|
||||||
useEffect(() => {
|
// Seed the form from the loaded rating during render (tracking the
|
||||||
if (rating) {
|
// previously-synced row) so it re-fills only when the data changes.
|
||||||
setScore(rating.score);
|
const [syncedRating, setSyncedRating] = useState(rating);
|
||||||
setComment(rating.comment || '');
|
if (rating && rating !== syncedRating) {
|
||||||
}
|
setSyncedRating(rating);
|
||||||
}, [rating]);
|
setScore(rating.score);
|
||||||
|
setComment(rating.comment || '');
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) navigate('/admin-panel');
|
if (error) navigate('/admin-panel');
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ const AdminReplyDetail: React.FC = () => {
|
|||||||
const { notify } = useToast();
|
const { notify } = useToast();
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
|
||||||
useEffect(() => {
|
// Seed the form from the loaded reply during render (tracking the
|
||||||
if (reply) setText(reply.text);
|
// previously-synced row) so it re-fills only when the data changes.
|
||||||
}, [reply]);
|
const [syncedReply, setSyncedReply] = useState(reply);
|
||||||
|
if (reply && reply !== syncedReply) {
|
||||||
|
setSyncedReply(reply);
|
||||||
|
setText(reply.text);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) navigate('/admin-panel');
|
if (error) navigate('/admin-panel');
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ const PublicProfile: React.FC = () => {
|
|||||||
const { notify } = useToast();
|
const { notify } = useToast();
|
||||||
const { confirm } = useConfirm();
|
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(() => {
|
useEffect(() => {
|
||||||
if (data?.avatar) {
|
if (data?.avatar) {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -96,8 +104,6 @@ const PublicProfile: React.FC = () => {
|
|||||||
setPalette([]);
|
setPalette([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
setPalette([]);
|
|
||||||
}
|
}
|
||||||
}, [data?.avatar]);
|
}, [data?.avatar]);
|
||||||
|
|
||||||
|
|||||||
@@ -74,20 +74,18 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
|||||||
const [pendingConfirmation, setPendingConfirmation] = useState(false);
|
const [pendingConfirmation, setPendingConfirmation] = useState(false);
|
||||||
const [oldPassword, setOldPassword] = useState('');
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [deletionCode, setDeletionCode] = useState('');
|
const [deletionCode, setDeletionCode] = useState(() => searchParams.get('deletion_code') ?? '');
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(() => searchParams.has('deletion_code'));
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
const [selectedBannerId, setSelectedBannerId] = useState<number | string>('');
|
const [selectedBannerId, setSelectedBannerId] = useState<number | string>('');
|
||||||
|
|
||||||
// Deep link from the deletion email: ?deletion_code=NNNNNN pre-fills the code
|
// 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
|
// and opens the confirm form (see the lazy useState initializers above). The
|
||||||
// is a convenience, not a one-click destruct. Strip the param after reading so
|
// user still has to click "Delete" — the link is a convenience, not a
|
||||||
// it doesn't linger in the URL / browser history.
|
// one-click destruct. Strip the param after reading so it doesn't linger in
|
||||||
|
// the URL / browser history.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const code = searchParams.get('deletion_code');
|
if (!searchParams.has('deletion_code')) return;
|
||||||
if (!code) return;
|
|
||||||
setDeletionCode(code);
|
|
||||||
setIsDeleting(true);
|
|
||||||
searchParams.delete('deletion_code');
|
searchParams.delete('deletion_code');
|
||||||
setSearchParams(searchParams, { replace: true });
|
setSearchParams(searchParams, { replace: true });
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams]);
|
||||||
@@ -97,15 +95,20 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
|||||||
else navigate('/');
|
else navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
// Seed the editable form fields from the loaded profile. Done during render
|
||||||
if (!me) return;
|
// (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);
|
setUsername(me.username);
|
||||||
setEmail(me.email);
|
setEmail(me.email);
|
||||||
setCurrentEmail(me.email);
|
setCurrentEmail(me.email);
|
||||||
setAvatar(me.avatar);
|
setAvatar(me.avatar);
|
||||||
setSelectedBannerId(me.selected_banner || '');
|
setSelectedBannerId(me.selected_banner || '');
|
||||||
if (me.language) setLanguage(me.language);
|
if (me.language) setLanguage(me.language);
|
||||||
}, [me]);
|
}
|
||||||
|
|
||||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useToast } from '../../hooks/useToast';
|
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 api from '../../lib/api';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { queryKeys } from '../../api/keys';
|
import { queryKeys } from '../../api/keys';
|
||||||
@@ -28,8 +28,8 @@ const AdminFlavors: React.FC = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: flavors = [], isLoading } = useFlavorsList();
|
const { data: flavors = [], isLoading } = useFlavorsList();
|
||||||
|
|
||||||
const [keepFlavor, setKeepFlavor] = useState<any>(null);
|
const [keepFlavor, setKeepFlavor] = useState<FlavorListItem | null>(null);
|
||||||
const [removeFlavor, setRemoveFlavor] = useState<any>(null);
|
const [removeFlavor, setRemoveFlavor] = useState<FlavorListItem | null>(null);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [isMerging, setIsMerging] = useState(false);
|
const [isMerging, setIsMerging] = useState(false);
|
||||||
|
|
||||||
@@ -55,8 +55,10 @@ const AdminFlavors: React.FC = () => {
|
|||||||
setKeepFlavor(null);
|
setKeepFlavor(null);
|
||||||
setRemoveFlavor(null);
|
setRemoveFlavor(null);
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
const errorMsg = err.response?.data?.error || err.message;
|
const errorMsg =
|
||||||
|
(err as { response?: { data?: { error?: string } } }).response?.data?.error ||
|
||||||
|
(err as Error).message;
|
||||||
notify({ message: t('admin.errorPrefix', { message: errorMsg }), severity: 'error' });
|
notify({ message: t('admin.errorPrefix', { message: errorMsg }), severity: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setIsMerging(false);
|
setIsMerging(false);
|
||||||
|
|||||||
+16
-2
@@ -77,8 +77,22 @@ export const isLightTheme = (mode: CatppuccinTheme): boolean => {
|
|||||||
].includes(mode);
|
].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) => {
|
export const getTheme = (mode: CatppuccinTheme) => {
|
||||||
let palette: any;
|
let palette: ThemePalette;
|
||||||
|
|
||||||
// Define Holy branding colors
|
// Define Holy branding colors
|
||||||
const holyPurple = { light: '#7c3aed', dark: '#a78bfa' };
|
const holyPurple = { light: '#7c3aed', dark: '#a78bfa' };
|
||||||
@@ -216,7 +230,7 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Handle Catppuccin flavors
|
// Handle Catppuccin flavors
|
||||||
const flavor = (flavors as any)[mode];
|
const flavor = flavors[mode as keyof typeof flavors];
|
||||||
if (flavor) {
|
if (flavor) {
|
||||||
const colors = flavor.colors;
|
const colors = flavor.colors;
|
||||||
palette = {
|
palette = {
|
||||||
|
|||||||
Reference in new issue
Block a user