- MapLibre map is now a fixed, full-viewport background layer (z-0) - New FloatingWindow: drag by title bar (pointer capture, viewport-clamped, position persisted to localStorage), collapsible to title bar, bring-to-front z-index; semi-transparent slate window with amber/cyan accents - Game screen re-laid out as floating windows over the map: slim top header (scenario, turn + date, player nation chip, Idle/Simulating status, Focus map collapse-all), dossier (top-left), event feed (right), Your move deck (bottom-center), legend (bottom-left), tiny zoom control (bottom-right) - MapLibre default nav controls removed; custom floating zoom-in/out control - Panels gain an inWindow mode (no card chrome, same testids) - server: honor HISTORY_API_PORT as PORT fallback (dev proxy/e2e agreement) - css: keep maplibre canvas base color and Radix portals above HUD windows
137 lines
5.2 KiB
TypeScript
137 lines
5.2 KiB
TypeScript
import {
|
|
DollarSign,
|
|
Flame,
|
|
FlaskConical,
|
|
Handshake,
|
|
Landmark,
|
|
Palette,
|
|
Radar,
|
|
Swords,
|
|
Users,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import { Badge } from './ui/badge';
|
|
import { ScrollArea } from './ui/scroll-area';
|
|
import { EVENT_COLORS, OUTCOME_COLORS } from '@/lib/colors';
|
|
import { nationColor } from '@shared/protocol';
|
|
import type { EventEntry, EventType, Nation } from '@shared/types';
|
|
|
|
/** One icon per event category, so a type is readable without reading. */
|
|
const EVENT_ICONS: Record<EventType, LucideIcon> = {
|
|
military: Swords,
|
|
diplomatic: Handshake,
|
|
economic: DollarSign,
|
|
political: Landmark,
|
|
social: Users,
|
|
disaster: Flame,
|
|
scientific: FlaskConical,
|
|
cultural: Palette,
|
|
};
|
|
|
|
interface Props {
|
|
events: EventEntry[];
|
|
nations: Record<string, Nation>;
|
|
/**
|
|
* Render without the outer chrome and the built-in header (the surrounding
|
|
* floating window provides the border, the "Event feed" title, and the count
|
|
* badge). Same content, same testids.
|
|
*/
|
|
inWindow?: boolean;
|
|
}
|
|
|
|
/**
|
|
* A scrolling event log, newest first. Each event is a card with a category
|
|
* icon, a color-coded outcome badge, a nation-colored accent edge, and the turn
|
|
* + date it resolved on. New events slide in and flash briefly at the top.
|
|
*/
|
|
export default function EventFeed({ events, nations, inWindow = false }: Props) {
|
|
const latestFirst = [...events].reverse();
|
|
|
|
const header = inWindow ? null : (
|
|
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<Radar className="size-4 text-primary" />
|
|
<h2 className="text-sm font-semibold tracking-wide">Event feed</h2>
|
|
</div>
|
|
<Badge variant="secondary" className="text-xs tabular-nums">
|
|
{events.length}
|
|
</Badge>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
inWindow
|
|
? 'flex h-full min-h-0 flex-col'
|
|
: 'flex h-full min-h-0 flex-col rounded-xl border bg-card'
|
|
}
|
|
data-testid="event-feed-panel"
|
|
>
|
|
{header}
|
|
|
|
<ScrollArea className={inWindow ? 'min-h-0 flex-1' : 'mx-3 mb-3 min-h-0 flex-1'}>
|
|
<div className="space-y-2 p-3 pr-1" data-testid="event-feed">
|
|
{latestFirst.length === 0 && (
|
|
<div className="mt-3 rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
|
No events yet. Take an action, send a message, or jump forward to move the world.
|
|
</div>
|
|
)}
|
|
|
|
{latestFirst.map((e) => {
|
|
const nation = nations[e.nation];
|
|
// Accent edge uses the nation's stable choropleth color (falls back to
|
|
// the type color if the nation is missing — never crashes).
|
|
const accent = nation ? nationColor(e.nation) : EVENT_COLORS[e.type] ?? '#94a3b8';
|
|
const typeColor = EVENT_COLORS[e.type] ?? '#94a3b8';
|
|
const outcomeColor = OUTCOME_COLORS[e.outcome] ?? '#94a3b8';
|
|
const Icon = EVENT_ICONS[e.type] ?? Landmark;
|
|
return (
|
|
<article
|
|
key={e.id}
|
|
className="event-new relative overflow-hidden rounded-lg border bg-card/50 p-3 pl-4"
|
|
style={{ borderLeft: `3px solid ${accent}` }}
|
|
data-testid="event-item"
|
|
>
|
|
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
<span className="font-semibold tabular-nums text-foreground/70">T{e.turn}</span>
|
|
<span>·</span>
|
|
<span className="tabular-nums">{e.date}</span>
|
|
<span
|
|
className="ml-auto inline-flex items-center gap-1.5 rounded-full px-1.5 py-0.5"
|
|
style={{ color: typeColor, background: `${typeColor}1a` }}
|
|
>
|
|
<Icon className="size-3" data-testid="event-type-icon" aria-hidden />
|
|
<span data-testid="event-type" className="capitalize">
|
|
{e.type}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<p className="text-sm font-medium leading-snug">{e.title}</p>
|
|
<p className="mt-1 text-[13px] leading-snug text-foreground/75">{e.text}</p>
|
|
<div className="mt-2 flex items-center gap-2 text-[11px]">
|
|
<span className="inline-flex min-w-0 items-center gap-1.5">
|
|
<span className="inline-block size-2 shrink-0 rounded-full" style={{ background: accent }} />
|
|
<span className="truncate text-muted-foreground">{nation?.name ?? e.nation}</span>
|
|
</span>
|
|
<span
|
|
className="ml-auto shrink-0 rounded-md px-2 py-0.5 font-semibold capitalize"
|
|
style={{
|
|
background: `${outcomeColor}22`,
|
|
color: outcomeColor,
|
|
border: `1px solid ${outcomeColor}55`,
|
|
}}
|
|
data-testid="event-outcome"
|
|
>
|
|
{e.outcome}
|
|
</span>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
}
|