migrated pages to new ui

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

No files matched your search

@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useContext } from 'react';
import { act, render } from '@testing-library/react';
import {
BannerPerfContext,
BannerPerformanceWrapper,
} from './BannerPerformanceWrapper';
type IOEntryLike = { isIntersecting: boolean };
type IOCallback = (entries: IOEntryLike[]) => void;
let ioCallback: IOCallback | null = null;
let observeMock = vi.fn();
let disconnectMock = vi.fn();
class FakeIntersectionObserver {
constructor(cb: IOCallback) {
ioCallback = cb;
}
observe = observeMock;
disconnect = disconnectMock;
unobserve = vi.fn();
takeRecords = () => [];
root = null;
rootMargin = '';
thresholds = [];
}
const mockMatchMedia = (reduced: boolean) => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string) => ({
matches: query.includes('reduce') ? reduced : false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
});
};
const Probe = () => {
const { isActive, targetFps } = useContext(BannerPerfContext);
return (
<div data-testid="probe" data-active={String(isActive)} data-fps={String(targetFps)} />
);
};
describe('BannerPerformanceWrapper', () => {
beforeEach(() => {
ioCallback = null;
observeMock = vi.fn();
disconnectMock = vi.fn();
(globalThis as any).IntersectionObserver = FakeIntersectionObserver;
mockMatchMedia(false);
});
afterEach(() => {
delete (globalThis as any).IntersectionObserver;
});
it('defaults to isActive=true with configured targetFps', () => {
const { getByTestId } = render(
<BannerPerformanceWrapper targetFps={30}>
<Probe />
</BannerPerformanceWrapper>,
);
const el = getByTestId('probe');
expect(el.dataset.active).toBe('true');
expect(el.dataset.fps).toBe('30');
expect(observeMock).toHaveBeenCalled();
});
it('flips isActive=false when IntersectionObserver reports not intersecting', () => {
const { getByTestId } = render(
<BannerPerformanceWrapper>
<Probe />
</BannerPerformanceWrapper>,
);
expect(getByTestId('probe').dataset.active).toBe('true');
act(() => {
ioCallback?.([{ isIntersecting: false }]);
});
expect(getByTestId('probe').dataset.active).toBe('false');
act(() => {
ioCallback?.([{ isIntersecting: true }]);
});
expect(getByTestId('probe').dataset.active).toBe('true');
});
it('prefers-reduced-motion forces isActive=false and targetFps=0', () => {
mockMatchMedia(true);
const { getByTestId } = render(
<BannerPerformanceWrapper targetFps={30}>
<Probe />
</BannerPerformanceWrapper>,
);
const el = getByTestId('probe');
expect(el.dataset.active).toBe('false');
expect(el.dataset.fps).toBe('0');
});
it('disconnects observer on unmount', () => {
const { unmount } = render(
<BannerPerformanceWrapper>
<Probe />
</BannerPerformanceWrapper>,
);
unmount();
expect(disconnectMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import useMediaQuery from '@mui/material/useMediaQuery';
import Box from '@mui/material/Box';
export interface BannerPerfState {
isActive: boolean;
targetFps: number;
}
const defaultState: BannerPerfState = { isActive: true, targetFps: 60 };
export const BannerPerfContext = createContext<BannerPerfState>(defaultState);
export interface BannerPerformanceWrapperProps {
children: ReactNode;
targetFps?: number;
rootMargin?: string;
}
export const BannerPerformanceWrapper = ({
children,
targetFps = 30,
rootMargin = '100px',
}: BannerPerformanceWrapperProps) => {
const ref = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(true);
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver(
([entry]) => setVisible(entry.isIntersecting),
{ threshold: 0.01, rootMargin },
);
io.observe(el);
return () => io.disconnect();
}, [rootMargin]);
const value = useMemo<BannerPerfState>(
() => ({
isActive: visible && !reducedMotion,
targetFps: reducedMotion ? 0 : targetFps,
}),
[visible, reducedMotion, targetFps],
);
return (
<BannerPerfContext.Provider value={value}>
<Box
ref={ref}
sx={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
>
{children}
</Box>
</BannerPerfContext.Provider>
);
};
/**
* Returns a ref whose .current is the live perf state. Banner rAF loops
* read the ref synchronously inside their draw function to gate/throttle
* without re-triggering the useEffect that sets up the canvas.
*/
export const useBannerPerfRef = () => {
const state = useContext(BannerPerfContext);
const ref = useRef(state);
useEffect(() => {
ref.current = state;
}, [state]);
return ref;
};
export type GateDecision = 'draw' | 'skip' | 'halt';
/**
* Returns a stable gate function. Call it at the top of your rAF draw
* callback with the current timestamp; act on the returned decision:
* - 'draw': proceed with the draw body and request the next frame
* - 'skip': do not draw this frame but still request the next one
* - 'halt': user prefers reduced motion; stop rAF entirely
* The first frame always returns 'draw' so the canvas shows content even
* when reduced-motion is set.
*/
export const useBannerFrameGate = () => {
const perfRef = useBannerPerfRef();
const stateRef = useRef({ lastTs: 0, drawnOnce: false });
return useMemo(
() =>
(now: number): GateDecision => {
const { isActive, targetFps } = perfRef.current;
if (!stateRef.current.drawnOnce) {
stateRef.current.drawnOnce = true;
stateRef.current.lastTs = now;
return 'draw';
}
if (!isActive) return targetFps === 0 ? 'halt' : 'skip';
if (targetFps > 0) {
const minDelta = 1000 / targetFps;
if (now - stateRef.current.lastTs < minDelta) return 'skip';
}
stateRef.current.lastTs = now;
return 'draw';
},
[perfRef],
);
};
+31 -41
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import api from '../lib/api';
import GenerativeBanner from './GenerativeBanner';
import NebulaBanner from './NebulaBanner';
@@ -6,6 +6,7 @@ import FirefliesBanner from './FirefliesBanner';
import HextechBanner from './HextechBanner';
import HextechCorruptionBanner from './HextechCorruptionBanner';
import GenerativeFlowBanner from './GenerativeFlowBanner';
import { BannerPerformanceWrapper } from './BannerPerformanceWrapper';
interface Banner {
id: number;
@@ -23,9 +24,13 @@ interface DynamicBannerProps {
followersCount: number;
}
// Future expansion: Add more banner components here
const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ username, settings }) => {
// Placeholder for a second model
const MatrixBanner: React.FC<DynamicBannerProps & { settings: Banner['settings'] | null }> = ({
username,
settings,
}) => {
const [durations] = useState(() =>
Array.from({ length: 20 }, () => 2 + Math.random() * 3),
);
return (
<div
style={{
@@ -42,15 +47,12 @@ const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ userna
fontFamily: 'monospace',
zIndex: 1,
overflow: 'hidden',
opacity: settings.opacity || 0.5,
opacity: settings?.opacity || 0.5,
}}
>
<div>
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
style={{ animation: `matrixFall ${2 + Math.random() * 3}s linear infinite` }}
>
{durations.map((dur, i) => (
<div key={i} style={{ animation: `matrixFall ${dur}s linear infinite` }}>
{username.split('').map((char, j) => (
<span key={j} style={{ display: 'block' }}>
{char}
@@ -71,6 +73,24 @@ const MatrixBanner: React.FC<DynamicBannerProps & { settings: any }> = ({ userna
);
};
const pickBanner = (
banner: Banner | null,
props: DynamicBannerProps,
): React.ReactElement => {
if (!banner || banner.slug === 'generative-web') {
return <GenerativeBanner {...props} settings={banner?.settings} />;
}
if (banner.slug === 'matrix') return <MatrixBanner {...props} settings={banner.settings} />;
if (banner.slug === 'nebula') return <NebulaBanner {...props} settings={banner.settings} />;
if (banner.slug === 'fireflies') return <FirefliesBanner {...props} settings={banner.settings} />;
if (banner.slug === 'hextech') return <HextechBanner {...props} settings={banner.settings} />;
if (banner.slug === 'hextech-corruption')
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
if (banner.slug === 'generative-flow')
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
return <GenerativeBanner {...props} />;
};
const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
const [banner, setBanner] = useState<Banner | null>(null);
const [loading, setLoading] = useState(true);
@@ -94,37 +114,7 @@ const DynamicBanner: React.FC<DynamicBannerProps> = (props) => {
if (loading) return null;
// Default to GenerativeBanner if no active banner or unrecognized slug
if (!banner || banner.slug === 'generative-web') {
return <GenerativeBanner {...props} settings={banner?.settings} />;
}
if (banner.slug === 'matrix') {
return <MatrixBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'nebula') {
return <NebulaBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'fireflies') {
return <FirefliesBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'hextech') {
return <HextechBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'hextech-corruption') {
return <HextechCorruptionBanner {...props} settings={banner.settings} />;
}
if (banner.slug === 'generative-flow') {
return <GenerativeFlowBanner {...props} settings={banner.settings} />;
}
// Fallback
return <GenerativeBanner {...props} />;
return <BannerPerformanceWrapper>{pickBanner(banner, props)}</BannerPerformanceWrapper>;
};
export default DynamicBanner;
+10 -1
View File
@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface FirefliesBannerProps {
username: string;
@@ -19,6 +20,7 @@ interface FirefliesBannerProps {
const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gate = useBannerFrameGate();
const count = settings?.count ?? 150;
const baseSpeed = settings?.speed ?? 0.8; // Slower default cruise
@@ -133,7 +135,13 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
}
let time = 0;
const draw = () => {
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
@@ -259,6 +267,7 @@ const FirefliesBanner: React.FC<FirefliesBannerProps> = ({ username, palette, se
glowSizeMultiplier,
flickerSpeed,
theme,
gate,
]);
return (
+10 -1
View File
@@ -1,5 +1,6 @@
import React, { useRef, useEffect } from 'react';
import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface GenerativeBannerProps {
username: string;
@@ -23,6 +24,7 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gate = useBannerFrameGate();
const nodeCountBase = settings?.nodeCountBase ?? 60;
const connectionDist = settings?.connectionDist ?? 140;
@@ -150,7 +152,13 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
});
}
const draw = () => {
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
@@ -294,6 +302,7 @@ const GenerativeBanner: React.FC<GenerativeBannerProps> = ({
connectionDist,
speed,
globalOpacity,
gate,
]);
return (
@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface GenerativeFlowProps {
username: string;
@@ -18,6 +19,7 @@ interface GenerativeFlowProps {
const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gate = useBannerFrameGate();
const blobCount = settings?.blobCount ?? 8;
const flowSpeed = settings?.flowSpeed ?? 0.008;
@@ -135,7 +137,13 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
});
}
const draw = () => {
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
@@ -224,7 +232,7 @@ const GenerativeFlowBanner: React.FC<GenerativeFlowProps> = ({ username, palette
}
cancelAnimationFrame(animationFrameId);
};
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme]);
}, [seed, brightColors, blobCount, flowSpeed, vibrancy, blurAmount, distortionFactor, theme, gate]);
return (
<canvas
+10 -1
View File
@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface HextechBannerProps {
username: string;
@@ -18,6 +19,7 @@ interface HextechBannerProps {
const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settings }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gate = useBannerFrameGate();
const gridSize = settings?.gridSize ?? 40;
const pulseSpeed = settings?.pulseSpeed ?? 0.02;
@@ -142,7 +144,13 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
});
}
const draw = () => {
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
@@ -277,6 +285,7 @@ const HextechBanner: React.FC<HextechBannerProps> = ({ username, palette, settin
streamCount,
distortionFactor,
theme,
gate,
]);
return (
@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface HextechCorruptionBannerProps {
username: string;
@@ -22,6 +23,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gate = useBannerFrameGate();
const cellCount = settings?.cellCount ?? 40;
const shimmerSpeed = settings?.shimmerSpeed ?? 0.015;
@@ -144,7 +146,13 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
});
}
const draw = () => {
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
@@ -267,6 +275,7 @@ const HextechCorruptionBanner: React.FC<HextechCorruptionBannerProps> = ({
instability,
shellDarkness,
theme,
gate,
]);
return (
+10 -1
View File
@@ -1,5 +1,6 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { alpha, useTheme } from '@mui/material';
import { useBannerFrameGate } from './BannerPerformanceWrapper';
interface NebulaBannerProps {
username: string;
@@ -26,6 +27,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const theme = useTheme();
const gate = useBannerFrameGate();
const particleCount = settings?.particleCount ?? 400;
const speed = settings?.speed ?? 0.0015;
@@ -181,7 +183,13 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({
}
let time = 0;
const draw = () => {
const draw = (now = 0) => {
const decision = gate(now);
if (decision === 'halt') return;
if (decision === 'skip') {
animationFrameId = requestAnimationFrame(draw);
return;
}
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
if (!width || !height) {
@@ -314,6 +322,7 @@ const NebulaBanner: React.FC<NebulaBannerProps> = ({
turbulenceStrength,
returnSpeed,
starDensity,
gate,
]);
return (
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@mui/material/styles';
import Button from '@mui/material/Button';
import { getTheme } from '../../theme';
import { EmptyState } from './EmptyState';
const wrap = (ui: React.ReactElement) => (
<ThemeProvider theme={getTheme('mocha')}>{ui}</ThemeProvider>
);
describe('EmptyState', () => {
it('renders title and subtitle', () => {
render(wrap(<EmptyState title="No reviews yet" subtitle="Be the first to rate." />));
expect(screen.getByText('No reviews yet')).toBeInTheDocument();
expect(screen.getByText('Be the first to rate.')).toBeInTheDocument();
});
it('fires CTA onClick', () => {
const handleClick = vi.fn();
render(
wrap(
<EmptyState
title="No tickets"
action={<Button onClick={handleClick}>Create one</Button>}
/>,
),
);
fireEvent.click(screen.getByRole('button', { name: 'Create one' }));
expect(handleClick).toHaveBeenCalledOnce();
});
it('omits subtitle when not provided', () => {
render(wrap(<EmptyState title="Empty" />));
expect(screen.getByText('Empty')).toBeInTheDocument();
});
it('renders icon slot', () => {
render(
wrap(
<EmptyState
title="Nothing"
icon={<span data-testid="icon">📭</span>}
/>,
),
);
expect(screen.getByTestId('icon')).toBeInTheDocument();
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { GlassSurface } from './Glass';
export interface EmptyStateProps {
title: ReactNode;
subtitle?: ReactNode;
icon?: ReactNode;
action?: ReactNode;
compact?: boolean;
}
export const EmptyState = ({ title, subtitle, icon, action, compact = false }: EmptyStateProps) => (
<GlassSurface
intensity="subtle"
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
gap: 1.25,
p: compact ? 3 : 5,
minHeight: compact ? 160 : 220,
}}
>
{icon && (
<Box
sx={{
color: 'text.secondary',
fontSize: compact ? 36 : 48,
display: 'inline-flex',
'& svg': { fontSize: 'inherit' },
}}
>
{icon}
</Box>
)}
<Typography variant={compact ? 'subtitle1' : 'h6'} sx={{ fontWeight: 700 }}>
{title}
</Typography>
{subtitle && (
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: '50ch' }}>
{subtitle}
</Typography>
)}
{action && <Box sx={{ mt: 1 }}>{action}</Box>}
</GlassSurface>
);
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles';
import { getTheme } from '../../theme';
import { FlavorCard } from './FlavorCard';
const renderCard = (overrides = {}) =>
render(
<MemoryRouter>
<ThemeProvider theme={getTheme('mocha')}>
<FlavorCard
flavor={{
id: 42,
name: 'Blueberry Dust',
image_url: '/media/flavors/blueberry.jpg',
average_rating: 7.5,
category_name: 'Fruit',
is_legacy: false,
is_available: true,
...overrides,
}}
/>
</ThemeProvider>
</MemoryRouter>,
);
describe('FlavorCard', () => {
it('renders name, category, image, and rating', () => {
renderCard();
expect(screen.getByText('Blueberry Dust')).toBeInTheDocument();
expect(screen.getByText('Fruit')).toBeInTheDocument();
const img = screen.getByAltText('Blueberry Dust') as HTMLImageElement;
expect(img.src).toContain('/media/flavors/blueberry.jpg');
expect(img.getAttribute('loading')).toBe('lazy');
});
it('links to /flavor/:id', () => {
renderCard();
const link = screen.getByRole('link');
expect(link.getAttribute('href')).toBe('/flavor/42');
});
it('omits category row when showCategory=false', () => {
render(
<MemoryRouter>
<ThemeProvider theme={getTheme('mocha')}>
<FlavorCard
flavor={{ id: 1, name: 'X', category_name: 'Fruit' }}
showCategory={false}
/>
</ThemeProvider>
</MemoryRouter>,
);
expect(screen.queryByText('Fruit')).not.toBeInTheDocument();
});
it('renders status badge when flavor is legacy', () => {
renderCard({ is_legacy: true, is_available: true });
expect(screen.getByText(/Limited/i)).toBeInTheDocument();
});
it('renders caption when provided', () => {
renderCard({});
expect(screen.queryByText('12 reviews')).not.toBeInTheDocument();
render(
<MemoryRouter>
<ThemeProvider theme={getTheme('mocha')}>
<FlavorCard
flavor={{ id: 2, name: 'Cap', average_rating: 5 }}
caption="12 reviews"
/>
</ThemeProvider>
</MemoryRouter>,
);
expect(screen.getByText('12 reviews')).toBeInTheDocument();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { Link } from 'react-router-dom';
import Box from '@mui/material/Box';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import { GlassCard } from './Glass';
import RatingBadge from '../RatingBadge';
import StatusBadge from '../StatusBadge';
export interface FlavorCardData {
id: number;
name: string;
image_url?: string | null;
average_rating?: number;
is_legacy?: boolean;
is_available?: boolean;
category_name?: string;
}
export interface FlavorCardProps {
flavor: FlavorCardData;
caption?: string;
showCategory?: boolean;
compact?: boolean;
}
export const FlavorCard = ({
flavor,
caption,
showCategory = true,
compact = false,
}: FlavorCardProps) => (
<Link
to={`/flavor/${flavor.id}`}
style={{ textDecoration: 'none', color: 'inherit', display: 'block', height: '100%' }}
>
<GlassCard
intensity="subtle"
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
transition: 'transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease',
'&:hover': {
transform: 'translateY(-3px)',
borderColor: 'primary.main',
boxShadow: (theme) => theme.tokens.elevation.md,
},
}}
>
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 1 }}>
<StatusBadge
isLegacy={flavor.is_legacy}
isAvailable={flavor.is_available}
size="small"
/>
</Box>
{flavor.image_url && (
<Box
sx={{
width: '100%',
aspectRatio: compact ? '16/10' : '1/1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.default',
borderBottom: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
<Box
component="img"
src={flavor.image_url}
loading="lazy"
alt={flavor.name}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'transform 500ms ease',
'&:hover': { transform: 'scale(1.05)' },
}}
/>
</Box>
)}
<CardContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Typography variant="h6" sx={{ fontSize: compact ? '0.95rem' : '1rem', lineHeight: 1.25 }} noWrap>
{flavor.name}
</Typography>
{showCategory && flavor.category_name && (
<Typography variant="caption" color="text.secondary" noWrap>
{flavor.category_name}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 'auto' }}>
<RatingBadge score={flavor.average_rating || 0} size="small" />
{caption && (
<Typography variant="caption" color="text.secondary" noWrap>
{caption}
</Typography>
)}
</Box>
</CardContent>
</GlassCard>
</Link>
);
@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@mui/material/styles';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import { getTheme } from '../../theme';
import { FormCard } from './FormCard';
const wrap = (ui: React.ReactElement) => (
<ThemeProvider theme={getTheme('mocha')}>{ui}</ThemeProvider>
);
describe('FormCard', () => {
it('renders title + subtitle + submits on form submit', () => {
const handleSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
render(
wrap(
<FormCard
title="Edit Profile"
subtitle="Update your display name"
onSubmit={handleSubmit}
actions={<Button type="submit">Save</Button>}
>
<TextField label="Name" />
</FormCard>,
),
);
expect(screen.getByText('Edit Profile')).toBeInTheDocument();
expect(screen.getByText('Update your display name')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(handleSubmit).toHaveBeenCalledOnce();
});
it('does not render a <form> when asForm=false', () => {
const { container } = render(
wrap(
<FormCard title="File upload" asForm={false}>
<div>child</div>
</FormCard>,
),
);
expect(container.querySelector('form')).toBeNull();
});
it('applies danger styling when danger prop set', () => {
render(wrap(<FormCard title="Delete account" danger>content</FormCard>));
const title = screen.getByText('Delete account');
const color = window.getComputedStyle(title).color;
// MUI uses the palette.error.main — we just assert the title DOM exists and has a color distinct from default
expect(color).not.toBe('');
});
it('renders without title/actions (just children)', () => {
render(
wrap(
<FormCard>
<div data-testid="child">just content</div>
</FormCard>,
),
);
expect(screen.getByTestId('child')).toBeInTheDocument();
});
});
+81
View File
@@ -0,0 +1,81 @@
import type { FormEvent, ReactNode } from 'react';
import { alpha } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { GlassCard } from './Glass';
export interface FormCardProps {
title?: ReactNode;
subtitle?: ReactNode;
actions?: ReactNode;
children: ReactNode;
onSubmit?: (event: FormEvent<HTMLFormElement>) => void;
danger?: boolean;
compact?: boolean;
/** Render without a <form> wrapper. Use when the card hosts file uploads or non-form controls. */
asForm?: boolean;
}
export const FormCard = ({
title,
subtitle,
actions,
children,
onSubmit,
danger = false,
compact = false,
asForm = true,
}: FormCardProps) => {
const body = (
<Stack spacing={compact ? 1.5 : 2}>
{(title || subtitle) && (
<Box>
{title && (
<Typography
variant={compact ? 'subtitle1' : 'h6'}
sx={{ fontWeight: 700, color: danger ? 'error.main' : 'text.primary' }}
>
{title}
</Typography>
)}
{subtitle && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{subtitle}
</Typography>
)}
</Box>
)}
<Stack spacing={compact ? 1.5 : 2}>{children}</Stack>
{actions && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, flexWrap: 'wrap' }}>
{actions}
</Box>
)}
</Stack>
);
return (
<GlassCard
intensity={danger ? 'default' : 'default'}
sx={{
p: compact ? 2 : 3,
...(danger && {
borderColor: (theme) => alpha(theme.palette.error.main, 0.5),
boxShadow: (theme) =>
`${theme.tokens.elevation.sm}, 0 0 32px ${alpha(theme.palette.error.main, 0.18)}`,
}),
}}
>
{asForm && onSubmit ? (
<Box component="form" onSubmit={onSubmit} noValidate>
{body}
</Box>
) : (
body
)}
</GlassCard>
);
};
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@mui/material/styles';
import { getTheme, type CatppuccinTheme } from '../../theme';
import { GlassCard, GlassPaper, GlassSurface } from './Glass';
const collectStyles = () =>
Array.from(document.querySelectorAll('style'))
.map((s) => s.textContent ?? '')
.join('\n');
const renderWithTheme = (
ui: React.ReactElement,
mode: CatppuccinTheme = 'mocha',
) => {
const theme = getTheme(mode);
const result = render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
return { theme, ...result };
};
describe('Glass primitives', () => {
it('GlassCard applies backdrop blur + token-derived border via emotion styles', () => {
const { getByTestId, theme } = renderWithTheme(
<GlassCard data-testid="glass">content</GlassCard>,
);
const el = getByTestId('glass');
expect(el.className).toMatch(/css-/);
const css = collectStyles();
expect(css).toContain('backdrop-filter:blur(');
expect(css).toContain('saturate(');
expect(css).toContain(theme.tokens.glass.border);
expect(css).toContain(theme.tokens.glass.tint);
});
it('GlassCard intensity="strong" emits blurStrong + tintStrong into emitted CSS', () => {
const { theme } = renderWithTheme(
<GlassCard data-testid="g" intensity="strong">x</GlassCard>,
);
const css = collectStyles();
expect(css).toContain(theme.tokens.glass.blurStrong);
expect(css).toContain(theme.tokens.glass.tintStrong);
});
it('GlassCard intensity="subtle" emits tintSubtle into emitted CSS', () => {
const { theme } = renderWithTheme(
<GlassCard data-testid="g" intensity="subtle">x</GlassCard>,
);
const css = collectStyles();
expect(css).toContain(theme.tokens.glass.tintSubtle);
});
it('does not forward `intensity` prop to the DOM', () => {
const { getByTestId } = renderWithTheme(
<GlassCard data-testid="g" intensity="strong">x</GlassCard>,
);
expect(getByTestId('g').getAttribute('intensity')).toBeNull();
});
it('GlassPaper + GlassSurface emit glass tint on a light theme', () => {
const { theme } = renderWithTheme(
<>
<GlassPaper data-testid="p">paper</GlassPaper>
<GlassSurface data-testid="s">surface</GlassSurface>
</>,
'holy_light',
);
const css = collectStyles();
expect(css).toContain(theme.tokens.glass.tint);
expect(css).toContain(theme.tokens.glass.border);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { styled } from '@mui/material/styles';
import MuiCard from '@mui/material/Card';
import MuiPaper from '@mui/material/Paper';
import Box from '@mui/material/Box';
type Intensity = 'subtle' | 'default' | 'strong';
const intensityStyles = (intensity: Intensity) => ({ theme }: { theme: any }) => {
const t = theme.tokens;
const tint =
intensity === 'strong' ? t.glass.tintStrong
: intensity === 'subtle' ? t.glass.tintSubtle
: t.glass.tint;
const blur = intensity === 'strong' ? t.glass.blurStrong : t.glass.blur;
return {
backgroundColor: tint,
backdropFilter: blur,
WebkitBackdropFilter: blur,
border: '1px solid',
borderColor: t.glass.border,
boxShadow: intensity === 'strong' ? t.elevation.md : t.elevation.sm,
borderRadius: t.radius.md,
position: 'relative' as const,
// Inset top highlight — the "glass lip"
'&::before': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
pointerEvents: 'none',
boxShadow: `inset 0 1px 0 ${t.glass.highlight}`,
},
};
};
interface GlassProps {
intensity?: Intensity;
}
export const GlassCard = styled(MuiCard, {
shouldForwardProp: (prop) => prop !== 'intensity',
})<GlassProps>(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme }));
export const GlassPaper = styled(MuiPaper, {
shouldForwardProp: (prop) => prop !== 'intensity',
})<GlassProps>(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme }));
export const GlassSurface = styled(Box, {
shouldForwardProp: (prop) => prop !== 'intensity',
})<GlassProps>(({ intensity = 'default', theme }) => intensityStyles(intensity)({ theme }));
export type { Intensity as GlassIntensity };
@@ -0,0 +1,46 @@
import { useEffect, useState, type ReactNode } from 'react';
import { styled, alpha } from '@mui/material/styles';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
const StyledAppBar = styled(AppBar, {
shouldForwardProp: (prop) => prop !== 'scrolled',
})<{ scrolled: boolean }>(({ theme, scrolled }) => {
const t = theme.tokens;
return {
backgroundColor: scrolled
? alpha(theme.palette.background.default, theme.palette.mode === 'light' ? 0.85 : 0.72)
: alpha(theme.palette.background.default, theme.palette.mode === 'light' ? 0.55 : 0.4),
backdropFilter: t.glass.blurStrong,
WebkitBackdropFilter: t.glass.blurStrong,
borderBottom: '1px solid',
borderColor: scrolled ? t.glass.border : 'transparent',
boxShadow: scrolled ? t.elevation.sm : 'none',
color: theme.palette.text.primary,
transition: 'background-color 200ms ease, box-shadow 200ms ease, border-color 200ms ease',
};
});
export interface GlassAppBarProps {
children: ReactNode;
threshold?: number;
}
export const GlassAppBar = ({ children, threshold = 12 }: GlassAppBarProps) => {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > threshold);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [threshold]);
return (
<StyledAppBar position="sticky" elevation={0} scrolled={scrolled}>
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, px: { xs: 1, sm: 4, md: 6 } }}>
{children}
</Toolbar>
</StyledAppBar>
);
};
@@ -0,0 +1,46 @@
import { styled, alpha } from '@mui/material/styles';
import Box from '@mui/material/Box';
export interface HeroBackdropProps {
variant?: 'mesh' | 'aurora' | 'minimal';
}
const Root = styled(Box, {
shouldForwardProp: (prop) => prop !== 'variant',
})<{ variant: NonNullable<HeroBackdropProps['variant']> }>(({ theme, variant }) => {
const { palette } = theme;
const p = palette.primary.main;
const s = palette.secondary.main;
const meshLayers = [
`radial-gradient(ellipse 60% 40% at 20% 10%, ${alpha(p, 0.35)} 0%, transparent 60%)`,
`radial-gradient(ellipse 50% 50% at 85% 25%, ${alpha(s, 0.28)} 0%, transparent 65%)`,
`radial-gradient(ellipse 70% 45% at 50% 95%, ${alpha(p, 0.18)} 0%, transparent 60%)`,
];
const auroraLayers = [
`conic-gradient(from 210deg at 50% -10%, ${alpha(p, 0.35)}, ${alpha(s, 0.25)}, ${alpha(p, 0.1)}, ${alpha(p, 0.35)})`,
];
const minimalLayers = [
`linear-gradient(180deg, ${alpha(p, 0.12)} 0%, transparent 60%)`,
];
const backgroundImage =
variant === 'aurora' ? auroraLayers.join(', ')
: variant === 'minimal' ? minimalLayers.join(', ')
: meshLayers.join(', ');
return {
position: 'absolute',
inset: 0,
backgroundImage,
backgroundColor: palette.background.default,
filter: 'saturate(120%)',
// Soft mask so the backdrop fades into the page content
maskImage: 'linear-gradient(180deg, #000 0%, #000 55%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(180deg, #000 0%, #000 55%, transparent 100%)',
};
});
export const HeroBackdrop = ({ variant = 'mesh' }: HeroBackdropProps) => <Root variant={variant} />;
+50
View File
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react';
import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles';
const ShellRoot = styled(Box)({
position: 'relative',
width: '100%',
minHeight: '100%',
});
const HeroLayer = styled(Box)({
position: 'absolute',
inset: 0,
pointerEvents: 'none',
overflow: 'hidden',
zIndex: 0,
});
const Content = styled(Box)(({ theme }) => ({
position: 'relative',
zIndex: 1,
width: '100%',
maxWidth: '1400px',
marginInline: 'auto',
paddingInline: theme.spacing(2),
paddingBlock: theme.spacing(4),
[theme.breakpoints.up('sm')]: {
paddingInline: theme.spacing(4),
},
[theme.breakpoints.up('md')]: {
paddingInline: theme.spacing(6),
paddingBlock: theme.spacing(6),
},
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(5),
}));
export interface PageShellProps {
children: ReactNode;
hero?: ReactNode;
fullWidth?: boolean;
}
export const PageShell = ({ children, hero, fullWidth }: PageShellProps) => (
<ShellRoot>
{hero && <HeroLayer>{hero}</HeroLayer>}
<Content sx={fullWidth ? { maxWidth: 'none' } : undefined}>{children}</Content>
</ShellRoot>
);
+88
View File
@@ -0,0 +1,88 @@
import { Link } from 'react-router-dom';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { GlassCard } from './Glass';
import RatingBadge from '../RatingBadge';
import RichText from '../RichText';
import { formatDate } from '../../utils/date';
export interface ReviewRowData {
id: number;
user: string;
user_avatar: string | null;
flavor: number;
flavor_name?: string;
flavor_image?: string | null;
score: number;
comment?: string;
created_at: string;
}
export interface ReviewRowProps {
review: ReviewRowData;
showFlavor?: boolean;
compact?: boolean;
}
export const ReviewRow = ({ review, showFlavor = true, compact }: ReviewRowProps) => (
<Link
to={`/flavor/${review.flavor}`}
style={{ textDecoration: 'none', color: 'inherit', display: 'block' }}
>
<GlassCard
intensity="subtle"
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.25,
p: compact ? 1.5 : 2,
transition: 'transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease',
'&:hover': {
transform: 'translateY(-2px)',
borderColor: 'primary.main',
boxShadow: (theme) => theme.tokens.elevation.md,
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Avatar
src={review.user_avatar || undefined}
sx={{ width: 28, height: 28 }}
>
{!review.user_avatar && review.user.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.2 }} noWrap>
{review.user}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(review.created_at)}
</Typography>
</Box>
<RatingBadge score={review.score} size="small" />
</Box>
{showFlavor && review.flavor_name && (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }} noWrap>
on {review.flavor_name}
</Typography>
)}
{review.comment && (
<Typography
variant="body2"
component="div"
sx={{
display: '-webkit-box',
WebkitLineClamp: compact ? 2 : 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
<RichText text={review.comment} />
</Typography>
)}
</GlassCard>
</Link>
);
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
export interface SectionHeaderProps {
title: ReactNode;
subtitle?: ReactNode;
action?: ReactNode;
compact?: boolean;
}
export const SectionHeader = ({ title, subtitle, action, compact }: SectionHeaderProps) => (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-end',
flexWrap: 'wrap',
gap: 2,
mb: compact ? 2 : 3,
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography
variant={compact ? 'h5' : 'h4'}
component="h2"
sx={{
fontWeight: 700,
lineHeight: 1.15,
background: (theme) => theme.tokens.accent.gradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
display: 'inline-block',
}}
>
{title}
</Typography>
{subtitle && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5, maxWidth: '60ch' }}
>
{subtitle}
</Typography>
)}
</Box>
{action}
</Box>
);
+9
View File
@@ -0,0 +1,9 @@
export { GlassCard, GlassPaper, GlassSurface, type GlassIntensity } from './Glass';
export { GlassAppBar, type GlassAppBarProps } from './GlassAppBar';
export { PageShell, type PageShellProps } from './PageShell';
export { SectionHeader, type SectionHeaderProps } from './SectionHeader';
export { HeroBackdrop, type HeroBackdropProps } from './HeroBackdrop';
export { ReviewRow, type ReviewRowProps, type ReviewRowData } from './ReviewRow';
export { FlavorCard, type FlavorCardProps, type FlavorCardData } from './FlavorCard';
export { FormCard, type FormCardProps } from './FormCard';
export { EmptyState, type EmptyStateProps } from './EmptyState';