feat(call-to-play)!: coordinate game sessions across peers
Implement the launcher design as a production peer-to-peer feature. Call to Play actions are immutable, validated events broadcast over the existing QUIC control channel, deduplicated in a bounded in-memory history, and exchanged in Hello/HelloAck so late joiners reconstruct current calls. Add the Tauri bridge and modular launcher surfaces for play-now and scheduled calls, check-in, readiness buffers, role-aware controls, chat, tickers, and actual caller launch. A deterministic frontend reducer derives presentation state from replicated history. Extend the JSONL peer harness with publish/list commands and a three-peer live-delivery and late-join scenario. This intentionally raises the only supported wire protocol from version 5 to version 6; older builds are not supported. Document the transport architecture and exclude generated peer-test state from Docker build contexts. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed - `just frontend-test` -- passed, 20 tests - `just build` -- passed - `just peer-cli-tests S2 S48` -- passed - `git diff --cached --check` -- passed
This commit is contained in:
31 files changed
+3032
-21
No files matched your search
@@ -105,4 +105,35 @@ export const Icon = {
|
||||
<path d="M5 9h2M6 8v2M10 9h.01M11 8h.01" />
|
||||
</svg>
|
||||
),
|
||||
flag: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.6} {...baseStroke} {...p}>
|
||||
<path d="M3 14V2.5M3.5 3h8l-1.4 2.5L11.5 8h-8" />
|
||||
</svg>
|
||||
),
|
||||
clock: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||
<circle cx="8" cy="8" r="5.5" />
|
||||
<path d="M8 4.5V8l2.5 1.5" />
|
||||
</svg>
|
||||
),
|
||||
chat: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||
<path d="M2.5 3.5h11v8h-6L4 14v-2.5H2.5z" />
|
||||
</svg>
|
||||
),
|
||||
send: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" strokeWidth={1.5} {...baseStroke} {...p}>
|
||||
<path d="m2 3 12 5-12 5 2-5zM4 8h6" />
|
||||
</svg>
|
||||
),
|
||||
caretUp: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="10" height="10" strokeWidth={1.8} {...baseStroke} {...p}>
|
||||
<path d="m4 10 4-4 4 4" />
|
||||
</svg>
|
||||
),
|
||||
caretDown: (p: Props) => (
|
||||
<svg viewBox="0 0 16 16" width="10" height="10" strokeWidth={1.8} {...baseStroke} {...p}>
|
||||
<path d="m4 6 4 4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
} satisfies Record<string, (p: Props) => JSX.Element>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Icon } from '../Icon';
|
||||
import { Nomination } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
nominations: ReadonlyArray<Nomination>;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
|
||||
const activeCount = nominations.filter(nomination => nomination.state !== 'started').length;
|
||||
return (
|
||||
<button className="ctp-btn" onClick={onClick}>
|
||||
<Icon.flag />
|
||||
<span>Call to Play</span>
|
||||
{activeCount > 0 && <span className="ctp-badge">{activeCount}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Icon } from '../Icon';
|
||||
import { Modal } from '../Modal';
|
||||
import { CreateNominationForm } from './CreateNominationForm';
|
||||
import { NominationCard } from './NominationCard';
|
||||
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
||||
import { Game, Nomination } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
nominations: ReadonlyArray<Nomination>;
|
||||
games: ReadonlyArray<Game>;
|
||||
username: string;
|
||||
actions: CallToPlayActions;
|
||||
focusId: string | null;
|
||||
transportReady: boolean;
|
||||
error: string | null;
|
||||
getThumbnail: (gameId: string) => string | null | undefined;
|
||||
totalPeerCount: number;
|
||||
onLaunch: (game: Game) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CallToPlayOverlay = ({
|
||||
nominations,
|
||||
games,
|
||||
username,
|
||||
actions,
|
||||
focusId,
|
||||
transportReady,
|
||||
error,
|
||||
getThumbnail,
|
||||
totalPeerCount,
|
||||
onLaunch,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const gameById = new Map(games.map(game => [game.id, game]));
|
||||
const sorted = [...nominations].sort((left, right) =>
|
||||
Number(left.state === 'started') - Number(right.state === 'started')
|
||||
|| left.deadline - right.deadline
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} className="ctp-modal">
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<Icon.close />
|
||||
</button>
|
||||
<div className="ctp-head">
|
||||
<h2>Call to Play</h2>
|
||||
<p className="ctp-head-sub">
|
||||
Rally the LAN around a game and a time — right now, or scheduled for later with
|
||||
an “I’m in” RSVP. The caller decides when it actually starts.
|
||||
</p>
|
||||
{!showCreate && (
|
||||
<button
|
||||
className="act-btn act-play ctp-head-new"
|
||||
disabled={!transportReady}
|
||||
onClick={() => setShowCreate(true)}
|
||||
><Icon.flag /><span>Call a new match</span></button>
|
||||
)}
|
||||
{!transportReady && !error && (
|
||||
<div className="ctp-transport-note">Connecting Call to Play to the LAN…</div>
|
||||
)}
|
||||
{error && <div className="ctp-transport-note is-error">{error}</div>}
|
||||
</div>
|
||||
<div className="ctp-body">
|
||||
{showCreate && (
|
||||
<CreateNominationForm
|
||||
games={games}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onCreate={(gameId, maxPlayers, duration, scheduledFor) => {
|
||||
actions.createNomination(gameId, maxPlayers, duration, scheduledFor);
|
||||
setShowCreate(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sorted.length === 0 && !showCreate && (
|
||||
<div className="ctp-empty">
|
||||
No active calls right now — be the one to start something.
|
||||
</div>
|
||||
)}
|
||||
{sorted.map(nomination => {
|
||||
const game = gameById.get(nomination.gameId);
|
||||
if (!game) return null;
|
||||
return (
|
||||
<NominationCard
|
||||
key={nomination.id}
|
||||
nomination={nomination}
|
||||
game={game}
|
||||
username={username}
|
||||
actions={actions}
|
||||
focused={nomination.id === focusId}
|
||||
thumbnailUrl={getThumbnail(game.id)}
|
||||
totalPeerCount={totalPeerCount}
|
||||
onLaunch={onLaunch}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { Icon } from '../Icon';
|
||||
import {
|
||||
avatarColor,
|
||||
formatClock,
|
||||
formatCountdown,
|
||||
formatCountdownShort,
|
||||
formatUntil,
|
||||
isReady,
|
||||
readyCountOf,
|
||||
statusOf,
|
||||
} from '../../lib/callToPlay';
|
||||
import { Game, Nomination } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
nominations: ReadonlyArray<Nomination>;
|
||||
games: ReadonlyArray<Game>;
|
||||
accent: string;
|
||||
onOpen: (callId: string) => void;
|
||||
}
|
||||
|
||||
const LABEL = {
|
||||
scheduled: 'Scheduled',
|
||||
call: 'Call to Play',
|
||||
soon: 'Starting soon',
|
||||
ready: 'Ready',
|
||||
} as const;
|
||||
|
||||
const RANK = { ready: 0, soon: 1, call: 2, scheduled: 2, started: 3 } as const;
|
||||
|
||||
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
||||
const entries = Object.entries(nomination.participants);
|
||||
return (
|
||||
<span className="ctp-ticker-bubbles">
|
||||
{entries.slice(0, 6).map(([name, participant]) => {
|
||||
const ready = isReady(participant, now);
|
||||
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
|
||||
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||||
const remaining = (participant.readyAt ?? now) - now;
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
className="ctp-mini"
|
||||
data-state={state}
|
||||
title={`${name} — ${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
|
||||
style={{ background: avatarColor(name) }}
|
||||
>
|
||||
{initials}
|
||||
{ready && <span className="ctp-mini-check"><Icon.check /></span>}
|
||||
{state === 'pending' && (
|
||||
<i className="ctp-mini-tag">{formatCountdownShort(remaining)}</i>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{entries.length > 6 && <span className="ctp-mini ctp-mini-more">+{entries.length - 6}</span>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
|
||||
const now = Date.now();
|
||||
const gameById = new Map(games.map(game => [game.id, game]));
|
||||
const active = nominations
|
||||
.filter(nomination => nomination.state !== 'started')
|
||||
.sort((left, right) =>
|
||||
RANK[statusOf(left, now)] - RANK[statusOf(right, now)]
|
||||
|| left.deadline - right.deadline
|
||||
);
|
||||
if (active.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="ctp-ticker-stack">
|
||||
{active.map(nomination => {
|
||||
const game = gameById.get(nomination.gameId);
|
||||
if (!game) return null;
|
||||
const status = statusOf(nomination, now);
|
||||
if (status === 'started') return null;
|
||||
const ready = readyCountOf(nomination, now);
|
||||
const total = Object.keys(nomination.participants).length;
|
||||
const remaining = Math.max(0, nomination.deadline - now);
|
||||
const last = nomination.messages[nomination.messages.length - 1];
|
||||
const count = status === 'scheduled'
|
||||
? `${total} in`
|
||||
: `${ready}/${nomination.maxPlayers} ready`;
|
||||
const time = status === 'ready'
|
||||
? 'waiting to start'
|
||||
: status === 'scheduled'
|
||||
? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}`
|
||||
: nomination.scheduledFor !== null
|
||||
? `starts ${formatClock(nomination.scheduledFor)} · ${formatCountdown(remaining)}`
|
||||
: formatCountdown(remaining);
|
||||
return (
|
||||
<button
|
||||
key={nomination.id}
|
||||
className="ctp-ticker"
|
||||
data-status={status}
|
||||
style={{ '--accent': accent } as CSSProperties}
|
||||
onClick={() => onOpen(nomination.id)}
|
||||
>
|
||||
<span className="ctp-ticker-dot" data-status={status} />
|
||||
<span className="ctp-ticker-label" data-status={status}>{LABEL[status]}</span>
|
||||
<span className="ctp-ticker-game">{game.name}</span>
|
||||
<span className="ctp-ticker-by">by {nomination.creator}</span>
|
||||
<span className="ctp-ticker-ready">{count}</span>
|
||||
<span className="ctp-ticker-time">{time}</span>
|
||||
{last ? (
|
||||
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
|
||||
<Icon.chat />
|
||||
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
|
||||
<span className="ctp-ticker-chat-text">{last.text}</span>
|
||||
</span>
|
||||
) : <span className="ctp-ticker-chat" />}
|
||||
<MiniBubbles nomination={nomination} now={now} />
|
||||
<span className="ctp-ticker-cta">
|
||||
{status === 'soon' ? 'Check in' : 'View'}<Icon.chevron />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '../Icon';
|
||||
import { Game } from '../../lib/types';
|
||||
import { bumpTime, normalizeTimeInput, sanitizeTimeDraft } from '../../lib/callToPlay';
|
||||
|
||||
interface Props {
|
||||
games: ReadonlyArray<Game>;
|
||||
onCancel: () => void;
|
||||
onCreate: (
|
||||
gameId: string,
|
||||
maxPlayers: number,
|
||||
durationMinutes: number,
|
||||
scheduledFor: number | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const nextTimeSlot = (): { time: string; dayOffset: number } => {
|
||||
const now = new Date();
|
||||
const slot = new Date(now.getTime() + 40 * 60_000);
|
||||
slot.setMinutes(slot.getMinutes() <= 30 ? 30 : 60, 0, 0);
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const slotDay = new Date(slot.getFullYear(), slot.getMonth(), slot.getDate());
|
||||
return {
|
||||
time: `${String(slot.getHours()).padStart(2, '0')}:${String(slot.getMinutes()).padStart(2, '0')}`,
|
||||
dayOffset: Math.round((slotDay.getTime() - today.getTime()) / 86_400_000),
|
||||
};
|
||||
};
|
||||
|
||||
const suggestedPlayers = (game: Game): number => Math.max(2, Math.min(64, game.max_players ?? 8));
|
||||
|
||||
const dayChips = (): ReadonlyArray<{ offset: number; label: string }> => {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return [0, 1, 2].map(offset => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + offset);
|
||||
return { offset, label: offset === 0 ? 'Today' : days[date.getDay()] };
|
||||
});
|
||||
};
|
||||
|
||||
export const CreateNominationForm = ({ games, onCancel, onCreate }: Props) => {
|
||||
const [initialSchedule] = useState(nextTimeSlot);
|
||||
const [query, setQuery] = useState('');
|
||||
const [gameId, setGameId] = useState<string | null>(null);
|
||||
const [maxPlayers, setMaxPlayers] = useState(8);
|
||||
const [duration, setDuration] = useState(10);
|
||||
const [when, setWhen] = useState<'now' | 'later'>('now');
|
||||
const [dayOffset, setDayOffset] = useState(initialSchedule.dayOffset);
|
||||
const [time, setTime] = useState(initialSchedule.time);
|
||||
const [editingTime, setEditingTime] = useState(false);
|
||||
const [timeDraft, setTimeDraft] = useState(time);
|
||||
const timeInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (editingTime) {
|
||||
timeInputRef.current?.focus();
|
||||
timeInputRef.current?.select();
|
||||
}
|
||||
}, [editingTime]);
|
||||
|
||||
const selected = games.find(game => game.id === gameId);
|
||||
const matches = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
if (!needle) return [];
|
||||
return games.filter(game => game.name.toLocaleLowerCase().includes(needle)).slice(0, 6);
|
||||
}, [games, query]);
|
||||
const days = dayChips();
|
||||
|
||||
const pickGame = (game: Game) => {
|
||||
setGameId(game.id);
|
||||
setMaxPlayers(suggestedPlayers(game));
|
||||
setQuery(game.name);
|
||||
};
|
||||
const scheduledTimestamp = (): number => {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + dayOffset);
|
||||
date.setHours(hours, minutes, 0, 0);
|
||||
return date.getTime();
|
||||
};
|
||||
const scheduledFor = scheduledTimestamp();
|
||||
const scheduledTimeIsFuture = scheduledFor >= Date.now() + 60_000;
|
||||
const commitTimeDraft = () => {
|
||||
const normalized = normalizeTimeInput(timeDraft);
|
||||
if (normalized) setTime(normalized);
|
||||
setEditingTime(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctp-create">
|
||||
<div className="ctp-create-row">
|
||||
<label className="ctp-create-label" htmlFor="ctp-game-search">Game</label>
|
||||
<div className="search ctp-create-search">
|
||||
<Icon.search />
|
||||
<input
|
||||
id="ctp-game-search"
|
||||
type="text"
|
||||
placeholder="Search the catalog…"
|
||||
autoComplete="off"
|
||||
value={query}
|
||||
onChange={event => {
|
||||
setQuery(event.target.value);
|
||||
setGameId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!selected && query.trim() && (
|
||||
<div className="ctp-create-matches">
|
||||
{matches.map(game => (
|
||||
<button key={game.id} onClick={() => pickGame(game)}>
|
||||
<span>{game.name}</span>
|
||||
<span className="ctp-create-match-meta">
|
||||
up to {suggestedPlayers(game)} players
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{matches.length === 0 && (
|
||||
<div className="ctp-create-nomatch">No games match “{query}”</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<>
|
||||
<div className="ctp-create-row ctp-create-row-inline">
|
||||
<label className="ctp-create-label" htmlFor="ctp-max-players">Max players</label>
|
||||
<input
|
||||
id="ctp-max-players"
|
||||
type="number"
|
||||
className="ctp-create-num"
|
||||
min={2}
|
||||
max={64}
|
||||
value={maxPlayers}
|
||||
onChange={event => setMaxPlayers(
|
||||
Math.max(2, Math.min(64, Number(event.target.value) || 2)),
|
||||
)}
|
||||
/>
|
||||
<span className="ctp-create-hint">
|
||||
Suggested for {selected.name}: {suggestedPlayers(selected)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ctp-create-row">
|
||||
<label className="ctp-create-label">When</label>
|
||||
<div className="ctp-duration-opts">
|
||||
<button
|
||||
className={`ctp-duration-btn ${when === 'now' ? 'is-active' : ''}`}
|
||||
onClick={() => setWhen('now')}
|
||||
>Now</button>
|
||||
<button
|
||||
className={`ctp-duration-btn ${when === 'later' ? 'is-active' : ''}`}
|
||||
onClick={() => setWhen('later')}
|
||||
>Schedule</button>
|
||||
</div>
|
||||
{when === 'later' && (
|
||||
<>
|
||||
<div className="ctp-timepick">
|
||||
{editingTime ? (
|
||||
<input
|
||||
ref={timeInputRef}
|
||||
className="ctp-time-editinput"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={5}
|
||||
placeholder="20:00"
|
||||
value={timeDraft}
|
||||
onChange={event => setTimeDraft(previous =>
|
||||
sanitizeTimeDraft(event.target.value, previous)
|
||||
)}
|
||||
onBlur={commitTimeDraft}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') commitTimeDraft();
|
||||
if (event.key === 'Escape') setEditingTime(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="ctp-timepick-field">
|
||||
<button
|
||||
type="button"
|
||||
className="ctp-time-step"
|
||||
aria-label="Hour up"
|
||||
onClick={() => setTime(value => bumpTime(value, 'hours', 1))}
|
||||
><Icon.caretUp /></button>
|
||||
<span className="ctp-time-cell">{time.slice(0, 2)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ctp-time-step"
|
||||
aria-label="Hour down"
|
||||
onClick={() => setTime(value => bumpTime(value, 'hours', -1))}
|
||||
><Icon.caretDown /></button>
|
||||
</div>
|
||||
<span className="ctp-time-colon">:</span>
|
||||
<div className="ctp-timepick-field">
|
||||
<button
|
||||
type="button"
|
||||
className="ctp-time-step"
|
||||
aria-label="Minute up"
|
||||
onClick={() => setTime(value => bumpTime(value, 'minutes', 15))}
|
||||
><Icon.caretUp /></button>
|
||||
<span className="ctp-time-cell">{time.slice(3)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ctp-time-step"
|
||||
aria-label="Minute down"
|
||||
onClick={() => setTime(value => bumpTime(value, 'minutes', -15))}
|
||||
><Icon.caretDown /></button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ctp-time-type"
|
||||
onClick={() => {
|
||||
setTimeDraft(time);
|
||||
setEditingTime(true);
|
||||
}}
|
||||
>Type a time</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="ctp-time-row ctp-day-row">
|
||||
<span className="ctp-day-label">Day</span>
|
||||
{days.map(day => (
|
||||
<button
|
||||
key={day.offset}
|
||||
className={`ctp-duration-btn ctp-day-btn ${dayOffset === day.offset ? 'is-active' : ''}`}
|
||||
onClick={() => setDayOffset(day.offset)}
|
||||
>{day.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ctp-create-hint">
|
||||
{scheduledTimeIsFuture
|
||||
? '24-hour clock. Check-in to ready up opens 15 min before start.'
|
||||
: 'Choose a time at least one minute from now.'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{when === 'now' && (
|
||||
<div className="ctp-create-row">
|
||||
<label className="ctp-create-label">Give people</label>
|
||||
<div className="ctp-duration-opts">
|
||||
{[5, 10, 15, 30, 60].map(minutes => (
|
||||
<button
|
||||
key={minutes}
|
||||
className={`ctp-duration-btn ${duration === minutes ? 'is-active' : ''}`}
|
||||
onClick={() => setDuration(minutes)}
|
||||
>{minutes}m</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="ctp-create-foot">
|
||||
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
|
||||
<button
|
||||
className="act-btn act-play"
|
||||
disabled={when === 'later' && !scheduledTimeIsFuture}
|
||||
onClick={() => onCreate(
|
||||
selected.id,
|
||||
maxPlayers,
|
||||
duration,
|
||||
when === 'later' ? scheduledFor : null,
|
||||
)}
|
||||
>
|
||||
{when === 'later'
|
||||
? `Schedule it — ${selected.name} · ${dayOffset > 0 ? `${days[dayOffset].label} ` : ''}${time}`
|
||||
: `Call it — ${selected.name}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!selected && (
|
||||
<div className="ctp-create-foot">
|
||||
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '../Icon';
|
||||
import { avatarColor, formatClock } from '../../lib/callToPlay';
|
||||
import { Nomination } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
nomination: Nomination;
|
||||
username: string;
|
||||
disabled: boolean;
|
||||
onSend: (text: string) => void;
|
||||
}
|
||||
|
||||
export const CtpChat = ({ nomination, username, disabled, onSend }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [seen, setSeen] = useState(nomination.messages.length);
|
||||
const [draft, setDraft] = useState('');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSeen(nomination.messages.length);
|
||||
}, [open, nomination.messages.length]);
|
||||
useEffect(() => {
|
||||
if (open && listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}, [open, nomination.messages.length]);
|
||||
|
||||
const unread = Math.max(0, nomination.messages.length - seen);
|
||||
const last = nomination.messages[nomination.messages.length - 1];
|
||||
const submit = () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
onSend(text);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`ctp-chat ${open ? 'is-open' : ''}`}>
|
||||
<button className="ctp-chat-toggle" onClick={() => setOpen(value => !value)}>
|
||||
<Icon.chat />
|
||||
<span>Chat</span>
|
||||
{nomination.messages.length > 0 && (
|
||||
<span className="ctp-chat-count">{nomination.messages.length}</span>
|
||||
)}
|
||||
{!open && unread > 0 && <span className="ctp-chat-unread">{unread}</span>}
|
||||
{!open && last && (
|
||||
<span className="ctp-chat-preview">
|
||||
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b> {last.text}
|
||||
</span>
|
||||
)}
|
||||
<span className="ctp-chat-chevron"><Icon.chevron /></span>
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="ctp-chat-list" ref={listRef}>
|
||||
{nomination.messages.length === 0 && (
|
||||
<div className="ctp-chat-empty">No messages yet — say hi.</div>
|
||||
)}
|
||||
{nomination.messages.map(message => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`ctp-chat-msg ${message.from === username ? 'is-me' : ''}`}
|
||||
>
|
||||
<b style={{ color: avatarColor(message.from) }}>{message.from}</b>
|
||||
<span className="ctp-chat-time">{formatClock(message.at)}</span>
|
||||
<span className="ctp-chat-text"> {message.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="ctp-chat-form">
|
||||
<input
|
||||
type="text"
|
||||
className="ctp-chat-input"
|
||||
placeholder="Message the group…"
|
||||
maxLength={500}
|
||||
value={draft}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') submit();
|
||||
}}
|
||||
/>
|
||||
<button className="ctp-chat-send" onClick={submit} aria-label="Send">
|
||||
<Icon.send />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '../Icon';
|
||||
import { GameCover } from '../grid/GameCover';
|
||||
import { CtpChat } from './CtpChat';
|
||||
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
||||
import {
|
||||
avatarColor,
|
||||
CHECKIN_LEAD_MS,
|
||||
formatClock,
|
||||
formatCountdown,
|
||||
formatCountdownShort,
|
||||
formatUntil,
|
||||
inCountOf,
|
||||
isReady,
|
||||
phaseOf,
|
||||
readyCountOf,
|
||||
} from '../../lib/callToPlay';
|
||||
import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
nomination: Nomination;
|
||||
game: Game;
|
||||
username: string;
|
||||
actions: CallToPlayActions;
|
||||
focused: boolean;
|
||||
thumbnailUrl?: string | null;
|
||||
totalPeerCount: number;
|
||||
onLaunch: (game: Game) => void;
|
||||
}
|
||||
|
||||
const AvatarChip = ({
|
||||
name,
|
||||
participant,
|
||||
now,
|
||||
}: {
|
||||
name: string;
|
||||
participant: CallToPlayParticipant;
|
||||
now: number;
|
||||
}) => {
|
||||
const ready = isReady(participant, now);
|
||||
const isIn = !ready && participant.status === 'in';
|
||||
const remaining = Math.max(0, (participant.readyAt ?? now) - now);
|
||||
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||||
return (
|
||||
<div
|
||||
className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`}
|
||||
title={`${name} — ${ready ? 'ready' : isIn ? 'in, not checked in' : `ready in ${formatCountdown(remaining)}`}`}
|
||||
>
|
||||
<span className="ctp-avatar-dot" style={{ background: avatarColor(name) }}>{initials}</span>
|
||||
{ready
|
||||
? <span className="ctp-avatar-check"><Icon.check /></span>
|
||||
: isIn
|
||||
? <span className="ctp-avatar-in">in</span>
|
||||
: <span className="ctp-avatar-pending">{formatCountdownShort(remaining)}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NominationCard = ({
|
||||
nomination,
|
||||
game,
|
||||
username,
|
||||
actions,
|
||||
focused,
|
||||
thumbnailUrl,
|
||||
totalPeerCount,
|
||||
onLaunch,
|
||||
}: Props) => {
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}, [focused]);
|
||||
|
||||
const now = Date.now();
|
||||
const entries = Object.entries(nomination.participants);
|
||||
const readyCount = readyCountOf(nomination, now);
|
||||
const inCount = inCountOf(nomination, now);
|
||||
const myStatus = nomination.participants[username];
|
||||
const isMe = myStatus !== undefined;
|
||||
const isCreator = nomination.creator === username;
|
||||
const isDone = nomination.state === 'done';
|
||||
const isStarted = nomination.state === 'started';
|
||||
const phase = phaseOf(nomination, now);
|
||||
const isScheduled = phase === 'scheduled' && !isDone && !isStarted;
|
||||
const isCheckin = phase === 'checkin' && !isDone && !isStarted;
|
||||
const windowStart = nomination.scheduledFor === null
|
||||
? nomination.createdAt
|
||||
: nomination.scheduledFor - CHECKIN_LEAD_MS;
|
||||
const remaining = Math.max(0, nomination.deadline - now);
|
||||
const percentage = Math.max(
|
||||
0,
|
||||
Math.min(100, (remaining / Math.max(1, nomination.deadline - windowStart)) * 100),
|
||||
);
|
||||
const urgency = percentage < 20 ? 'high' : percentage < 50 ? 'mid' : 'low';
|
||||
const installedCount = (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
|
||||
const lanCount = Math.max(totalPeerCount + 1, installedCount);
|
||||
|
||||
const timer = isStarted
|
||||
? <div className="ctp-card-timer" data-urgency="off">Launching…</div>
|
||||
: isDone
|
||||
? <div className="ctp-card-timer" data-urgency="off">Ready</div>
|
||||
: isScheduled
|
||||
? (
|
||||
<div className="ctp-card-timer is-sched" data-urgency="off">
|
||||
<span className="ctp-card-clock">{formatClock(nomination.scheduledFor!)}</span>
|
||||
<span className="ctp-card-until">{formatUntil(nomination.scheduledFor! - now)}</span>
|
||||
</div>
|
||||
)
|
||||
: isCheckin
|
||||
? (
|
||||
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
|
||||
<span className="ctp-card-clock">{formatCountdown(remaining)}</span>
|
||||
<span className="ctp-card-until">starts {formatClock(nomination.scheduledFor!)}</span>
|
||||
</div>
|
||||
)
|
||||
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
|
||||
|
||||
const rosterLabel = isScheduled
|
||||
? `${entries.length} in · up to ${nomination.maxPlayers} players`
|
||||
: isCheckin && inCount > 0
|
||||
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
|
||||
: `${readyCount}/${nomination.maxPlayers} ready`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`ctp-card ${isDone ? 'is-done' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
|
||||
>
|
||||
<div className="ctp-card-top">
|
||||
<div className="ctp-card-cover">
|
||||
<GameCover game={game} aspect="square" thumbnailUrl={thumbnailUrl} />
|
||||
</div>
|
||||
<div className="ctp-card-info">
|
||||
<div className="ctp-card-title">{game.name}</div>
|
||||
<div className="ctp-card-sub">
|
||||
{nomination.scheduledFor === null ? 'Called' : 'Scheduled'} by{' '}
|
||||
<strong>{nomination.creator}</strong>
|
||||
{nomination.scheduledFor !== null && ` · starts at ${formatClock(nomination.scheduledFor)}`}
|
||||
{` · ${installedCount}/${lanCount} peers have it installed`}
|
||||
</div>
|
||||
</div>
|
||||
{timer}
|
||||
</div>
|
||||
|
||||
{isCheckin && (
|
||||
<div className="ctp-checkin-note">
|
||||
<Icon.clock />
|
||||
<span>{isMe && myStatus.status === 'in'
|
||||
? 'Starting soon — you said you’re in. Check in below.'
|
||||
: 'Starting soon — check-in is open.'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isScheduled && (
|
||||
<div className="ctp-progress">
|
||||
<div
|
||||
className="ctp-progress-fill"
|
||||
style={{
|
||||
width: `${isStarted || isDone ? 100 : percentage}%`,
|
||||
background: isStarted || isDone ? 'var(--ok)' : 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctp-roster">
|
||||
<div className="ctp-roster-count">{rosterLabel}</div>
|
||||
<div className="ctp-avatars">
|
||||
{entries.map(([name, participant]) => (
|
||||
<AvatarChip key={name} name={name} participant={participant} now={now} />
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
|
||||
.map((_, index) => (
|
||||
<div key={index} className="ctp-avatar ctp-avatar-empty" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ctp-actions">
|
||||
<CardActions
|
||||
nomination={nomination}
|
||||
game={game}
|
||||
username={username}
|
||||
actions={actions}
|
||||
onLaunch={onLaunch}
|
||||
now={now}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CtpChat
|
||||
nomination={nomination}
|
||||
username={username}
|
||||
disabled={isStarted}
|
||||
onSend={text => actions.sendMessage(nomination.id, text)}
|
||||
/>
|
||||
|
||||
{isCreator && !isStarted && (
|
||||
confirmCancel
|
||||
? (
|
||||
<div className="ctp-cancel-confirm">
|
||||
<span>Cancel this call for everyone?</span>
|
||||
<div className="ctp-cancel-confirm-btns">
|
||||
<button
|
||||
className="ghost-btn ghost-danger"
|
||||
onClick={() => actions.cancel(nomination.id)}
|
||||
>Yes, cancel it</button>
|
||||
<button className="ghost-btn" onClick={() => setConfirmCancel(false)}>
|
||||
No, keep it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<button className="ctp-cancel-link" onClick={() => setConfirmCancel(true)}>
|
||||
<Icon.trash /><span>Cancel this call</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardActionsProps {
|
||||
nomination: Nomination;
|
||||
game: Game;
|
||||
username: string;
|
||||
actions: CallToPlayActions;
|
||||
onLaunch: (game: Game) => void;
|
||||
now: number;
|
||||
}
|
||||
|
||||
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
||||
nomination: Nomination;
|
||||
actions: CallToPlayActions;
|
||||
includeThirty?: boolean;
|
||||
}) => (
|
||||
<>
|
||||
<button className="act-btn act-play" onClick={() => actions.respond(nomination.id, 'ready')}>
|
||||
<Icon.play /><span>Ready now</span>
|
||||
</button>
|
||||
<div className="ctp-buffer-group">
|
||||
{[5, 10, 15, ...(includeThirty ? [30] : [])].map(minutes => (
|
||||
<button
|
||||
key={minutes}
|
||||
className="ctp-buffer-btn"
|
||||
onClick={() => actions.respond(nomination.id, minutes)}
|
||||
>+{minutes}m</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const CardActions = ({
|
||||
nomination,
|
||||
game,
|
||||
username,
|
||||
actions,
|
||||
onLaunch,
|
||||
now,
|
||||
}: CardActionsProps) => {
|
||||
const myStatus = nomination.participants[username];
|
||||
const isMe = myStatus !== undefined;
|
||||
const isCreator = nomination.creator === username;
|
||||
const isDone = nomination.state === 'done';
|
||||
const isStarted = nomination.state === 'started';
|
||||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
|
||||
const readyCount = readyCountOf(nomination, now);
|
||||
|
||||
if (isStarted) {
|
||||
return <div className="ctp-note ctp-note-launch">Launching {game.name}…</div>;
|
||||
}
|
||||
if (scheduled) {
|
||||
if (isCreator) {
|
||||
return (
|
||||
<div className="ctp-note">
|
||||
Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isMe) {
|
||||
return (
|
||||
<>
|
||||
<div className="ctp-me-status">You’re in — we’ll nudge you when check-in opens</div>
|
||||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||||
Can’t make it
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button className="act-btn act-play" onClick={() => actions.rsvp(nomination.id)}>
|
||||
<Icon.check /><span>I’m in</span>
|
||||
</button>
|
||||
<div className="ctp-note">Check-in opens 15 min before start</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (isCreator) {
|
||||
if (isDone) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="act-btn act-play"
|
||||
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
||||
if (accepted) onLaunch(game);
|
||||
})}
|
||||
><Icon.play /><span>Start now</span></button>
|
||||
<button className="ghost-btn" onClick={() => actions.addTime(nomination.id)}>
|
||||
Add 5 more minutes
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (myStatus?.status === 'in') {
|
||||
return <ReadyButtons nomination={nomination} actions={actions} />;
|
||||
}
|
||||
return <div className="ctp-note">Waiting for players to ready up…</div>;
|
||||
}
|
||||
if (isDone) {
|
||||
return (
|
||||
<div className="ctp-note">
|
||||
{readyCount >= nomination.maxPlayers
|
||||
? 'Everyone’s ready.'
|
||||
: `It’s time — waiting for ${nomination.creator} to start.`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isMe) {
|
||||
if (myStatus.status === 'in') {
|
||||
return (
|
||||
<>
|
||||
<div className="ctp-me-status">You said you’re in — check in:</div>
|
||||
<ReadyButtons nomination={nomination} actions={actions} />
|
||||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||||
Leave
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const ready = isReady(myStatus, now);
|
||||
return (
|
||||
<>
|
||||
<div className="ctp-me-status">
|
||||
{ready ? 'You’re in — ready' : `You: ready in ${formatCountdown((myStatus.readyAt ?? now) - now)}`}
|
||||
</div>
|
||||
{!ready && (
|
||||
<button className="ghost-btn" onClick={() => actions.respond(nomination.id, 'ready')}>
|
||||
I’m ready now
|
||||
</button>
|
||||
)}
|
||||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||||
Leave
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <ReadyButtons nomination={nomination} actions={actions} includeThirty />;
|
||||
};
|
||||
@@ -3,9 +3,10 @@ import { SegmentedFilters } from './SegmentedFilters';
|
||||
import { SearchField } from './SearchField';
|
||||
import { SortMenu } from './SortMenu';
|
||||
import { KebabMenu, KebabItem } from './KebabMenu';
|
||||
import { CallToPlayButton } from '../calltoplay/CallToPlayButton';
|
||||
|
||||
import { FilterCounts } from '../../lib/gameState';
|
||||
import { GameFilter, GameSort } from '../../lib/types';
|
||||
import { GameFilter, GameSort, Nomination } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
accent: string;
|
||||
@@ -18,6 +19,8 @@ interface Props {
|
||||
sort: GameSort;
|
||||
setSort: (value: GameSort) => void;
|
||||
kebabItems: ReadonlyArray<KebabItem>;
|
||||
nominations: ReadonlyArray<Nomination>;
|
||||
onOpenCallToPlay: () => void;
|
||||
}
|
||||
|
||||
export const TopBar = ({
|
||||
@@ -31,6 +34,8 @@ export const TopBar = ({
|
||||
sort,
|
||||
setSort,
|
||||
kebabItems,
|
||||
nominations,
|
||||
onOpenCallToPlay,
|
||||
}: Props) => (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
@@ -47,6 +52,7 @@ export const TopBar = ({
|
||||
<SortMenu value={sort} onChange={setSort} />
|
||||
</div>
|
||||
<div className="topbar-right-tail">
|
||||
<CallToPlayButton nominations={nominations} onClick={onOpenCallToPlay} />
|
||||
<KebabMenu items={kebabItems} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user