WP-17. Minor: push notifications become usable and nothing a player could reach was removed. The admin console's jobs need backend 3.0.0. Push (M35): the whole browser half - the service worker's handlers, the subscribe/unsubscribe calls, the server's VAPID key - had shipped with nothing calling it, so no browser ever held a subscription. The toggle lives in Profile because the permission prompt has to come from a click. The old isPushSubscribed awaited serviceWorker.ready, which never settles with no worker registered; it uses getRegistration() now. Admin: each job pages and the button becomes Continue with the server's cursor; the recalculate card also stopped printing "undefined leaderboard entries" for a field the server had not sent for several releases. The three copy-pasted job cards are one component. Consolidation: the four game screens share use-game-screen and one search panel; the review page's static chain is ChainDisplay in a static mode (21 rendered states verified byte-identical); formatTime and error extraction exist once; Endless and quick play share a franchise-aware picker instead of two divergent sort(() => Math.random() - 0.5) copies. The dead api/index barrel and the fourteen functions only it referenced are gone. noUncheckedIndexedAccess is on. Whiplash's TMDB id is corrected and every preset title is checked against TMDB.
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import { useGameStore } from '@/stores/game-store';
|
|
import MovieCard from './MovieCard';
|
|
import ActorCard from './ActorCard';
|
|
import SerpentineConnector from './SerpentineConnector';
|
|
import { cn } from '@/lib/utils';
|
|
import { Loader2 } from 'lucide-react';
|
|
import type {
|
|
ActorChainLink,
|
|
ChainLink,
|
|
GameStatus,
|
|
MovieChainLink,
|
|
} from '@/types';
|
|
|
|
interface ChainPair {
|
|
actor: ActorChainLink;
|
|
movie?: MovieChainLink;
|
|
}
|
|
|
|
/**
|
|
* Without props the display is live: it follows the game store, animates the
|
|
* newest row and shows a pending slot while the next link is validated. Given
|
|
* a `chain`, it is a still of that finished chain (e.g. a game review) and
|
|
* reads nothing from the store.
|
|
*/
|
|
type ChainDisplayProps =
|
|
| { chain?: undefined; movieAId?: undefined; movieBId?: undefined }
|
|
| { chain: ChainLink[]; movieAId: number; movieBId: number };
|
|
|
|
export default function ChainDisplay(props: ChainDisplayProps) {
|
|
if (props.chain !== undefined) {
|
|
// A chain handed in as a prop is a finished game: the loop is complete
|
|
// and nothing is being validated.
|
|
return (
|
|
<ChainRows
|
|
chain={props.chain}
|
|
movieAId={props.movieAId}
|
|
movieBId={props.movieBId}
|
|
status="completed"
|
|
isValidating={false}
|
|
live={false}
|
|
/>
|
|
);
|
|
}
|
|
return <LiveChainDisplay />;
|
|
}
|
|
|
|
function LiveChainDisplay() {
|
|
const chain = useGameStore((s) => s.chain);
|
|
const movieA = useGameStore((s) => s.movieA);
|
|
const movieB = useGameStore((s) => s.movieB);
|
|
const status = useGameStore((s) => s.status);
|
|
const isValidating = useGameStore((s) => s.isValidating);
|
|
|
|
return (
|
|
<ChainRows
|
|
chain={chain}
|
|
movieAId={movieA?.id}
|
|
movieBId={movieB?.id}
|
|
status={status}
|
|
isValidating={isValidating}
|
|
live
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface ChainRowsProps {
|
|
chain: ChainLink[];
|
|
movieAId: number | undefined;
|
|
movieBId: number | undefined;
|
|
status: GameStatus;
|
|
isValidating: boolean;
|
|
/** Store-driven: animate the newest row and show the pending movie slot. */
|
|
live: boolean;
|
|
}
|
|
|
|
function ChainRows({
|
|
chain,
|
|
movieAId,
|
|
movieBId,
|
|
status,
|
|
isValidating,
|
|
live,
|
|
}: ChainRowsProps) {
|
|
// chain[0] = MovieA (standalone starting point)
|
|
// chain[1..2] = pair 0 (Actor, Movie)
|
|
// chain[3..4] = pair 1 (Actor, Movie)
|
|
// If chain length is even, last pair is incomplete (actor only, awaiting movie)
|
|
const pairs: ChainPair[] = [];
|
|
for (let i = 1; i < chain.length; i += 2) {
|
|
const actor = chain[i] as ActorChainLink;
|
|
const movie = chain[i + 1] as MovieChainLink | undefined;
|
|
pairs.push({ actor, movie });
|
|
}
|
|
|
|
const startMovie = chain[0] as MovieChainLink | undefined;
|
|
|
|
// Geometry for w-[85%] offset cards with L-shaped connectors:
|
|
// Odd pairs (0,2,4) are right-aligned → card spans 15%-100%
|
|
// Even pairs (1,3,5) are left-aligned → card spans 0%-85%
|
|
//
|
|
// Vertical line at 7.5% (left) or 92.5% (right) runs through the gap
|
|
// AND continues into the card row down to its vertical center, where
|
|
// a horizontal arm connects to the card edge (15% or 85%).
|
|
|
|
return (
|
|
<div>
|
|
{/* Starting movie (Movie A) — full width */}
|
|
{startMovie && (
|
|
<MovieCard movie={startMovie} isTarget="A" />
|
|
)}
|
|
|
|
{/* Chain pairs */}
|
|
{pairs.map((pair, pairIndex) => {
|
|
const isOdd = pairIndex % 2 === 0; // 0-indexed: pair 0,2,4 are "odd" layout
|
|
const isLastPair = pairIndex === pairs.length - 1;
|
|
const isComplete = !!pair.movie;
|
|
|
|
// Determine movie target markers
|
|
let movieTarget: 'A' | 'B' | undefined;
|
|
if (pair.movie) {
|
|
if (pair.movie.id === movieBId) movieTarget = 'B';
|
|
else if (
|
|
pair.movie.id === movieAId &&
|
|
isLastPair &&
|
|
status === 'completed'
|
|
)
|
|
movieTarget = 'A';
|
|
}
|
|
|
|
// Connector geometry — L-shaped, aligned verticals
|
|
const lineX = isOdd ? 7.5 : 92.5;
|
|
const cardEdge = isOdd ? 15 : 85;
|
|
// For the horizontal arm: left% and right% in CSS
|
|
const armLeft = Math.min(lineX, cardEdge);
|
|
const armRight = 100 - Math.max(lineX, cardEdge);
|
|
|
|
return (
|
|
<div key={pairIndex}>
|
|
{/* Vertical line through the gap */}
|
|
<SerpentineConnector lineX={lineX} />
|
|
|
|
{/* Pair row — 85% width, alternating alignment */}
|
|
<div className={cn('relative flex', isOdd ? 'justify-end' : 'justify-start')}>
|
|
{/* Vertical continuation from top of row to vertical center */}
|
|
<div
|
|
className="absolute top-0 h-1/2 w-0 border-l-2 border-muted-foreground/30"
|
|
style={{ left: `${lineX}%` }}
|
|
/>
|
|
{/* Horizontal arm at vertical center to card edge */}
|
|
<div
|
|
className="absolute top-1/2 h-0 border-t-2 border-muted-foreground/30"
|
|
style={{ left: `${armLeft}%`, right: `${armRight}%` }}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
'flex w-[85%] items-center overflow-hidden rounded-lg border p-2.5',
|
|
movieTarget === 'B' && 'border-green-500 bg-green-500/5',
|
|
movieTarget === 'A' && 'border-primary bg-primary/5',
|
|
!movieTarget && 'border-border bg-card',
|
|
live && [
|
|
isLastPair && !isComplete && 'animate-slide-in',
|
|
isLastPair && isComplete && status !== 'completed' && 'animate-slide-in',
|
|
isLastPair && status === 'completed' && 'animate-glow-pulse',
|
|
],
|
|
)}
|
|
>
|
|
{isOdd ? (
|
|
<>
|
|
{/* Odd pairs: [Actor | dot | Movie] */}
|
|
<div className="flex min-w-0 flex-1 items-center justify-center">
|
|
<ActorCard actor={pair.actor} compact />
|
|
</div>
|
|
<Dot muted={live && !isComplete} />
|
|
<div className="flex min-w-0 flex-1 items-center justify-center">
|
|
{pair.movie ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<MovieCard movie={pair.movie} compact />
|
|
{movieTarget && <TargetBadge target={movieTarget} />}
|
|
</div>
|
|
) : (
|
|
live && <PendingSlot loading={isValidating && status === 'playing'} />
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Even pairs: [Movie | dot | Actor] (reversed) */}
|
|
<div className="flex min-w-0 flex-1 items-center justify-center">
|
|
{pair.movie ? (
|
|
<div className="flex items-center gap-1.5">
|
|
{movieTarget && <TargetBadge target={movieTarget} />}
|
|
<MovieCard movie={pair.movie} compact />
|
|
</div>
|
|
) : (
|
|
live && <PendingSlot loading={isValidating && status === 'playing'} />
|
|
)}
|
|
</div>
|
|
<Dot muted={live && !isComplete} />
|
|
<div className="flex min-w-0 flex-1 items-center justify-center">
|
|
<ActorCard actor={pair.actor} compact />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Validating spinner when searching for an actor (no incomplete pair yet) */}
|
|
{isValidating && status === 'playing' && chain.length > 0 && chain.length % 2 === 1 && (
|
|
<div>
|
|
<SerpentineConnector
|
|
lineX={pairs.length % 2 === 0 ? 7.5 : 92.5}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
'relative flex',
|
|
pairs.length % 2 === 0 ? 'justify-end' : 'justify-start',
|
|
)}
|
|
>
|
|
{/* Vertical continuation + horizontal arm for validating row */}
|
|
<div
|
|
className="absolute top-0 h-1/2 w-0 border-l-2 border-muted-foreground/30"
|
|
style={{ left: `${pairs.length % 2 === 0 ? 7.5 : 92.5}%` }}
|
|
/>
|
|
<div
|
|
className="absolute top-1/2 h-0 border-t-2 border-muted-foreground/30"
|
|
style={{
|
|
left: `${pairs.length % 2 === 0 ? 7.5 : 85}%`,
|
|
right: `${pairs.length % 2 === 0 ? 85 : 7.5}%`,
|
|
}}
|
|
/>
|
|
<div className="flex w-[85%] items-center gap-3 rounded-lg border border-dashed border-border p-2.5">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">
|
|
Validating...
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'completed' && (
|
|
<div className="flex justify-center py-3">
|
|
<div className="rounded-full bg-green-500/10 px-4 py-1.5 text-sm font-medium text-green-600">
|
|
Loop Complete!
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Dot({ muted }: { muted?: boolean }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'mx-2 h-1.5 w-1.5 shrink-0 rounded-full',
|
|
muted ? 'bg-muted-foreground/20' : 'bg-muted-foreground/40',
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function PendingSlot({ loading }: { loading?: boolean }) {
|
|
return (
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
{loading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<div className="h-10 w-10 shrink-0 rounded border border-dashed border-muted-foreground/30" />
|
|
)}
|
|
<span className="text-xs">
|
|
{loading ? 'Validating...' : '?'}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TargetBadge({ target }: { target: 'A' | 'B' }) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
|
|
target === 'A'
|
|
? 'bg-blue-500/10 text-blue-600'
|
|
: 'bg-green-500/10 text-green-600',
|
|
)}
|
|
>
|
|
{target}
|
|
</span>
|
|
);
|
|
}
|