diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55263f0..84a4147 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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', }} > - alpha(th.palette.background.paper, 0.8), - backdropFilter: 'blur(12px)', - color: 'text.primary', - }} - > - - + { )} - - + setDrawerOpen(false)}> {drawer} diff --git a/frontend/src/components/BannerPerformanceWrapper.test.tsx b/frontend/src/components/BannerPerformanceWrapper.test.tsx new file mode 100644 index 0000000..d52ddcf --- /dev/null +++ b/frontend/src/components/BannerPerformanceWrapper.test.tsx @@ -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 ( +
+ ); +}; + +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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + const el = getByTestId('probe'); + expect(el.dataset.active).toBe('false'); + expect(el.dataset.fps).toBe('0'); + }); + + it('disconnects observer on unmount', () => { + const { unmount } = render( + + + , + ); + unmount(); + expect(disconnectMock).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/BannerPerformanceWrapper.tsx b/frontend/src/components/BannerPerformanceWrapper.tsx new file mode 100644 index 0000000..2ab9610 --- /dev/null +++ b/frontend/src/components/BannerPerformanceWrapper.tsx @@ -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(defaultState); + +export interface BannerPerformanceWrapperProps { + children: ReactNode; + targetFps?: number; + rootMargin?: string; +} + +export const BannerPerformanceWrapper = ({ + children, + targetFps = 30, + rootMargin = '100px', +}: BannerPerformanceWrapperProps) => { + const ref = useRef(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( + () => ({ + isActive: visible && !reducedMotion, + targetFps: reducedMotion ? 0 : targetFps, + }), + [visible, reducedMotion, targetFps], + ); + + return ( + + + {children} + + + ); +}; + +/** + * 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], + ); +}; diff --git a/frontend/src/components/DynamicBanner.tsx b/frontend/src/components/DynamicBanner.tsx index c785c30..b078320 100644 --- a/frontend/src/components/DynamicBanner.tsx +++ b/frontend/src/components/DynamicBanner.tsx @@ -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 = ({ username, settings }) => { - // Placeholder for a second model +const MatrixBanner: React.FC = ({ + username, + settings, +}) => { + const [durations] = useState(() => + Array.from({ length: 20 }, () => 2 + Math.random() * 3), + ); return (
= ({ userna fontFamily: 'monospace', zIndex: 1, overflow: 'hidden', - opacity: settings.opacity || 0.5, + opacity: settings?.opacity || 0.5, }} >
- {Array.from({ length: 20 }).map((_, i) => ( -
+ {durations.map((dur, i) => ( +
{username.split('').map((char, j) => ( {char} @@ -71,6 +73,24 @@ const MatrixBanner: React.FC = ({ userna ); }; +const pickBanner = ( + banner: Banner | null, + props: DynamicBannerProps, +): React.ReactElement => { + if (!banner || banner.slug === 'generative-web') { + return ; + } + if (banner.slug === 'matrix') return ; + if (banner.slug === 'nebula') return ; + if (banner.slug === 'fireflies') return ; + if (banner.slug === 'hextech') return ; + if (banner.slug === 'hextech-corruption') + return ; + if (banner.slug === 'generative-flow') + return ; + return ; +}; + const DynamicBanner: React.FC = (props) => { const [banner, setBanner] = useState(null); const [loading, setLoading] = useState(true); @@ -94,37 +114,7 @@ const DynamicBanner: React.FC = (props) => { if (loading) return null; - // Default to GenerativeBanner if no active banner or unrecognized slug - if (!banner || banner.slug === 'generative-web') { - return ; - } - - if (banner.slug === 'matrix') { - return ; - } - - if (banner.slug === 'nebula') { - return ; - } - - if (banner.slug === 'fireflies') { - return ; - } - - if (banner.slug === 'hextech') { - return ; - } - - if (banner.slug === 'hextech-corruption') { - return ; - } - - if (banner.slug === 'generative-flow') { - return ; - } - - // Fallback - return ; + return {pickBanner(banner, props)}; }; export default DynamicBanner; diff --git a/frontend/src/components/FirefliesBanner.tsx b/frontend/src/components/FirefliesBanner.tsx index 9667217..24ff8a3 100644 --- a/frontend/src/components/FirefliesBanner.tsx +++ b/frontend/src/components/FirefliesBanner.tsx @@ -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 = ({ username, palette, settings }) => { const canvasRef = useRef(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 = ({ 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 = ({ username, palette, se glowSizeMultiplier, flickerSpeed, theme, + gate, ]); return ( diff --git a/frontend/src/components/GenerativeBanner.tsx b/frontend/src/components/GenerativeBanner.tsx index f3397f1..e939f0a 100644 --- a/frontend/src/components/GenerativeBanner.tsx +++ b/frontend/src/components/GenerativeBanner.tsx @@ -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 = ({ }) => { const canvasRef = useRef(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 = ({ }); } - 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 = ({ connectionDist, speed, globalOpacity, + gate, ]); return ( diff --git a/frontend/src/components/GenerativeFlowBanner.tsx b/frontend/src/components/GenerativeFlowBanner.tsx index 523646a..affa2db 100644 --- a/frontend/src/components/GenerativeFlowBanner.tsx +++ b/frontend/src/components/GenerativeFlowBanner.tsx @@ -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 = ({ username, palette, settings }) => { const canvasRef = useRef(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 = ({ 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 = ({ username, palette } cancelAnimationFrame(animationFrameId); }; - }, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]); + }, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme, gate]); return ( = ({ username, palette, settings }) => { const canvasRef = useRef(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 = ({ 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 = ({ username, palette, settin streamCount, distortionFactor, theme, + gate, ]); return ( diff --git a/frontend/src/components/HextechCorruptionBanner.tsx b/frontend/src/components/HextechCorruptionBanner.tsx index b8b3eda..c424575 100644 --- a/frontend/src/components/HextechCorruptionBanner.tsx +++ b/frontend/src/components/HextechCorruptionBanner.tsx @@ -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 = ({ }) => { const canvasRef = useRef(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 = ({ }); } - 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 = ({ instability, shellDarkness, theme, + gate, ]); return ( diff --git a/frontend/src/components/NebulaBanner.tsx b/frontend/src/components/NebulaBanner.tsx index 978dc41..54e34b7 100644 --- a/frontend/src/components/NebulaBanner.tsx +++ b/frontend/src/components/NebulaBanner.tsx @@ -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 = ({ }) => { const canvasRef = useRef(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 = ({ } 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 = ({ turbulenceStrength, returnSpeed, starDensity, + gate, ]); return ( diff --git a/frontend/src/components/ui/EmptyState.test.tsx b/frontend/src/components/ui/EmptyState.test.tsx new file mode 100644 index 0000000..3f93d13 --- /dev/null +++ b/frontend/src/components/ui/EmptyState.test.tsx @@ -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) => ( + {ui} +); + +describe('EmptyState', () => { + it('renders title and subtitle', () => { + render(wrap()); + 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( + Create one} + />, + ), + ); + fireEvent.click(screen.getByRole('button', { name: 'Create one' })); + expect(handleClick).toHaveBeenCalledOnce(); + }); + + it('omits subtitle when not provided', () => { + render(wrap()); + expect(screen.getByText('Empty')).toBeInTheDocument(); + }); + + it('renders icon slot', () => { + render( + wrap( + đź“­} + />, + ), + ); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..7f7c2f5 --- /dev/null +++ b/frontend/src/components/ui/EmptyState.tsx @@ -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) => ( + + {icon && ( + + {icon} + + )} + + {title} + + {subtitle && ( + + {subtitle} + + )} + {action && {action}} + +); diff --git a/frontend/src/components/ui/FlavorCard.test.tsx b/frontend/src/components/ui/FlavorCard.test.tsx new file mode 100644 index 0000000..b58c47c --- /dev/null +++ b/frontend/src/components/ui/FlavorCard.test.tsx @@ -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( + + + + + , + ); + +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( + + + + + , + ); + 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( + + + + + , + ); + expect(screen.getByText('12 reviews')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/FlavorCard.tsx b/frontend/src/components/ui/FlavorCard.tsx new file mode 100644 index 0000000..f4b58c8 --- /dev/null +++ b/frontend/src/components/ui/FlavorCard.tsx @@ -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) => ( + + theme.tokens.elevation.md, + }, + }} + > + + + + + {flavor.image_url && ( + + + + )} + + + + {flavor.name} + + + {showCategory && flavor.category_name && ( + + {flavor.category_name} + + )} + + + + {caption && ( + + {caption} + + )} + + + + +); diff --git a/frontend/src/components/ui/FormCard.test.tsx b/frontend/src/components/ui/FormCard.test.tsx new file mode 100644 index 0000000..f40f3c7 --- /dev/null +++ b/frontend/src/components/ui/FormCard.test.tsx @@ -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) => ( + {ui} +); + +describe('FormCard', () => { + it('renders title + subtitle + submits on form submit', () => { + const handleSubmit = vi.fn((e: React.FormEvent) => e.preventDefault()); + render( + wrap( + Save} + > + + , + ), + ); + + 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
when asForm=false', () => { + const { container } = render( + wrap( + +
child
+
, + ), + ); + expect(container.querySelector('form')).toBeNull(); + }); + + it('applies danger styling when danger prop set', () => { + render(wrap(content)); + 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( + +
just content
+
, + ), + ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/FormCard.tsx b/frontend/src/components/ui/FormCard.tsx new file mode 100644 index 0000000..e87fdb1 --- /dev/null +++ b/frontend/src/components/ui/FormCard.tsx @@ -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) => void; + danger?: boolean; + compact?: boolean; + /** Render without a 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 = ( + + {(title || subtitle) && ( + + {title && ( + + {title} + + )} + {subtitle && ( + + {subtitle} + + )} + + )} + + {children} + + {actions && ( + + {actions} + + )} + + ); + + return ( + 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 ? ( + + {body} + + ) : ( + body + )} + + ); +}; diff --git a/frontend/src/components/ui/Glass.test.tsx b/frontend/src/components/ui/Glass.test.tsx new file mode 100644 index 0000000..c6901f8 --- /dev/null +++ b/frontend/src/components/ui/Glass.test.tsx @@ -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({ui}); + return { theme, ...result }; +}; + +describe('Glass primitives', () => { + it('GlassCard applies backdrop blur + token-derived border via emotion styles', () => { + const { getByTestId, theme } = renderWithTheme( + content, + ); + 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( + x, + ); + 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( + x, + ); + const css = collectStyles(); + expect(css).toContain(theme.tokens.glass.tintSubtle); + }); + + it('does not forward `intensity` prop to the DOM', () => { + const { getByTestId } = renderWithTheme( + x, + ); + expect(getByTestId('g').getAttribute('intensity')).toBeNull(); + }); + + it('GlassPaper + GlassSurface emit glass tint on a light theme', () => { + const { theme } = renderWithTheme( + <> + paper + surface + , + 'holy_light', + ); + const css = collectStyles(); + expect(css).toContain(theme.tokens.glass.tint); + expect(css).toContain(theme.tokens.glass.border); + }); +}); diff --git a/frontend/src/components/ui/Glass.tsx b/frontend/src/components/ui/Glass.tsx new file mode 100644 index 0000000..2d46108 --- /dev/null +++ b/frontend/src/components/ui/Glass.tsx @@ -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', +})(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme })); + +export const GlassPaper = styled(MuiPaper, { + shouldForwardProp: (prop) => prop !== 'intensity', +})(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme })); + +export const GlassSurface = styled(Box, { + shouldForwardProp: (prop) => prop !== 'intensity', +})(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme })); + +export type { Intensity as GlassIntensity }; diff --git a/frontend/src/components/ui/GlassAppBar.tsx b/frontend/src/components/ui/GlassAppBar.tsx new file mode 100644 index 0000000..5675176 --- /dev/null +++ b/frontend/src/components/ui/GlassAppBar.tsx @@ -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 ( + + + {children} + + + ); +}; diff --git a/frontend/src/components/ui/HeroBackdrop.tsx b/frontend/src/components/ui/HeroBackdrop.tsx new file mode 100644 index 0000000..30f3c25 --- /dev/null +++ b/frontend/src/components/ui/HeroBackdrop.tsx @@ -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 }>(({ 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) => ; diff --git a/frontend/src/components/ui/PageShell.tsx b/frontend/src/components/ui/PageShell.tsx new file mode 100644 index 0000000..b9c6c50 --- /dev/null +++ b/frontend/src/components/ui/PageShell.tsx @@ -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) => ( + + {hero && {hero}} + {children} + +); diff --git a/frontend/src/components/ui/ReviewRow.tsx b/frontend/src/components/ui/ReviewRow.tsx new file mode 100644 index 0000000..2abe922 --- /dev/null +++ b/frontend/src/components/ui/ReviewRow.tsx @@ -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) => ( + + theme.tokens.elevation.md, + }, + }} + > + + + {!review.user_avatar && review.user.charAt(0).toUpperCase()} + + + + {review.user} + + + {formatDate(review.created_at)} + + + + + + {showFlavor && review.flavor_name && ( + + on {review.flavor_name} + + )} + + {review.comment && ( + + + + )} + + +); diff --git a/frontend/src/components/ui/SectionHeader.tsx b/frontend/src/components/ui/SectionHeader.tsx new file mode 100644 index 0000000..4828b46 --- /dev/null +++ b/frontend/src/components/ui/SectionHeader.tsx @@ -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) => ( + + + theme.tokens.accent.gradient, + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + backgroundClip: 'text', + display: 'inline-block', + }} + > + {title} + + {subtitle && ( + + {subtitle} + + )} + + {action} + +); diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..80f8ff4 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -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'; diff --git a/frontend/src/pages/About.tsx b/frontend/src/pages/About.tsx index 1ebdfc4..4373c02 100644 --- a/frontend/src/pages/About.tsx +++ b/frontend/src/pages/About.tsx @@ -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 ( - + }> - - - {t('about.title')} - + + { - - {t('about.canDoTitle')} - - + +
  • {t('about.featureExplore')}
  • {t('about.featureRate')}
  • {t('about.featureCommunity')}
  • @@ -55,9 +51,7 @@ const About: React.FC = () => { - - {t('about.impressumTitle')} - + {t('about.legalInfo')}
    @@ -101,28 +95,16 @@ const About: React.FC = () => {
    - - {t('about.disclaimerTitle')} - - + + {t('about.liabilityContentTitle')} {t('about.liabilityContentDesc')} - + {t('about.copyrightTitle')} {t('about.copyrightDesc')} -
    -
    + + ); }; diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index d38f246..8aee25a 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -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; + schema: BannerSettingSchema[]; } interface Job { @@ -127,7 +135,7 @@ const AdminDashboard: React.FC = () => { const [selectedBanner, setSelectedBanner] = useState(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 = { 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 ( - + @@ -267,7 +277,7 @@ const AdminDashboard: React.FC = () => { - alpha(theme.palette.primary.main, 0.1) }}> + alpha(theme.palette.primary.main, 0.1) }}> {t('admin.totalUsers')} @@ -276,10 +286,10 @@ const AdminDashboard: React.FC = () => { {stats?.total_users} - + - alpha(theme.palette.error.main, 0.1) }}> + alpha(theme.palette.error.main, 0.1) }}> {t('admin.openTickets')} @@ -291,10 +301,10 @@ const AdminDashboard: React.FC = () => { {stats?.open_tickets} - + - + { - + @@ -337,7 +347,7 @@ const AdminDashboard: React.FC = () => { System Configuration (Live) - + {[ { key: 'site_name', label: 'Site Name', type: 'text' }, @@ -352,11 +362,17 @@ const AdminDashboard: React.FC = () => { )?.[item.key] + : null + } /> {item.type === 'switch' ? ( )?.[item.key] || false + } onChange={(e: React.ChangeEvent) => 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)?.[item.key], ); if (val !== null) handleUpdateConfig(item.key, val); }} @@ -378,7 +394,7 @@ const AdminDashboard: React.FC = () => { ))} - + {/* Environment Info */} @@ -386,10 +402,7 @@ const AdminDashboard: React.FC = () => { Environment Info (.env) - + { - + @@ -467,7 +480,7 @@ const AdminDashboard: React.FC = () => { {isMobile ? ( {filteredUsers.map((user) => ( - + @@ -500,14 +513,11 @@ const AdminDashboard: React.FC = () => { )} - + ))} ) : ( - + @@ -588,18 +598,19 @@ const AdminDashboard: React.FC = () => { {banners.length === 0 ? ( - - - No banner models found in the database. - - - + fetchData()}> + Retry Loading + + } + /> ) : ( {banners.map((banner) => ( - @@ -671,7 +682,7 @@ const AdminDashboard: React.FC = () => { )} - + ))} )} @@ -685,7 +696,7 @@ const AdminDashboard: React.FC = () => { {jobs.map((job) => ( - @@ -819,7 +830,7 @@ const AdminDashboard: React.FC = () => { Trigger Now - + ))} @@ -834,7 +845,7 @@ const AdminDashboard: React.FC = () => { banner={selectedBanner} onSave={fetchData} /> - + ); }; diff --git a/frontend/src/pages/AdminRatingDetail.tsx b/frontend/src/pages/AdminRatingDetail.tsx index 9798e1c..fe67266 100644 --- a/frontend/src/pages/AdminRatingDetail.tsx +++ b/frontend/src/pages/AdminRatingDetail.tsx @@ -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(null); + const [rating, setRating] = useState(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(`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 ( - - - + + + + + ); + if (!rating) return null; + return ( - + - - {t('admin.manageRating')} - + - - - {rating.flavor_name} (User: {rating.user}) - - + + + + + + } + > { onChange={(e) => setScore(Number(e.target.value))} /> - - - - - - - + + + ); }; diff --git a/frontend/src/pages/AdminReplyDetail.tsx b/frontend/src/pages/AdminReplyDetail.tsx index fe32676..07daf89 100644 --- a/frontend/src/pages/AdminReplyDetail.tsx +++ b/frontend/src/pages/AdminReplyDetail.tsx @@ -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(null); + const [reply, setReply] = useState(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(`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 ( - - - + + + + + ); + if (!reply) return null; + return ( - + - - {t('admin.manageReply')} - + - - - Reply by {reply.user} - - + + + + + + } + > - - - - - - - + + + ); }; diff --git a/frontend/src/pages/AdminUserDetail.tsx b/frontend/src/pages/AdminUserDetail.tsx index a78c611..cdbccd2 100644 --- a/frontend/src/pages/AdminUserDetail.tsx +++ b/frontend/src/pages/AdminUserDetail.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState(0); - // Edit state for Ratings const [editingRatingId, setEditingRatingId] = useState(null); const [editScore, setEditScore] = useState(0); const [editComment, setEditComment] = useState(''); - // Edit state for Replies const [editingReplyId, setEditingReplyId] = useState(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 User not found; return ( - + - {/* Profile Header */} - + @@ -226,9 +238,8 @@ const AdminUserDetail: React.FC = () => { - + - {/* Content Tabs */} setActiveTab(v)} variant="fullWidth"> { - {/* TAB 0: USER CONTENT (Ratings & Replies) */} {activeTab === 0 && ( - - User Ratings & Discussion - + {user.ratings.map((rating) => ( - + {editingRatingId === rating.id ? ( @@ -351,7 +355,7 @@ const AdminUserDetail: React.FC = () => { {rating.replies.length > 0 && ( - {rating.replies.map((reply: any) => ( + {rating.replies.map((reply) => ( {editingReplyId === reply.id ? ( @@ -428,29 +432,15 @@ const AdminUserDetail: React.FC = () => { )} - + ))} - {user.ratings.length === 0 && ( - - No ratings yet. - - )} + {user.ratings.length === 0 && } )} - {/* TAB 1: SECURITY & IP */} {activeTab === 1 && ( - + { No IP history available. )} - + )} - {/* TAB 2: DANGER ZONE */} {activeTab === 2 && ( - alpha(theme.palette.error.main, 0.05), - }} + } + onClick={handleDelete} + sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }} + > + {t('admin.deleteUser')} + + } > - - {t('admin.dangerZone')} + + Proceed only if the user has been reviewed for policy violations or requested deletion. - - Deleting this user will permanently remove their profile, all their ratings, replies, - and IP history. - This action is irreversible. - - - + )} - + ); }; diff --git a/frontend/src/pages/CommunityFeed.tsx b/frontend/src/pages/CommunityFeed.tsx index c82b889..29e9b4b 100644 --- a/frontend/src/pages/CommunityFeed.tsx +++ b/frontend/src/pages/CommunityFeed.tsx @@ -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, +}) => ( + + + {icon} + + {title} + + + {children} + +); + const CommunityFeed: React.FC = ({ adminMode }) => { const { t } = useTranslation(); useTitle(t('community.title')); @@ -100,60 +136,54 @@ const CommunityFeed: React.FC = ({ 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([]); - - // UI State - const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({}); - const [expandedReplies, setExpandedReplies] = useState<{ [key: number]: boolean }>({}); + const [topFollowed, setTopFollowed] = useState([]); + const [replyInputs, setReplyInputs] = useState>({}); + const [expandedReplies, setExpandedReplies] = useState>({}); 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 = ({ adminMode }) => { return; } fetchFeedData(page); - }, [page, navigate]); + }, [page, navigate, fetchFeedData]); const handlePageChange = (_: React.ChangeEvent, value: number) => { setPage(value); @@ -178,109 +208,54 @@ const CommunityFeed: React.FC = ({ 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 ( - - - + + + + + ); return ( - - - - + }> + - - - {t('community.title')} - - - {t('community.subtitle')} - - + - {/* Main Feed Column */} {ratings.length === 0 ? ( - alpha(theme.palette.background.paper, 0.6), - backdropFilter: 'blur(10px)', - border: '1px dashed', - borderColor: 'divider', - }} - > - - {t('community.quietFeed')} - - - {t('community.followMore')} - - - + + {t('home.exploreFlavors')} + + } + /> ) : ( {ratings.map((rating) => ( - alpha(theme.palette.background.paper, 0.8), - transition: 'transform 0.2s', - '&:hover': { - transform: 'translateY(-2px)', - borderColor: (theme) => alpha(theme.palette.primary.main, 0.2), - }, - }} - > + - {/* User Info */} @@ -301,19 +276,12 @@ const CommunityFeed: React.FC = ({ adminMode }) => { to={`/profile/${rating.user}`} style={{ textDecoration: 'none', color: 'inherit' }} > - + {rating.user} - - {formatTimestamp(rating.created_at)} + + {formatDate(rating.created_at)} @@ -332,7 +300,6 @@ const CommunityFeed: React.FC = ({ adminMode }) => { )} - {/* Flavor and Comment */} @@ -340,11 +307,7 @@ const CommunityFeed: React.FC = ({ adminMode }) => { {rating.flavor_name} @@ -357,11 +320,7 @@ const CommunityFeed: React.FC = ({ adminMode }) => { ) : ( - + No comment provided. )} @@ -381,6 +340,7 @@ const CommunityFeed: React.FC = ({ adminMode }) => { @@ -389,7 +349,6 @@ const CommunityFeed: React.FC = ({ adminMode }) => { - {/* Footer & Replies */} @@ -288,47 +305,24 @@ const Dashboard: React.FC = () => { - {/* Header Profile Info */} - alpha(theme.palette.background.paper, 0.6), - backdropFilter: 'blur(20px)', - border: '1px solid', - borderColor: 'divider', - overflow: 'hidden', - position: 'relative', - }} - > - - {/* Layer 1: Abstract Mesh Background */} + {/* Profile Header — keeps inline ColorThief gradient per Phase-2 risk fallback */} + + - + { 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 = () => { - alpha(theme.palette.text.primary, 0.04), - border: '1px solid', - borderColor: 'divider', - borderRadius: 1, - overflow: 'hidden', - backdropFilter: 'blur(8px)', - }} + {[ { label: t('dashboard.myRatings'), val: data.rated_count }, @@ -411,23 +397,19 @@ const Dashboard: React.FC = () => { {i < 1 && ( - + )} ))} - + - + - {/* Tabs Section */} - + {/* Tabs */} + setActiveTab(v)} @@ -449,8 +431,7 @@ const Dashboard: React.FC = () => { {/* TAB 0: MY REVIEWS */} {activeTab === 0 && ( - {/* Filter Controls for Reviews */} - + { ), }, }} - sx={{ - bgcolor: 'background.paper', - borderRadius: 1, - '& .MuiOutlinedInput-root': { borderRadius: 1, height: { xs: 48, lg: 40 } }, - }} /> @@ -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 }} /> ))} @@ -517,66 +486,34 @@ const Dashboard: React.FC = () => { - + {filteredAndSortedRated.length === 0 ? ( - - - {t('dashboard.noRatings')} - - - + setActiveTab(1)} variant="contained" sx={{ borderRadius: 2 }}> + {t('dashboard.exploreFlavors')} + + } + /> ) : ( filteredAndSortedRated.map((rating) => ( - alpha(theme.palette.background.paper, 0.8), - transition: 'border-color 0.2s', - '&:hover': { borderColor: 'primary.main' }, - }} - > + {editingRatingId === rating.id ? ( @@ -588,7 +525,7 @@ const Dashboard: React.FC = () => { setEditScore(val || 0)} + onChange={(_, val) => setEditScore(val || 0)} /> { onChange={setEditComment} /> - @@ -729,12 +647,9 @@ const Dashboard: React.FC = () => { - {/* Replies Inline */} - - {rating.replies.map((reply: any) => ( + + {rating.replies.map((reply) => ( { )} - + )) )} @@ -820,8 +735,7 @@ const Dashboard: React.FC = () => { {/* TAB 1: EXPLORE NEW */} {activeTab === 1 && ( - {/* Filter Controls for Explore */} - + { ), }, }} - sx={{ - bgcolor: 'background.paper', - borderRadius: 1, - '& .MuiOutlinedInput-root': { borderRadius: 1, height: { xs: 48, lg: 40 } }, - }} /> @@ -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 }} /> ))} @@ -888,51 +790,36 @@ const Dashboard: React.FC = () => { - + {filteredMissing.length === 0 ? ( - - - {t('dashboard.allRated')} - - + ) : ( {filteredMissing.map((flavor) => ( - th.tokens.elevation.md, }, overflow: 'hidden', }} @@ -957,6 +844,8 @@ 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', }} /> @@ -1032,14 +915,14 @@ const Dashboard: React.FC = () => { - + ))} )} )} - + ); }; diff --git a/frontend/src/pages/FlavorList.tsx b/frontend/src/pages/FlavorList.tsx index e6f2734..d74bcdb 100644 --- a/frontend/src/pages/FlavorList.tsx +++ b/frontend/src/pages/FlavorList.tsx @@ -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([]); const [flavors, setFlavors] = useState([]); const [loading, setLoading] = useState(true); - const [ratingInput, setRatingInput] = useState<{ - [key: number]: { score: number; comment: string }; - }>({}); + const [ratingInput, setRatingInput] = useState>({}); 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 Loading...; + if (loading) + return ( + + + + + + ); return ( - - - Holy Flavors - + }> + + {categories.map((category) => ( - - - {category.name} - - + + + {flavors .filter((f) => f.category_name === category.name) .map((flavor) => ( - - - {!flavor.is_available && ( - + + + + + {flavor.image_url && ( + theme.tokens.surface.sunken, + }} + /> + )} + + + {flavor.name} + + {flavor.description} + + {flavor.shop_url && ( + )} - {flavor.image_url && ( - - )} - - {flavor.name} - - {flavor.description} - - {flavor.shop_url && ( - - )} - - - Avg: - - - - {flavor.ratings && flavor.ratings.length > 0 && ( - - }> - - Comments ({flavor.ratings.filter((r) => r.comment).length}) - - - - {flavor.ratings - .filter((r) => r.comment) - .map((r) => ( - - - {r.user} ({r.score}/10): - - - {r.comment} - - - ))} - - - )} + + Avg: + + - {flavor.user_rating ? ( - - Your Rating: {flavor.user_rating}/10 - - ) : ( - - }> - Rate this flavor - - - - - setRatingInput({ - ...ratingInput, - [flavor.id]: { - ...(ratingInput[flavor.id] || { comment: '' }), - score: val || 0, - }, - }) - } - /> - - setRatingInput({ - ...ratingInput, - [flavor.id]: { - ...(ratingInput[flavor.id] || { score: 0 }), - comment: e.target.value, - }, - }) - } - /> - - - - - )} - - - + + {r.user} ({r.score}/10): + + + {r.comment} + + + ))} + + + )} + + {flavor.user_rating ? ( + + Your Rating: {flavor.user_rating}/10 + + ) : ( + + }> + Rate this flavor + + + + + setRatingInput({ + ...ratingInput, + [flavor.id]: { + ...(ratingInput[flavor.id] || { comment: '' }), + score: val || 0, + }, + }) + } + /> + + setRatingInput({ + ...ratingInput, + [flavor.id]: { + ...(ratingInput[flavor.id] || { score: 0 }), + comment: e.target.value, + }, + }) + } + /> + + + + + )} + + ))} ))} - + ); }; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 2c52576..13b1a13 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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 = () => { +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 = () => { 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 = () => { 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 = () => { 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 = () => { 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 = () => { 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 = () => { 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 && {message.text}; + const renderContent = () => { if (isVerifying) { return ( - - - {t('auth.verifyTitle')} - + + + + + + } + > + {/* i18next escapes interpolation values by default; {username} is not raw user HTML */} setVerificationCode(e.target.value)} autoFocus /> - - - - - - + ); } if (isResetting) { return ( -
    - - Reset Password - + + + + + } + > setEmail(e.target.value)} disabled={resetStep === 2} @@ -180,7 +217,6 @@ const Login: React.FC = () => { setVerificationCode(e.target.value)} /> @@ -188,136 +224,125 @@ const Login: React.FC = () => { fullWidth label="New Password" type="password" - margin="normal" value={password} onChange={(e) => setPassword(e.target.value)} /> )} - - + + + } + > + { - setIsResetting(false); - setResetStep(1); - setMessage(null); - }} - sx={{ mt: 1 }} - > - Back to Login - - + label="Username" + value={username} + onChange={(e) => setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + ); } return ( - <> - { - setTab(v); - setMessage(null); - }} - sx={{ mb: 2 }} - > - - - - - {tab === 0 ? ( -
    - setUsername(e.target.value)} - /> - setPassword(e.target.value)} - /> - - - - ) : ( -
    - setUsername(e.target.value)} - /> - setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> - - - )} - + + {t('auth.signupTitle')} + + } + > + setUsername(e.target.value)} + /> + setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + ); }; + const showTabs = !isVerifying && !isResetting; + return ( - + - - {message && ( - - {message.text} - - )} - {renderContent()} - - + + + {showTabs && ( + + { + setTab(v); + setMessage(null); + }} + variant="fullWidth" + > + + + + + )} + {alertBlock} + {renderContent()} + + + ); }; diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index 0943b2c..a587a74 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -1,31 +1,33 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Typography, Box, - Card, CardContent, CircularProgress, Button, Avatar, - Paper, Chip, - Container, IconButton, - alpha, Tabs, Tab, } from '@mui/material'; -import { Link, useLocation } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import api from '../lib/api'; import { useTitle } from '../hooks/useTitle'; -import { formatDate } from '../utils/date'; import RatingBadge from '../components/RatingBadge'; import StatusBadge from '../components/StatusBadge'; -import { useNavigate } from 'react-router-dom'; +import { + GlassCard, + PageShell, + SectionHeader, + HeroBackdrop, + ReviewRow, + FlavorCard, +} from '../components/ui'; interface Category { id: number; @@ -42,7 +44,21 @@ interface Flavor { image_url: string | null; is_available: boolean; is_legacy: boolean; - ratings: any[]; + ratings: FeedRating[]; +} + +interface FeedRating { + id: number; + user: string; + user_avatar: string | null; + flavor: number; + flavor_name: string; + flavor_image: string | null; + flavor_category?: string; + score: number; + comment: string; + created_at: string; + replies?: unknown[]; } interface Review { @@ -68,7 +84,7 @@ const MainPage: React.FC = ({ adminMode }) => { const [newestFlavors, setNewestFlavors] = useState([]); const [recentReviews, setRecentReviews] = useState([]); const [allFlavors, setAllFlavors] = useState([]); - const [feedRatings, setFeedRatings] = useState([]); + const [feedRatings, setFeedRatings] = useState([]); const [categories, setCategories] = useState([]); const [selectedCategorySlug, setSelectedCategorySlug] = useState(''); const [loading, setLoading] = useState(true); @@ -86,11 +102,10 @@ const MainPage: React.FC = ({ adminMode }) => { setLoading(true); try { if (query) { - // Perform server-side search to get all matching flavors across pages const res = await api.get(`flavors/?search=${encodeURIComponent(query)}`); setAllFlavors(Array.isArray(res.data) ? res.data : res.data.results || []); } else { - const requests: any[] = [ + const requests = [ api.get('flavors/top/'), api.get('ratings/recent/'), api.get('flavors/newest/'), @@ -120,7 +135,7 @@ const MainPage: React.FC = ({ adminMode }) => { const feedData = Array.isArray(results[4].data) ? results[4].data : results[4].data.results || []; - setFeedRatings(feedData.slice(0, 6)); // Show top 6 on home + setFeedRatings(feedData.slice(0, 6)); } } } catch (err) { @@ -133,7 +148,7 @@ const MainPage: React.FC = ({ adminMode }) => { }, [query, isLoggedIn]); useEffect(() => { - if (query) return; // Don't refetch if we are in search mode + if (query) return; const fetchTopFlavors = async () => { setCategoryLoading(true); @@ -143,7 +158,7 @@ const MainPage: React.FC = ({ adminMode }) => { : 'flavors/top/'; const res = await api.get(url); setTopFlavors(Array.isArray(res.data) ? res.data : res.data.results || []); - setActiveIndex(0); // Reset carousel + setActiveIndex(0); } catch (err) { console.error(err); } finally { @@ -163,14 +178,13 @@ const MainPage: React.FC = ({ adminMode }) => { setSelectedCategorySlug(newValue); }; - const startTimer = () => { + const startTimer = useCallback(() => { if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => { setActiveIndex((prev) => (prev + 1) % topFlavors.length); }, 6000); - }; + }, [topFlavors.length]); - // Auto-rotate useEffect(() => { if (topFlavors.length > 0 && !query) { startTimer(); @@ -178,7 +192,7 @@ const MainPage: React.FC = ({ adminMode }) => { if (timerRef.current) clearInterval(timerRef.current); }; } - }, [topFlavors, query]); + }, [topFlavors, query, startTimer]); useTitle(query ? `Search: ${query}` : 'Holy Flavors Archive'); @@ -208,14 +222,11 @@ const MainPage: React.FC = ({ adminMode }) => { ); return ( - - - Search Results for "{query}" - - - Found {filteredFlavors.length} items - - + + = ({ adminMode }) => { }} > {filteredFlavors.map((flavor) => ( - - - - - - - {flavor.image_url && ( - - - - )} - - - {flavor.name} - - - {flavor.category_name} - - - - - - - - + ))} - + ); } @@ -314,44 +251,14 @@ const MainPage: React.FC = ({ adminMode }) => { const featuredReview = currentTop?.ratings?.find((r) => r.comment) || null; return ( - - {/* Hero Section / Activity Feed */} + }> + {/* Hero / Activity Feed */} {isLoggedIn ? ( - - theme.palette.mode === 'dark' - ? `linear-gradient(180deg, ${alpha('#000', 0.4)} 0%, ${alpha('#000', 0)} 100%)` - : `linear-gradient(180deg, ${alpha(theme.palette.primary.main, 0.05)} 0%, ${alpha(theme.palette.primary.main, 0)} 100%)`, - borderBottom: '1px solid', - borderColor: 'divider', - mb: 4, - }} - > - - - - - {t('home.activity')} - - - {t('home.activitySubtitle')} - - + + = ({ adminMode }) => { > {t('home.viewFullFeed')} + } + /> + + {feedRatings.length === 0 ? ( + + {t('home.quietFeed')} + + {t('home.followMore')} + + + + ) : ( + + {feedRatings.map((rating) => ( + + + {adminMode && ( + + )} + + ))} - - {feedRatings.length === 0 ? ( - - {t('home.quietFeed')} - - {t('home.followMore')} - - - - ) : ( - - {feedRatings.map((rating) => ( - alpha(theme.palette.background.paper, 0.4), - backdropFilter: 'blur(10px)', - border: '1px solid', - borderColor: 'divider', - color: 'inherit', - display: 'flex', - flexDirection: 'column', - }} - > - - - - - {!rating.user_avatar && rating.user.charAt(0).toUpperCase()} - - - {rating.user} - - - - {t('home.rated')} {formatDate(rating.created_at)} - - - - {adminMode && ( - - )} - - - - - - {rating.flavor_name} - - - alpha(theme.palette.primary.main, 0.1), - px: 1, - py: 0.2, - borderRadius: 1, - fontSize: '0.75rem', - }} - > - {rating.score} / 10 - - - - - - - ))} - - )} - + )} ) : ( - - theme.palette.mode === 'dark' - ? `radial-gradient(circle at top right, ${alpha(theme.palette.primary.main, 0.15)}, transparent), linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%)` - : `radial-gradient(circle at top right, ${alpha(theme.palette.primary.main, 0.1)}, transparent), linear-gradient(135deg, #fdf6f7 0%, #f8e1e5 100%)`, textAlign: 'center', - borderBottom: '1px solid', - borderColor: 'divider', - width: '100%', - boxSizing: 'border-box', overflow: 'hidden', position: 'relative', }} @@ -563,7 +352,10 @@ const MainPage: React.FC = ({ adminMode }) => { fontWeight: 'bold', fontSize: { xs: '2.2rem', sm: '3rem', md: '4.5rem' }, overflowWrap: 'break-word', - px: 2, + background: (theme) => theme.tokens.accent.gradient, + WebkitBackgroundClip: 'text', + WebkitTextFillColor: 'transparent', + backgroundClip: 'text', }} > {t('hero.title')} @@ -595,28 +387,21 @@ const MainPage: React.FC = ({ adminMode }) => { {t('hero.whatIsThis')} - + )} - - - {/* Hall of Fame Carousel - SIDE BY SIDE */} - {topFlavors.length > 0 && currentTop && ( - - - - - - {t('home.hallOfFame')} - + + {/* Hall of Fame Carousel */} + {topFlavors.length > 0 && currentTop && ( + + + + {t('home.hallOfFame')} + } + action={ = ({ adminMode }) => { - + } + /> - - - - {categories.map((cat) => ( - - ))} - - - - - {categoryLoading && ( - - - - )} - - - - - - - - - - - - - {currentTop.name} - - - - - - {currentTop.ratings.length} {t('common.reviews')} - - - - {featuredReview && ( - alpha(theme.palette.background.paper, 0.4), - backdropFilter: 'blur(8px)', - borderRadius: 3, - border: '1px solid', - borderColor: (theme) => alpha(theme.palette.text.primary, 0.05), - position: 'relative', - }} - > - - "{featuredReview.comment}" - - - - {!featuredReview.user_avatar && featuredReview.user.charAt(0)} - - - — {featuredReview.user} - - - - )} - - - - - - - {/* Progress Indicators */} - - {topFlavors.map((_, i) => ( - setActiveIndex(i)} - sx={{ - width: activeIndex === i ? 24 : 8, - height: 8, - borderRadius: 4, - bgcolor: activeIndex === i ? 'primary.main' : 'divider', - cursor: 'pointer', - transition: 'all 0.3s ease', - }} - /> + + + + {categories.map((cat) => ( + ))} - + - )} - {/* Recent Reviews - SIDE BY SIDE */} - {recentReviews.length > 0 && ( - - - {t('home.communityVoice')} - - + {categoryLoading && ( + theme.tokens.glass.tint, + backdropFilter: (theme) => theme.tokens.glass.blur, + zIndex: 2, + borderRadius: 4, + }} + > + + + )} + - {recentReviews.map((review) => ( - + + + + + + + + - + {currentTop.name} + + + + + {currentTop.ratings.length} {t('common.reviews')} + + + + {featuredReview && ( + + + "{featuredReview.comment}" + - {!review.user_avatar && review.user.charAt(0).toUpperCase()} + {!featuredReview.user_avatar && featuredReview.user.charAt(0)} - - {review.user} + + — {featuredReview.user} - - - - {adminMode && ( - - )} - - {formatDate(review.created_at)} • on{' '} - - {review.flavor_name} - - - - "{review.comment}" - - - ))} - - - - )} - + + )} - {/* Newest Arrivals */} - {newestFlavors.length > 0 && ( - - - {t('home.newArrivals')} - - - {newestFlavors.map((flavor) => ( - - - - - - - - - - - {flavor.name} - - - {flavor.category_name} - - - - + {t('home.viewAllReviews')} + + + + + + + {topFlavors.map((_, i) => ( + setActiveIndex(i)} + sx={{ + width: activeIndex === i ? 24 : 8, + height: 8, + borderRadius: 4, + bgcolor: activeIndex === i ? 'primary.main' : 'divider', + cursor: 'pointer', + transition: 'all 0.3s ease', + }} + /> ))} )} - -
    + + {/* Recent Reviews */} + {recentReviews.length > 0 && ( + + + + {recentReviews.map((review) => ( + + + {adminMode && ( + + )} + + ))} + + + + )} + + + {/* Newest Arrivals */} + {newestFlavors.length > 0 && ( + + + + {newestFlavors.map((flavor) => ( + + ))} + + + )} + ); }; diff --git a/frontend/src/pages/Privacy.tsx b/frontend/src/pages/Privacy.tsx index 1cc8652..e08adef 100644 --- a/frontend/src/pages/Privacy.tsx +++ b/frontend/src/pages/Privacy.tsx @@ -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, newLang: string | null) => { if (newLang) { i18n.changeLanguage(newLang); } }; return ( - + }> { - - - {t('privacy.title')} - + + {t('privacy.intro')} - - {t('privacy.controllerTitle')} - - - {t('privacy.controllerDesc')} -
    - {import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'} -
    - {import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street]'} -
    - {import.meta.env.VITE_IMPRESSUM_CITY || '[Your City]'} -
    - - - {t('privacy.collectionTitle')} - - - - - {t('privacy.regTitle')} - - - {t('privacy.regDesc')} + + + + {t('privacy.controllerDesc')} +
    + {import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'} +
    + {import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street]'} +
    + {import.meta.env.VITE_IMPRESSUM_CITY || '[Your City]'}
    - - - {t('privacy.ipTitle')} - - - {t('privacy.ipDesc')} + + + + {( + [ + ['regTitle', 'regDesc'], + ['ipTitle', 'ipDesc'], + ['profileTitle', 'profileDesc'], + ['socialTitle', 'socialDesc'], + ['supportTitle', 'supportDesc'], + ['personalizationTitle', 'personalizationDesc'], + ['deletionTitle', 'deletionDesc'], + ] as const + ).map(([titleKey, descKey]) => ( + + + {t(`privacy.${titleKey}`)} + + + {t(`privacy.${descKey}`)} + + + ))} + + + + + + {t('privacy.transferDesc')} - - - {t('privacy.profileTitle')} - - - {t('privacy.profileDesc')} + + + + {t('privacy.rightsDesc')} {import.meta.env.VITE_IMPRESSUM_EMAIL} - - - - {t('privacy.socialTitle')} - - - {t('privacy.socialDesc')} - - - - - - {t('privacy.supportTitle')} - - - {t('privacy.supportDesc')} - - - - - - {t('privacy.personalizationTitle')} - - - {t('privacy.personalizationDesc')} - - - - - - {t('privacy.deletionTitle')} - - - {t('privacy.deletionDesc')} - - - - - {t('privacy.transferTitle')} - - - {t('privacy.transferDesc')} - - - - {t('privacy.rightsTitle')} - - - {t('privacy.rightsDesc')} {import.meta.env.VITE_IMPRESSUM_EMAIL} - -
    -
    + + ); }; diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index f64e7ac..9b32b9e 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -1,16 +1,13 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { Typography, Box, - Card, CardContent, Avatar, - Container, CircularProgress, Tabs, Tab, - Paper, Divider, Button, Stack, @@ -36,6 +33,7 @@ import RatingBadge from '../components/RatingBadge'; import RichText from '../components/RichText'; import DynamicBanner from '../components/DynamicBanner'; import { formatDate } from '../utils/date'; +import { PageShell, GlassCard, GlassSurface, FormCard, EmptyState } from '../components/ui'; interface Rating { id: number; @@ -95,10 +93,9 @@ const PublicProfile: React.FC = ({ adminMode }) => { is_superuser: boolean; } | null>(null); const [palette, setPalette] = useState([]); - const [newComment, setNewComment] = useState(''); - const fetchProfile = async () => { + const fetchProfile = useCallback(async () => { try { const [profileRes, meRes] = await Promise.all([ api.get(`users/profile/${username}/`), @@ -111,11 +108,11 @@ const PublicProfile: React.FC = ({ adminMode }) => { } finally { setLoading(false); } - }; + }, [username]); useEffect(() => { fetchProfile(); - }, [username]); + }, [fetchProfile]); useEffect(() => { if (data?.avatar) { @@ -125,11 +122,8 @@ const PublicProfile: React.FC = ({ adminMode }) => { img.onload = () => { const colorThief = new ColorThief(); try { - const palette = colorThief.getPalette(img, 10); - - // Filter for colors that are neither too dark nor too bright (washed out) - // and prefer higher saturation - const processed = palette.map((c) => { + const rawPalette = colorThief.getPalette(img, 10); + const processed = rawPalette.map((c) => { const r = c[0] / 255, g = c[1] / 255, b = c[2] / 255; @@ -140,20 +134,11 @@ const PublicProfile: React.FC = ({ adminMode }) => { 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 }; }); - - // Sort by saturation (highest first) then filter by luminance (0.3 - 0.8 range) - const vibrant = processed - .filter((c) => c.l > 0.2 && c.l < 0.85) // Avoid pure black/white - .sort((a, b) => b.s - a.s); // Prefer colorful ones - - 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([]); } }; @@ -172,20 +157,17 @@ const PublicProfile: React.FC = ({ adminMode }) => { await api.post(`users/${data.id}/follow/`); setData({ ...data, is_following: true, followers_count: data.followers_count + 1 }); } - } catch (err) { + } catch { alert('Failed to update follow status. Please login.'); } }; const handleMiniFollowToggle = async (user: MiniUser) => { try { - if (user.is_following) { - await api.post(`users/${user.id}/unfollow/`); - } else { - await api.post(`users/${user.id}/follow/`); - } + if (user.is_following) await api.post(`users/${user.id}/unfollow/`); + else await api.post(`users/${user.id}/follow/`); fetchProfile(); - } catch (err) { + } catch { alert('Failed'); } }; @@ -197,7 +179,7 @@ const PublicProfile: React.FC = ({ adminMode }) => { await api.post(`users/${data.id}/add_comment/`, { text: newComment }); setNewComment(''); fetchProfile(); - } catch (err) { + } catch { alert('Failed to add comment'); } }; @@ -207,7 +189,7 @@ const PublicProfile: React.FC = ({ adminMode }) => { try { await api.delete(`users/${data.id}/delete_comment/${commentId}/`); fetchProfile(); - } catch (err) { + } catch { alert('Failed to delete comment'); } }; @@ -216,13 +198,20 @@ const PublicProfile: React.FC = ({ adminMode }) => { if (loading) return ( - - - + + + + + + ); + if (!data) + return ( + + Profile not found. + ); - if (!data) return Profile not found.; - const categoryGroups = data.ratings.reduce((acc: any, rating) => { + const categoryGroups = data.ratings.reduce>((acc, rating) => { const cat = rating.category_name || 'Other'; if (!acc[cat]) acc[cat] = []; acc[cat].push(rating); @@ -230,15 +219,15 @@ const PublicProfile: React.FC = ({ adminMode }) => { }, {}); const ratingCategories = ['All', ...Object.keys(categoryGroups)]; - const currentRatings = + const currentRatings: Rating[] = categoryTab === 0 ? data.ratings : categoryGroups[ratingCategories[categoryTab]]; const tiers = [ - { key: 'S', title: 'S-Tier', min: 9, color: '#ff7f7f' }, - { key: 'A', title: 'A-Tier', min: 7, color: '#ffbf7f' }, - { key: 'B', title: 'B-Tier', min: 5, color: '#ffff7f' }, - { key: 'C', title: 'C-Tier', min: 3, color: '#bfff7f' }, - { key: 'D', title: 'D-Tier', min: 0, color: '#7fff7f' }, + { key: 'S', title: 'S-Tier', color: '#ff7f7f' }, + { key: 'A', title: 'A-Tier', color: '#ffbf7f' }, + { key: 'B', title: 'B-Tier', color: '#ffff7f' }, + { key: 'C', title: 'C-Tier', color: '#bfff7f' }, + { key: 'D', title: 'D-Tier', color: '#7fff7f' }, ]; const bannerColors = [ @@ -262,59 +251,98 @@ const PublicProfile: React.FC = ({ adminMode }) => { else navigate('/'); }; + const renderUserCard = (user: MiniUser) => ( + + + + + {user.username.charAt(0)} + + + + {user.username} + + + Community Member + + + {currentUser && currentUser.username !== user.username && ( + handleMiniFollowToggle(user)} + sx={{ + bgcolor: alpha( + user.is_following ? theme.palette.secondary.main : theme.palette.primary.main, + 0.1, + ), + '&:hover': { + bgcolor: alpha( + user.is_following ? theme.palette.secondary.main : theme.palette.primary.main, + 0.2, + ), + }, + }} + > + {user.is_following ? : } + + )} + + + + ); + return ( - + - {/* Uplifted Profile Header */} - alpha(theme.palette.background.paper, 0.6), - backdropFilter: 'blur(20px)', - mb: 4, - overflow: 'hidden', - position: 'relative', - }} - > - - {/* Layer 1: Abstract Mesh Background */} - - - {/* Layer 2: Interactive Generative Art */} + {/* Profile Header */} + + + = ({ adminMode }) => { /> - + = ({ adminMode }) => { width: { xs: 140, sm: 180 }, height: { xs: 140, sm: 180 }, border: '6px solid', - borderColor: (theme) => theme.palette.background.paper, + borderColor: theme.palette.background.paper, fontSize: '4.5rem', - bgcolor: (theme) => theme.palette.background.paper, + bgcolor: theme.palette.background.paper, color: bannerColors[0], }} > @@ -394,7 +414,6 @@ const PublicProfile: React.FC = ({ adminMode }) => { fontSize: { xs: '2.8rem', sm: '4.2rem' }, lineHeight: 0.9, color: 'text.primary', - position: 'relative', display: 'inline-block', mb: 2, }} @@ -403,17 +422,9 @@ const PublicProfile: React.FC = ({ adminMode }) => { - alpha(theme.palette.text.primary, 0.04), - border: '1px solid', - borderColor: 'divider', - borderRadius: 1, - overflow: 'hidden', - backdropFilter: 'blur(8px)', - }} + {[ { label: 'Rated', val: data.ratings.length }, @@ -422,10 +433,7 @@ const PublicProfile: React.FC = ({ adminMode }) => { ].map((stat, i) => ( - + {stat.val} = ({ adminMode }) => { )} ))} - + @@ -489,20 +497,17 @@ const PublicProfile: React.FC = ({ adminMode }) => { - + - = ({ adminMode }) => { - + {activeTab === 0 && ( @@ -551,177 +556,181 @@ const PublicProfile: React.FC = ({ adminMode }) => { ))} - - {tiers.map((tier) => { - const items = currentRatings.filter((r: Rating) => { - const s = r.score; - if (tier.key === 'S') return s >= 9; - if (tier.key === 'A') return s >= 7 && s < 9; - if (tier.key === 'B') return s >= 5 && s < 7; - if (tier.key === 'C') return s >= 3 && s < 5; - return s < 3; - }); + {currentRatings.length === 0 ? ( + + ) : ( + + {tiers.map((tier) => { + const items = currentRatings.filter((r) => { + const s = r.score; + if (tier.key === 'S') return s >= 9; + if (tier.key === 'A') return s >= 7 && s < 9; + if (tier.key === 'B') return s >= 5 && s < 7; + if (tier.key === 'C') return s >= 3 && s < 5; + return s < 3; + }); - if (items.length === 0) return null; + if (items.length === 0) return null; - return ( - - - - {tier.key} - - - + + - {tier.title} - - - {items.length} items - - - - - - - {items.map((rating: Rating) => ( - - alpha(theme.palette.background.paper, 0.4), - transition: 'all 0.3s ease', - overflow: 'hidden', - '&:hover': { - transform: 'translateY(-8px)', - borderColor: 'primary.main', - boxShadow: '0 12px 32px rgba(0,0,0,0.1)', - }, - }} + {tier.key} + + + - + + {items.length} items + + + + + + + {items.map((rating) => ( + + th.tokens.elevation.md, + }, + }} > - - - - - - - - {rating.flavor_name} - - - - {adminMode && ( - - - - )} - - - ))} - - - ); - })} - + + + + + + + + {rating.flavor_name} + + + + {adminMode && ( + + + + )} + + + ))} + + + ); + })} + + )} )} {activeTab === 1 && ( - + {currentUser && ( - - - Leave a message - -
    + + } + sx={{ borderRadius: 2, px: 5, fontWeight: '900', textTransform: 'none' }} + > + Send Message + + } + > = ({ adminMode }) => { placeholder="Say something about their taste..." value={newComment} onChange={(e) => setNewComment(e.target.value)} - sx={{ - mb: 3, - '& .MuiOutlinedInput-root': { borderRadius: 3, bgcolor: 'background.paper' }, - }} /> - - -
    + +
    )} {data.comments.length === 0 ? ( - - - No entries in the guestbook yet. - - - Be the first to leave a message! - - + ) : ( data.comments.map((comment) => ( - + = ({ adminMode }) => { }} > - + = ({ adminMode }) => { to={`/profile/${comment.author_username}`} style={{ textDecoration: 'none', color: 'inherit' }} > - + {comment.author_username} @@ -837,11 +795,7 @@ const PublicProfile: React.FC = ({ adminMode }) => { {(currentUser?.username === comment.author_username || currentUser?.username === data.username || currentUser?.is_superuser) && ( - handleDeleteComment(comment.id)} - > + handleDeleteComment(comment.id)}> )} @@ -853,7 +807,7 @@ const PublicProfile: React.FC = ({ adminMode }) => { - + )) )} @@ -864,95 +818,10 @@ const PublicProfile: React.FC = ({ adminMode }) => { {data.followers.length === 0 ? ( - - No followers yet. - + ) : ( - data.followers.map((user) => ( - - - - - {user.username.charAt(0)} - - - - {user.username} - - - Community Member - - - {currentUser && currentUser.username !== user.username && ( - handleMiniFollowToggle(user)} - sx={{ - bgcolor: alpha( - user.is_following - ? theme.palette.secondary.main - : theme.palette.primary.main, - 0.1, - ), - '&:hover': { - bgcolor: alpha( - user.is_following - ? theme.palette.secondary.main - : theme.palette.primary.main, - 0.2, - ), - }, - }} - > - {user.is_following ? : } - - )} - - - - )) + data.followers.map(renderUserCard) )} )} @@ -961,100 +830,15 @@ const PublicProfile: React.FC = ({ adminMode }) => { {data.following.length === 0 ? ( - - Not following anyone yet. - + ) : ( - data.following.map((user) => ( - - - - - {user.username.charAt(0)} - - - - {user.username} - - - Community Member - - - {currentUser && currentUser.username !== user.username && ( - handleMiniFollowToggle(user)} - sx={{ - bgcolor: alpha( - user.is_following - ? theme.palette.secondary.main - : theme.palette.primary.main, - 0.1, - ), - '&:hover': { - bgcolor: alpha( - user.is_following - ? theme.palette.secondary.main - : theme.palette.primary.main, - 0.2, - ), - }, - }} - > - {user.is_following ? : } - - )} - - - - )) + data.following.map(renderUserCard) )} )}
    -
    + ); }; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index f300e47..22530a6 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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 = ({ themeName, onThemeChange }) => { const { t, i18n } = useTranslation(); const navigate = useNavigate(); @@ -44,15 +51,12 @@ const Settings: React.FC = ({ 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([]); + const [banners, setBanners] = useState([]); const [selectedBannerId, setSelectedBannerId] = useState(''); 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ themeName, onThemeChange }) => { localStorage.removeItem('access'); localStorage.removeItem('refresh'); window.location.href = '/'; - } catch (err: any) { + } catch { setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' }); } }; return ( - + - + = ({ themeName, onThemeChange }) => { {message && ( - setMessage(null)}> + setMessage(null)}> {message.text} )} - {/* Left Column: Visuals & Language */} + {/* Left Column: Visuals, Avatar */} - - - {t('settings.appearanceTitle')} - - - {t('settings.themeLabel')} - onThemeChange(e.target.value as CatppuccinTheme)} + > + Holy Archive + Holy Light + Holy Dark - Light Themes - Catppuccin Latte - Pink Pastel - Mint Pastel - Lavender Pastel - Scaled and Icy (T0P) + Light Themes + Catppuccin Latte + Pink Pastel + Mint Pastel + Lavender Pastel + Scaled and Icy (T0P) - + - Dark Themes - Catppuccin Mocha - Catppuccin Frappé - Catppuccin Macchiato - Dracula - Nordic Frost - Gruvbox Retro - Oceanic Deep - Trench (T0P) - Blurryface (T0P) - Clancy (T0P) - - + Dark Themes + Catppuccin Mocha + Catppuccin Frappé + Catppuccin Macchiato + Dracula + Nordic Frost + Gruvbox Retro + Oceanic Deep + Trench (T0P) + Blurryface (T0P) + Clancy (T0P) + + - - {t('settings.bannerLabel')} - - + + {t('settings.bannerLabel')} + + - - {t('settings.langLabel')} - - - + + {t('settings.langLabel')} + + + - - - {t('settings.avatarTitle')} - - - - {!avatar && username.charAt(0).toUpperCase()} - - - - - {t('settings.avatarHint')} - + {/* Avatar: NOT a FormCard — file input lives outside any form */} + + + {t('settings.avatarTitle')} + + + + {!avatar && username.charAt(0).toUpperCase()} + + + + + {t('settings.avatarHint')} + + - - + + - {/* Right Column: Account Info & Security */} + {/* Right Column: Info / Password / Danger */} - - - {t('settings.infoTitle')} - -
    + + + {t('settings.updateButton')} + + } + > setUsername(e.target.value)} - InputLabelProps={{ shrink: true }} + slotProps={{ inputLabel: { shrink: true } }} /> setEmail(e.target.value)} helperText={email !== currentEmail ? t('settings.emailHint') : ''} - InputLabelProps={{ shrink: true }} + slotProps={{ inputLabel: { shrink: true } }} /> - - + {pendingConfirmation && ( - - - {t('settings.confirmEmailTitle')} - - - {t('settings.confirmEmailHint', { email })} - -
    - setConfirmationCode(e.target.value)} - InputLabelProps={{ shrink: true }} - /> - - -
    + } + > + setConfirmationCode(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + )} -
    - - - {t('settings.passwordTitle')} - -
    + + {t('settings.passwordButton')} + + } + > setOldPassword(e.target.value)} - InputLabelProps={{ shrink: true }} + slotProps={{ inputLabel: { shrink: true } }} /> setNewPassword(e.target.value)} - InputLabelProps={{ shrink: true }} + slotProps={{ inputLabel: { shrink: true } }} /> - - -
    - - - theme.palette.mode === 'dark' ? 'rgba(255,0,0,0.05)' : 'rgba(255,0,0,0.02)', - '& .MuiInputLabel-root': { bgcolor: 'background.paper', px: 0.5 }, - }} - > - - {t('settings.dangerTitle')} - - - {t('settings.dangerDesc')} - + {!isDeleting ? ( - + } > - {t('settings.deleteButton')} - + + ) : ( -
    - - {t('settings.deletionCodeHint')} - + + + +
    + } + > setDeletionCode(e.target.value)} - sx={{ mb: 2 }} - InputLabelProps={{ shrink: true }} + slotProps={{ inputLabel: { shrink: true } }} /> - - - - - + )} -
    +
    -
    + ); }; diff --git a/frontend/src/pages/Support.tsx b/frontend/src/pages/Support.tsx index 9559b3e..b2046b9 100644 --- a/frontend/src/pages/Support.tsx +++ b/frontend/src/pages/Support.tsx @@ -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>({}); const [expandedId, setExpandedId] = useState(null); - const [currentUser, setCurrentUser] = useState(null); + const [currentUser, setCurrentUser] = useState(null); const [unreadTicketIds, setUnreadTicketIds] = useState>(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( 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 ( - - - + + + + + ); return ( - + - - - {t('support.title')} - - - + + } + onClick={() => setShowCreate(!showCreate)} + sx={{ borderRadius: 2 }} + > + {t('support.createTicket')} + + } + /> - -
    - setSubject(e.target.value)} - required - /> - setDescription(e.target.value)} - required - /> - - -
    + } + > + setSubject(e.target.value)} + required + /> + setDescription(e.target.value)} + required + /> +
    - - {currentUser?.is_superuser ? t('admin.users') + ' Tickets' : t('support.myTickets')} - + {tickets.length === 0 ? ( - - {t('support.noTickets')} - + ) : ( - {tickets.map((ticket: Ticket) => ( - ( + @@ -309,7 +324,7 @@ const Support: React.FC = () => { )} @@ -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), }} > { - alpha(theme.palette.background.default, 0.5) }} - > + alpha(theme.palette.background.default, 0.5) }}> {currentUser?.is_superuser && ( - {['open', 'in_progress', 'resolved', 'closed'].map((s) => ( + {(['open', 'in_progress', 'resolved', 'closed'] as const).map((s) => ( )} - -
    + + ); }; diff --git a/frontend/src/pages/categories/CategoryFlavors.tsx b/frontend/src/pages/categories/CategoryFlavors.tsx index cdb210d..04d5c46 100644 --- a/frontend/src/pages/categories/CategoryFlavors.tsx +++ b/frontend/src/pages/categories/CategoryFlavors.tsx @@ -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 ( - - - + + + + + ); return ( - - - - {categoryName || 'Flavors'} - - + + + + {flavors.length === 0 ? ( + + ) : ( + - {window.history.length > 1 ? t('common.back') : t('common.backToHome')} - - - - - {flavors.map((flavor) => ( - - - - - - - {flavor.image_url && ( - - - - )} - - - {flavor.name} - - - - - - - {flavor.ratings.length} reviews - - - - - - ))} - - + {flavors.map((flavor) => ( + + ))} + + )} + ); }; diff --git a/frontend/src/pages/flavors/FlavorDetail.tsx b/frontend/src/pages/flavors/FlavorDetail.tsx index 2be5e39..1ec631e 100644 --- a/frontend/src/pages/flavors/FlavorDetail.tsx +++ b/frontend/src/pages/flavors/FlavorDetail.tsx @@ -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 = ({ adminMode }) => { const [editingReplyId, setEditingReplyId] = useState(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ adminMode }) => { try { await api.delete(`replies/${replyId}/`); fetchFlavor(); - } catch (err) { + } catch { alert('Failed to delete reply'); } }; @@ -227,7 +233,7 @@ const FlavorDetail: React.FC = ({ adminMode }) => { try { await api.delete(`ratings/${ratingId}/`); fetchFlavor(); - } catch (err) { + } catch { alert('Failed to delete review'); } }; @@ -243,7 +249,7 @@ const FlavorDetail: React.FC = ({ 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 = ({ adminMode }) => { if (loading) return ( - - - + + + + + + ); + if (!flavor) + return ( + + Flavor not found + ); - if (!flavor) return Flavor not found; return ( - - {/* Top Bar */} - - - + }> + - {/* Main Content Grid */} {/* Left: Product Info Card */} - alpha(theme.palette.background.paper, 0.6), - backdropFilter: 'blur(12px)', - overflow: 'hidden', - }} - > + = ({ adminMode }) => { component="a" href={flavor.shop_url} target="_blank" + rel="noreferrer" startIcon={} sx={{ borderRadius: 3, @@ -494,94 +489,66 @@ const FlavorDetail: React.FC = ({ adminMode }) => { )} - + {/* Right: Ratings & Comments */} - - - Ratings & Comments - - - {flavor.ratings.length === 0 + - + : `Join the discussion with ${flavor.ratings.length} other fans.` + } + compact + /> {/* Add Rating Form */} {currentUser && flavor.user_rating === null && ( - - - - Rate this flavor - -
    - - setNewScore(val)} - size="large" + + + + + Rate this flavor + + + + setNewScore(val)} + size="large" + /> + + setNewComment(val)} /> - - setNewComment(val)} - /> - - -
    -
    + + + + + )} {flavor.ratings.length === 0 ? ( - - {t('dashboard.noRatings')} - + ) : ( flavor.ratings.map((rating: Rating) => ( - alpha(theme.palette.background.paper, 0.8), - }} - > + {editMode === rating.id ? ( @@ -733,7 +700,7 @@ const FlavorDetail: React.FC = ({ adminMode }) => { {/* Replies Section */} - {rating.replies.map((reply: any) => ( + {rating.replies.map((reply) => ( = ({ adminMode }) => { - + )) )}
    -
    + ); }; diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index 83c5ee8..49208cf 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -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);