Files
movieloop-frontend/src/components/game/ChainDisplay.tsx
T
TehRiehlDeal 5a7e8cebc3 feat: repo hygiene - push opt-in, paged admin jobs, shared game screen (2.2.0)
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.
2026-09-12 00:01:42 -07:00

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>
);
}