mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
fix: theme preset persistence, page transition flash, and wheel LED jank
- ThemeTab: auto-save presets to server on apply so they persist across navigation - PageLoader: add bg-dark-950 to prevent transparent flash during lazy-load transitions - FortuneWheel: replace React state-based LED animation (10 re-renders/sec) with pure CSS @keyframes
This commit is contained in:
@@ -92,40 +92,6 @@ export function ThemeTab() {
|
|||||||
|
|
||||||
const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current);
|
const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current);
|
||||||
|
|
||||||
// Update a single color in the draft and apply preview instantly
|
|
||||||
const updateDraftColor = useCallback(
|
|
||||||
(key: keyof ThemeColors, value: string) => {
|
|
||||||
setDraftColors((prev) => {
|
|
||||||
const next = { ...prev, [key]: value };
|
|
||||||
applyThemeColors(next);
|
|
||||||
queryClient.setQueryData(['theme-colors'], next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply a full preset to draft
|
|
||||||
const applyPreset = useCallback(
|
|
||||||
(colors: Partial<ThemeColors>) => {
|
|
||||||
setDraftColors((prev) => {
|
|
||||||
const next = { ...prev, ...colors };
|
|
||||||
applyThemeColors(next);
|
|
||||||
queryClient.setQueryData(['theme-colors'], next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[queryClient],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cancel: revert draft to saved
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
const saved = savedColorsRef.current;
|
|
||||||
setDraftColors(saved);
|
|
||||||
applyThemeColors(saved);
|
|
||||||
queryClient.setQueryData(['theme-colors'], saved);
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateColorsMutation = useMutation({
|
const updateColorsMutation = useMutation({
|
||||||
mutationFn: themeColorsApi.updateColors,
|
mutationFn: themeColorsApi.updateColors,
|
||||||
@@ -183,6 +149,42 @@ export function ThemeTab() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update a single color in the draft and apply preview instantly
|
||||||
|
const updateDraftColor = useCallback(
|
||||||
|
(key: keyof ThemeColors, value: string) => {
|
||||||
|
setDraftColors((prev) => {
|
||||||
|
const next = { ...prev, [key]: value };
|
||||||
|
applyThemeColors(next);
|
||||||
|
queryClient.setQueryData(['theme-colors'], next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply a full preset and auto-save to server
|
||||||
|
const applyPreset = useCallback(
|
||||||
|
(colors: Partial<ThemeColors>) => {
|
||||||
|
setDraftColors((prev) => {
|
||||||
|
const next = { ...prev, ...colors };
|
||||||
|
applyThemeColors(next);
|
||||||
|
queryClient.setQueryData(['theme-colors'], next);
|
||||||
|
// Auto-save preset to server so it persists across navigation
|
||||||
|
updateColorsMutation.mutate(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient, updateColorsMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel: revert draft to saved
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
const saved = savedColorsRef.current;
|
||||||
|
setDraftColors(saved);
|
||||||
|
applyThemeColors(saved);
|
||||||
|
queryClient.setQueryData(['theme-colors'], saved);
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Theme toggles */}
|
{/* Theme toggles */}
|
||||||
@@ -416,17 +418,21 @@ export function ThemeTab() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={() => resetColorsMutation.mutate()}
|
|
||||||
disabled={resetColorsMutation.isPending}
|
|
||||||
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('admin.settings.resetAllColors')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reset all colors */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => resetColorsMutation.mutate()}
|
||||||
|
disabled={resetColorsMutation.isPending}
|
||||||
|
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('admin.settings.resetAllColors')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
|
|||||||
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
|
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[50vh] items-center justify-center">
|
<div className="flex min-h-[50vh] items-center justify-center bg-dark-950">
|
||||||
<div
|
<div
|
||||||
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
|
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, useMemo, memo } from 'react';
|
import { useEffect, useRef, useState, memo } from 'react';
|
||||||
import type { WheelPrize } from '../../api/wheel';
|
import type { WheelPrize } from '../../api/wheel';
|
||||||
|
|
||||||
interface FortuneWheelProps {
|
interface FortuneWheelProps {
|
||||||
@@ -24,20 +24,6 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
const wheelRef = useRef<SVGGElement>(null);
|
const wheelRef = useRef<SVGGElement>(null);
|
||||||
const accumulatedRotation = useRef(0);
|
const accumulatedRotation = useRef(0);
|
||||||
const [displayRotation, setDisplayRotation] = useState(0);
|
const [displayRotation, setDisplayRotation] = useState(0);
|
||||||
const [lightPhase, setLightPhase] = useState(0);
|
|
||||||
|
|
||||||
// Animated lights effect - always running, speed depends on spinning state
|
|
||||||
useEffect(() => {
|
|
||||||
// Faster animation when spinning, slower when idle
|
|
||||||
const interval = setInterval(
|
|
||||||
() => {
|
|
||||||
setLightPhase((p) => (p + 1) % 20);
|
|
||||||
},
|
|
||||||
isSpinning ? 100 : 300,
|
|
||||||
); // 100ms when spinning, 300ms when idle
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isSpinning]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSpinning && targetRotation !== null && wheelRef.current) {
|
if (isSpinning && targetRotation !== null && wheelRef.current) {
|
||||||
@@ -57,18 +43,6 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
}
|
}
|
||||||
}, [isSpinning, targetRotation, onSpinComplete]);
|
}, [isSpinning, targetRotation, onSpinComplete]);
|
||||||
|
|
||||||
// Memoize light pattern calculation
|
|
||||||
const lightPattern = useMemo(() => {
|
|
||||||
const numLights = isSpinning ? 3 : 4; // 3 lights when spinning, 4 when idle
|
|
||||||
|
|
||||||
return Array.from({ length: 20 }, (_, i) => {
|
|
||||||
// Calculate distance from current lightPhase
|
|
||||||
const distance = (i - lightPhase + 20) % 20;
|
|
||||||
// Light is on if within range [0, numLights)
|
|
||||||
return distance < numLights;
|
|
||||||
});
|
|
||||||
}, [isSpinning, lightPhase]);
|
|
||||||
|
|
||||||
if (prizes.length === 0) {
|
if (prizes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex aspect-square w-full max-w-md items-center justify-center">
|
<div className="mx-auto flex aspect-square w-full max-w-md items-center justify-center">
|
||||||
@@ -257,36 +231,53 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* LED lights on outer ring - positioned toward outer edge to avoid bleeding into sectors */}
|
{/* LED chase animation — pure CSS, no React re-renders */}
|
||||||
{Array.from({ length: 20 }).map((_, i) => {
|
<style>
|
||||||
const angle = (i * 18 - 90) * (Math.PI / 180);
|
{`
|
||||||
const ledRadius = outerRadius + 3;
|
@keyframes ledChase {
|
||||||
const dotX = center + ledRadius * Math.cos(angle);
|
0%, 100% { fill: #374151; stroke: #1F2937; }
|
||||||
const dotY = center + ledRadius * Math.sin(angle);
|
10%, 30% { fill: #FEF08A; stroke: #FDE047; }
|
||||||
const isLit = lightPattern[i] ?? i % 2 === 0;
|
}
|
||||||
return (
|
@keyframes ledGlow {
|
||||||
<g key={`led-${i}`}>
|
0%, 100% { opacity: 0; }
|
||||||
{isLit && (
|
10%, 30% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
.led-dot { animation: ledChase 6s linear infinite; }
|
||||||
|
.led-glow { opacity: 0; animation: ledGlow 6s linear infinite; }
|
||||||
|
.led-spinning .led-dot { animation-duration: 2s; }
|
||||||
|
.led-spinning .led-glow { animation-duration: 2s; }
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<g className={isSpinning ? 'led-spinning' : undefined}>
|
||||||
|
{Array.from({ length: 20 }).map((_, i) => {
|
||||||
|
const angle = (i * 18 - 90) * (Math.PI / 180);
|
||||||
|
const ledRadius = outerRadius + 3;
|
||||||
|
const dotX = center + ledRadius * Math.cos(angle);
|
||||||
|
const dotY = center + ledRadius * Math.sin(angle);
|
||||||
|
// Delay as fraction of full cycle — CSS handles speed via animation-duration
|
||||||
|
const delay = `${(i / 20) * 6}s`;
|
||||||
|
return (
|
||||||
|
<g key={`led-${i}`}>
|
||||||
<circle
|
<circle
|
||||||
|
className="led-glow"
|
||||||
cx={dotX}
|
cx={dotX}
|
||||||
cy={dotY}
|
cy={dotY}
|
||||||
r={5}
|
r={5}
|
||||||
fill="#FEF08A"
|
fill="#FEF08A"
|
||||||
opacity={0.4}
|
style={{ filter: 'blur(2px)', animationDelay: delay }}
|
||||||
style={{ filter: 'blur(2px)' }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<circle
|
||||||
<circle
|
className="led-dot"
|
||||||
cx={dotX}
|
cx={dotX}
|
||||||
cy={dotY}
|
cy={dotY}
|
||||||
r={3.5}
|
r={3.5}
|
||||||
fill={isLit ? '#FEF08A' : '#374151'}
|
strokeWidth="1"
|
||||||
stroke={isLit ? '#FDE047' : '#1F2937'}
|
style={{ animationDelay: delay }}
|
||||||
strokeWidth="1"
|
/>
|
||||||
/>
|
</g>
|
||||||
</g>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</g>
|
||||||
|
|
||||||
{/* Rotating wheel group */}
|
{/* Rotating wheel group */}
|
||||||
<g
|
<g
|
||||||
|
|||||||
Reference in New Issue
Block a user