migrated pages to new ui

This commit is contained in:
nvrl committed 2026-04-24 11:57:21 +02:00
1 parent e4d59a13ba
commit 607f8f9bc1
42 files changed
+3428 -3187

No files matched your search

+4 -17
View File
@@ -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],
);
};
+31 -41
View File
@@ -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;
+10 -1
View File
@@ -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 (
+10 -1
View File
@@ -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
+10 -1
View File
@@ -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 (
+10 -1
View File
@@ -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();
});
});
+50
View File
@@ -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();
});
});
+112
View File
@@ -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();
});
});
+81
View File
@@ -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>
);
};
+73
View File
@@ -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);
});
});
+52
View File
@@ -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} />;
+50
View File
@@ -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>
);
+88
View File
@@ -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>
);
+9
View File
@@ -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';
+14 -32
View File
@@ -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>
);
};
+67 -56
View File
@@ -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>
);
};
+55 -49
View File
@@ -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>
);
};
+52 -41
View File
@@ -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>
);
};
+67 -84
View File
@@ -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>
);
};
+139 -250
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
};
File diff suppressed because it is too large. Load diff
+50 -114
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
};
+34 -26
View File
@@ -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>
);
};
+42 -124
View File
@@ -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>
);
};
+97 -130
View File
@@ -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
View File
@@ -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);