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