refactor(guest): retire legacy guest app and move shared modules
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 08:42:53 +01:00
parent b14435df8b
commit 0a08f2704f
191 changed files with 243 additions and 12631 deletions

View File

@@ -0,0 +1,69 @@
import React from 'react';
import type { LiveShowBackgroundMode, LiveShowPhoto } from '../services/liveShowApi';
function resolvePhotoUrl(photo?: LiveShowPhoto | null): string | null {
if (!photo) {
return null;
}
return photo.full_url || photo.thumb_url || null;
}
function resolveBlurAmount(intensity: number): number {
const safe = Number.isFinite(intensity) ? intensity : 70;
return 28 + Math.min(60, Math.max(0, safe)) * 0.45;
}
export default function LiveShowBackdrop({
mode,
photo,
intensity,
}: {
mode: LiveShowBackgroundMode;
photo?: LiveShowPhoto | null;
intensity: number;
}) {
const photoUrl = resolvePhotoUrl(photo);
const blurAmount = resolveBlurAmount(intensity);
const fallbackMode = mode === 'blur_last' && !photoUrl ? 'gradient' : mode;
if (fallbackMode === 'solid') {
return (
<div
className="pointer-events-none absolute inset-0 z-0"
style={{ backgroundColor: 'rgb(8, 10, 16)' }}
/>
);
}
if (fallbackMode === 'gradient') {
return <div className="pointer-events-none absolute inset-0 z-0 bg-aurora-enhanced" />;
}
if (fallbackMode === 'brand') {
return (
<div
className="pointer-events-none absolute inset-0 z-0"
style={{
backgroundImage:
'radial-gradient(120% 120% at 15% 15%, var(--guest-primary) 0%, rgba(0,0,0,0.8) 55%), radial-gradient(120% 120% at 85% 20%, var(--guest-secondary) 0%, transparent 60%)',
backgroundColor: 'rgb(5, 8, 16)',
}}
/>
);
}
return (
<div className="pointer-events-none absolute inset-0 z-0">
<div
className="absolute inset-0 scale-110"
style={{
backgroundImage: photoUrl ? `url(${photoUrl})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: `blur(${blurAmount}px) saturate(1.15)`,
}}
/>
<div className="absolute inset-0 bg-black/60" />
</div>
);
}

View File

@@ -0,0 +1,80 @@
import React from 'react';
import type { LiveShowLayoutMode, LiveShowPhoto } from '../services/liveShowApi';
const BASE_TILE =
'relative overflow-hidden rounded-[28px] bg-black/70 shadow-[0_24px_70px_rgba(0,0,0,0.55)]';
function PhotoTile({
photo,
fit,
label,
className = '',
}: {
photo: LiveShowPhoto;
fit: 'cover' | 'contain';
label: string;
className?: string;
}) {
const src = photo.full_url || photo.thumb_url || '';
return (
<div className={`${BASE_TILE} ${className}`.trim()}>
{src ? (
<img
src={src}
alt={label}
className={`h-full w-full object-${fit} object-center`}
loading="eager"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-white/60">
{label}
</div>
)}
</div>
);
}
export default function LiveShowStage({
layout,
photos,
title,
}: {
layout: LiveShowLayoutMode;
photos: LiveShowPhoto[];
title: string;
}) {
if (photos.length === 0) {
return null;
}
if (layout === 'single') {
return (
<div className="flex h-full w-full items-center justify-center px-6 py-12">
<PhotoTile
photo={photos[0]}
fit="contain"
label={title}
className="h-[62vh] w-full max-w-[1200px] sm:h-[72vh]"
/>
</div>
);
}
if (layout === 'split') {
return (
<div className="grid h-[72vh] w-full grid-cols-1 gap-6 px-6 py-12 lg:grid-cols-2">
{photos.slice(0, 2).map((photo) => (
<PhotoTile key={photo.id} photo={photo} fit="cover" label={title} className="h-full w-full" />
))}
</div>
);
}
return (
<div className="grid h-[72vh] w-full grid-cols-2 grid-rows-2 gap-5 px-6 py-12">
{photos.slice(0, 4).map((photo) => (
<PhotoTile key={photo.id} photo={photo} fit="cover" label={title} className="h-full w-full" />
))}
</div>
);
}

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { ArrowDown, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { triggerHaptic } from '../lib/haptics';
const MAX_PULL = 96;
const TRIGGER_PULL = 72;
const DAMPING = 0.55;
type PullToRefreshProps = {
onRefresh: () => Promise<void> | void;
disabled?: boolean;
className?: string;
pullLabel?: string;
releaseLabel?: string;
refreshingLabel?: string;
children: React.ReactNode;
};
export default function PullToRefresh({
onRefresh,
disabled = false,
className,
pullLabel = 'Pull to refresh',
releaseLabel = 'Release to refresh',
refreshingLabel = 'Refreshing…',
children,
}: PullToRefreshProps) {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const startYRef = React.useRef<number | null>(null);
const pullDistanceRef = React.useRef(0);
const readyRef = React.useRef(false);
const [pullDistance, setPullDistance] = React.useState(0);
const [dragging, setDragging] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const updatePull = React.useCallback((value: number) => {
pullDistanceRef.current = value;
setPullDistance(value);
}, []);
React.useEffect(() => {
const container = containerRef.current;
if (!container || disabled) {
return;
}
const handleStart = (event: TouchEvent) => {
if (refreshing || window.scrollY > 0) {
return;
}
startYRef.current = event.touches[0]?.clientY ?? null;
setDragging(true);
};
const handleMove = (event: TouchEvent) => {
if (refreshing || startYRef.current === null) {
return;
}
if (window.scrollY > 0) {
startYRef.current = null;
updatePull(0);
setDragging(false);
return;
}
const currentY = event.touches[0]?.clientY ?? 0;
const delta = currentY - startYRef.current;
if (delta <= 0) {
updatePull(0);
return;
}
event.preventDefault();
const next = Math.min(MAX_PULL, delta * DAMPING);
updatePull(next);
const isReady = next >= TRIGGER_PULL;
if (isReady && !readyRef.current) {
readyRef.current = true;
triggerHaptic('selection');
} else if (!isReady && readyRef.current) {
readyRef.current = false;
}
};
const handleEnd = async () => {
if (startYRef.current === null) {
return;
}
startYRef.current = null;
setDragging(false);
readyRef.current = false;
if (pullDistanceRef.current >= TRIGGER_PULL) {
triggerHaptic('medium');
setRefreshing(true);
updatePull(TRIGGER_PULL);
try {
await onRefresh();
} finally {
setRefreshing(false);
updatePull(0);
}
return;
}
updatePull(0);
};
container.addEventListener('touchstart', handleStart, { passive: true });
container.addEventListener('touchmove', handleMove, { passive: false });
container.addEventListener('touchend', handleEnd);
container.addEventListener('touchcancel', handleEnd);
return () => {
container.removeEventListener('touchstart', handleStart);
container.removeEventListener('touchmove', handleMove);
container.removeEventListener('touchend', handleEnd);
container.removeEventListener('touchcancel', handleEnd);
};
}, [disabled, onRefresh, refreshing, updatePull]);
const progress = Math.min(pullDistance / TRIGGER_PULL, 1);
const ready = pullDistance >= TRIGGER_PULL;
const indicatorLabel = refreshing
? refreshingLabel
: ready
? releaseLabel
: pullLabel;
return (
<div ref={containerRef} className={cn('relative', className)}>
<div
className="pointer-events-none absolute left-0 right-0 top-2 flex h-10 items-center justify-center"
style={{
opacity: progress,
transform: `translateY(${Math.min(pullDistance, TRIGGER_PULL) - 48}px)`,
transition: dragging ? 'none' : 'transform 200ms ease-out, opacity 200ms ease-out',
}}
>
<div className="flex items-center gap-2 rounded-full border border-white/30 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-slate-900/80 dark:text-slate-100">
{refreshing ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<ArrowDown
className={cn('h-4 w-4 transition-transform duration-200', ready && 'rotate-180')}
aria-hidden
/>
)}
<span>{indicatorLabel}</span>
</div>
</div>
<div
className={cn('will-change-transform', !dragging && 'transition-transform duration-200 ease-out')}
style={{ transform: `translateY(${pullDistance}px)` }}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
// @ts-nocheck
import React from 'react';
type ToastAction = { label: string; onClick: () => void };
type Toast = {
id: number;
text: string;
type?: 'success' | 'error' | 'info';
action?: ToastAction;
durationMs?: number;
};
const Ctx = React.createContext<{ push: (t: Omit<Toast,'id'>) => void } | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [list, setList] = React.useState<Toast[]>([]);
const push = React.useCallback((t: Omit<Toast,'id'>) => {
const id = Date.now() + Math.random();
const durationMs = t.durationMs ?? 3000;
setList((arr) => [...arr, { id, ...t, durationMs }]);
if (durationMs > 0) {
setTimeout(() => setList((arr) => arr.filter((x) => x.id !== id)), durationMs);
}
}, []);
const dismiss = React.useCallback((id: number) => {
setList((arr) => arr.filter((x) => x.id !== id));
}, []);
const contextValue = React.useMemo(() => ({ push }), [push]);
React.useEffect(() => {
const onEvt = (e: CustomEvent<Omit<Toast, 'id'>>) => push(e.detail);
window.addEventListener('guest-toast', onEvt);
return () => window.removeEventListener('guest-toast', onEvt);
}, [push]);
return (
<Ctx.Provider value={contextValue}>
{children}
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
<div className="flex w-full max-w-sm flex-col gap-2">
{list.map((t) => (
<div
key={t.id}
className={`pointer-events-auto rounded-md border p-3 shadow-sm ${
t.type === 'error'
? 'border-red-300 bg-red-50 text-red-700'
: t.type === 'info'
? 'border-blue-300 bg-blue-50 text-blue-700'
: 'border-green-300 bg-green-50 text-green-700'
}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm">{t.text}</span>
{t.action ? (
<button
type="button"
className="pointer-events-auto rounded-full border border-current/30 px-3 py-1 text-xs font-semibold uppercase tracking-wide transition hover:border-current"
onClick={() => {
try {
t.action?.onClick();
} finally {
dismiss(t.id);
}
}}
>
{t.action.label}
</button>
) : null}
</div>
</div>
))}
</div>
</div>
</Ctx.Provider>
);
}
export function useToast() {
const ctx = React.useContext(Ctx);
if (!ctx) throw new Error('ToastProvider missing');
return ctx;
}

View File

@@ -0,0 +1,31 @@
import React from "react";
type Props = {
markdown?: string;
html?: string;
};
export function LegalMarkdown({ markdown = '', html }: Props) {
const derived = React.useMemo(() => {
if (html && html.trim().length > 0) {
return html;
}
const escaped = markdown
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return escaped
.split(/\n{2,}/)
.map((block) => `<p>${block.replace(/\n/g, '<br/>')}</p>`)
.join('\n');
}, [markdown, html]);
return (
<div
className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
dangerouslySetInnerHTML={{ __html: derived }}
/>
);
}