mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
migrated pages to new ui
This commit is contained in:
1 parent
e4d59a13ba
commit
607f8f9bc1
42 files changed
+3428
-3187
No files matched your search
+4
-17
@@ -1,8 +1,6 @@
|
||||
import React, { Suspense, lazy, useEffect, useState } from 'react';
|
||||
import { Routes, Route, Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
@@ -25,6 +23,7 @@ import {
|
||||
useMediaQuery,
|
||||
useTheme as useMuiTheme,
|
||||
} from '@mui/material';
|
||||
import { GlassAppBar } from './components/ui';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
@@ -378,19 +377,8 @@ const App: React.FC = () => {
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position="sticky"
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: (th) => alpha(th.palette.background.paper, 0.8),
|
||||
backdropFilter: 'blur(12px)',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, px: { xs: 1, sm: 4, md: 6 } }}>
|
||||
<Typography
|
||||
<GlassAppBar>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="div"
|
||||
sx={{
|
||||
@@ -543,8 +531,7 @@ const App: React.FC = () => {
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</GlassAppBar>
|
||||
|
||||
<Drawer anchor="right" open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{drawer}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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';
|
||||
|
||||
type IOEntryLike = { isIntersecting: boolean };
|
||||
type IOCallback = (entries: IOEntryLike[]) => void;
|
||||
|
||||
let ioCallback: IOCallback | null = null;
|
||||
let observeMock = vi.fn();
|
||||
let disconnectMock = vi.fn();
|
||||
|
||||
class FakeIntersectionObserver {
|
||||
constructor(cb: IOCallback) {
|
||||
ioCallback = cb;
|
||||
}
|
||||
observe = observeMock;
|
||||
disconnect = disconnectMock;
|
||||
unobserve = vi.fn();
|
||||
takeRecords = () => [];
|
||||
root = null;
|
||||
rootMargin = '';
|
||||
thresholds = [];
|
||||
}
|
||||
|
||||
const mockMatchMedia = (reduced: boolean) => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: query.includes('reduce') ? reduced : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const Probe = () => {
|
||||
const { isActive, targetFps } = useContext(BannerPerfContext);
|
||||
return (
|
||||
<div data-testid="probe" data-active={String(isActive)} data-fps={String(targetFps)} />
|
||||
);
|
||||
};
|
||||
|
||||
describe('BannerPerformanceWrapper', () => {
|
||||
beforeEach(() => {
|
||||
ioCallback = null;
|
||||
observeMock = vi.fn();
|
||||
disconnectMock = vi.fn();
|
||||
(globalThis as any).IntersectionObserver = FakeIntersectionObserver;
|
||||
mockMatchMedia(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as any).IntersectionObserver;
|
||||
});
|
||||
|
||||
it('defaults to isActive=true with configured targetFps', () => {
|
||||
const { getByTestId } = render(
|
||||
<BannerPerformanceWrapper targetFps={30}>
|
||||
<Probe />
|
||||
</BannerPerformanceWrapper>,
|
||||
);
|
||||
const el = getByTestId('probe');
|
||||
expect(el.dataset.active).toBe('true');
|
||||
expect(el.dataset.fps).toBe('30');
|
||||
expect(observeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flips isActive=false when IntersectionObserver reports not intersecting', () => {
|
||||
const { getByTestId } = render(
|
||||
<BannerPerformanceWrapper>
|
||||
<Probe />
|
||||
</BannerPerformanceWrapper>,
|
||||
);
|
||||
expect(getByTestId('probe').dataset.active).toBe('true');
|
||||
|
||||
act(() => {
|
||||
ioCallback?.([{ isIntersecting: false }]);
|
||||
});
|
||||
|
||||
expect(getByTestId('probe').dataset.active).toBe('false');
|
||||
|
||||
act(() => {
|
||||
ioCallback?.([{ isIntersecting: true }]);
|
||||
});
|
||||
expect(getByTestId('probe').dataset.active).toBe('true');
|
||||
});
|
||||
|
||||
it('prefers-reduced-motion forces isActive=false and targetFps=0', () => {
|
||||
mockMatchMedia(true);
|
||||
const { getByTestId } = render(
|
||||
<BannerPerformanceWrapper targetFps={30}>
|
||||
<Probe />
|
||||
</BannerPerformanceWrapper>,
|
||||
);
|
||||
const el = getByTestId('probe');
|
||||
expect(el.dataset.active).toBe('false');
|
||||
expect(el.dataset.fps).toBe('0');
|
||||
});
|
||||
|
||||
it('disconnects observer on unmount', () => {
|
||||
const { unmount } = render(
|
||||
<BannerPerformanceWrapper>
|
||||
<Probe />
|
||||
</BannerPerformanceWrapper>,
|
||||
);
|
||||
unmount();
|
||||
expect(disconnectMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
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);
|
||||
|
||||
export interface BannerPerformanceWrapperProps {
|
||||
children: ReactNode;
|
||||
targetFps?: number;
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
export const BannerPerformanceWrapper = ({
|
||||
children,
|
||||
targetFps = 30,
|
||||
rootMargin = '100px',
|
||||
}: BannerPerformanceWrapperProps) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || typeof IntersectionObserver === 'undefined') return;
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => setVisible(entry.isIntersecting),
|
||||
{ threshold: 0.01, rootMargin },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [rootMargin]);
|
||||
|
||||
const value = useMemo<BannerPerfState>(
|
||||
() => ({
|
||||
isActive: visible && !reducedMotion,
|
||||
targetFps: reducedMotion ? 0 : targetFps,
|
||||
}),
|
||||
[visible, reducedMotion, targetFps],
|
||||
);
|
||||
|
||||
return (
|
||||
<BannerPerfContext.Provider value={value}>
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</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],
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../lib/api';
|
||||
import GenerativeBanner from './GenerativeBanner';
|
||||
import NebulaBanner from './NebulaBanner';
|
||||
@@ -6,6 +6,7 @@ import FirefliesBanner from './FirefliesBanner';
|
||||
import HextechBanner from './HextechBanner';
|
||||
import HextechCorruptionBanner from './HextechCorruptionBanner';
|
||||
import GenerativeFlowBanner from './GenerativeFlowBanner';
|
||||
import { BannerPerformanceWrapper } from './BannerPerformanceWrapper';
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
@@ -23,9 +24,13 @@ interface DynamicBannerProps {
|
||||
followersCount: number;
|
||||
}
|
||||
|
||||
// Future expansion: Add more banner components here
|
||||
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
|
||||
// Placeholder for a second model
|
||||
const MatrixBanner: React.FC<DynamicBannerProps & { settings: Banner['settings'] | null }> = ({
|
||||
username,
|
||||
settings,
|
||||
}) => {
|
||||
const [durations] = useState(() =>
|
||||
Array.from({ length: 20 }, () => 2 + Math.random() * 3),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -42,15 +47,12 @@ const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ userna
|
||||
fontFamily: 'monospace',
|
||||
zIndex: 1,
|
||||
overflow: 'hidden',
|
||||
opacity: settings.opacity || 0.5,
|
||||
opacity: settings?.opacity || 0.5,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}
|
||||
>
|
||||
{durations.map((dur, i) => (
|
||||
<div key={i} style={{ animation: `matrixFall ${dur}s linear infinite` }}>
|
||||
{username.split('').map((char, j) => (
|
||||
<span key={j} style={{ display: 'block' }}>
|
||||
{char}
|
||||
@@ -71,6 +73,24 @@ const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ userna
|
||||
);
|
||||
};
|
||||
|
||||
const pickBanner = (
|
||||
banner: Banner | null,
|
||||
props: DynamicBannerProps,
|
||||
): React.ReactElement => {
|
||||
if (!banner || banner.slug === 'generative-web') {
|
||||
return <GenerativeBanner {...props} settings={banner?.settings} />;
|
||||
}
|
||||
if (banner.slug === 'matrix') return <MatrixBanner {...props} settings={banner.settings} />;
|
||||
if (banner.slug === 'nebula') return <NebulaBanner {...props} settings={banner.settings} />;
|
||||
if (banner.slug === 'fireflies') return <FirefliesBanner {...props} settings={banner.settings} />;
|
||||
if (banner.slug === 'hextech') return <HextechBanner {...props} settings={banner.settings} />;
|
||||
if (banner.slug === 'hextech-corruption')
|
||||
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
|
||||
if (banner.slug === 'generative-flow')
|
||||
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
|
||||
return <GenerativeBanner {...props} />;
|
||||
};
|
||||
|
||||
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||
const [banner, setBanner] = useState<Banner | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -94,37 +114,7 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
// Default to GenerativeBanner if no active banner or unrecognized slug
|
||||
if (!banner || banner.slug === 'generative-web') {
|
||||
return <GenerativeBanner {...props} settings={banner?.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'matrix') {
|
||||
return <MatrixBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'nebula') {
|
||||
return <NebulaBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'fireflies') {
|
||||
return <FirefliesBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'hextech') {
|
||||
return <HextechBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'hextech-corruption') {
|
||||
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
if (banner.slug === 'generative-flow') {
|
||||
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return <GenerativeBanner {...props} />;
|
||||
return <BannerPerformanceWrapper>{pickBanner(banner, props)}</BannerPerformanceWrapper>;
|
||||
};
|
||||
|
||||
export default DynamicBanner;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import { useBannerFrameGate } from './BannerPerformanceWrapper';
|
||||
|
||||
interface FirefliesBannerProps {
|
||||
username: string;
|
||||
@@ -19,6 +20,7 @@ interface FirefliesBannerProps {
|
||||
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
const gate = useBannerFrameGate();
|
||||
|
||||
const count = settings?.count ?? 150;
|
||||
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
|
||||
@@ -133,7 +135,13 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
}
|
||||
|
||||
let time = 0;
|
||||
const draw = () => {
|
||||
const draw = (now = 0) => {
|
||||
const decision = gate(now);
|
||||
if (decision === 'halt') return;
|
||||
if (decision === 'skip') {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
@@ -259,6 +267,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
|
||||
glowSizeMultiplier,
|
||||
flickerSpeed,
|
||||
theme,
|
||||
gate,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import { useBannerFrameGate } from './BannerPerformanceWrapper';
|
||||
|
||||
interface GenerativeBannerProps {
|
||||
username: string;
|
||||
@@ -23,6 +24,7 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
const gate = useBannerFrameGate();
|
||||
|
||||
const nodeCountBase = settings?.nodeCountBase ?? 60;
|
||||
const connectionDist = settings?.connectionDist ?? 140;
|
||||
@@ -150,7 +152,13 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const draw = (now = 0) => {
|
||||
const decision = gate(now);
|
||||
if (decision === 'halt') return;
|
||||
if (decision === 'skip') {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
|
||||
@@ -294,6 +302,7 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
|
||||
connectionDist,
|
||||
speed,
|
||||
globalOpacity,
|
||||
gate,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import { useBannerFrameGate } from './BannerPerformanceWrapper';
|
||||
|
||||
interface GenerativeFlowProps {
|
||||
username: string;
|
||||
@@ -18,6 +19,7 @@ interface GenerativeFlowProps {
|
||||
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
const gate = useBannerFrameGate();
|
||||
|
||||
const blobCount = settings?.blobCount ?? 8;
|
||||
const flowSpeed = settings?.flowSpeed ?? 0.008;
|
||||
@@ -135,7 +137,13 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const draw = (now = 0) => {
|
||||
const decision = gate(now);
|
||||
if (decision === 'halt') return;
|
||||
if (decision === 'skip') {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
@@ -224,7 +232,7 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
|
||||
}
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
|
||||
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme, gate]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import { useBannerFrameGate } from './BannerPerformanceWrapper';
|
||||
|
||||
interface HextechBannerProps {
|
||||
username: string;
|
||||
@@ -18,6 +19,7 @@ interface HextechBannerProps {
|
||||
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
const gate = useBannerFrameGate();
|
||||
|
||||
const gridSize = settings?.gridSize ?? 40;
|
||||
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
|
||||
@@ -142,7 +144,13 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const draw = (now = 0) => {
|
||||
const decision = gate(now);
|
||||
if (decision === 'halt') return;
|
||||
if (decision === 'skip') {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
@@ -277,6 +285,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
|
||||
streamCount,
|
||||
distortionFactor,
|
||||
theme,
|
||||
gate,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import { useBannerFrameGate } from './BannerPerformanceWrapper';
|
||||
|
||||
interface HextechCorruptionBannerProps {
|
||||
username: string;
|
||||
@@ -22,6 +23,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
const gate = useBannerFrameGate();
|
||||
|
||||
const cellCount = settings?.cellCount ?? 40;
|
||||
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
|
||||
@@ -144,7 +146,13 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
const draw = (now = 0) => {
|
||||
const decision = gate(now);
|
||||
if (decision === 'halt') return;
|
||||
if (decision === 'skip') {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
@@ -267,6 +275,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
|
||||
instability,
|
||||
shellDarkness,
|
||||
theme,
|
||||
gate,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { alpha, useTheme } from '@mui/material';
|
||||
import { useBannerFrameGate } from './BannerPerformanceWrapper';
|
||||
|
||||
interface NebulaBannerProps {
|
||||
username: string;
|
||||
@@ -26,6 +27,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const theme = useTheme();
|
||||
const gate = useBannerFrameGate();
|
||||
|
||||
const particleCount = settings?.particleCount ?? 400;
|
||||
const speed = settings?.speed ?? 0.0015;
|
||||
@@ -181,7 +183,13 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({
|
||||
}
|
||||
|
||||
let time = 0;
|
||||
const draw = () => {
|
||||
const draw = (now = 0) => {
|
||||
const decision = gate(now);
|
||||
if (decision === 'halt') return;
|
||||
if (decision === 'skip') {
|
||||
animationFrameId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
const width = canvas.width / (window.devicePixelRatio || 1);
|
||||
const height = canvas.height / (window.devicePixelRatio || 1);
|
||||
if (!width || !height) {
|
||||
@@ -314,6 +322,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({
|
||||
turbulenceStrength,
|
||||
returnSpeed,
|
||||
starDensity,
|
||||
gate,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import Button from '@mui/material/Button';
|
||||
import { getTheme } from '../../theme';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
const wrap = (ui: React.ReactElement) => (
|
||||
<ThemeProvider theme={getTheme('mocha')}>{ui}</ThemeProvider>
|
||||
);
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders title and subtitle', () => {
|
||||
render(wrap(<EmptyState title="No reviews yet" subtitle="Be the first to rate." />));
|
||||
expect(screen.getByText('No reviews yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Be the first to rate.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires CTA onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
wrap(
|
||||
<EmptyState
|
||||
title="No tickets"
|
||||
action={<Button onClick={handleClick}>Create one</Button>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create one' }));
|
||||
expect(handleClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('omits subtitle when not provided', () => {
|
||||
render(wrap(<EmptyState title="Empty" />));
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon slot', () => {
|
||||
render(
|
||||
wrap(
|
||||
<EmptyState
|
||||
title="Nothing"
|
||||
icon={<span data-testid="icon">📭</span>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { GlassSurface } from './Glass';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
action?: ReactNode;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const EmptyState = ({ title, subtitle, icon, action, compact = false }: EmptyStateProps) => (
|
||||
<GlassSurface
|
||||
intensity="subtle"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
gap: 1.25,
|
||||
p: compact ? 3 : 5,
|
||||
minHeight: compact ? 160 : 220,
|
||||
}}
|
||||
>
|
||||
{icon && (
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontSize: compact ? 36 : 48,
|
||||
display: 'inline-flex',
|
||||
'& svg': { fontSize: 'inherit' },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Typography variant={compact ? 'subtitle1' : 'h6'} sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: '50ch' }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
{action && <Box sx={{ mt: 1 }}>{action}</Box>}
|
||||
</GlassSurface>
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { getTheme } from '../../theme';
|
||||
import { FlavorCard } from './FlavorCard';
|
||||
|
||||
const renderCard = (overrides = {}) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={getTheme('mocha')}>
|
||||
<FlavorCard
|
||||
flavor={{
|
||||
id: 42,
|
||||
name: 'Blueberry Dust',
|
||||
image_url: '/media/flavors/blueberry.jpg',
|
||||
average_rating: 7.5,
|
||||
category_name: 'Fruit',
|
||||
is_legacy: false,
|
||||
is_available: true,
|
||||
...overrides,
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('FlavorCard', () => {
|
||||
it('renders name, category, image, and rating', () => {
|
||||
renderCard();
|
||||
expect(screen.getByText('Blueberry Dust')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fruit')).toBeInTheDocument();
|
||||
const img = screen.getByAltText('Blueberry Dust') as HTMLImageElement;
|
||||
expect(img.src).toContain('/media/flavors/blueberry.jpg');
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
});
|
||||
|
||||
it('links to /flavor/:id', () => {
|
||||
renderCard();
|
||||
const link = screen.getByRole('link');
|
||||
expect(link.getAttribute('href')).toBe('/flavor/42');
|
||||
});
|
||||
|
||||
it('omits category row when showCategory=false', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={getTheme('mocha')}>
|
||||
<FlavorCard
|
||||
flavor={{ id: 1, name: 'X', category_name: 'Fruit' }}
|
||||
showCategory={false}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.queryByText('Fruit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders status badge when flavor is legacy', () => {
|
||||
renderCard({ is_legacy: true, is_available: true });
|
||||
expect(screen.getByText(/Limited/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders caption when provided', () => {
|
||||
renderCard({});
|
||||
expect(screen.queryByText('12 reviews')).not.toBeInTheDocument();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={getTheme('mocha')}>
|
||||
<FlavorCard
|
||||
flavor={{ id: 2, name: 'Cap', average_rating: 5 }}
|
||||
caption="12 reviews"
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByText('12 reviews')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { GlassCard } from './Glass';
|
||||
import RatingBadge from '../RatingBadge';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
|
||||
export interface FlavorCardData {
|
||||
id: number;
|
||||
name: string;
|
||||
image_url?: string | null;
|
||||
average_rating?: number;
|
||||
is_legacy?: boolean;
|
||||
is_available?: boolean;
|
||||
category_name?: string;
|
||||
}
|
||||
|
||||
export interface FlavorCardProps {
|
||||
flavor: FlavorCardData;
|
||||
caption?: string;
|
||||
showCategory?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const FlavorCard = ({
|
||||
flavor,
|
||||
caption,
|
||||
showCategory = true,
|
||||
compact = false,
|
||||
}: FlavorCardProps) => (
|
||||
<Link
|
||||
to={`/flavor/${flavor.id}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit', display: 'block', height: '100%' }}
|
||||
>
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transition: 'transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-3px)',
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: (theme) => theme.tokens.elevation.md,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}>
|
||||
<StatusBadge
|
||||
isLegacy={flavor.is_legacy}
|
||||
isAvailable={flavor.is_available}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: compact ? '16/10' : '1/1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'background.default',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
loading="lazy"
|
||||
alt={flavor.name}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 500ms ease',
|
||||
'&:hover': { transform: 'scale(1.05)' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<CardContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: compact ? '0.95rem' : '1rem', lineHeight: 1.25 }} noWrap>
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
|
||||
{showCategory && flavor.category_name && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{flavor.category_name}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 'auto' }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
{caption && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{caption}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
</Link>
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import Button from '@mui/material/Button';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import { getTheme } from '../../theme';
|
||||
import { FormCard } from './FormCard';
|
||||
|
||||
const wrap = (ui: React.ReactElement) => (
|
||||
<ThemeProvider theme={getTheme('mocha')}>{ui}</ThemeProvider>
|
||||
);
|
||||
|
||||
describe('FormCard', () => {
|
||||
it('renders title + subtitle + submits on form submit', () => {
|
||||
const handleSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
|
||||
render(
|
||||
wrap(
|
||||
<FormCard
|
||||
title="Edit Profile"
|
||||
subtitle="Update your display name"
|
||||
onSubmit={handleSubmit}
|
||||
actions={<Button type="submit">Save</Button>}
|
||||
>
|
||||
<TextField label="Name" />
|
||||
</FormCard>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByText('Edit Profile')).toBeInTheDocument();
|
||||
expect(screen.getByText('Update your display name')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(handleSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not render a <form> when asForm=false', () => {
|
||||
const { container } = render(
|
||||
wrap(
|
||||
<FormCard title="File upload" asForm={false}>
|
||||
<div>child</div>
|
||||
</FormCard>,
|
||||
),
|
||||
);
|
||||
expect(container.querySelector('form')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies danger styling when danger prop set', () => {
|
||||
render(wrap(<FormCard title="Delete account" danger>content</FormCard>));
|
||||
const title = screen.getByText('Delete account');
|
||||
const color = window.getComputedStyle(title).color;
|
||||
// MUI uses the palette.error.main — we just assert the title DOM exists and has a color distinct from default
|
||||
expect(color).not.toBe('');
|
||||
});
|
||||
|
||||
it('renders without title/actions (just children)', () => {
|
||||
render(
|
||||
wrap(
|
||||
<FormCard>
|
||||
<div data-testid="child">just content</div>
|
||||
</FormCard>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { GlassCard } from './Glass';
|
||||
|
||||
export interface FormCardProps {
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
onSubmit?: (event: FormEvent<HTMLFormElement>) => void;
|
||||
danger?: boolean;
|
||||
compact?: boolean;
|
||||
/** Render without a <form> wrapper. Use when the card hosts file uploads or non-form controls. */
|
||||
asForm?: boolean;
|
||||
}
|
||||
|
||||
export const FormCard = ({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
onSubmit,
|
||||
danger = false,
|
||||
compact = false,
|
||||
asForm = true,
|
||||
}: FormCardProps) => {
|
||||
const body = (
|
||||
<Stack spacing={compact ? 1.5 : 2}>
|
||||
{(title || subtitle) && (
|
||||
<Box>
|
||||
{title && (
|
||||
<Typography
|
||||
variant={compact ? 'subtitle1' : 'h6'}
|
||||
sx={{ fontWeight: 700, color: danger ? 'error.main' : 'text.primary' }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack spacing={compact ? 1.5 : 2}>{children}</Stack>
|
||||
|
||||
{actions && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, flexWrap: 'wrap' }}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
intensity={danger ? 'default' : 'default'}
|
||||
sx={{
|
||||
p: compact ? 2 : 3,
|
||||
...(danger && {
|
||||
borderColor: (theme) => alpha(theme.palette.error.main, 0.5),
|
||||
boxShadow: (theme) =>
|
||||
`${theme.tokens.elevation.sm}, 0 0 32px ${alpha(theme.palette.error.main, 0.18)}`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{asForm && onSubmit ? (
|
||||
<Box component="form" onSubmit={onSubmit} noValidate>
|
||||
{body}
|
||||
</Box>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { getTheme, type CatppuccinTheme } from '../../theme';
|
||||
import { GlassCard, GlassPaper, GlassSurface } from './Glass';
|
||||
|
||||
const collectStyles = () =>
|
||||
Array.from(document.querySelectorAll('style'))
|
||||
.map((s) => s.textContent ?? '')
|
||||
.join('\n');
|
||||
|
||||
const renderWithTheme = (
|
||||
ui: React.ReactElement,
|
||||
mode: CatppuccinTheme = 'mocha',
|
||||
) => {
|
||||
const theme = getTheme(mode);
|
||||
const result = render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
|
||||
return { theme, ...result };
|
||||
};
|
||||
|
||||
describe('Glass primitives', () => {
|
||||
it('GlassCard applies backdrop blur + token-derived border via emotion styles', () => {
|
||||
const { getByTestId, theme } = renderWithTheme(
|
||||
<GlassCard data-testid="glass">content</GlassCard>,
|
||||
);
|
||||
const el = getByTestId('glass');
|
||||
|
||||
expect(el.className).toMatch(/css-/);
|
||||
|
||||
const css = collectStyles();
|
||||
expect(css).toContain('backdrop-filter:blur(');
|
||||
expect(css).toContain('saturate(');
|
||||
expect(css).toContain(theme.tokens.glass.border);
|
||||
expect(css).toContain(theme.tokens.glass.tint);
|
||||
});
|
||||
|
||||
it('GlassCard intensity="strong" emits blurStrong + tintStrong into emitted CSS', () => {
|
||||
const { theme } = renderWithTheme(
|
||||
<GlassCard data-testid="g" intensity="strong">x</GlassCard>,
|
||||
);
|
||||
const css = collectStyles();
|
||||
expect(css).toContain(theme.tokens.glass.blurStrong);
|
||||
expect(css).toContain(theme.tokens.glass.tintStrong);
|
||||
});
|
||||
|
||||
it('GlassCard intensity="subtle" emits tintSubtle into emitted CSS', () => {
|
||||
const { theme } = renderWithTheme(
|
||||
<GlassCard data-testid="g" intensity="subtle">x</GlassCard>,
|
||||
);
|
||||
const css = collectStyles();
|
||||
expect(css).toContain(theme.tokens.glass.tintSubtle);
|
||||
});
|
||||
|
||||
it('does not forward `intensity` prop to the DOM', () => {
|
||||
const { getByTestId } = renderWithTheme(
|
||||
<GlassCard data-testid="g" intensity="strong">x</GlassCard>,
|
||||
);
|
||||
expect(getByTestId('g').getAttribute('intensity')).toBeNull();
|
||||
});
|
||||
|
||||
it('GlassPaper + GlassSurface emit glass tint on a light theme', () => {
|
||||
const { theme } = renderWithTheme(
|
||||
<>
|
||||
<GlassPaper data-testid="p">paper</GlassPaper>
|
||||
<GlassSurface data-testid="s">surface</GlassSurface>
|
||||
</>,
|
||||
'holy_light',
|
||||
);
|
||||
const css = collectStyles();
|
||||
expect(css).toContain(theme.tokens.glass.tint);
|
||||
expect(css).toContain(theme.tokens.glass.border);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import MuiCard from '@mui/material/Card';
|
||||
import MuiPaper from '@mui/material/Paper';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
type Intensity = 'subtle' | 'default' | 'strong';
|
||||
|
||||
const intensityStyles = (intensity: Intensity) => ({ theme }: { theme: any }) => {
|
||||
const t = theme.tokens;
|
||||
const tint =
|
||||
intensity === 'strong' ? t.glass.tintStrong
|
||||
: intensity === 'subtle' ? t.glass.tintSubtle
|
||||
: t.glass.tint;
|
||||
const blur = intensity === 'strong' ? t.glass.blurStrong : t.glass.blur;
|
||||
return {
|
||||
backgroundColor: tint,
|
||||
backdropFilter: blur,
|
||||
WebkitBackdropFilter: blur,
|
||||
border: '1px solid',
|
||||
borderColor: t.glass.border,
|
||||
boxShadow: intensity === 'strong' ? t.elevation.md : t.elevation.sm,
|
||||
borderRadius: t.radius.md,
|
||||
position: 'relative' as const,
|
||||
// Inset top highlight — the "glass lip"
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
pointerEvents: 'none',
|
||||
boxShadow: `inset 0 1px 0 ${t.glass.highlight}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface GlassProps {
|
||||
intensity?: Intensity;
|
||||
}
|
||||
|
||||
export const GlassCard = styled(MuiCard, {
|
||||
shouldForwardProp: (prop) => prop !== 'intensity',
|
||||
})<GlassProps>(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme }));
|
||||
|
||||
export const GlassPaper = styled(MuiPaper, {
|
||||
shouldForwardProp: (prop) => prop !== 'intensity',
|
||||
})<GlassProps>(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme }));
|
||||
|
||||
export const GlassSurface = styled(Box, {
|
||||
shouldForwardProp: (prop) => prop !== 'intensity',
|
||||
})<GlassProps>(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme }));
|
||||
|
||||
export type { Intensity as GlassIntensity };
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { styled, alpha } from '@mui/material/styles';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
|
||||
const StyledAppBar = styled(AppBar, {
|
||||
shouldForwardProp: (prop) => prop !== 'scrolled',
|
||||
})<{ scrolled: boolean }>(({ theme, scrolled }) => {
|
||||
const t = theme.tokens;
|
||||
return {
|
||||
backgroundColor: scrolled
|
||||
? alpha(theme.palette.background.default, theme.palette.mode === 'light' ? 0.85 : 0.72)
|
||||
: alpha(theme.palette.background.default, theme.palette.mode === 'light' ? 0.55 : 0.4),
|
||||
backdropFilter: t.glass.blurStrong,
|
||||
WebkitBackdropFilter: t.glass.blurStrong,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: scrolled ? t.glass.border : 'transparent',
|
||||
boxShadow: scrolled ? t.elevation.sm : 'none',
|
||||
color: theme.palette.text.primary,
|
||||
transition: 'background-color 200ms ease, box-shadow 200ms ease, border-color 200ms ease',
|
||||
};
|
||||
});
|
||||
|
||||
export interface GlassAppBarProps {
|
||||
children: ReactNode;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export const GlassAppBar = ({ children, threshold = 12 }: GlassAppBarProps) => {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > threshold);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, [threshold]);
|
||||
|
||||
return (
|
||||
<StyledAppBar position="sticky" elevation={0} scrolled={scrolled}>
|
||||
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, px: { xs: 1, sm: 4, md: 6 } }}>
|
||||
{children}
|
||||
</Toolbar>
|
||||
</StyledAppBar>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { styled, alpha } from '@mui/material/styles';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
export interface HeroBackdropProps {
|
||||
variant?: 'mesh' | 'aurora' | 'minimal';
|
||||
}
|
||||
|
||||
const Root = styled(Box, {
|
||||
shouldForwardProp: (prop) => prop !== 'variant',
|
||||
})<{ variant: NonNullable<HeroBackdropProps['variant']> }>(({ theme, variant }) => {
|
||||
const { palette } = theme;
|
||||
const p = palette.primary.main;
|
||||
const s = palette.secondary.main;
|
||||
|
||||
const meshLayers = [
|
||||
`radial-gradient(ellipse 60% 40% at 20% 10%, ${alpha(p, 0.35)} 0%, transparent 60%)`,
|
||||
`radial-gradient(ellipse 50% 50% at 85% 25%, ${alpha(s, 0.28)} 0%, transparent 65%)`,
|
||||
`radial-gradient(ellipse 70% 45% at 50% 95%, ${alpha(p, 0.18)} 0%, transparent 60%)`,
|
||||
];
|
||||
|
||||
const auroraLayers = [
|
||||
`conic-gradient(from 210deg at 50% -10%, ${alpha(p, 0.35)}, ${alpha(s, 0.25)}, ${alpha(p, 0.1)}, ${alpha(p, 0.35)})`,
|
||||
];
|
||||
|
||||
const minimalLayers = [
|
||||
`linear-gradient(180deg, ${alpha(p, 0.12)} 0%, transparent 60%)`,
|
||||
];
|
||||
|
||||
const backgroundImage =
|
||||
variant === 'aurora' ? auroraLayers.join(', ')
|
||||
: variant === 'minimal' ? minimalLayers.join(', ')
|
||||
: meshLayers.join(', ');
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage,
|
||||
backgroundColor: palette.background.default,
|
||||
filter: 'saturate(120%)',
|
||||
// Soft mask so the backdrop fades into the page content
|
||||
maskImage: 'linear-gradient(180deg, #000 0%, #000 55%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(180deg, #000 0%, #000 55%, transparent 100%)',
|
||||
};
|
||||
});
|
||||
|
||||
export const HeroBackdrop = ({ variant = 'mesh' }: HeroBackdropProps) => <Root variant={variant} />;
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
const ShellRoot = styled(Box)({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: '100%',
|
||||
});
|
||||
|
||||
const HeroLayer = styled(Box)({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
const Content = styled(Box)(({ theme }) => ({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
width: '100%',
|
||||
maxWidth: '1400px',
|
||||
marginInline: 'auto',
|
||||
paddingInline: theme.spacing(2),
|
||||
paddingBlock: theme.spacing(4),
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
paddingInline: theme.spacing(4),
|
||||
},
|
||||
[theme.breakpoints.up('md')]: {
|
||||
paddingInline: theme.spacing(6),
|
||||
paddingBlock: theme.spacing(6),
|
||||
},
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(5),
|
||||
}));
|
||||
|
||||
export interface PageShellProps {
|
||||
children: ReactNode;
|
||||
hero?: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const PageShell = ({ children, hero, fullWidth }: PageShellProps) => (
|
||||
<ShellRoot>
|
||||
{hero && <HeroLayer>{hero}</HeroLayer>}
|
||||
<Content sx={fullWidth ? { maxWidth: 'none' } : undefined}>{children}</Content>
|
||||
</ShellRoot>
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { GlassCard } from './Glass';
|
||||
import RatingBadge from '../RatingBadge';
|
||||
import RichText from '../RichText';
|
||||
import { formatDate } from '../../utils/date';
|
||||
|
||||
export interface ReviewRowData {
|
||||
id: number;
|
||||
user: string;
|
||||
user_avatar: string | null;
|
||||
flavor: number;
|
||||
flavor_name?: string;
|
||||
flavor_image?: string | null;
|
||||
score: number;
|
||||
comment?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ReviewRowProps {
|
||||
review: ReviewRowData;
|
||||
showFlavor?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const ReviewRow = ({ review, showFlavor = true, compact }: ReviewRowProps) => (
|
||||
<Link
|
||||
to={`/flavor/${review.flavor}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit', display: 'block' }}
|
||||
>
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.25,
|
||||
p: compact ? 1.5 : 2,
|
||||
transition: 'transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: (theme) => theme.tokens.elevation.md,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||
<Avatar
|
||||
src={review.user_avatar || undefined}
|
||||
sx={{ width: 28, height: 28 }}
|
||||
>
|
||||
{!review.user_avatar && review.user.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.2 }} noWrap>
|
||||
{review.user}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(review.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<RatingBadge score={review.score} size="small" />
|
||||
</Box>
|
||||
|
||||
{showFlavor && review.flavor_name && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }} noWrap>
|
||||
on {review.flavor_name}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{review.comment && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="div"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: compact ? 2 : 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<RichText text={review.comment} />
|
||||
</Typography>
|
||||
)}
|
||||
</GlassCard>
|
||||
</Link>
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export interface SectionHeaderProps {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
action?: ReactNode;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const SectionHeader = ({ title, subtitle, action, compact }: SectionHeaderProps) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
mb: compact ? 2 : 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
variant={compact ? 'h5' : 'h4'}
|
||||
component="h2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
background: (theme) => theme.tokens.accent.gradient,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5, maxWidth: '60ch' }}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{action}
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
export { GlassCard, GlassPaper, GlassSurface, type GlassIntensity } from './Glass';
|
||||
export { GlassAppBar, type GlassAppBarProps } from './GlassAppBar';
|
||||
export { PageShell, type PageShellProps } from './PageShell';
|
||||
export { SectionHeader, type SectionHeaderProps } from './SectionHeader';
|
||||
export { HeroBackdrop, type HeroBackdropProps } from './HeroBackdrop';
|
||||
export { ReviewRow, type ReviewRowProps, type ReviewRowData } from './ReviewRow';
|
||||
export { FlavorCard, type FlavorCardProps, type FlavorCardData } from './FlavorCard';
|
||||
export { FormCard, type FormCardProps } from './FormCard';
|
||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
@@ -1,31 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Typography, Box, Container, Paper, Divider, Button, useMediaQuery } from '@mui/material';
|
||||
import { Typography, Box, Button, Divider } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui';
|
||||
|
||||
const About: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
useTitle(t('nav.about'));
|
||||
const isMobile = useMediaQuery('(max-width:600px)');
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
|
||||
sx={{ alignSelf: 'flex-start', textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
{t('common.backToArchive')}
|
||||
</Button>
|
||||
|
||||
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}>
|
||||
<Typography variant={isMobile ? 'h4' : 'h3'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.title')}
|
||||
</Typography>
|
||||
<GlassCard intensity="subtle" sx={{ p: { xs: 2.5, sm: 4, md: 6 }, maxWidth: 900, width: '100%' }}>
|
||||
<SectionHeader title={t('about.title')} compact />
|
||||
<Typography
|
||||
variant="body1"
|
||||
paragraph
|
||||
@@ -35,10 +33,8 @@ const About: React.FC = () => {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant={isMobile ? 'h6' : 'h5'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.canDoTitle')}
|
||||
</Typography>
|
||||
<Typography component="ul" sx={{ lineHeight: 2, pl: 2 }}>
|
||||
<SectionHeader title={t('about.canDoTitle')} compact />
|
||||
<Typography component="ul" sx={{ lineHeight: 2, pl: 2, m: 0 }}>
|
||||
<li>{t('about.featureExplore')}</li>
|
||||
<li>{t('about.featureRate')}</li>
|
||||
<li>{t('about.featureCommunity')}</li>
|
||||
@@ -55,9 +51,7 @@ const About: React.FC = () => {
|
||||
|
||||
<Divider sx={{ my: { xs: 4, md: 6 } }} />
|
||||
|
||||
<Typography variant={isMobile ? 'h5' : 'h4'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.impressumTitle')}
|
||||
</Typography>
|
||||
<SectionHeader title={t('about.impressumTitle')} compact />
|
||||
<Typography variant="body2" sx={{ lineHeight: 2 }}>
|
||||
<strong>{t('about.legalInfo')}</strong>
|
||||
<br />
|
||||
@@ -101,28 +95,16 @@ const About: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('about.disclaimerTitle')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="p"
|
||||
sx={{ mb: 2, lineHeight: 1.6 }}
|
||||
>
|
||||
<SectionHeader title={t('about.disclaimerTitle')} compact />
|
||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2, lineHeight: 1.6 }}>
|
||||
<strong>{t('about.liabilityContentTitle')}</strong> {t('about.liabilityContentDesc')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="p"
|
||||
sx={{ lineHeight: 1.6 }}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" component="p" sx={{ lineHeight: 1.6 }}>
|
||||
<strong>{t('about.copyrightTitle')}</strong> {t('about.copyrightDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</GlassCard>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Table,
|
||||
@@ -13,7 +11,6 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Stack,
|
||||
alpha,
|
||||
CircularProgress,
|
||||
@@ -33,6 +30,7 @@ import {
|
||||
MenuItem,
|
||||
FormControl,
|
||||
} from '@mui/material';
|
||||
import { PageShell, GlassCard, GlassPaper, EmptyState } from '../components/ui';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
@@ -81,6 +79,16 @@ interface AdminUser {
|
||||
ips: string[];
|
||||
}
|
||||
|
||||
interface BannerSettingSchema {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'number' | 'slider' | 'text' | 'boolean';
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -88,8 +96,8 @@ interface Banner {
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
is_enabled: boolean;
|
||||
settings: any;
|
||||
schema: any[];
|
||||
settings: Record<string, unknown>;
|
||||
schema: BannerSettingSchema[];
|
||||
}
|
||||
|
||||
interface Job {
|
||||
@@ -127,7 +135,7 @@ const AdminDashboard: React.FC = () => {
|
||||
const [selectedBanner, setSelectedBanner] = useState<Banner | null>(null);
|
||||
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
|
||||
|
||||
const fetchData = async (silent = false) => {
|
||||
const fetchData = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const [statsRes, usersRes, bannersRes, jobsRes, configRes] = await Promise.all([
|
||||
@@ -153,7 +161,7 @@ const AdminDashboard: React.FC = () => {
|
||||
}
|
||||
|
||||
if (!silent) setLoading(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Poll for job updates if any are running or pending
|
||||
useEffect(() => {
|
||||
@@ -162,13 +170,13 @@ const AdminDashboard: React.FC = () => {
|
||||
const interval = setInterval(() => fetchData(true), 3000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [jobs, currentTab]);
|
||||
}, [jobs, currentTab, fetchData]);
|
||||
|
||||
const handleUpdateConfig = async (key: string, value: any) => {
|
||||
const handleUpdateConfig = async (key: string, value: unknown) => {
|
||||
try {
|
||||
const res = await api.patch('admin-custom/config/', { [key]: value });
|
||||
setConfig(res.data);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to update configuration');
|
||||
}
|
||||
};
|
||||
@@ -177,18 +185,18 @@ const AdminDashboard: React.FC = () => {
|
||||
try {
|
||||
await api.post(`admin-custom/${id}/trigger_job/`);
|
||||
fetchData(true);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to trigger job');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateJobSchedule = async (id: number, interval: number, nextRun?: string) => {
|
||||
try {
|
||||
const data: any = { interval_hours: interval };
|
||||
const data: Record<string, unknown> = { interval_hours: interval };
|
||||
if (nextRun) data.next_run = nextRun;
|
||||
await api.patch(`admin-custom/${id}/update_job_schedule/`, data);
|
||||
fetchData(true);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to update schedule');
|
||||
}
|
||||
};
|
||||
@@ -206,21 +214,23 @@ const AdminDashboard: React.FC = () => {
|
||||
try {
|
||||
await api.post(`banners/${id}/toggle_enabled/`);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Failed to toggle visibility');
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
alert(error.response?.data?.error || 'Failed to toggle visibility');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSendTestEmail = async () => {
|
||||
try {
|
||||
await api.post('admin-custom/send_test_email/');
|
||||
alert(t('admin.testEmailSuccess'));
|
||||
} catch (err: any) {
|
||||
alert(`Error: ${err.response?.data?.error || 'Failed to send'}`);
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
alert(`Error: ${error.response?.data?.error || 'Failed to send'}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -238,12 +248,12 @@ const AdminDashboard: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
sx={{ mb: 3, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
@@ -267,7 +277,7 @@ const AdminDashboard: React.FC = () => {
|
||||
<Box>
|
||||
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
|
||||
<GlassCard intensity="subtle" sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
|
||||
{t('admin.totalUsers')}
|
||||
@@ -276,10 +286,10 @@ const AdminDashboard: React.FC = () => {
|
||||
{stats?.total_users}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
|
||||
<GlassCard intensity="subtle" sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
|
||||
{t('admin.openTickets')}
|
||||
@@ -291,10 +301,10 @@ const AdminDashboard: React.FC = () => {
|
||||
{stats?.open_tickets}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<GlassCard intensity="subtle">
|
||||
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
||||
<Stack
|
||||
direction={isMobile ? 'column' : 'row'}
|
||||
@@ -327,7 +337,7 @@ const AdminDashboard: React.FC = () => {
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -337,7 +347,7 @@ const AdminDashboard: React.FC = () => {
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
System Configuration (Live)
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 0, borderRadius: 3, overflow: 'hidden' }}>
|
||||
<GlassPaper intensity="subtle" sx={{ p: 0, overflow: 'hidden' }}>
|
||||
<List disablePadding>
|
||||
{[
|
||||
{ key: 'site_name', label: 'Site Name', type: 'text' },
|
||||
@@ -352,11 +362,17 @@ const AdminDashboard: React.FC = () => {
|
||||
<ListItem key={item.key} divider={idx < 3} sx={{ py: 2 }}>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
secondary={item.type === 'text' ? (config as any)?.[item.key] : null}
|
||||
secondary={
|
||||
item.type === 'text'
|
||||
? (config as unknown as Record<string, string>)?.[item.key]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
{item.type === 'switch' ? (
|
||||
<Switch
|
||||
checked={(config as any)?.[item.key] || false}
|
||||
checked={
|
||||
(config as unknown as Record<string, boolean>)?.[item.key] || false
|
||||
}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleUpdateConfig(item.key, e.target.checked)
|
||||
}
|
||||
@@ -367,7 +383,7 @@ const AdminDashboard: React.FC = () => {
|
||||
onClick={() => {
|
||||
const val = prompt(
|
||||
`Enter new value for ${item.label}:`,
|
||||
(config as any)?.[item.key],
|
||||
(config as unknown as Record<string, string>)?.[item.key],
|
||||
);
|
||||
if (val !== null) handleUpdateConfig(item.key, val);
|
||||
}}
|
||||
@@ -378,7 +394,7 @@ const AdminDashboard: React.FC = () => {
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</GlassPaper>
|
||||
</Grid>
|
||||
|
||||
{/* Environment Info */}
|
||||
@@ -386,10 +402,7 @@ const AdminDashboard: React.FC = () => {
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>
|
||||
Environment Info (.env)
|
||||
</Typography>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{ p: 3, borderRadius: 3, bgcolor: alpha('#000', 0.02) }}
|
||||
>
|
||||
<GlassPaper intensity="subtle" sx={{ p: 3 }}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography
|
||||
@@ -436,7 +449,7 @@ const AdminDashboard: React.FC = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</GlassPaper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
@@ -467,7 +480,7 @@ const AdminDashboard: React.FC = () => {
|
||||
{isMobile ? (
|
||||
<Stack spacing={2}>
|
||||
{filteredUsers.map((user) => (
|
||||
<Card key={user.id} variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<GlassCard key={user.id} intensity="subtle" sx={{ borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
||||
@@ -500,14 +513,11 @@ const AdminDashboard: React.FC = () => {
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<TableContainer component={GlassPaper} sx={{ borderRadius: 3 }}>
|
||||
<Table>
|
||||
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableRow>
|
||||
@@ -588,18 +598,19 @@ const AdminDashboard: React.FC = () => {
|
||||
</Typography>
|
||||
|
||||
{banners.length === 0 ? (
|
||||
<Card variant="outlined" sx={{ p: 4, textAlign: 'center', borderRadius: 3 }}>
|
||||
<Typography color="text.secondary">
|
||||
No banner models found in the database.
|
||||
</Typography>
|
||||
<Button variant="outlined" sx={{ mt: 2 }} onClick={() => fetchData()}>
|
||||
Retry Loading
|
||||
</Button>
|
||||
</Card>
|
||||
<EmptyState
|
||||
title="No banner models found"
|
||||
subtitle="No banners registered in the database."
|
||||
action={
|
||||
<Button variant="outlined" onClick={() => fetchData()}>
|
||||
Retry Loading
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
{banners.map((banner) => (
|
||||
<Card
|
||||
<GlassCard
|
||||
key={banner.id}
|
||||
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
@@ -671,7 +682,7 @@ const AdminDashboard: React.FC = () => {
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
@@ -685,7 +696,7 @@ const AdminDashboard: React.FC = () => {
|
||||
</Typography>
|
||||
<Stack spacing={3}>
|
||||
{jobs.map((job) => (
|
||||
<Card
|
||||
<GlassCard
|
||||
key={job.id}
|
||||
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
@@ -819,7 +830,7 @@ const AdminDashboard: React.FC = () => {
|
||||
Trigger Now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -834,7 +845,7 @@ const AdminDashboard: React.FC = () => {
|
||||
banner={selectedBanner}
|
||||
onSave={fetchData}
|
||||
/>
|
||||
</Container>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
TextField,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Button, Stack, CircularProgress, TextField } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -16,6 +7,15 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
import { PageShell, SectionHeader, FormCard } from '../components/ui';
|
||||
|
||||
interface AdminRating {
|
||||
id: number;
|
||||
user: string;
|
||||
score: number;
|
||||
comment: string | null;
|
||||
flavor_name: string;
|
||||
}
|
||||
|
||||
const AdminRatingDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -23,12 +23,12 @@ const AdminRatingDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [score, setScore] = useState(0);
|
||||
const [comment, setComment] = useState('');
|
||||
const [rating, setRating] = useState<any>(null);
|
||||
const [rating, setRating] = useState<AdminRating | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchRating = async () => {
|
||||
const fetchRating = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get(`ratings/${id}/`);
|
||||
const res = await api.get<AdminRating>(`ratings/${id}/`);
|
||||
setRating(res.data);
|
||||
setScore(res.data.score);
|
||||
setComment(res.data.comment || '');
|
||||
@@ -38,20 +38,21 @@ const AdminRatingDetail: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [id, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRating();
|
||||
}, [id]);
|
||||
}, [fetchRating]);
|
||||
|
||||
useTitle(rating ? `${t('admin.manageRating')}: ${rating.flavor_name}` : 'Rating Management');
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.patch(`ratings/${id}/`, { score, comment });
|
||||
fetchRating();
|
||||
alert('Rating updated!');
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Update failed');
|
||||
}
|
||||
};
|
||||
@@ -61,38 +62,57 @@ const AdminRatingDetail: React.FC = () => {
|
||||
try {
|
||||
await api.delete(`ratings/${id}/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<PageShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
if (!rating) return null;
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 4 }}>
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
sx={{ alignSelf: 'flex-start', borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>
|
||||
{t('admin.manageRating')}
|
||||
</Typography>
|
||||
<SectionHeader title={t('admin.manageRating')} />
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{rating.flavor_name} (User: {rating.user})
|
||||
</Typography>
|
||||
<Stack spacing={3} sx={{ mt: 3 }}>
|
||||
<Box sx={{ maxWidth: 560, width: '100%' }}>
|
||||
<FormCard
|
||||
title={rating.flavor_name}
|
||||
subtitle={`User: ${rating.user}`}
|
||||
onSubmit={handleUpdate}
|
||||
actions={
|
||||
<Stack direction="row" spacing={2} sx={{ width: '100%' }}>
|
||||
<Button variant="contained" fullWidth type="submit">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
label="Score"
|
||||
type="number"
|
||||
@@ -101,23 +121,9 @@ const AdminRatingDetail: React.FC = () => {
|
||||
onChange={(e) => setScore(Number(e.target.value))}
|
||||
/>
|
||||
<MentionTextField multiline rows={4} value={comment} onChange={setComment} />
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button variant="contained" fullWidth onClick={handleUpdate}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</FormCard>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Container, Typography, Box, Button, Stack, CircularProgress, Paper } from '@mui/material';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Button, Stack, CircularProgress } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,19 +7,25 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
import { PageShell, SectionHeader, FormCard } from '../components/ui';
|
||||
|
||||
interface AdminReply {
|
||||
id: number;
|
||||
user: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const AdminReplyDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [text, setText] = useState('');
|
||||
const [reply, setReply] = useState<any>(null);
|
||||
const [reply, setReply] = useState<AdminReply | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchReply = async () => {
|
||||
const fetchReply = useCallback(async () => {
|
||||
try {
|
||||
// We use the replies list/detail endpoint
|
||||
const res = await api.get(`replies/${id}/`);
|
||||
const res = await api.get<AdminReply>(`replies/${id}/`);
|
||||
setReply(res.data);
|
||||
setText(res.data.text);
|
||||
} catch (err) {
|
||||
@@ -28,20 +34,21 @@ const AdminReplyDetail: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [id, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReply();
|
||||
}, [id]);
|
||||
}, [fetchReply]);
|
||||
|
||||
useTitle(reply ? `${t('admin.manageReply')}` : 'Reply Management');
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.patch(`replies/${id}/`, { text });
|
||||
fetchReply();
|
||||
alert('Reply updated!');
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Update failed');
|
||||
}
|
||||
};
|
||||
@@ -51,56 +58,60 @@ const AdminReplyDetail: React.FC = () => {
|
||||
try {
|
||||
await api.delete(`replies/${id}/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<PageShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
if (!reply) return null;
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 4 }}>
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
sx={{ alignSelf: 'flex-start', borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', mb: 4 }}>
|
||||
{t('admin.manageReply')}
|
||||
</Typography>
|
||||
<SectionHeader title={t('admin.manageReply')} />
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
Reply by {reply.user}
|
||||
</Typography>
|
||||
<Stack spacing={3} sx={{ mt: 3 }}>
|
||||
<Box sx={{ maxWidth: 560, width: '100%' }}>
|
||||
<FormCard
|
||||
title={`Reply by ${reply.user}`}
|
||||
onSubmit={handleUpdate}
|
||||
actions={
|
||||
<Stack direction="row" spacing={2} sx={{ width: '100%' }}>
|
||||
<Button variant="contained" fullWidth type="submit">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<MentionTextField multiline rows={4} value={text} onChange={setText} />
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button variant="contained" fullWidth onClick={handleUpdate}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</FormCard>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Stack,
|
||||
alpha,
|
||||
useTheme,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
@@ -34,6 +29,30 @@ import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
import RichText from '../components/RichText';
|
||||
import {
|
||||
PageShell,
|
||||
SectionHeader,
|
||||
GlassCard,
|
||||
GlassPaper,
|
||||
FormCard,
|
||||
EmptyState,
|
||||
} from '../components/ui';
|
||||
|
||||
interface AdminReply {
|
||||
id: number;
|
||||
user: string;
|
||||
text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AdminRating {
|
||||
id: number;
|
||||
flavor_name: string;
|
||||
score: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
replies: AdminReply[];
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
@@ -44,28 +63,25 @@ interface AdminUser {
|
||||
date_joined: string;
|
||||
last_login: string;
|
||||
ips: string[];
|
||||
ratings: any[];
|
||||
ratings: AdminRating[];
|
||||
}
|
||||
|
||||
const AdminUserDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// Edit state for Ratings
|
||||
const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
|
||||
const [editScore, setEditScore] = useState(0);
|
||||
const [editComment, setEditComment] = useState('');
|
||||
|
||||
// Edit state for Replies
|
||||
const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
|
||||
const [editReplyText, setEditReplyText] = useState('');
|
||||
|
||||
const fetchUser = async () => {
|
||||
const fetchUser = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get(`admin-custom/${id}/user_detail/`);
|
||||
setUser(res.data);
|
||||
@@ -75,11 +91,11 @@ const AdminUserDetail: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [id, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [id]);
|
||||
}, [fetchUser]);
|
||||
|
||||
useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management');
|
||||
|
||||
@@ -88,7 +104,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
try {
|
||||
await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active });
|
||||
fetchUser();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Action failed');
|
||||
}
|
||||
};
|
||||
@@ -98,7 +114,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
|
||||
setEditingRatingId(null);
|
||||
fetchUser();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Update failed');
|
||||
}
|
||||
};
|
||||
@@ -108,7 +124,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
try {
|
||||
await api.delete(`ratings/${ratingId}/`);
|
||||
fetchUser();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
@@ -118,7 +134,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
await api.patch(`replies/${replyId}/`, { text: editReplyText });
|
||||
setEditingReplyId(null);
|
||||
fetchUser();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Update failed');
|
||||
}
|
||||
};
|
||||
@@ -128,7 +144,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
try {
|
||||
await api.delete(`replies/${replyId}/`);
|
||||
fetchUser();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
@@ -139,7 +155,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
try {
|
||||
await api.delete(`admin-custom/${id}/user_detail/`);
|
||||
navigate('/admin-panel');
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Delete failed');
|
||||
}
|
||||
};
|
||||
@@ -153,21 +169,17 @@ const AdminUserDetail: React.FC = () => {
|
||||
if (!user) return <Typography>User not found</Typography>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
sx={{ mb: 3, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
{/* Profile Header */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{ borderRadius: 4, border: '1px solid', borderColor: 'divider', mb: 4 }}
|
||||
>
|
||||
<GlassCard intensity="strong" sx={{ mb: 4 }}>
|
||||
<CardContent sx={{ p: { xs: 2, md: 4 } }}>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid size={{ xs: 12, sm: 'auto' }}>
|
||||
@@ -226,9 +238,8 @@ const AdminUserDetail: React.FC = () => {
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
{/* Content Tabs */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} variant="fullWidth">
|
||||
<Tab
|
||||
@@ -246,19 +257,12 @@ const AdminUserDetail: React.FC = () => {
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
{/* TAB 0: USER CONTENT (Ratings & Replies) */}
|
||||
{activeTab === 0 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>
|
||||
User Ratings & Discussion
|
||||
</Typography>
|
||||
<SectionHeader title="User Ratings & Discussion" compact />
|
||||
<Stack spacing={2}>
|
||||
{user.ratings.map((rating) => (
|
||||
<Card
|
||||
key={rating.id}
|
||||
variant="outlined"
|
||||
sx={{ borderRadius: 3, bgcolor: alpha(theme.palette.background.paper, 0.4) }}
|
||||
>
|
||||
<GlassCard key={rating.id} intensity="subtle">
|
||||
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
|
||||
{editingRatingId === rating.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
@@ -351,7 +355,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
|
||||
{rating.replies.length > 0 && (
|
||||
<Box sx={{ pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||
{rating.replies.map((reply: any) => (
|
||||
{rating.replies.map((reply) => (
|
||||
<Box key={reply.id} sx={{ mb: 1.5 }}>
|
||||
{editingReplyId === reply.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
@@ -428,29 +432,15 @@ const AdminUserDetail: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
))}
|
||||
{user.ratings.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
py: 6,
|
||||
textAlign: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 3,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">No ratings yet.</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{user.ratings.length === 0 && <EmptyState title="No ratings yet." />}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* TAB 1: SECURITY & IP */}
|
||||
{activeTab === 1 && (
|
||||
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<GlassPaper intensity="subtle" sx={{ p: 3 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
@@ -471,41 +461,34 @@ const AdminUserDetail: React.FC = () => {
|
||||
<Typography variant="caption">No IP history available.</Typography>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
</GlassPaper>
|
||||
)}
|
||||
|
||||
{/* TAB 2: DANGER ZONE */}
|
||||
{activeTab === 2 && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'error.main',
|
||||
bgcolor: (theme) => alpha(theme.palette.error.main, 0.05),
|
||||
}}
|
||||
<FormCard
|
||||
title={t('admin.dangerZone')}
|
||||
subtitle="Deleting this user will permanently remove their profile, all their ratings, replies, and IP history. This action is irreversible."
|
||||
danger
|
||||
asForm={false}
|
||||
actions={
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="large"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }}
|
||||
>
|
||||
{t('admin.deleteUser')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="h5" color="error" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('admin.dangerZone')}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Proceed only if the user has been reviewed for policy violations or requested deletion.
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 4, maxWidth: 600 }}>
|
||||
Deleting this user will permanently remove their profile, all their ratings, replies,
|
||||
and IP history.
|
||||
<strong> This action is irreversible.</strong>
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="large"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }}
|
||||
>
|
||||
{t('admin.deleteUser')}
|
||||
</Button>
|
||||
</Paper>
|
||||
</FormCard>
|
||||
)}
|
||||
</Container>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Avatar,
|
||||
Container,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Button,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
ListItemButton,
|
||||
Pagination,
|
||||
TextField,
|
||||
alpha,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Link as MuiLink,
|
||||
@@ -36,6 +33,13 @@ import { formatDate } from '../utils/date';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
import {
|
||||
PageShell,
|
||||
HeroBackdrop,
|
||||
SectionHeader,
|
||||
GlassCard,
|
||||
EmptyState,
|
||||
} from '../components/ui';
|
||||
|
||||
interface Reply {
|
||||
id: number;
|
||||
@@ -85,10 +89,42 @@ interface Notification {
|
||||
flavor_id: number | null;
|
||||
}
|
||||
|
||||
interface TopFlavor {
|
||||
id: number;
|
||||
name: string;
|
||||
image_url: string | null;
|
||||
average_rating: number;
|
||||
}
|
||||
|
||||
interface CommunityFeedProps {
|
||||
adminMode?: boolean;
|
||||
}
|
||||
|
||||
const SidebarCard: React.FC<{ icon: React.ReactNode; title: React.ReactNode; children: React.ReactNode }> = ({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
}) => (
|
||||
<GlassCard intensity="subtle">
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
{children}
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
const { t } = useTranslation();
|
||||
useTitle(t('community.title'));
|
||||
@@ -100,60 +136,54 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [followingSearch, setFollowingSearch] = useState('');
|
||||
|
||||
// Top Rated Flavors by followed users
|
||||
const [topFollowed, setTopFollowed] = useState<any[]>([]);
|
||||
|
||||
// UI State
|
||||
const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<{ [key: number]: boolean }>({});
|
||||
const [topFollowed, setTopFollowed] = useState<TopFlavor[]>([]);
|
||||
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({});
|
||||
|
||||
const filteredFollowing = following.filter((user) =>
|
||||
user.username.toLowerCase().includes(followingSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
if (window.history.length > 1) navigate(-1);
|
||||
else navigate('/');
|
||||
};
|
||||
|
||||
const fetchFeedData = async (pageNum: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [feedRes, followingRes, topFollowedRes, notifRes] = await Promise.all([
|
||||
api.get(`ratings/feed/?page=${pageNum}`),
|
||||
api.get('users/following_list/'),
|
||||
api.get('flavors/followed_top/'),
|
||||
api.get('notifications/'),
|
||||
]);
|
||||
const fetchFeedData = useCallback(
|
||||
async (pageNum: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [feedRes, followingRes, topFollowedRes, notifRes] = await Promise.all([
|
||||
api.get(`ratings/feed/?page=${pageNum}`),
|
||||
api.get('users/following_list/'),
|
||||
api.get('flavors/followed_top/'),
|
||||
api.get('notifications/'),
|
||||
]);
|
||||
|
||||
const feedData = feedRes.data.results || (Array.isArray(feedRes.data) ? feedRes.data : []);
|
||||
setRatings(feedData);
|
||||
const feedData = feedRes.data.results || (Array.isArray(feedRes.data) ? feedRes.data : []);
|
||||
setRatings(feedData);
|
||||
const count = feedRes.data.count || 0;
|
||||
setTotalPages(Math.ceil(count / 10));
|
||||
|
||||
const count = feedRes.data.count || 0;
|
||||
setTotalPages(Math.ceil(count / 10));
|
||||
const followData = Array.isArray(followingRes.data)
|
||||
? followingRes.data
|
||||
: followingRes.data.results || [];
|
||||
setFollowing(followData);
|
||||
|
||||
const followData = Array.isArray(followingRes.data)
|
||||
? followingRes.data
|
||||
: followingRes.data.results || [];
|
||||
setFollowing(followData);
|
||||
setTopFollowed(Array.isArray(topFollowedRes.data) ? topFollowedRes.data.slice(0, 5) : []);
|
||||
|
||||
setTopFollowed(Array.isArray(topFollowedRes.data) ? topFollowedRes.data.slice(0, 5) : []);
|
||||
|
||||
const notifData = Array.isArray(notifRes.data) ? notifRes.data : notifRes.data.results || [];
|
||||
setNotifications(notifData.slice(0, 5)); // Just the latest 5 for sidebar
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401) {
|
||||
navigate('/login');
|
||||
const notifData = Array.isArray(notifRes.data) ? notifRes.data : notifRes.data.results || [];
|
||||
setNotifications(notifData.slice(0, 5));
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } }).response?.status;
|
||||
if (status === 401) navigate('/login');
|
||||
console.error('Failed to fetch feed data', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
console.error('Failed to fetch feed data', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access');
|
||||
@@ -162,7 +192,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
return;
|
||||
}
|
||||
fetchFeedData(page);
|
||||
}, [page, navigate]);
|
||||
}, [page, navigate, fetchFeedData]);
|
||||
|
||||
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
||||
setPage(value);
|
||||
@@ -178,109 +208,54 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
if (!text) return;
|
||||
try {
|
||||
const res = await api.post(`ratings/${ratingId}/reply/`, { text });
|
||||
setRatings((prevRatings) =>
|
||||
prevRatings.map((r) => {
|
||||
if (r.id === ratingId) {
|
||||
return { ...r, replies: [...r.replies, res.data] };
|
||||
}
|
||||
return r;
|
||||
}),
|
||||
setRatings((prev) =>
|
||||
prev.map((r) => (r.id === ratingId ? { ...r, replies: [...r.replies, res.data] } : r)),
|
||||
);
|
||||
setReplyInputs((prev) => ({ ...prev, [ratingId]: '' }));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to send reply');
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (dateStr: string) => {
|
||||
return formatDate(dateStr);
|
||||
};
|
||||
|
||||
if (loading && ratings.length === 0)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<PageShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ alignSelf: 'flex-start', borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: '800', mb: 1 }}>
|
||||
{t('community.title')}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
{t('community.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<SectionHeader title={t('community.title')} subtitle={t('community.subtitle')} />
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* Main Feed Column */}
|
||||
<Grid size={{ xs: 12, lg: 8 }}>
|
||||
{ratings.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
borderRadius: 4,
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6),
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom color="text.secondary">
|
||||
{t('community.quietFeed')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t('community.followMore')}
|
||||
</Typography>
|
||||
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}>
|
||||
{t('home.exploreFlavors')}
|
||||
</Button>
|
||||
</Box>
|
||||
<EmptyState
|
||||
title={t('community.quietFeed')}
|
||||
subtitle={t('community.followMore')}
|
||||
action={
|
||||
<Button variant="contained" component={Link} to="/" sx={{ borderRadius: 2 }}>
|
||||
{t('home.exploreFlavors')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{ratings.map((rating) => (
|
||||
<Card
|
||||
key={rating.id}
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8),
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: (theme) => alpha(theme.palette.primary.main, 0.2),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<GlassCard key={rating.id} intensity="default">
|
||||
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
{/* User Info */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Link to={`/profile/${rating.user}`} style={{ textDecoration: 'none' }}>
|
||||
@@ -301,19 +276,12 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
to={`/profile/${rating.user}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: 'bold', lineHeight: 1.2 }}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', lineHeight: 1.2 }}>
|
||||
{rating.user}
|
||||
</Typography>
|
||||
</Link>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block' }}
|
||||
>
|
||||
{formatTimestamp(rating.created_at)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{formatDate(rating.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -332,7 +300,6 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Flavor and Comment */}
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
@@ -340,11 +307,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
<MuiLink
|
||||
component={Link}
|
||||
to={`/flavor/${rating.flavor}`}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
sx={{ color: 'primary.main', fontWeight: 'bold', textDecoration: 'none' }}
|
||||
>
|
||||
{rating.flavor_name}
|
||||
</MuiLink>
|
||||
@@ -357,11 +320,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
<RichText text={rating.comment} />
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontStyle: 'italic' }}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
No comment provided.
|
||||
</Typography>
|
||||
)}
|
||||
@@ -381,6 +340,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
<Box
|
||||
component="img"
|
||||
src={rating.flavor_image || undefined}
|
||||
alt={rating.flavor_name}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
@@ -389,7 +349,6 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Footer & Replies */}
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CommentIcon fontSize="small" />}
|
||||
@@ -406,23 +365,14 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
{rating.replies.map((reply) => (
|
||||
<Box key={reply.id} sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 'bold', fontSize: '0.8rem' }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', fontSize: '0.8rem' }}>
|
||||
{reply.user}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatTimestamp(reply.created_at)}
|
||||
{formatDate(reply.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2">
|
||||
<RichText text={reply.text} />
|
||||
</Typography>
|
||||
@@ -442,9 +392,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
<MentionTextField
|
||||
placeholder={t('community.writeReply')}
|
||||
value={replyInputs[rating.id] || ''}
|
||||
onChange={(val) =>
|
||||
setReplyInputs((prev) => ({ ...prev, [rating.id]: val }))
|
||||
}
|
||||
onChange={(val) => setReplyInputs((prev) => ({ ...prev, [rating.id]: val }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
handleReplySubmit(rating.id);
|
||||
@@ -462,46 +410,23 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
))}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChange={handlePageChange}
|
||||
color="primary"
|
||||
/>
|
||||
<Pagination count={totalPages} page={page} onChange={handlePageChange} color="primary" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Sidebar Column */}
|
||||
<Grid size={{ xs: 12, lg: 4 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Notifications Widget */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}
|
||||
<SidebarCard
|
||||
icon={<NotificationsIcon color="primary" fontSize="small" />}
|
||||
title={t('community.notifications')}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: alpha('#000', 0.02),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<NotificationsIcon color="primary" fontSize="small" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{t('community.notifications')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<List disablePadding>
|
||||
{notifications.length === 0 ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
@@ -518,7 +443,6 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
navigate(`/flavor/${n.flavor_id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItemButton key={n.id} onClick={handleClick} sx={{ py: 1.5 }}>
|
||||
<ListItemAvatar sx={{ minWidth: 40 }}>
|
||||
@@ -542,7 +466,7 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
: n.notification_type}
|
||||
</Typography>
|
||||
}
|
||||
secondary={formatTimestamp(n.created_at)}
|
||||
secondary={formatDate(n.created_at)}
|
||||
secondaryTypographyProps={{ sx: { fontSize: '0.7rem' } }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
@@ -550,29 +474,12 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
})
|
||||
)}
|
||||
</List>
|
||||
</Card>
|
||||
</SidebarCard>
|
||||
|
||||
{/* Following Widget */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}
|
||||
<SidebarCard
|
||||
icon={<PeopleIcon color="primary" fontSize="small" />}
|
||||
title={t('nav.following')}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: alpha('#000', 0.02),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<PeopleIcon color="primary" fontSize="small" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{t('nav.following')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
@@ -580,10 +487,12 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
placeholder={t('community.searchFriends')}
|
||||
value={followingSearch}
|
||||
onChange={(e) => setFollowingSearch(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<SearchIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
),
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<SearchIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 1 } }}
|
||||
/>
|
||||
@@ -604,29 +513,12 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
</SidebarCard>
|
||||
|
||||
{/* Top Rated by Circle */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{ borderRadius: 1, border: '1px solid', borderColor: 'divider' }}
|
||||
<SidebarCard
|
||||
icon={<WhatshotIcon color="error" fontSize="small" />}
|
||||
title={t('community.topRated')}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: alpha('#000', 0.02),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<WhatshotIcon color="error" fontSize="small" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{t('community.topRated')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<List disablePadding>
|
||||
{topFollowed.map((flavor, idx) => (
|
||||
<ListItemButton key={flavor.id} component={Link} to={`/flavor/${flavor.id}`}>
|
||||
@@ -648,27 +540,24 @@ const CommunityFeed: React.FC<CommunityFeedProps> = ({ adminMode }) => {
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
src={flavor.image_url || undefined}
|
||||
alt={flavor.name}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
<ListItemText
|
||||
primary={flavor.name}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
fontWeight: 'bold',
|
||||
noWrap: true,
|
||||
}}
|
||||
primaryTypographyProps={{ variant: 'body2', fontWeight: 'bold', noWrap: true }}
|
||||
/>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
</SidebarCard>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+126
-243
@@ -1,16 +1,13 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
CircularProgress,
|
||||
Avatar,
|
||||
Tabs,
|
||||
Tab,
|
||||
Paper,
|
||||
Grid,
|
||||
alpha,
|
||||
useTheme,
|
||||
@@ -42,13 +39,48 @@ import StatusBadge from '../components/StatusBadge';
|
||||
import MentionTextField from '../components/MentionTextField';
|
||||
import RichText from '../components/RichText';
|
||||
import { formatDate } from '../utils/date';
|
||||
import { PageShell, GlassCard, GlassSurface, EmptyState } from '../components/ui';
|
||||
|
||||
interface Reply {
|
||||
id: number;
|
||||
user: string;
|
||||
text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface RatedItem {
|
||||
id: number;
|
||||
flavor: number;
|
||||
flavor_name: string;
|
||||
flavor_image: string | null;
|
||||
category_name: string;
|
||||
category_slug: string;
|
||||
score: number;
|
||||
comment: string | null;
|
||||
created_at: string;
|
||||
replies: Reply[];
|
||||
is_legacy?: boolean;
|
||||
is_available?: boolean;
|
||||
}
|
||||
|
||||
interface MissingFlavor {
|
||||
id: number;
|
||||
name: string;
|
||||
image_url: string | null;
|
||||
category_name: string;
|
||||
category_slug: string;
|
||||
average_rating: number;
|
||||
followed_average_rating?: number;
|
||||
is_legacy?: boolean;
|
||||
is_available?: boolean;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
user: { username: string; avatar: string | null };
|
||||
rated_count: number;
|
||||
missing_count: number;
|
||||
missing_flavors: any[];
|
||||
my_ratings: any[];
|
||||
missing_flavors: MissingFlavor[];
|
||||
my_ratings: RatedItem[];
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
@@ -62,24 +94,20 @@ const Dashboard: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [palette, setPalette] = useState<string[]>([]);
|
||||
|
||||
// Filtering states for Explore New
|
||||
const [exploreCategory, setExploreCategory] = useState<string>('All');
|
||||
const [exploreSearch, setExploreSearch] = useState('');
|
||||
const [exploreSort, setExploreSort] = useState<'community' | 'circle'>('community');
|
||||
|
||||
// Filtering states for My Reviews
|
||||
const [ratedCategory, setRatedCategory] = useState<string>('All');
|
||||
const [ratedSearch, setRatedSearch] = useState('');
|
||||
const [ratedSort, setRatedSort] = useState<'date' | 'rating'>('date');
|
||||
|
||||
// Inline Review Edit state
|
||||
const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
|
||||
const [editScore, setEditScore] = useState(0);
|
||||
const [editComment, setEditComment] = useState('');
|
||||
|
||||
// Inline Reply state
|
||||
const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<{ [key: number]: boolean }>({});
|
||||
const [replyInputs, setReplyInputs] = useState<Record<number, string>>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<Record<number, boolean>>({});
|
||||
|
||||
const shareUrl = data ? `${window.location.origin}/profile/${data.user.username}` : '';
|
||||
|
||||
@@ -93,7 +121,7 @@ const Dashboard: React.FC = () => {
|
||||
else navigate('/');
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('users/dashboard/');
|
||||
setData(res.data);
|
||||
@@ -102,11 +130,11 @@ const Dashboard: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.user?.avatar) {
|
||||
@@ -128,18 +156,11 @@ const Dashboard: React.FC = () => {
|
||||
max === min ? 0 : l > 0.5 ? (max - min) / (2 - max - min) : (max - min) / (max + min);
|
||||
return { rgb: `rgb(${c[0]}, ${c[1]}, ${c[2]})`, l, s };
|
||||
});
|
||||
const vibrant = processed
|
||||
.filter((c) => c.l > 0.2 && c.l < 0.85)
|
||||
.sort((a, b) => b.s - a.s);
|
||||
|
||||
if (vibrant.length >= 2) {
|
||||
setPalette([vibrant[0].rgb, vibrant[1].rgb]);
|
||||
} else if (vibrant.length === 1) {
|
||||
setPalette([vibrant[0].rgb, vibrant[0].rgb]);
|
||||
} else {
|
||||
setPalette([]);
|
||||
}
|
||||
} catch (e) {
|
||||
const vibrant = processed.filter((c) => c.l > 0.2 && c.l < 0.85).sort((a, b) => b.s - a.s);
|
||||
if (vibrant.length >= 2) setPalette([vibrant[0].rgb, vibrant[1].rgb]);
|
||||
else if (vibrant.length === 1) setPalette([vibrant[0].rgb, vibrant[0].rgb]);
|
||||
else setPalette([]);
|
||||
} catch {
|
||||
setPalette([]);
|
||||
}
|
||||
};
|
||||
@@ -148,13 +169,12 @@ const Dashboard: React.FC = () => {
|
||||
}
|
||||
}, [data?.user?.avatar]);
|
||||
|
||||
// Update Review Logic
|
||||
const handleUpdateRating = async (ratingId: number) => {
|
||||
try {
|
||||
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
|
||||
setEditingRatingId(null);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to update review');
|
||||
}
|
||||
};
|
||||
@@ -164,12 +184,11 @@ const Dashboard: React.FC = () => {
|
||||
try {
|
||||
await api.delete(`ratings/${ratingId}/`);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to delete review');
|
||||
}
|
||||
};
|
||||
|
||||
// Reply Logic
|
||||
const handleReplySubmit = async (ratingId: number) => {
|
||||
const text = replyInputs[ratingId];
|
||||
if (!text) return;
|
||||
@@ -178,7 +197,7 @@ const Dashboard: React.FC = () => {
|
||||
setReplyInputs({ ...replyInputs, [ratingId]: '' });
|
||||
setExpandedReplies({ ...expandedReplies, [ratingId]: true });
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to send reply');
|
||||
}
|
||||
};
|
||||
@@ -188,18 +207,15 @@ const Dashboard: React.FC = () => {
|
||||
try {
|
||||
await api.delete(`replies/${replyId}/`);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to delete reply');
|
||||
}
|
||||
};
|
||||
|
||||
// Performance Optimization: Memoize categories and filtered lists
|
||||
const exploreCategories = useMemo(() => {
|
||||
if (!data) return ['All'];
|
||||
const cats = new Set(
|
||||
data.missing_flavors
|
||||
.filter((f) => f.category_name !== 'Packs and other')
|
||||
.map((f) => f.category_name),
|
||||
data.missing_flavors.filter((f) => f.category_name !== 'Packs and other').map((f) => f.category_name),
|
||||
);
|
||||
return ['All', ...Array.from(cats).sort()];
|
||||
}, [data]);
|
||||
@@ -248,28 +264,29 @@ const Dashboard: React.FC = () => {
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<PageShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
if (!data)
|
||||
return (
|
||||
<PageShell>
|
||||
<Typography>Please login to view dashboard.</Typography>
|
||||
</PageShell>
|
||||
);
|
||||
if (!data) return <Typography>Please login to view dashboard.</Typography>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<PageShell>
|
||||
{/* Action Bar */}
|
||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
@@ -288,47 +305,24 @@ const Dashboard: React.FC = () => {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Header Profile Info */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 5,
|
||||
mb: 4,
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6),
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: { xs: 80, sm: 100 },
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Layer 1: Abstract Mesh Background */}
|
||||
{/* Profile Header — keeps inline ColorThief gradient per Phase-2 risk fallback */}
|
||||
<GlassCard intensity="strong" sx={{ overflow: 'hidden' }}>
|
||||
<Box sx={{ height: { xs: 80, sm: 100 }, position: 'relative', overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
inset: 0,
|
||||
background: `
|
||||
radial-gradient(at 0% 0%, ${alpha(palette[0] || theme.palette.primary.main, 0.6)} 0px, transparent 55%),
|
||||
radial-gradient(at 100% 0%, ${alpha(palette[1] || theme.palette.secondary.main, 0.5)} 0px, transparent 55%),
|
||||
radial-gradient(at 50% 100%, ${alpha(theme.palette.primary.main, 0.3)} 0px, transparent 55%),
|
||||
linear-gradient(135deg, ${alpha(palette[0] || theme.palette.primary.main, 0.1)} 0%, ${alpha(palette[1] || theme.palette.secondary.main, 0.1)} 100%)
|
||||
`,
|
||||
zIndex: 0,
|
||||
radial-gradient(at 0% 0%, ${alpha(palette[0] || theme.palette.primary.main, 0.6)} 0px, transparent 55%),
|
||||
radial-gradient(at 100% 0%, ${alpha(palette[1] || theme.palette.secondary.main, 0.5)} 0px, transparent 55%),
|
||||
radial-gradient(at 50% 100%, ${alpha(theme.palette.primary.main, 0.3)} 0px, transparent 55%),
|
||||
linear-gradient(135deg, ${alpha(palette[0] || theme.palette.primary.main, 0.1)} 0%, ${alpha(palette[1] || theme.palette.secondary.main, 0.1)} 100%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<CardContent sx={{ pt: 0, px: { xs: 2, sm: 4 }, pb: 4, position: 'relative', zIndex: 2 }}>
|
||||
<CardContent sx={{ pt: 0, px: { xs: 2, sm: 4 }, pb: 4, position: 'relative' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -354,9 +348,9 @@ const Dashboard: React.FC = () => {
|
||||
width: { xs: 90, sm: 110 },
|
||||
height: { xs: 90, sm: 110 },
|
||||
border: '4px solid',
|
||||
borderColor: (theme) => theme.palette.background.paper,
|
||||
borderColor: theme.palette.background.paper,
|
||||
fontSize: '3rem',
|
||||
bgcolor: (theme) => theme.palette.background.paper,
|
||||
bgcolor: theme.palette.background.paper,
|
||||
color: palette[0] || 'primary.main',
|
||||
}}
|
||||
>
|
||||
@@ -373,17 +367,9 @@ const Dashboard: React.FC = () => {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
bgcolor: (theme: any) => alpha(theme.palette.text.primary, 0.04),
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
<GlassSurface
|
||||
intensity="subtle"
|
||||
sx={{ display: 'inline-flex', borderRadius: 1, overflow: 'hidden' }}
|
||||
>
|
||||
{[
|
||||
{ label: t('dashboard.myRatings'), val: data.rated_count },
|
||||
@@ -411,23 +397,19 @@ const Dashboard: React.FC = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
{i < 1 && (
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ borderStyle: 'solid', opacity: 0.1 }}
|
||||
/>
|
||||
<Divider orientation="vertical" flexItem sx={{ borderStyle: 'solid', opacity: 0.1 }} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Paper>
|
||||
</GlassSurface>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
{/* Tabs Section */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 4 }}>
|
||||
{/* Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
@@ -449,8 +431,7 @@ const Dashboard: React.FC = () => {
|
||||
{/* TAB 0: MY REVIEWS */}
|
||||
{activeTab === 0 && (
|
||||
<Box>
|
||||
{/* Filter Controls for Reviews */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<GlassSurface intensity="subtle" sx={{ p: 2, mb: 3 }}>
|
||||
<Grid container spacing={{ xs: 2, lg: 1.5 }} alignItems="center">
|
||||
<Grid size={{ xs: 12, lg: 4, xl: 5 }}>
|
||||
<TextField
|
||||
@@ -468,11 +449,6 @@ const Dashboard: React.FC = () => {
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
'& .MuiOutlinedInput-root': { borderRadius: 1, height: { xs: 48, lg: 40 } },
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -483,7 +459,6 @@ const Dashboard: React.FC = () => {
|
||||
gap: 1,
|
||||
overflowX: 'auto',
|
||||
py: 0.5,
|
||||
px: 0.2,
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
@@ -501,13 +476,7 @@ const Dashboard: React.FC = () => {
|
||||
onClick={() => setRatedCategory(cat)}
|
||||
color={ratedCategory === cat ? 'primary' : 'default'}
|
||||
variant={ratedCategory === cat ? 'filled' : 'outlined'}
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
height: { xs: 36, lg: 32 },
|
||||
borderRadius: 1,
|
||||
fontSize: '0.8rem',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
sx={{ fontWeight: 'bold', flexShrink: 0 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -517,66 +486,34 @@ const Dashboard: React.FC = () => {
|
||||
<Select
|
||||
size="small"
|
||||
value={ratedSort}
|
||||
onChange={(e) => setRatedSort(e.target.value as any)}
|
||||
onChange={(e) => setRatedSort(e.target.value as 'date' | 'rating')}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<SortIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
height: { xs: 48, lg: 40 },
|
||||
minWidth: { xs: '100%', lg: 140 },
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.85rem',
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'divider' },
|
||||
}}
|
||||
sx={{ minWidth: { xs: '100%', lg: 140 }, fontWeight: 'bold' }}
|
||||
>
|
||||
<MenuItem value="date">{t('dashboard.date')}</MenuItem>
|
||||
<MenuItem value="rating">{t('dashboard.rating')}</MenuItem>
|
||||
</Select>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</GlassSurface>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{filteredAndSortedRated.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
py: 10,
|
||||
textAlign: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 4,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
{t('dashboard.noRatings')}
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={() => setActiveTab(1)}
|
||||
variant="contained"
|
||||
sx={{ mt: 2, borderRadius: 2 }}
|
||||
>
|
||||
{t('dashboard.exploreFlavors')}
|
||||
</Button>
|
||||
</Box>
|
||||
<EmptyState
|
||||
title={t('dashboard.noRatings')}
|
||||
action={
|
||||
<Button onClick={() => setActiveTab(1)} variant="contained" sx={{ borderRadius: 2 }}>
|
||||
{t('dashboard.exploreFlavors')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
filteredAndSortedRated.map((rating) => (
|
||||
<Card
|
||||
key={rating.id}
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8),
|
||||
transition: 'border-color 0.2s',
|
||||
'&:hover': { borderColor: 'primary.main' },
|
||||
}}
|
||||
>
|
||||
<GlassCard key={rating.id}>
|
||||
<CardContent sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
{editingRatingId === rating.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
@@ -588,7 +525,7 @@ const Dashboard: React.FC = () => {
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={editScore}
|
||||
onChange={(_: any, val: number | null) => setEditScore(val || 0)}
|
||||
onChange={(_, val) => setEditScore(val || 0)}
|
||||
/>
|
||||
</Stack>
|
||||
<MentionTextField
|
||||
@@ -598,11 +535,7 @@ const Dashboard: React.FC = () => {
|
||||
onChange={setEditComment}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => handleUpdateRating(rating.id)}
|
||||
>
|
||||
<Button variant="contained" size="small" onClick={() => handleUpdateRating(rating.id)}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -640,6 +573,7 @@ const Dashboard: React.FC = () => {
|
||||
<Box
|
||||
component="img"
|
||||
src={rating.flavor_image || undefined}
|
||||
alt={rating.flavor_name}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
@@ -652,12 +586,7 @@ const Dashboard: React.FC = () => {
|
||||
{rating.flavor_name}
|
||||
</Link>
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t(`categories.${rating.category_slug}`, {
|
||||
defaultValue: rating.category_name,
|
||||
@@ -675,15 +604,11 @@ const Dashboard: React.FC = () => {
|
||||
<RatingBadge score={rating.score} size={isMobile ? 'medium' : 'large'} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: alpha('#000', 0.02), borderRadius: 2 }}>
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: alpha(theme.palette.text.primary, 0.03), borderRadius: 2 }}>
|
||||
{rating.comment ? (
|
||||
<RichText text={rating.comment} />
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontStyle: 'italic' }}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
{t('dashboard.noComment')}
|
||||
</Typography>
|
||||
)}
|
||||
@@ -694,16 +619,9 @@ const Dashboard: React.FC = () => {
|
||||
size="small"
|
||||
startIcon={<CommentIcon fontSize="small" />}
|
||||
onClick={() =>
|
||||
setExpandedReplies((prev) => ({
|
||||
...prev,
|
||||
[rating.id]: !prev[rating.id],
|
||||
}))
|
||||
setExpandedReplies((prev) => ({ ...prev, [rating.id]: !prev[rating.id] }))
|
||||
}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
sx={{ textTransform: 'none', fontWeight: 'bold', color: 'text.secondary' }}
|
||||
>
|
||||
{rating.replies.length} {t('common.replies')}
|
||||
</Button>
|
||||
@@ -729,12 +647,9 @@ const Dashboard: React.FC = () => {
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Replies Inline */}
|
||||
<Collapse in={expandedReplies[rating.id]}>
|
||||
<Box
|
||||
sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
{rating.replies.map((reply: any) => (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
{rating.replies.map((reply) => (
|
||||
<Box
|
||||
key={reply.id}
|
||||
sx={{
|
||||
@@ -810,7 +725,7 @@ const Dashboard: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
@@ -820,8 +735,7 @@ const Dashboard: React.FC = () => {
|
||||
{/* TAB 1: EXPLORE NEW */}
|
||||
{activeTab === 1 && (
|
||||
<Box>
|
||||
{/* Filter Controls for Explore */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<GlassSurface intensity="subtle" sx={{ p: 2, mb: 3 }}>
|
||||
<Grid container spacing={{ xs: 2, lg: 1.5 }} alignItems="center">
|
||||
<Grid size={{ xs: 12, lg: 4, xl: 5 }}>
|
||||
<TextField
|
||||
@@ -839,11 +753,6 @@ const Dashboard: React.FC = () => {
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
'& .MuiOutlinedInput-root': { borderRadius: 1, height: { xs: 48, lg: 40 } },
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -854,7 +763,6 @@ const Dashboard: React.FC = () => {
|
||||
gap: 1,
|
||||
overflowX: 'auto',
|
||||
py: 0.5,
|
||||
px: 0.2,
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
@@ -872,13 +780,7 @@ const Dashboard: React.FC = () => {
|
||||
onClick={() => setExploreCategory(cat)}
|
||||
color={exploreCategory === cat ? 'primary' : 'default'}
|
||||
variant={exploreCategory === cat ? 'filled' : 'outlined'}
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
height: { xs: 36, lg: 32 },
|
||||
borderRadius: 1,
|
||||
fontSize: '0.8rem',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
sx={{ fontWeight: 'bold', flexShrink: 0 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -888,51 +790,36 @@ const Dashboard: React.FC = () => {
|
||||
<Select
|
||||
size="small"
|
||||
value={exploreSort}
|
||||
onChange={(e) => setExploreSort(e.target.value as any)}
|
||||
onChange={(e) => setExploreSort(e.target.value as 'community' | 'circle')}
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<SortIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
height: { xs: 48, lg: 40 },
|
||||
minWidth: { xs: '100%', lg: 180 },
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.85rem',
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'divider' },
|
||||
}}
|
||||
sx={{ minWidth: { xs: '100%', lg: 180 }, fontWeight: 'bold' }}
|
||||
>
|
||||
<MenuItem value="community">{t('dashboard.communityRating')}</MenuItem>
|
||||
<MenuItem value="circle">{t('dashboard.circleRating')}</MenuItem>
|
||||
</Select>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</GlassSurface>
|
||||
|
||||
{filteredMissing.length === 0 ? (
|
||||
<Box sx={{ py: 10, textAlign: 'center' }}>
|
||||
<Typography variant="h5" color="primary" sx={{ fontWeight: '900' }}>
|
||||
{t('dashboard.allRated')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<EmptyState title={t('dashboard.allRated')} />
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{filteredMissing.map((flavor) => (
|
||||
<Grid key={flavor.id} size={{ xs: 12, sm: 6, lg: 3 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: 4,
|
||||
boxShadow: (th) => th.tokens.elevation.md,
|
||||
},
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
@@ -957,6 +844,8 @@ const Dashboard: React.FC = () => {
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url || undefined}
|
||||
alt={flavor.name}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -993,13 +882,7 @@ const Dashboard: React.FC = () => {
|
||||
size="small"
|
||||
sx={{
|
||||
height: 24,
|
||||
bgcolor:
|
||||
exploreSort === 'circle' ? 'secondary.main' : 'primary.main',
|
||||
'& .MuiTypography-root': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
},
|
||||
bgcolor: exploreSort === 'circle' ? 'secondary.main' : 'primary.main',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -1032,14 +915,14 @@ const Dashboard: React.FC = () => {
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+165
-160
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
TextField,
|
||||
@@ -10,10 +9,13 @@ import {
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Rating as MuiRating,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import api from '../lib/api';
|
||||
import RatingBadge from '../components/RatingBadge';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui';
|
||||
|
||||
interface Rating {
|
||||
id: number;
|
||||
@@ -33,6 +35,7 @@ interface Flavor {
|
||||
ratings: Rating[];
|
||||
image_url: string | null;
|
||||
is_available: boolean;
|
||||
is_legacy?: boolean;
|
||||
shop_url: string | null;
|
||||
}
|
||||
|
||||
@@ -42,13 +45,16 @@ interface Category {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface RatingInput {
|
||||
score: number;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
const FlavorList: React.FC = () => {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [flavors, setFlavors] = useState<Flavor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ratingInput, setRatingInput] = useState<{
|
||||
[key: number]: { score: number; comment: string };
|
||||
}>({});
|
||||
const [ratingInput, setRatingInput] = useState<Record<number, RatingInput>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -68,190 +74,189 @@ const FlavorList: React.FC = () => {
|
||||
const handleRate = async (flavorId: number) => {
|
||||
const input = ratingInput[flavorId];
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
await api.post('ratings/', {
|
||||
flavor: flavorId,
|
||||
score: input.score,
|
||||
comment: input.comment,
|
||||
});
|
||||
// Refresh flavors
|
||||
const res = await api.get('flavors/');
|
||||
setFlavors(res.data);
|
||||
alert('Rating submitted!');
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert("Failed to submit rating. Make sure you are logged in and haven't rated this yet.");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Typography>Loading...</Typography>;
|
||||
if (loading)
|
||||
return (
|
||||
<PageShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Holy Flavors
|
||||
</Typography>
|
||||
<PageShell hero={<HeroBackdrop variant="mesh" />}>
|
||||
<SectionHeader title="Holy Flavors" />
|
||||
|
||||
{categories.map((category) => (
|
||||
<Box key={category.id} sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" color="primary" gutterBottom>
|
||||
{category.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
<Box key={category.id}>
|
||||
<SectionHeader title={category.name} compact />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
},
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{flavors
|
||||
.filter((f) => f.category_name === category.name)
|
||||
.map((flavor) => (
|
||||
<Box sx={{ flex: '1 1 30%', minWidth: 280 }} key={flavor.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{!flavor.is_available && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
bgcolor: 'error.main',
|
||||
color: 'white',
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: '0.75rem',
|
||||
zIndex: 1,
|
||||
}}
|
||||
<GlassCard
|
||||
key={flavor.id}
|
||||
intensity="subtle"
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}>
|
||||
<StatusBadge
|
||||
isLegacy={flavor.is_legacy}
|
||||
isAvailable={flavor.is_available}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
alt={flavor.name}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 200,
|
||||
objectFit: 'contain',
|
||||
p: 2,
|
||||
bgcolor: (theme) => theme.tokens.surface.sunken,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="h6">{flavor.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{flavor.description}
|
||||
</Typography>
|
||||
{flavor.shop_url && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
component="a"
|
||||
href={flavor.shop_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
sx={{ alignSelf: 'flex-start', fontSize: '0.7rem' }}
|
||||
>
|
||||
Out of Stock
|
||||
</Box>
|
||||
View in Shop
|
||||
</Button>
|
||||
)}
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 200,
|
||||
objectFit: 'contain',
|
||||
p: 2,
|
||||
bgcolor: 'rgba(255,255,255,0.05)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6">{flavor.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{flavor.description}
|
||||
</Typography>
|
||||
{flavor.shop_url && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
component="a"
|
||||
href={flavor.shop_url}
|
||||
target="_blank"
|
||||
sx={{ mb: 2, fontSize: '0.7rem' }}
|
||||
>
|
||||
View in Shop
|
||||
</Button>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>
|
||||
Avg:
|
||||
</Typography>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
</Box>
|
||||
|
||||
{flavor.ratings && flavor.ratings.length > 0 && (
|
||||
<Accordion variant="outlined" sx={{ mt: 1, mb: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="caption">
|
||||
Comments ({flavor.ratings.filter((r) => r.comment).length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ maxHeight: 200, overflow: 'auto', p: 1 }}>
|
||||
{flavor.ratings
|
||||
.filter((r) => r.comment)
|
||||
.map((r) => (
|
||||
<Box
|
||||
key={r.id}
|
||||
sx={{ mb: 1, borderBottom: '1px solid rgba(0,0,0,0.1)', pb: 0.5 }}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 'bold', display: 'block' }}
|
||||
>
|
||||
{r.user} ({r.score}/10):
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
{r.comment}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2">Avg:</Typography>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
</Box>
|
||||
|
||||
{flavor.user_rating ? (
|
||||
<Typography variant="body2" color="secondary">
|
||||
Your Rating: {flavor.user_rating}/10
|
||||
</Typography>
|
||||
) : (
|
||||
<Accordion variant="outlined" sx={{ mt: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="body2">Rate this flavor</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={ratingInput[flavor.id]?.score || 0}
|
||||
onChange={(_: any, val: number | null) =>
|
||||
setRatingInput({
|
||||
...ratingInput,
|
||||
[flavor.id]: {
|
||||
...(ratingInput[flavor.id] || { comment: '' }),
|
||||
score: val || 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Comment (Optional)"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
multiline
|
||||
value={ratingInput[flavor.id]?.comment || ''}
|
||||
onChange={(e) =>
|
||||
setRatingInput({
|
||||
...ratingInput,
|
||||
[flavor.id]: {
|
||||
...(ratingInput[flavor.id] || { score: 0 }),
|
||||
comment: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => handleRate(flavor.id)}
|
||||
{flavor.ratings && flavor.ratings.length > 0 && (
|
||||
<Accordion variant="outlined" sx={{ mt: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="caption">
|
||||
Comments ({flavor.ratings.filter((r) => r.comment).length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ maxHeight: 200, overflow: 'auto', p: 1 }}>
|
||||
{flavor.ratings
|
||||
.filter((r) => r.comment)
|
||||
.map((r) => (
|
||||
<Box
|
||||
key={r.id}
|
||||
sx={{ mb: 1, borderBottom: '1px solid', borderColor: 'divider', pb: 0.5 }}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
|
||||
{r.user} ({r.score}/10):
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
|
||||
{r.comment}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{flavor.user_rating ? (
|
||||
<Typography variant="body2" color="secondary">
|
||||
Your Rating: {flavor.user_rating}/10
|
||||
</Typography>
|
||||
) : (
|
||||
<Accordion variant="outlined" sx={{ mt: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="body2">Rate this flavor</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={ratingInput[flavor.id]?.score || 0}
|
||||
onChange={(_, val) =>
|
||||
setRatingInput({
|
||||
...ratingInput,
|
||||
[flavor.id]: {
|
||||
...(ratingInput[flavor.id] || { comment: '' }),
|
||||
score: val || 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Comment (Optional)"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
multiline
|
||||
value={ratingInput[flavor.id]?.comment || ''}
|
||||
onChange={(e) =>
|
||||
setRatingInput({
|
||||
...ratingInput,
|
||||
[flavor.id]: {
|
||||
...(ratingInput[flavor.id] || { score: 0 }),
|
||||
comment: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button variant="contained" size="small" onClick={() => handleRate(flavor.id)}>
|
||||
Submit
|
||||
</Button>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+189
-164
@@ -1,14 +1,28 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Typography, TextField, Button, Paper, Tab, Tabs, Alert, Container } from '@mui/material';
|
||||
import {
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Tab,
|
||||
Tabs,
|
||||
Alert,
|
||||
Stack,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api, { setTokens } from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageShell, GlassCard, FormCard } from '../components/ui';
|
||||
|
||||
interface LoginProps {}
|
||||
interface ApiError {
|
||||
response?: { data?: { error?: string; non_field_errors?: string[] } };
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = () => {
|
||||
const readError = (err: unknown): ApiError => err as ApiError;
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
useTitle('Login');
|
||||
const [tab, setTab] = useState(0);
|
||||
@@ -19,21 +33,19 @@ const Login: React.FC<LoginProps> = () => {
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [showResend, setShowResend] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [resetStep, setResetStep] = useState(1); // 1: email, 2: code/new pass
|
||||
const [resetStep, setResetStep] = useState(1);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await api.post('auth/token/', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
const res = await api.post('auth/token/', { username, password });
|
||||
setTokens(res.data.access, res.data.refresh);
|
||||
window.location.href = '/';
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.non_field_errors?.[0] || t('auth.loginFailed');
|
||||
} catch (err) {
|
||||
const data = readError(err).response?.data;
|
||||
const errorMsg = data?.non_field_errors?.[0] || t('auth.loginFailed');
|
||||
if (
|
||||
errorMsg.toLowerCase().includes('inactive') ||
|
||||
errorMsg.toLowerCase().includes('verify') ||
|
||||
@@ -54,8 +66,8 @@ const Login: React.FC<LoginProps> = () => {
|
||||
await api.post('users/signup/', { username, email, password });
|
||||
setIsVerifying(true);
|
||||
setMessage({ type: 'success', text: t('auth.signupSuccess') });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Signup failed' });
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: readError(err).response?.data?.error || 'Signup failed' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,8 +80,11 @@ const Login: React.FC<LoginProps> = () => {
|
||||
setIsVerifying(false);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Verification failed' });
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: readError(err).response?.data?.error || 'Verification failed',
|
||||
});
|
||||
setShowResend(true);
|
||||
}
|
||||
};
|
||||
@@ -80,8 +95,11 @@ const Login: React.FC<LoginProps> = () => {
|
||||
await api.post('users/resend_verification/', { username });
|
||||
setMessage({ type: 'success', text: t('auth.resendSuccess') });
|
||||
setShowResend(false);
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to resend code' });
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: readError(err).response?.data?.error || 'Failed to resend code',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,7 +110,7 @@ const Login: React.FC<LoginProps> = () => {
|
||||
await api.post('users/request_password_reset/', { email });
|
||||
setResetStep(2);
|
||||
setMessage({ type: 'success', text: 'If an account exists, a reset code has been sent.' });
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'An error occurred. Please try again later.' });
|
||||
}
|
||||
};
|
||||
@@ -101,76 +119,95 @@ const Login: React.FC<LoginProps> = () => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.post('users/complete_password_reset/', { email, code: verificationCode, password });
|
||||
await api.post('users/complete_password_reset/', {
|
||||
email,
|
||||
code: verificationCode,
|
||||
password,
|
||||
});
|
||||
setMessage({ type: 'success', text: 'Password reset successful! You can now login.' });
|
||||
setIsResetting(false);
|
||||
setResetStep(1);
|
||||
setTab(0);
|
||||
setPassword('');
|
||||
setVerificationCode('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Reset failed' });
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: readError(err).response?.data?.error || 'Reset failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const alertBlock = message && <Alert severity={message.type}>{message.text}</Alert>;
|
||||
|
||||
const renderContent = () => {
|
||||
if (isVerifying) {
|
||||
return (
|
||||
<form onSubmit={handleVerify}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('auth.verifyTitle')}
|
||||
</Typography>
|
||||
<FormCard
|
||||
title={t('auth.verifyTitle')}
|
||||
onSubmit={handleVerify}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant={showResend ? 'contained' : 'outlined'}
|
||||
color={showResend ? 'secondary' : 'primary'}
|
||||
onClick={handleResendCode}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
{t('auth.resendButton')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsVerifying(false);
|
||||
setMessage(null);
|
||||
}}
|
||||
>
|
||||
{t('auth.backToAuth')}
|
||||
</Button>
|
||||
<Button variant="contained" type="submit">
|
||||
Verify
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* i18next escapes interpolation values by default; {username} is not raw user HTML */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mb: 2 }}
|
||||
dangerouslySetInnerHTML={{ __html: t('auth.verifyDesc', { username }) }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Verification Code"
|
||||
margin="normal"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
Verify
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant={showResend ? 'contained' : 'outlined'}
|
||||
color={showResend ? 'secondary' : 'primary'}
|
||||
onClick={handleResendCode}
|
||||
sx={{ mt: 2, textTransform: 'none' }}
|
||||
>
|
||||
{t('auth.resendButton')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setIsVerifying(false);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{t('auth.backToAuth')}
|
||||
</Button>
|
||||
</form>
|
||||
</FormCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isResetting) {
|
||||
return (
|
||||
<form onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Reset Password
|
||||
</Typography>
|
||||
<FormCard
|
||||
title="Reset Password"
|
||||
onSubmit={resetStep === 1 ? handleRequestReset : handleCompleteReset}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsResetting(false);
|
||||
setResetStep(1);
|
||||
setMessage(null);
|
||||
}}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
<Button variant="contained" type="submit">
|
||||
{resetStep === 1 ? 'Send Reset Code' : 'Reset Password'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={resetStep === 2}
|
||||
@@ -180,7 +217,6 @@ const Login: React.FC<LoginProps> = () => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Reset Code"
|
||||
margin="normal"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
/>
|
||||
@@ -188,136 +224,125 @@ const Login: React.FC<LoginProps> = () => {
|
||||
fullWidth
|
||||
label="New Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{resetStep === 1 ? 'Send Reset Code' : 'Reset Password'}
|
||||
</Button>
|
||||
<Button
|
||||
</FormCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (tab === 0) {
|
||||
return (
|
||||
<FormCard
|
||||
title={t('auth.loginTitle')}
|
||||
onSubmit={handleLogin}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsResetting(true);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ textTransform: 'none' }}
|
||||
size="small"
|
||||
>
|
||||
{t('auth.forgotPassword')}
|
||||
</Button>
|
||||
<Button variant="contained" type="submit">
|
||||
{t('auth.loginTitle')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setIsResetting(false);
|
||||
setResetStep(1);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</form>
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</FormCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => {
|
||||
setTab(v);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Tab label={t('auth.loginTitle')} />
|
||||
<Tab label={t('auth.signupTitle')} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 ? (
|
||||
<form onSubmit={handleLogin}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{t('auth.loginTitle')}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setIsResetting(true);
|
||||
setMessage(null);
|
||||
}}
|
||||
sx={{ mt: 1, textTransform: 'none' }}
|
||||
size="small"
|
||||
>
|
||||
{t('auth.forgotPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSignup}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
type="email"
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button fullWidth variant="contained" type="submit" sx={{ mt: 2 }}>
|
||||
{t('auth.signupTitle')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
<FormCard
|
||||
title={t('auth.signupTitle')}
|
||||
onSubmit={handleSignup}
|
||||
actions={
|
||||
<Button variant="contained" type="submit">
|
||||
{t('auth.signupTitle')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</FormCard>
|
||||
);
|
||||
};
|
||||
|
||||
const showTabs = !isVerifying && !isResetting;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xs" sx={{ py: 8 }}>
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ mb: 4, textTransform: 'none', borderRadius: 2 }}
|
||||
sx={{ alignSelf: 'flex-start', textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 },
|
||||
}}
|
||||
>
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 2 }}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
{renderContent()}
|
||||
</Paper>
|
||||
</Container>
|
||||
<Box sx={{ maxWidth: 480, width: '100%', mx: 'auto' }}>
|
||||
<Stack spacing={2}>
|
||||
{showTabs && (
|
||||
<GlassCard intensity="subtle" sx={{ p: 1 }}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => {
|
||||
setTab(v);
|
||||
setMessage(null);
|
||||
}}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab label={t('auth.loginTitle')} />
|
||||
<Tab label={t('auth.signupTitle')} />
|
||||
</Tabs>
|
||||
</GlassCard>
|
||||
)}
|
||||
{alertBlock}
|
||||
{renderContent()}
|
||||
</Stack>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+388
-740
File diff suppressed because it is too large.
Load diff
+50
-114
@@ -2,38 +2,34 @@ import React from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Button,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageShell, HeroBackdrop, GlassCard, SectionHeader } from '../components/ui';
|
||||
|
||||
const Privacy: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language.startsWith('de') ? 'de' : 'en';
|
||||
useTitle(t('privacy.title'));
|
||||
const isMobile = useMediaQuery('(max-width:600px)');
|
||||
|
||||
const handleLangChange = (_: any, newLang: string | null) => {
|
||||
const handleLangChange = (_: React.MouseEvent<HTMLElement>, newLang: string | null) => {
|
||||
if (newLang) {
|
||||
i18n.changeLanguage(newLang);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: { xs: 4, md: 8 }, px: { xs: 2, sm: 3 } }}>
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 4,
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
}}
|
||||
@@ -54,125 +50,65 @@ const Privacy: React.FC = () => {
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: { xs: 2.5, sm: 4, md: 6 }, borderRadius: 4, overflow: 'hidden' }}>
|
||||
<Typography variant={isMobile ? 'h4' : 'h3'} gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('privacy.title')}
|
||||
</Typography>
|
||||
<GlassCard intensity="subtle" sx={{ p: { xs: 2.5, sm: 4, md: 6 }, maxWidth: 900, width: '100%' }}>
|
||||
<SectionHeader title={t('privacy.title')} />
|
||||
<Typography variant="body1" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.intro')}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant={isMobile ? 'h6' : 'h5'}
|
||||
gutterBottom
|
||||
sx={{ mt: 4, fontWeight: 'bold' }}
|
||||
>
|
||||
{t('privacy.controllerTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.controllerDesc')}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Your City]'}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant={isMobile ? 'h6' : 'h5'}
|
||||
gutterBottom
|
||||
sx={{ mt: 4, fontWeight: 'bold' }}
|
||||
>
|
||||
{t('privacy.collectionTitle')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('privacy.regTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.regDesc')}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<SectionHeader title={t('privacy.controllerTitle')} compact />
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.controllerDesc')}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street]'}
|
||||
<br />
|
||||
{import.meta.env.VITE_IMPRESSUM_CITY || '[Your City]'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('privacy.ipTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.ipDesc')}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<SectionHeader title={t('privacy.collectionTitle')} compact />
|
||||
|
||||
{(
|
||||
[
|
||||
['regTitle', 'regDesc'],
|
||||
['ipTitle', 'ipDesc'],
|
||||
['profileTitle', 'profileDesc'],
|
||||
['socialTitle', 'socialDesc'],
|
||||
['supportTitle', 'supportDesc'],
|
||||
['personalizationTitle', 'personalizationDesc'],
|
||||
['deletionTitle', 'deletionDesc'],
|
||||
] as const
|
||||
).map(([titleKey, descKey]) => (
|
||||
<Box key={titleKey} sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{t(`privacy.${titleKey}`)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t(`privacy.${descKey}`)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<SectionHeader title={t('privacy.transferTitle')} compact />
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.transferDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('privacy.profileTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.profileDesc')}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<SectionHeader title={t('privacy.rightsTitle')} compact />
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.rightsDesc')} {import.meta.env.VITE_IMPRESSUM_EMAIL}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('privacy.socialTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.socialDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('privacy.supportTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.supportDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('privacy.personalizationTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.personalizationDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('privacy.deletionTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.deletionDesc')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant={isMobile ? 'h6' : 'h5'}
|
||||
gutterBottom
|
||||
sx={{ mt: 4, fontWeight: 'bold' }}
|
||||
>
|
||||
{t('privacy.transferTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.transferDesc')}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant={isMobile ? 'h6' : 'h5'}
|
||||
gutterBottom
|
||||
sx={{ mt: 4, fontWeight: 'bold' }}
|
||||
>
|
||||
{t('privacy.rightsTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
|
||||
{t('privacy.rightsDesc')} {import.meta.env.VITE_IMPRESSUM_EMAIL}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Container>
|
||||
</GlassCard>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+204
-252
@@ -4,9 +4,7 @@ import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Paper,
|
||||
Alert,
|
||||
Container,
|
||||
Avatar,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
@@ -22,12 +20,21 @@ import api from '../lib/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import type { CatppuccinTheme } from '../theme';
|
||||
import { PageShell, GlassCard, FormCard } from '../components/ui';
|
||||
|
||||
interface SettingsProps {
|
||||
themeName: CatppuccinTheme;
|
||||
onThemeChange: (newTheme: CatppuccinTheme) => void;
|
||||
}
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const readErr = (err: unknown): string | undefined =>
|
||||
(err as { response?: { data?: { error?: string } } }).response?.data?.error;
|
||||
|
||||
const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -44,15 +51,12 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
const [deletionCode, setDeletionCode] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [banners, setBanners] = useState<any[]>([]);
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [selectedBannerId, setSelectedBannerId] = useState<number | string>('');
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
if (window.history.length > 1) navigate(-1);
|
||||
else navigate('/');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,8 +77,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
setLanguage(userRes.data.language);
|
||||
}
|
||||
|
||||
// Handle both paginated and non-paginated
|
||||
const bannersData = Array.isArray(bannersRes.data)
|
||||
const bannersData: Banner[] = Array.isArray(bannersRes.data)
|
||||
? bannersRes.data
|
||||
: bannersRes.data.results || [];
|
||||
setBanners(bannersData);
|
||||
@@ -103,8 +106,8 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
});
|
||||
setAvatar(res.data.avatar);
|
||||
setMessage({ type: 'success', text: t('settings.avatarSuccess') });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload avatar' });
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: readErr(err) || 'Failed to upload avatar' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,7 +116,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
i18n.changeLanguage(newLang);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { language: newLang });
|
||||
} catch (err) {
|
||||
} catch {
|
||||
console.error('Failed to update language on server');
|
||||
}
|
||||
};
|
||||
@@ -129,8 +132,8 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
setMessage({ type: 'success', text: t('settings.updateSuccess') });
|
||||
setCurrentEmail(email);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to update profile' });
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: readErr(err) || 'Failed to update profile' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,8 +145,8 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
setCurrentEmail(res.data.email);
|
||||
setPendingConfirmation(false);
|
||||
setConfirmationCode('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Confirmation failed' });
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: readErr(err) || 'Confirmation failed' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,8 +160,8 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
setMessage({ type: 'success', text: t('settings.passwordSuccess') });
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to change password' });
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: readErr(err) || 'Failed to change password' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,7 +170,7 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
await api.post('users/request_account_deletion/');
|
||||
setIsDeleting(true);
|
||||
setMessage({ type: 'success', text: t('settings.codeSent') });
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to request deletion.' });
|
||||
}
|
||||
};
|
||||
@@ -180,35 +183,23 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
localStorage.removeItem('access');
|
||||
localStorage.removeItem('refresh');
|
||||
window.location.href = '/';
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
mb: 4,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent',
|
||||
},
|
||||
}}
|
||||
sx={{ alignSelf: 'flex-start', borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 6 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<Avatar
|
||||
src={avatar || undefined}
|
||||
sx={{ width: 80, height: 80, border: '3px solid', borderColor: 'primary.main' }}
|
||||
@@ -226,285 +217,246 @@ const Settings: React.FC<SettingsProps> = ({ themeName, onThemeChange }) => {
|
||||
</Box>
|
||||
|
||||
{message && (
|
||||
<Alert severity={message.type} sx={{ mb: 4 }} onClose={() => setMessage(null)}>
|
||||
<Alert severity={message.type} onClose={() => setMessage(null)}>
|
||||
{message.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* Left Column: Visuals & Language */}
|
||||
{/* Left Column: Visuals, Avatar */}
|
||||
<Grid size={{ xs: 12, lg: 5 }}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
borderRadius: 3,
|
||||
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.appearanceTitle')}
|
||||
</Typography>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.themeLabel')}</InputLabel>
|
||||
<Select
|
||||
value={themeName}
|
||||
label={t('settings.themeLabel')}
|
||||
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)}
|
||||
>
|
||||
<ListSubheader>Holy Archive</ListSubheader>
|
||||
<MenuItem value="holy_light">Holy Light</MenuItem>
|
||||
<MenuItem value="holy_dark">Holy Dark</MenuItem>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<GlassCard sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.appearanceTitle')}
|
||||
</Typography>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.themeLabel')}</InputLabel>
|
||||
<Select
|
||||
value={themeName}
|
||||
label={t('settings.themeLabel')}
|
||||
onChange={(e) => onThemeChange(e.target.value as CatppuccinTheme)}
|
||||
>
|
||||
<ListSubheader>Holy Archive</ListSubheader>
|
||||
<MenuItem value="holy_light">Holy Light</MenuItem>
|
||||
<MenuItem value="holy_dark">Holy Dark</MenuItem>
|
||||
|
||||
<ListSubheader>Light Themes</ListSubheader>
|
||||
<MenuItem value="latte">Catppuccin Latte</MenuItem>
|
||||
<MenuItem value="pink_pastel">Pink Pastel</MenuItem>
|
||||
<MenuItem value="mint_pastel">Mint Pastel</MenuItem>
|
||||
<MenuItem value="lavender_pastel">Lavender Pastel</MenuItem>
|
||||
<MenuItem value="t0p_sai">Scaled and Icy (T0P)</MenuItem>
|
||||
<ListSubheader>Light Themes</ListSubheader>
|
||||
<MenuItem value="latte">Catppuccin Latte</MenuItem>
|
||||
<MenuItem value="pink_pastel">Pink Pastel</MenuItem>
|
||||
<MenuItem value="mint_pastel">Mint Pastel</MenuItem>
|
||||
<MenuItem value="lavender_pastel">Lavender Pastel</MenuItem>
|
||||
<MenuItem value="t0p_sai">Scaled and Icy (T0P)</MenuItem>
|
||||
|
||||
<Divider />
|
||||
<Divider />
|
||||
|
||||
<ListSubheader>Dark Themes</ListSubheader>
|
||||
<MenuItem value="mocha">Catppuccin Mocha</MenuItem>
|
||||
<MenuItem value="frappe">Catppuccin Frappé</MenuItem>
|
||||
<MenuItem value="macchiato">Catppuccin Macchiato</MenuItem>
|
||||
<MenuItem value="dracula">Dracula</MenuItem>
|
||||
<MenuItem value="nord">Nordic Frost</MenuItem>
|
||||
<MenuItem value="gruvbox">Gruvbox Retro</MenuItem>
|
||||
<MenuItem value="oceanic">Oceanic Deep</MenuItem>
|
||||
<MenuItem value="t0p_trench">Trench (T0P)</MenuItem>
|
||||
<MenuItem value="t0p_blurryface">Blurryface (T0P)</MenuItem>
|
||||
<MenuItem value="t0p_clancy">Clancy (T0P)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<ListSubheader>Dark Themes</ListSubheader>
|
||||
<MenuItem value="mocha">Catppuccin Mocha</MenuItem>
|
||||
<MenuItem value="frappe">Catppuccin Frappé</MenuItem>
|
||||
<MenuItem value="macchiato">Catppuccin Macchiato</MenuItem>
|
||||
<MenuItem value="dracula">Dracula</MenuItem>
|
||||
<MenuItem value="nord">Nordic Frost</MenuItem>
|
||||
<MenuItem value="gruvbox">Gruvbox Retro</MenuItem>
|
||||
<MenuItem value="oceanic">Oceanic Deep</MenuItem>
|
||||
<MenuItem value="t0p_trench">Trench (T0P)</MenuItem>
|
||||
<MenuItem value="t0p_blurryface">Blurryface (T0P)</MenuItem>
|
||||
<MenuItem value="t0p_clancy">Clancy (T0P)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.bannerLabel')}</InputLabel>
|
||||
<Select
|
||||
value={selectedBannerId}
|
||||
label={t('settings.bannerLabel')}
|
||||
onChange={async (e) => {
|
||||
const newId = e.target.value;
|
||||
setSelectedBannerId(newId);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { selected_banner: newId });
|
||||
} catch (err) {
|
||||
console.error('Failed to update banner preference');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">{t('settings.bannerDefault')}</MenuItem>
|
||||
{banners.map((b) => (
|
||||
<MenuItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.bannerLabel')}</InputLabel>
|
||||
<Select
|
||||
value={selectedBannerId}
|
||||
label={t('settings.bannerLabel')}
|
||||
onChange={async (e) => {
|
||||
const newId = e.target.value;
|
||||
setSelectedBannerId(newId);
|
||||
try {
|
||||
await api.patch('users/update_preferences/', { selected_banner: newId });
|
||||
} catch {
|
||||
console.error('Failed to update banner preference');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">{t('settings.bannerDefault')}</MenuItem>
|
||||
{banners.map((b) => (
|
||||
<MenuItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.langLabel')}</InputLabel>
|
||||
<Select
|
||||
value={language}
|
||||
label={t('settings.langLabel')}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Paper>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('settings.langLabel')}</InputLabel>
|
||||
<Select
|
||||
value={language}
|
||||
label={t('settings.langLabel')}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</GlassCard>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.avatarTitle')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}>
|
||||
<Avatar src={avatar || undefined} sx={{ width: 100, height: 100 }}>
|
||||
{!avatar && username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Button variant="outlined" component="label" size="small">
|
||||
{t('settings.avatarButton')}
|
||||
<input type="file" hidden accept="image/*" onChange={handleAvatarUpload} />
|
||||
</Button>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1 }} color="text.secondary">
|
||||
{t('settings.avatarHint')}
|
||||
</Typography>
|
||||
{/* Avatar: NOT a FormCard — file input lives outside any form */}
|
||||
<GlassCard sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.avatarTitle')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, my: 2 }}>
|
||||
<Avatar src={avatar || undefined} sx={{ width: 100, height: 100 }}>
|
||||
{!avatar && username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Button variant="outlined" component="label" size="small">
|
||||
{t('settings.avatarButton')}
|
||||
<input type="file" hidden accept="image/*" onChange={handleAvatarUpload} />
|
||||
</Button>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 1 }} color="text.secondary">
|
||||
{t('settings.avatarHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</GlassCard>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Right Column: Account Info & Security */}
|
||||
{/* Right Column: Info / Password / Danger */}
|
||||
<Grid size={{ xs: 12, lg: 7 }}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
borderRadius: 3,
|
||||
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.infoTitle')}
|
||||
</Typography>
|
||||
<form onSubmit={handleUpdateProfile}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<FormCard
|
||||
title={t('settings.infoTitle')}
|
||||
onSubmit={handleUpdateProfile}
|
||||
actions={
|
||||
<Button variant="contained" type="submit" sx={{ borderRadius: 2 }}>
|
||||
{t('settings.updateButton')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.usernameLabel')}
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.emailLabel')}
|
||||
margin="normal"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
helperText={email !== currentEmail ? t('settings.emailHint') : ''}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<Button variant="contained" type="submit" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
{t('settings.updateButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</FormCard>
|
||||
|
||||
{pendingConfirmation && (
|
||||
<Box sx={{ mt: 4, pt: 3, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="h6" color="primary" gutterBottom>
|
||||
{t('settings.confirmEmailTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{t('settings.confirmEmailHint', { email })}
|
||||
</Typography>
|
||||
<form onSubmit={handleConfirmEmail}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.deletionCodeLabel')}
|
||||
margin="normal"
|
||||
value={confirmationCode}
|
||||
onChange={(e) => setConfirmationCode(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
sx={{ mt: 1, borderRadius: 2 }}
|
||||
color="secondary"
|
||||
>
|
||||
<FormCard
|
||||
title={t('settings.confirmEmailTitle')}
|
||||
subtitle={t('settings.confirmEmailHint', { email })}
|
||||
onSubmit={handleConfirmEmail}
|
||||
actions={
|
||||
<Button variant="contained" color="secondary" type="submit" sx={{ borderRadius: 2 }}>
|
||||
{t('settings.confirmButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.deletionCodeLabel')}
|
||||
value={confirmationCode}
|
||||
onChange={(e) => setConfirmationCode(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
</FormCard>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
borderRadius: 3,
|
||||
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.passwordTitle')}
|
||||
</Typography>
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<FormCard
|
||||
title={t('settings.passwordTitle')}
|
||||
onSubmit={handleChangePassword}
|
||||
actions={
|
||||
<Button variant="contained" color="warning" type="submit" sx={{ borderRadius: 2 }}>
|
||||
{t('settings.passwordButton')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.oldPasswordLabel')}
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settings.newPasswordLabel')}
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
sx={{ mt: 2, borderRadius: 2 }}
|
||||
color="warning"
|
||||
>
|
||||
{t('settings.passwordButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'error.main',
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'rgba(255,0,0,0.05)' : 'rgba(255,0,0,0.02)',
|
||||
'& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom color="error" sx={{ fontWeight: 'bold' }}>
|
||||
{t('settings.dangerTitle')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('settings.dangerDesc')}
|
||||
</Typography>
|
||||
</FormCard>
|
||||
|
||||
{!isDeleting ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={handleRequestDeletion}
|
||||
sx={{ borderRadius: 2 }}
|
||||
<FormCard
|
||||
title={t('settings.dangerTitle')}
|
||||
subtitle={t('settings.dangerDesc')}
|
||||
danger
|
||||
asForm={false}
|
||||
actions={
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={handleRequestDeletion}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('settings.deleteButton')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{t('settings.deleteButton')}
|
||||
</Button>
|
||||
<Box />
|
||||
</FormCard>
|
||||
) : (
|
||||
<form onSubmit={handleConfirmDeletion}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
|
||||
{t('settings.deletionCodeHint')}
|
||||
</Typography>
|
||||
<FormCard
|
||||
title={t('settings.dangerTitle')}
|
||||
subtitle={t('settings.deletionCodeHint')}
|
||||
danger
|
||||
onSubmit={handleConfirmDeletion}
|
||||
actions={
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button onClick={() => setIsDeleting(false)} sx={{ borderRadius: 2 }}>
|
||||
{t('settings.cancelButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
type="submit"
|
||||
disabled={!deletionCode}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('settings.confirmDeleteButton')}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('settings.deletionCodeLabel')}
|
||||
value={deletionCode}
|
||||
onChange={(e) => setDeletionCode(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
type="submit"
|
||||
disabled={!deletionCode}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('settings.confirmDeleteButton')}
|
||||
</Button>
|
||||
<Button onClick={() => setIsDeleting(false)} sx={{ borderRadius: 2 }}>
|
||||
{t('settings.cancelButton')}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</FormCard>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+113
-103
@@ -1,9 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
TextField,
|
||||
@@ -11,11 +9,10 @@ import {
|
||||
Chip,
|
||||
Divider,
|
||||
Collapse,
|
||||
Paper,
|
||||
alpha,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Avatar,
|
||||
alpha,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
@@ -27,6 +24,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
import { PageShell, SectionHeader, GlassCard, FormCard, EmptyState } from '../components/ui';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
@@ -36,6 +34,8 @@ interface Message {
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
type TicketStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
user: number;
|
||||
@@ -44,11 +44,26 @@ interface Ticket {
|
||||
user_avatar: string | null;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
status: TicketStatus;
|
||||
created_at: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
interface CurrentUser {
|
||||
id: number;
|
||||
username: string;
|
||||
is_superuser: boolean;
|
||||
}
|
||||
|
||||
interface NotificationDTO {
|
||||
is_read: boolean;
|
||||
notification_type: string;
|
||||
ticket: number | null;
|
||||
}
|
||||
|
||||
type ChipStatusColor = 'error' | 'warning' | 'success' | 'default';
|
||||
type ButtonStatusColor = 'error' | 'warning' | 'success' | 'primary';
|
||||
|
||||
const Support: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
@@ -58,17 +73,15 @@ const Support: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
// Create form
|
||||
const [subject, setSubject] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Reply state
|
||||
const [replyText, setReplyText] = useState<{ [key: number]: string }>({});
|
||||
const [replyText, setReplyText] = useState<Record<number, string>>({});
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const fetchTickets = async () => {
|
||||
const fetchTickets = useCallback(async () => {
|
||||
try {
|
||||
const [ticketRes, userRes, notifRes] = await Promise.all([
|
||||
api.get('tickets/'),
|
||||
@@ -78,11 +91,11 @@ const Support: React.FC = () => {
|
||||
setTickets(ticketRes.data.results || ticketRes.data);
|
||||
setCurrentUser(userRes.data);
|
||||
|
||||
const notifs = notifRes.data.results || notifRes.data;
|
||||
const notifs: NotificationDTO[] = notifRes.data.results || notifRes.data;
|
||||
const unreadIds = new Set<number>(
|
||||
notifs
|
||||
.filter((n: any) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket)
|
||||
.map((n: any) => n.ticket),
|
||||
.filter((n) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket != null)
|
||||
.map((n) => n.ticket as number),
|
||||
);
|
||||
setUnreadTicketIds(unreadIds);
|
||||
} catch (err) {
|
||||
@@ -90,29 +103,22 @@ const Support: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
}, [fetchTickets]);
|
||||
|
||||
const handleExpand = async (ticketId: number) => {
|
||||
const handleExpand = (ticketId: number) => {
|
||||
if (expandedId === ticketId) {
|
||||
setExpandedId(null);
|
||||
return;
|
||||
}
|
||||
setExpandedId(ticketId);
|
||||
|
||||
// Mark notifications for this ticket as read
|
||||
if (unreadTicketIds.has(ticketId)) {
|
||||
try {
|
||||
// We'd need an endpoint to mark all for a specific ticket,
|
||||
// or just rely on the user clicking the notification.
|
||||
// For now, let's just clear it from the local state.
|
||||
const newUnread = new Set(unreadTicketIds);
|
||||
newUnread.delete(ticketId);
|
||||
setUnreadTicketIds(newUnread);
|
||||
} catch (e) {}
|
||||
const newUnread = new Set(unreadTicketIds);
|
||||
newUnread.delete(ticketId);
|
||||
setUnreadTicketIds(newUnread);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,7 +130,7 @@ const Support: React.FC = () => {
|
||||
setDescription('');
|
||||
setShowCreate(false);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to create ticket');
|
||||
}
|
||||
};
|
||||
@@ -136,7 +142,7 @@ const Support: React.FC = () => {
|
||||
await api.post(`tickets/${ticketId}/add_message/`, { text });
|
||||
setReplyText({ ...replyText, [ticketId]: '' });
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to send message');
|
||||
}
|
||||
};
|
||||
@@ -145,7 +151,7 @@ const Support: React.FC = () => {
|
||||
try {
|
||||
await api.post(`tickets/${ticketId}/update_status/`, { status });
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to update status');
|
||||
}
|
||||
};
|
||||
@@ -155,12 +161,12 @@ const Support: React.FC = () => {
|
||||
try {
|
||||
await api.delete(`tickets/${ticketId}/`);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to delete ticket');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const getChipColor = (status: string): ChipStatusColor => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'error';
|
||||
@@ -173,87 +179,96 @@ const Support: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonColor = (status: string): ButtonStatusColor => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'error';
|
||||
case 'in_progress':
|
||||
return 'warning';
|
||||
case 'resolved':
|
||||
return 'success';
|
||||
default:
|
||||
return 'primary';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<PageShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
sx={{ alignSelf: 'flex-start', borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>
|
||||
{t('support.title')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('support.createTicket')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<SectionHeader
|
||||
title={t('support.title')}
|
||||
action={
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{t('support.createTicket')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Collapse in={showCreate}>
|
||||
<Paper
|
||||
sx={{ p: 3, mb: 4, borderRadius: 3, border: '1px solid', borderColor: 'primary.main' }}
|
||||
>
|
||||
<form onSubmit={handleCreate}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('support.subject')}
|
||||
margin="normal"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
label={t('support.description')}
|
||||
margin="normal"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="contained" sx={{ mt: 2, borderRadius: 2 }}>
|
||||
<FormCard
|
||||
title={t('support.createTicket')}
|
||||
onSubmit={handleCreate}
|
||||
actions={
|
||||
<Button type="submit" variant="contained" sx={{ borderRadius: 2 }}>
|
||||
{t('support.createTicket')}
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('support.subject')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
label={t('support.description')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormCard>
|
||||
</Collapse>
|
||||
|
||||
<Typography variant="h5" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||
{currentUser?.is_superuser ? t('admin.users') + ' Tickets' : t('support.myTickets')}
|
||||
</Typography>
|
||||
<SectionHeader
|
||||
title={currentUser?.is_superuser ? `${t('admin.users')} Tickets` : t('support.myTickets')}
|
||||
compact
|
||||
/>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<Box sx={{ py: 8, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4 }}>
|
||||
<Typography color="text.secondary">{t('support.noTickets')}</Typography>
|
||||
</Box>
|
||||
<EmptyState title={t('support.noTickets')} />
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{tickets.map((ticket: Ticket) => (
|
||||
<Card
|
||||
{tickets.map((ticket) => (
|
||||
<GlassCard
|
||||
key={ticket.id}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: unreadTicketIds.has(ticket.id) ? 'primary.main' : 'divider',
|
||||
bgcolor: unreadTicketIds.has(ticket.id)
|
||||
? alpha(theme.palette.primary.main, 0.02)
|
||||
: 'inherit',
|
||||
borderColor: unreadTicketIds.has(ticket.id) ? 'primary.main' : undefined,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
@@ -309,7 +324,7 @@ const Support: React.FC = () => {
|
||||
)}
|
||||
<Chip
|
||||
label={t(`support.${ticket.status}`)}
|
||||
color={getStatusColor(ticket.status) as any}
|
||||
color={getChipColor(ticket.status)}
|
||||
size="small"
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
/>
|
||||
@@ -337,7 +352,7 @@ const Support: React.FC = () => {
|
||||
mt: 1,
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha('#000', 0.03),
|
||||
bgcolor: alpha(theme.palette.action.active, 0.05),
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
@@ -386,18 +401,16 @@ const Support: React.FC = () => {
|
||||
|
||||
<Collapse in={expandedId === ticket.id}>
|
||||
<Divider />
|
||||
<Box
|
||||
sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}
|
||||
>
|
||||
<Box sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}>
|
||||
{currentUser?.is_superuser && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, overflowX: 'auto', pb: 1 }}>
|
||||
{['open', 'in_progress', 'resolved', 'closed'].map((s) => (
|
||||
{(['open', 'in_progress', 'resolved', 'closed'] as const).map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="small"
|
||||
variant={ticket.status === s ? 'contained' : 'outlined'}
|
||||
onClick={() => handleUpdateStatus(ticket.id, s)}
|
||||
color={getStatusColor(s) as any}
|
||||
color={getButtonColor(s)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
borderRadius: 4,
|
||||
@@ -428,7 +441,7 @@ const Support: React.FC = () => {
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
{ticket.messages.map((msg: Message) => (
|
||||
{ticket.messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
@@ -441,10 +454,7 @@ const Support: React.FC = () => {
|
||||
boxShadow: msg.is_admin ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 'bold', display: 'block' }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
|
||||
{msg.username} {msg.is_admin && '(Support)'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ my: 0.5 }}>
|
||||
@@ -486,11 +496,11 @@ const Support: React.FC = () => {
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Container>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,52 +1,60 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
||||
import { Container, Paper, Typography, Box, CircularProgress, Button, Alert } from '@mui/material';
|
||||
import { Typography, Box, CircularProgress, Button, Alert } from '@mui/material';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../lib/api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { PageShell, HeroBackdrop, GlassCard } from '../components/ui';
|
||||
|
||||
type VerifyStatus = 'loading' | 'success' | 'error';
|
||||
|
||||
const VerifyEmail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useTitle(t('auth.verifyTitle'));
|
||||
|
||||
useEffect(() => {
|
||||
const { username, code } = useMemo(() => {
|
||||
const query = new URLSearchParams(location.search);
|
||||
const username = query.get('username');
|
||||
const code = query.get('code');
|
||||
return { username: query.get('username'), code: query.get('code') };
|
||||
}, [location.search]);
|
||||
|
||||
if (!username || !code) {
|
||||
setStatus('error');
|
||||
setErrorMsg('Invalid verification link.');
|
||||
return;
|
||||
}
|
||||
const linkValid = Boolean(username && code);
|
||||
const [status, setStatus] = useState<VerifyStatus>(linkValid ? 'loading' : 'error');
|
||||
const [errorMsg, setErrorMsg] = useState(linkValid ? '' : 'Invalid verification link.');
|
||||
|
||||
const verify = async () => {
|
||||
useEffect(() => {
|
||||
if (!linkValid) return;
|
||||
let cancelled = false;
|
||||
let redirectTimer: number | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
await api.post('users/verify_signup/', { username, code });
|
||||
if (cancelled) return;
|
||||
setStatus('success');
|
||||
// Optional: Auto redirect after 3 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
redirectTimer = window.setTimeout(() => navigate('/login'), 3000);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
const response = (err as { response?: { data?: { error?: string } } }).response;
|
||||
setStatus('error');
|
||||
setErrorMsg(err.response?.data?.error || 'Verification failed.');
|
||||
setErrorMsg(response?.data?.error || 'Verification failed.');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (redirectTimer) window.clearTimeout(redirectTimer);
|
||||
};
|
||||
|
||||
verify();
|
||||
}, [location, navigate]);
|
||||
}, [linkValid, username, code, navigate]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: 12 }}>
|
||||
<Paper sx={{ p: 6, textAlign: 'center', borderRadius: 4 }}>
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<GlassCard
|
||||
intensity="subtle"
|
||||
sx={{ p: 6, textAlign: 'center', maxWidth: 520, width: '100%', mx: 'auto' }}
|
||||
>
|
||||
{status === 'loading' && (
|
||||
<Box>
|
||||
<CircularProgress size={60} sx={{ mb: 4 }} />
|
||||
@@ -103,8 +111,8 @@ const VerifyEmail: React.FC = () => {
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</GlassCard>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Button } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import api from '../../lib/api';
|
||||
import { useTitle } from '../../hooks/useTitle';
|
||||
import RatingBadge from '../../components/RatingBadge';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageShell, HeroBackdrop, SectionHeader, FlavorCard, EmptyState } from '../../components/ui';
|
||||
|
||||
interface Rating {
|
||||
id: number;
|
||||
@@ -59,7 +50,7 @@ const CategoryFlavors: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get(`flavors/?category__slug=${slug}`);
|
||||
const data = Array.isArray(res.data) ? res.data : res.data.results || [];
|
||||
const data: Flavor[] = Array.isArray(res.data) ? res.data : res.data.results || [];
|
||||
setFlavors(data);
|
||||
if (data.length > 0) {
|
||||
setCategoryName(data[0].category_name);
|
||||
@@ -77,124 +68,51 @@ const CategoryFlavors: React.FC = () => {
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<PageShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 6 }, py: 4 }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{categoryName || 'Flavors'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
<PageShell hero={<HeroBackdrop variant="mesh" />}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ alignSelf: 'flex-start', borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
|
||||
<SectionHeader title={categoryName || 'Flavors'} />
|
||||
|
||||
{flavors.length === 0 ? (
|
||||
<EmptyState title="No flavors here yet" subtitle="Check back soon." />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
},
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
lg: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
},
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{flavors.map((flavor) => (
|
||||
<Box key={flavor.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': { transform: 'scale(1.02)' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/flavor/${flavor.id}`}
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}>
|
||||
<StatusBadge
|
||||
isLegacy={flavor.is_legacy}
|
||||
isAvailable={flavor.is_available}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
{flavor.image_url && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'background.default',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={flavor.image_url}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transition: 'transform 0.5s ease',
|
||||
'&:hover': { transform: 'scale(1.1)' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1.1rem', mb: 1 }}>
|
||||
{flavor.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<RatingBadge score={flavor.average_rating || 0} size="small" />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{flavor.ratings.length} reviews
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Container>
|
||||
{flavors.map((flavor) => (
|
||||
<FlavorCard
|
||||
key={flavor.id}
|
||||
flavor={flavor}
|
||||
showCategory={false}
|
||||
caption={`${flavor.ratings.length} reviews`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Rating as MuiRating,
|
||||
Button,
|
||||
Avatar,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Grid,
|
||||
Chip,
|
||||
Stack,
|
||||
@@ -36,6 +34,13 @@ import MentionTextField from '../../components/MentionTextField';
|
||||
import RichText from '../../components/RichText';
|
||||
import RatingBadge from '../../components/RatingBadge';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import {
|
||||
PageShell,
|
||||
HeroBackdrop,
|
||||
GlassCard,
|
||||
SectionHeader,
|
||||
EmptyState,
|
||||
} from '../../components/ui';
|
||||
|
||||
interface Reply {
|
||||
id: number;
|
||||
@@ -108,7 +113,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
const [editingReplyId, setEditingReplyId] = useState<number | null>(null);
|
||||
const [editReplyText, setEditReplyText] = useState('');
|
||||
|
||||
const fetchFlavor = async () => {
|
||||
const fetchFlavor = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get(`flavors/${id}/`);
|
||||
setFlavor(res.data);
|
||||
@@ -124,20 +129,20 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
const getUser = async () => {
|
||||
try {
|
||||
const res = await api.get('users/me/');
|
||||
setCurrentUser(res.data.username);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
getUser();
|
||||
fetchFlavor();
|
||||
}, [id]);
|
||||
}, [fetchFlavor]);
|
||||
|
||||
useTitle(flavor?.name || t('common.loading'));
|
||||
|
||||
@@ -146,7 +151,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
await api.patch(`flavors/${id}/`, editFlavorData);
|
||||
setIsAdminEditing(false);
|
||||
fetchFlavor();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Admin update failed');
|
||||
}
|
||||
};
|
||||
@@ -164,7 +169,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
fetchFlavor();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Image upload failed');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
@@ -182,8 +187,9 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
setNewScore(null);
|
||||
setNewComment('');
|
||||
fetchFlavor();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Failed to submit rating');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } }).response?.data?.error;
|
||||
alert(msg || 'Failed to submit rating');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,7 +202,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
// Auto-expand replies when submitting a new one
|
||||
setExpandedReplies((prev) => ({ ...prev, [ratingId]: true }));
|
||||
fetchFlavor();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to submit reply');
|
||||
}
|
||||
};
|
||||
@@ -207,7 +213,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
await api.patch(`replies/${replyId}/`, { text: editReplyText });
|
||||
setEditingReplyId(null);
|
||||
fetchFlavor();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to update reply');
|
||||
}
|
||||
};
|
||||
@@ -217,7 +223,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
try {
|
||||
await api.delete(`replies/${replyId}/`);
|
||||
fetchFlavor();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to delete reply');
|
||||
}
|
||||
};
|
||||
@@ -227,7 +233,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
try {
|
||||
await api.delete(`ratings/${ratingId}/`);
|
||||
fetchFlavor();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to delete review');
|
||||
}
|
||||
};
|
||||
@@ -243,7 +249,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
await api.patch(`ratings/${ratingId}/`, { score: editScore, comment: editComment });
|
||||
setEditMode(null);
|
||||
fetchFlavor();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
alert('Failed to update review');
|
||||
}
|
||||
};
|
||||
@@ -258,53 +264,41 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
<PageShell>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
if (!flavor)
|
||||
return (
|
||||
<PageShell>
|
||||
<Typography>Flavor not found</Typography>
|
||||
</PageShell>
|
||||
);
|
||||
if (!flavor) return <Typography>Flavor not found</Typography>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
{/* Top Bar */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
bgcolor: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
<PageShell hero={<HeroBackdrop variant="minimal" />}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGoBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 'bold',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{window.history.length > 1 ? t('common.back') : t('common.backToHome')}
|
||||
</Button>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<Grid container spacing={4}>
|
||||
{/* Left: Product Info Card */}
|
||||
<Grid size={{ xs: 12, lg: 4 }}>
|
||||
<Box sx={{ position: { md: 'sticky' }, top: 100 }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.6),
|
||||
backdropFilter: 'blur(12px)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<GlassCard intensity="strong" sx={{ overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
@@ -481,6 +475,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
component="a"
|
||||
href={flavor.shop_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
@@ -494,94 +489,66 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Right: Ratings & Comments */}
|
||||
<Grid size={{ xs: 12, lg: 8 }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: '800', mb: 1 }}>
|
||||
Ratings & Comments
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{flavor.ratings.length === 0
|
||||
<SectionHeader
|
||||
title="Ratings & Comments"
|
||||
subtitle={
|
||||
flavor.ratings.length === 0
|
||||
? 'Be the first to rate this flavor!'
|
||||
: `Join the discussion with ${flavor.ratings.length} other fans.`}
|
||||
</Typography>
|
||||
</Box>
|
||||
: `Join the discussion with ${flavor.ratings.length} other fans.`
|
||||
}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Add Rating Form */}
|
||||
{currentUser && flavor.user_rating === null && (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mb: 4,
|
||||
borderRadius: 4,
|
||||
border: '2px dashed',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
Rate this flavor
|
||||
</Typography>
|
||||
<form onSubmit={handleRatingSubmit}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={newScore}
|
||||
onChange={(_, val) => setNewScore(val)}
|
||||
size="large"
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<GlassCard intensity="subtle">
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>
|
||||
Rate this flavor
|
||||
</Typography>
|
||||
<form onSubmit={handleRatingSubmit}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<MuiRating
|
||||
max={10}
|
||||
value={newScore}
|
||||
onChange={(_, val) => setNewScore(val)}
|
||||
size="large"
|
||||
/>
|
||||
</Box>
|
||||
<MentionTextField
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="Share your thoughts..."
|
||||
value={newComment}
|
||||
onChange={(val) => setNewComment(val)}
|
||||
/>
|
||||
</Box>
|
||||
<MentionTextField
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="Share your thoughts..."
|
||||
value={newComment}
|
||||
onChange={(val) => setNewComment(val)}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={!newScore}
|
||||
sx={{ mt: 2, borderRadius: 2, fontWeight: 'bold' }}
|
||||
>
|
||||
Submit Review
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={!newScore}
|
||||
sx={{ mt: 2, borderRadius: 2, fontWeight: 'bold' }}
|
||||
>
|
||||
Submit Review
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</GlassCard>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack spacing={2.5}>
|
||||
{flavor.ratings.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 8,
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">{t('dashboard.noRatings')}</Typography>
|
||||
</Box>
|
||||
<EmptyState title={t('dashboard.noRatings')} />
|
||||
) : (
|
||||
flavor.ratings.map((rating: Rating) => (
|
||||
<Card
|
||||
key={rating.id}
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8),
|
||||
}}
|
||||
>
|
||||
<GlassCard key={rating.id}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{editMode === rating.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
@@ -733,7 +700,7 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
{/* Replies Section */}
|
||||
<Collapse in={expandedReplies[rating.id]}>
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
{rating.replies.map((reply: any) => (
|
||||
{rating.replies.map((reply) => (
|
||||
<Box
|
||||
key={reply.id}
|
||||
sx={{
|
||||
@@ -896,13 +863,13 @@ const FlavorDetail: React.FC<FlavorDetailProps> = ({ adminMode }) => {
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</PageShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+145
-36
@@ -1,6 +1,52 @@
|
||||
import { createTheme, type ThemeOptions, alpha } from '@mui/material/styles';
|
||||
import { flavors } from '@catppuccin/palette';
|
||||
|
||||
declare module '@mui/material/styles' {
|
||||
interface Theme {
|
||||
tokens: ThemeTokens;
|
||||
}
|
||||
interface ThemeOptions {
|
||||
tokens?: ThemeTokens;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ThemeTokens {
|
||||
surface: {
|
||||
base: string;
|
||||
elevated: string;
|
||||
overlay: string;
|
||||
sunken: string;
|
||||
};
|
||||
glass: {
|
||||
tint: string;
|
||||
tintStrong: string;
|
||||
tintSubtle: string;
|
||||
border: string;
|
||||
highlight: string;
|
||||
blur: string;
|
||||
blurStrong: string;
|
||||
};
|
||||
accent: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
gradient: string;
|
||||
softGradient: string;
|
||||
};
|
||||
elevation: {
|
||||
sm: string;
|
||||
md: string;
|
||||
lg: string;
|
||||
glow: string;
|
||||
};
|
||||
radius: {
|
||||
xs: number;
|
||||
sm: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
pill: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type CatppuccinTheme =
|
||||
| 'holy_light'
|
||||
| 'holy_dark'
|
||||
@@ -90,23 +136,23 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
};
|
||||
} else if (mode === 't0p_sai') {
|
||||
palette = {
|
||||
base: { hex: '#fdf2f8' }, // Very light SAI Pink
|
||||
base: { hex: '#fdf2f8' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
crust: { hex: '#fce7f3' }, // SAI Pink accent
|
||||
crust: { hex: '#fce7f3' },
|
||||
text: { hex: '#1e1b4b' },
|
||||
subtext0: { hex: '#4b5563' },
|
||||
primary: { hex: '#00d7ff' }, // SAI Cyan
|
||||
secondary: { hex: '#ff8ad8' }, // SAI Pink
|
||||
primary: { hex: '#00d7ff' },
|
||||
secondary: { hex: '#ff8ad8' },
|
||||
};
|
||||
} else if (mode === 't0p_trench') {
|
||||
palette = {
|
||||
base: { hex: '#1a1a1a' },
|
||||
surface0: { hex: '#242424' },
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#fce300' }, // Trench Yellow
|
||||
text: { hex: '#fce300' },
|
||||
subtext0: { hex: '#a0a0a0' },
|
||||
primary: { hex: '#fce300' },
|
||||
secondary: { hex: '#4b5320' }, // Olive
|
||||
secondary: { hex: '#4b5320' },
|
||||
};
|
||||
} else if (mode === 't0p_blurryface') {
|
||||
palette = {
|
||||
@@ -115,7 +161,7 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#ffffff' },
|
||||
subtext0: { hex: '#e31e24' },
|
||||
primary: { hex: '#e31e24' }, // BF Red
|
||||
primary: { hex: '#e31e24' },
|
||||
secondary: { hex: '#ffffff' },
|
||||
};
|
||||
} else if (mode === 't0p_clancy') {
|
||||
@@ -125,8 +171,8 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
crust: { hex: '#000000' },
|
||||
text: { hex: '#ffffff' },
|
||||
subtext0: { hex: '#fce300' },
|
||||
primary: { hex: '#e31e24' }, // Clancy Red
|
||||
secondary: { hex: '#fce300' }, // Clancy Yellow
|
||||
primary: { hex: '#e31e24' },
|
||||
secondary: { hex: '#fce300' },
|
||||
};
|
||||
} else if (mode === 'dracula') {
|
||||
palette = {
|
||||
@@ -183,7 +229,6 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
secondary: colors.pink,
|
||||
};
|
||||
} else {
|
||||
// Fallback to holy_light
|
||||
palette = {
|
||||
base: { hex: '#f8fafc' },
|
||||
surface0: { hex: '#ffffff' },
|
||||
@@ -199,6 +244,49 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
|
||||
const isLight = isLightTheme(mode);
|
||||
|
||||
const tokens: ThemeTokens = {
|
||||
surface: {
|
||||
base: palette.base.hex,
|
||||
elevated: alpha(palette.surface0.hex, isLight ? 0.72 : 0.6),
|
||||
overlay: alpha(palette.surface0.hex, isLight ? 0.82 : 0.5),
|
||||
sunken: palette.crust.hex,
|
||||
},
|
||||
glass: {
|
||||
tint: alpha(palette.surface0.hex, isLight ? 0.6 : 0.42),
|
||||
tintStrong: alpha(palette.surface0.hex, isLight ? 0.78 : 0.6),
|
||||
tintSubtle: alpha(palette.surface0.hex, isLight ? 0.42 : 0.28),
|
||||
border: alpha(palette.text.hex, isLight ? 0.08 : 0.12),
|
||||
highlight: alpha('#ffffff', isLight ? 0.65 : 0.08),
|
||||
blur: 'blur(18px) saturate(160%)',
|
||||
blurStrong: 'blur(28px) saturate(180%)',
|
||||
},
|
||||
accent: {
|
||||
primary: palette.primary.hex,
|
||||
secondary: palette.secondary.hex,
|
||||
gradient: `linear-gradient(135deg, ${palette.primary.hex} 0%, ${palette.secondary.hex} 100%)`,
|
||||
softGradient: `linear-gradient(135deg, ${alpha(palette.primary.hex, 0.18)} 0%, ${alpha(palette.secondary.hex, 0.12)} 100%)`,
|
||||
},
|
||||
elevation: {
|
||||
sm: isLight
|
||||
? '0 1px 2px rgba(15,23,42,0.06), 0 1px 3px rgba(15,23,42,0.08)'
|
||||
: '0 1px 2px rgba(0,0,0,0.3), 0 2px 6px rgba(0,0,0,0.25)',
|
||||
md: isLight
|
||||
? '0 4px 12px rgba(15,23,42,0.08), 0 2px 4px rgba(15,23,42,0.06)'
|
||||
: '0 6px 18px rgba(0,0,0,0.35), 0 2px 6px rgba(0,0,0,0.3)',
|
||||
lg: isLight
|
||||
? '0 18px 40px rgba(15,23,42,0.12), 0 6px 12px rgba(15,23,42,0.06)'
|
||||
: '0 24px 48px rgba(0,0,0,0.45), 0 8px 16px rgba(0,0,0,0.3)',
|
||||
glow: `0 0 40px ${alpha(palette.primary.hex, isLight ? 0.28 : 0.4)}`,
|
||||
},
|
||||
radius: {
|
||||
xs: 6,
|
||||
sm: 10,
|
||||
md: 14,
|
||||
lg: 20,
|
||||
pill: 999,
|
||||
},
|
||||
};
|
||||
|
||||
const themeOptions: ThemeOptions = {
|
||||
palette: {
|
||||
mode: isLight ? 'light' : 'dark',
|
||||
@@ -217,21 +305,21 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
primary: palette.text.hex,
|
||||
secondary: palette.subtext0.hex,
|
||||
},
|
||||
divider: alpha(palette.text.hex, 0.1),
|
||||
divider: tokens.glass.border,
|
||||
action: {
|
||||
hover: alpha(palette.primary.hex, 0.08),
|
||||
selected: alpha(palette.primary.hex, 0.12),
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
borderRadius: tokens.radius.sm,
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Plus Jakarta Sans", "Inter", "Roboto", sans-serif',
|
||||
h1: { fontWeight: 800 },
|
||||
h2: { fontWeight: 800 },
|
||||
h3: { fontWeight: 700 },
|
||||
h4: { fontWeight: 700 },
|
||||
h1: { fontWeight: 800, letterSpacing: '-0.02em' },
|
||||
h2: { fontWeight: 800, letterSpacing: '-0.02em' },
|
||||
h3: { fontWeight: 700, letterSpacing: '-0.015em' },
|
||||
h4: { fontWeight: 700, letterSpacing: '-0.01em' },
|
||||
h5: { fontWeight: 600 },
|
||||
h6: { fontWeight: 600 },
|
||||
button: { textTransform: 'none', fontWeight: 600 },
|
||||
@@ -240,6 +328,8 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
backgroundColor: palette.base.hex,
|
||||
color: palette.text.hex,
|
||||
scrollbarColor: `${alpha(palette.text.hex, 0.2)} transparent`,
|
||||
'&::-webkit-scrollbar': { width: '8px', height: '8px' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
@@ -253,14 +343,13 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.8),
|
||||
backgroundColor: tokens.glass.tint,
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: isLight ? 'none' : 'blur(16px)',
|
||||
backdropFilter: tokens.glass.blur,
|
||||
WebkitBackdropFilter: tokens.glass.blur,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(palette.text.hex, 0.08),
|
||||
boxShadow: isLight
|
||||
? '0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.1)'
|
||||
: '0 4px 20px rgba(0,0,0,0.2)',
|
||||
borderColor: tokens.glass.border,
|
||||
boxShadow: tokens.elevation.sm,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -270,36 +359,42 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: isLight ? palette.surface0.hex : alpha(palette.surface0.hex, 0.6),
|
||||
backdropFilter: 'blur(12px)',
|
||||
backgroundColor: tokens.glass.tint,
|
||||
backgroundImage: 'none',
|
||||
backdropFilter: tokens.glass.blur,
|
||||
WebkitBackdropFilter: tokens.glass.blur,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(palette.text.hex, 0.1),
|
||||
borderRadius: '12px',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
borderColor: tokens.glass.border,
|
||||
borderRadius: tokens.radius.md,
|
||||
boxShadow: tokens.elevation.sm,
|
||||
transition: 'transform 200ms ease, box-shadow 200ms ease, border-color 200ms ease',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: alpha(palette.base.hex, 0.8),
|
||||
backdropFilter: 'blur(20px)',
|
||||
backgroundColor: alpha(palette.base.hex, isLight ? 0.72 : 0.62),
|
||||
backdropFilter: tokens.glass.blurStrong,
|
||||
WebkitBackdropFilter: tokens.glass.blurStrong,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: alpha(palette.text.hex, 0.08),
|
||||
borderColor: tokens.glass.border,
|
||||
color: palette.text.hex,
|
||||
boxShadow: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
borderRadius: tokens.radius.sm,
|
||||
padding: '8px 20px',
|
||||
fontWeight: 600,
|
||||
},
|
||||
containedPrimary: {
|
||||
boxShadow: `0 4px 14px 0 ${alpha(palette.primary.hex, 0.39)}`,
|
||||
boxShadow: `0 4px 14px 0 ${alpha(palette.primary.hex, 0.35)}`,
|
||||
'&:hover': {
|
||||
boxShadow: `0 6px 20px rgba(0,0,0,0.23)`,
|
||||
boxShadow: `0 8px 24px 0 ${alpha(palette.primary.hex, 0.45)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -308,19 +403,33 @@ export const getTheme = (mode: CatppuccinTheme) => {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minHeight: '48px',
|
||||
fontWeight: 700,
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
borderRadius: tokens.radius.sm,
|
||||
textTransform: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
borderRadius: tokens.radius.sm,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiMenu: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: tokens.surface.overlay,
|
||||
backdropFilter: tokens.glass.blurStrong,
|
||||
WebkitBackdropFilter: tokens.glass.blurStrong,
|
||||
border: '1px solid',
|
||||
borderColor: tokens.glass.border,
|
||||
boxShadow: tokens.elevation.md,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tokens,
|
||||
};
|
||||
|
||||
return createTheme(themeOptions);
|
||||
|
||||
Reference in new issue
Block a user