docs(launcher): add Call to Play specification and design reference
Extend the launcher design specification and prototype reference with the "Call to Play" multiplayer coordination feature and clean up top-bar chrome. Key additions and changes: - Spec & Roadmap: Document Call to Play mechanics in SPEC.md, including Play Now / Scheduled call flavors, 15-minute check-in windows, top-bar button with active count badge, quick-bar ticker stack above grid, overlay modal, and per-call group chat. Update design/README.md. - Components & Logic: Add calltoplay.jsx and ctp-chat.jsx components for call creation, status progression, and chat. Extend data.jsx with a mock peer roster (PEERS) and helper functions for peer install count tracking. - Top-bar Cleanup: Move game-folder configuration from top bar into Settings -> Library, freeing top-bar space for the Call to Play action button. - Styling & Layout: Add CTP quick-bar, ticker, badge, card, and chat styles to styles.css, and integrate CTP components into launcher.jsx, components.jsx, and SoftLAN Launcher.html. Test Plan: - `git diff --cached --check` -- passed (no trailing whitespace or conflict markers) - `cargo check --workspace` -- passed cleanly - Manual review of staged diff across 9 design/launcher files -- confirmed clean staging
This commit is contained in:
@@ -0,0 +1,814 @@
|
||||
// calltoplay.jsx — "Call to Play": rally the LAN around a game + a time.
|
||||
//
|
||||
// Two flavors of call, one feature:
|
||||
// · Play NOW — pick a game, max players, and how long to give people.
|
||||
// Others respond "Ready now" or "+N minutes". Resolves early if the
|
||||
// roster fills, or when the clock runs out — the CALLER starts it.
|
||||
// · SCHEDULED — pick a game and a clock time ("Among Us at 20:00").
|
||||
// People RSVP with "I'm in". 15 minutes before start the call enters
|
||||
// its CHECK-IN window: it gets highlighted everywhere and everyone
|
||||
// who's in is nudged to answer with the same ready / +N-minute states.
|
||||
//
|
||||
// Every call also carries a small group chat (see ctp-chat.jsx).
|
||||
//
|
||||
// Loaded after data.jsx + components.jsx + ctp-chat.jsx, before launcher.jsx.
|
||||
|
||||
const { useState, useEffect, useMemo, useRef, useLayoutEffect } = React;
|
||||
|
||||
const GAMES_ = window.GAMES;
|
||||
const PEERS_ = window.PEERS;
|
||||
const installedPeersFor_ = window.installedPeersFor;
|
||||
const parseMaxPlayers_ = window.parseMaxPlayers;
|
||||
const Icon_ = window.Icon;
|
||||
const GameCover_ = window.GameCover;
|
||||
const CtpChat_ = window.CtpChat;
|
||||
const avatarColor = window.ctpAvatarColor;
|
||||
const fmtClock = window.ctpFmtClock;
|
||||
|
||||
// Check-in opens this long before a scheduled call's start time — and any
|
||||
// call (scheduled or not) counts as "starting soon" inside this window.
|
||||
const CHECKIN_LEAD_MS = 15 * 60000;
|
||||
|
||||
function phaseOf(n, now) {
|
||||
if (!n.scheduledFor) return 'now';
|
||||
return now < n.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin';
|
||||
}
|
||||
|
||||
// Quick-bar status: READY > STARTING SOON > CALL TO PLAY / SCHEDULED.
|
||||
function statusOf(n, now) {
|
||||
if (n.state === 'started') return 'started';
|
||||
if (n.state === 'done' || readyCountOf(n, now) >= n.maxPlayers) return 'ready';
|
||||
if (n.deadline - now <= CHECKIN_LEAD_MS) return 'soon';
|
||||
return n.scheduledFor ? 'scheduled' : 'call';
|
||||
}
|
||||
|
||||
function fmtCountdown(ms) {
|
||||
if (ms <= 0) return '0:00';
|
||||
const total = Math.ceil(ms / 1000);
|
||||
const m = Math.floor(total / 60), s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
function fmtCountdownShort(ms) {
|
||||
if (ms <= 0) return 'now';
|
||||
const total = Math.ceil(ms / 1000);
|
||||
if (total < 60) return `${total}s`;
|
||||
return `${Math.ceil(total / 60)}m`;
|
||||
}
|
||||
function fmtUntil(ms) {
|
||||
if (ms <= 60000) return 'in <1 min';
|
||||
const min = Math.round(ms / 60000);
|
||||
if (min < 60) return `in ${min} min`;
|
||||
const h = Math.floor(min / 60), m = min % 60;
|
||||
return m ? `in ${h}h ${m}m` : `in ${h}h`;
|
||||
}
|
||||
function isReady(p, now) {
|
||||
if (!p) return false;
|
||||
return p.status === 'ready' || (p.readyAt != null && now >= p.readyAt);
|
||||
}
|
||||
function readyCountOf(n, now) {
|
||||
return Object.values(n.participants).filter(p => isReady(p, now)).length;
|
||||
}
|
||||
function inCountOf(n, now) {
|
||||
return Object.values(n.participants).filter(p => !isReady(p, now) && p.status === 'in').length;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Simulation — bots trickle into open calls (RSVP on scheduled ones,
|
||||
// ready-up during check-in / now-calls) and occasionally post a chat
|
||||
// line. Bots use second-scale buffers purely so the demo resolves
|
||||
// visibly; real people would use minute-scale ones.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
const CTP_BOT_LINES = [
|
||||
'anyone need the game files? I can seed',
|
||||
'gimme 5, grabbing food',
|
||||
'who hosts?',
|
||||
'voice is up — channel 2',
|
||||
'ok, back at my desk',
|
||||
'poke your neighbor, we need one more',
|
||||
'default settings, no mods — right?',
|
||||
'glhf',
|
||||
];
|
||||
|
||||
function maybeBotChat(n, now) {
|
||||
const msgs = n.messages || [];
|
||||
const speakers = Object.keys(n.participants).filter(p => PEERS_.includes(p));
|
||||
if (!speakers.length || msgs.length > 40 || Math.random() >= 0.03) return msgs;
|
||||
const from = speakers[Math.floor(Math.random() * speakers.length)];
|
||||
const text = CTP_BOT_LINES[Math.floor(Math.random() * CTP_BOT_LINES.length)];
|
||||
return [...msgs, { id: `bm${now}${Math.random().toString(36).slice(2, 6)}`, from, text, at: now }];
|
||||
}
|
||||
|
||||
function tickNomination(n, now) {
|
||||
if (n.state !== 'open') return n;
|
||||
const phase = phaseOf(n, now);
|
||||
let participants = n.participants;
|
||||
const joined = Object.keys(participants);
|
||||
const available = PEERS_.filter(p => p !== n.creator && !joined.includes(p));
|
||||
|
||||
if (phase === 'scheduled') {
|
||||
// RSVP trickle — far from start, people just say "I'm in".
|
||||
if (available.length && joined.length < n.maxPlayers && Math.random() < 0.06) {
|
||||
const bot = available[Math.floor(Math.random() * available.length)];
|
||||
participants = { ...participants, [bot]: { status: 'in', joinedAt: now } };
|
||||
}
|
||||
return { ...n, participants, messages: maybeBotChat({ ...n, participants }, now) };
|
||||
}
|
||||
|
||||
// Check-in window: RSVP'd bots convert to ready / "+N minutes".
|
||||
const inBots = joined.filter(p => PEERS_.includes(p) && participants[p].status === 'in');
|
||||
if (phase === 'checkin' && inBots.length && Math.random() < 0.22) {
|
||||
const bot = inBots[Math.floor(Math.random() * inBots.length)];
|
||||
participants = {
|
||||
...participants,
|
||||
[bot]: Math.random() < 0.6
|
||||
? { ...participants[bot], status: 'ready' }
|
||||
: { ...participants[bot], status: 'pending', readyAt: now + (10 + Math.random() * 70) * 1000 },
|
||||
};
|
||||
} else if (available.length && joined.length < n.maxPlayers && Math.random() < 0.10) {
|
||||
// Walk-ins can still join late.
|
||||
const bot = available[Math.floor(Math.random() * available.length)];
|
||||
const readyNow = Math.random() < 0.55;
|
||||
participants = {
|
||||
...participants,
|
||||
[bot]: readyNow
|
||||
? { status: 'ready', joinedAt: now }
|
||||
: { status: 'pending', joinedAt: now, readyAt: now + (10 + Math.random() * 70) * 1000 },
|
||||
};
|
||||
}
|
||||
const messages = maybeBotChat({ ...n, participants }, now);
|
||||
const readyCount = readyCountOf({ participants }, now);
|
||||
const state = (readyCount >= n.maxPlayers || now >= n.deadline) ? 'done' : 'open';
|
||||
return { ...n, participants, messages, state };
|
||||
}
|
||||
|
||||
function makeSeedNomination({ gameId, creator, maxPlayers, ageSec = 0, durationMin = 10, scheduledInMin = null, participants = {}, messages = [] }) {
|
||||
const now = Date.now() - ageSec * 1000;
|
||||
const scheduledFor = scheduledInMin != null ? Date.now() + scheduledInMin * 60000 : null;
|
||||
return {
|
||||
id: `seed-${gameId}-${creator}`,
|
||||
gameId, creator, maxPlayers,
|
||||
createdAt: now,
|
||||
scheduledFor,
|
||||
deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000,
|
||||
participants: { [creator]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now }, ...participants },
|
||||
messages: messages.map((m, i) => ({ id: `sm-${gameId}-${i}`, from: m.from, text: m.text, at: Date.now() - (m.agoSec || 0) * 1000 })),
|
||||
state: 'open',
|
||||
};
|
||||
}
|
||||
|
||||
function useNominations({ username, seed = [] }) {
|
||||
const [noms, setNoms] = useState(() => seed.map(makeSeedNomination));
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setNoms(prev => {
|
||||
const now = Date.now();
|
||||
return prev
|
||||
.map(n => tickNomination(n, now))
|
||||
.filter(n => !(n.state === 'started' && now - (n.startedAt || 0) > 2600));
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const createNomination = (gameId, maxPlayers, durationMin, scheduledFor = null) => {
|
||||
const now = Date.now();
|
||||
const nom = {
|
||||
id: `n${now}${Math.random().toString(36).slice(2, 7)}`,
|
||||
gameId, creator: username, maxPlayers,
|
||||
createdAt: now,
|
||||
scheduledFor,
|
||||
deadline: scheduledFor != null ? scheduledFor : now + durationMin * 60000,
|
||||
participants: { [username]: { status: scheduledFor ? 'in' : 'ready', joinedAt: now } },
|
||||
messages: [],
|
||||
state: 'open',
|
||||
};
|
||||
setNoms(prev => [nom, ...prev]);
|
||||
};
|
||||
|
||||
const respond = (id, choice) => {
|
||||
setNoms(prev => prev.map(n => {
|
||||
if (n.id !== id) return n;
|
||||
const now = Date.now();
|
||||
const participants = { ...n.participants };
|
||||
participants[username] = choice === 'ready'
|
||||
? { status: 'ready', joinedAt: (participants[username] || {}).joinedAt || now }
|
||||
: { status: 'pending', joinedAt: (participants[username] || {}).joinedAt || now, readyAt: now + choice * 60000 };
|
||||
return { ...n, participants };
|
||||
}));
|
||||
};
|
||||
|
||||
// RSVP for a scheduled call: "I'm in".
|
||||
const rsvp = (id) => {
|
||||
setNoms(prev => prev.map(n => n.id === id
|
||||
? { ...n, participants: { ...n.participants, [username]: { status: 'in', joinedAt: Date.now() } } }
|
||||
: n));
|
||||
};
|
||||
|
||||
const sendMessage = (id, text) => {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return;
|
||||
setNoms(prev => prev.map(n => n.id === id
|
||||
? { ...n, messages: [...(n.messages || []), { id: `m${Date.now()}${Math.random().toString(36).slice(2, 6)}`, from: username, text: t, at: Date.now() }] }
|
||||
: n));
|
||||
};
|
||||
|
||||
const leave = (id) => {
|
||||
setNoms(prev => prev.map(n => {
|
||||
if (n.id !== id) return n;
|
||||
const participants = { ...n.participants };
|
||||
delete participants[username];
|
||||
return { ...n, participants };
|
||||
}));
|
||||
};
|
||||
|
||||
const cancel = (id) => setNoms(prev => prev.filter(n => n.id !== id));
|
||||
const startNow = (id) => setNoms(prev => prev.map(n => n.id === id ? { ...n, state: 'started', startedAt: Date.now() } : n));
|
||||
const addTime = (id, minutes = 5) => setNoms(prev => prev.map(n => n.id === id ? { ...n, deadline: Date.now() + minutes * 60000, state: 'open' } : n));
|
||||
|
||||
return { nominations: noms, createNomination, respond, rsvp, sendMessage, leave, cancel, startNow, addTime };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Top-bar entry point
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
function CallToPlayButton({ nominations, onClick }) {
|
||||
const active = nominations.filter(n => n.state !== 'started');
|
||||
return (
|
||||
<button className="ctp-btn" onClick={onClick}>
|
||||
<Icon_.flag/>
|
||||
<span>Call to Play</span>
|
||||
{active.length > 0 && <span className="ctp-badge">{active.length}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Mini ready-bubbles — the per-player state, compressed for the
|
||||
// quick bars: green ring + check = ready, "+Nm" tag = needs minutes,
|
||||
// dimmed = RSVP'd but not checked in yet.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
function MiniBubbles({ nomination: n, now, max = 6 }) {
|
||||
const entries = Object.entries(n.participants);
|
||||
const shown = entries.slice(0, max);
|
||||
return (
|
||||
<span className="ctp-ticker-bubbles">
|
||||
{shown.map(([name, p]) => {
|
||||
const ready = isReady(p, now);
|
||||
const st = ready ? 'ready' : p.status === 'in' ? 'in' : 'pending';
|
||||
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||||
const title = ready ? `${name} — ready`
|
||||
: st === 'in' ? `${name} — in, not checked in yet`
|
||||
: `${name} — ready in ${fmtCountdownShort(p.readyAt - now)}`;
|
||||
return (
|
||||
<span key={name} className="ctp-mini" data-state={st} title={title} style={{ background: avatarColor(name) }}>
|
||||
{initials}
|
||||
{ready && <span className="ctp-mini-check"><Icon_.check/></span>}
|
||||
{st === 'pending' && <i className="ctp-mini-tag">{fmtCountdownShort(p.readyAt - now)}</i>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{entries.length > max && <span className="ctp-mini ctp-mini-more">+{entries.length - max}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Persistent quick bars — one row per active call, soonest first.
|
||||
// Scheduled calls show their clock time; once check-in opens the row
|
||||
// lights up to nudge everyone who said "I'm in".
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
const TICKER_LABEL = { scheduled: 'Scheduled', call: 'Call to Play', soon: 'Starting soon', ready: 'Ready' };
|
||||
// Sort rank: ready first, starting-soon next, everything else after —
|
||||
// ties broken by whichever resolves/starts soonest.
|
||||
const TICKER_RANK = { ready: 0, soon: 1, call: 2, scheduled: 2 };
|
||||
|
||||
function CallToPlayTickerRow({ nomination: n, accent, onOpen }) {
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []);
|
||||
const game = GAMES_.find(g => g.id === n.gameId);
|
||||
if (!game) return null;
|
||||
const now = Date.now();
|
||||
const status = statusOf(n, now);
|
||||
const ready = readyCountOf(n, now);
|
||||
const total = Object.keys(n.participants).length;
|
||||
const remaining = Math.max(0, n.deadline - now);
|
||||
const msgs = n.messages || [];
|
||||
const last = msgs[msgs.length - 1];
|
||||
|
||||
const count = status === 'scheduled' ? `${total} in` : `${ready}/${n.maxPlayers} ready`;
|
||||
const time = status === 'ready' ? 'waiting to start'
|
||||
: status === 'scheduled' ? `${fmtClock(n.scheduledFor)} · ${fmtUntil(n.scheduledFor - now)}`
|
||||
: n.scheduledFor ? `starts ${fmtClock(n.scheduledFor)} · ${fmtCountdown(remaining)}`
|
||||
: fmtCountdown(remaining);
|
||||
|
||||
return (
|
||||
<button className="ctp-ticker" data-status={status} style={{ '--accent': accent }} onClick={() => onOpen(n.id)}>
|
||||
<span className="ctp-ticker-dot" data-status={status}/>
|
||||
<span className="ctp-ticker-label" data-status={status}>{TICKER_LABEL[status]}</span>
|
||||
<span className="ctp-ticker-game">{game.title}</span>
|
||||
<span className="ctp-ticker-by">by {n.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={n} now={now}/>
|
||||
<span className="ctp-ticker-cta">{status === 'soon' ? 'Check in' : 'View'}<Icon_.chevron/></span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CallToPlayTicker({ nominations, accent, onOpen }) {
|
||||
const now = Date.now();
|
||||
const active = nominations.filter(n => n.state !== 'started')
|
||||
.sort((a, b) => TICKER_RANK[statusOf(a, now)] - TICKER_RANK[statusOf(b, now)] || a.deadline - b.deadline);
|
||||
if (!active.length) return null;
|
||||
return (
|
||||
<div className="ctp-ticker-stack">
|
||||
{active.map(n => <CallToPlayTickerRow key={n.id} nomination={n} accent={accent} onOpen={onOpen}/>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Avatar chip — ready / counting-down / RSVP'd ("in")
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
function AvatarChip({ name, participant, now }) {
|
||||
const ready = isReady(participant, now);
|
||||
const isIn = !ready && participant.status === 'in';
|
||||
const remaining = participant.readyAt ? Math.max(0, participant.readyAt - now) : 0;
|
||||
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||||
const title = ready ? `${name} — ready`
|
||||
: isIn ? `${name} — in, not checked in yet`
|
||||
: `${name} — ready in ${fmtCountdown(remaining)}`;
|
||||
return (
|
||||
<div className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`} title={title}>
|
||||
<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">{fmtCountdownShort(remaining)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Nomination card
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
function NominationCard({ nomination: n, username, accent, actions, focused }) {
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(id); }, []);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
|
||||
const game = GAMES_.find(g => g.id === n.gameId);
|
||||
if (!game) return null;
|
||||
const now = Date.now();
|
||||
const entries = Object.entries(n.participants);
|
||||
const readyCount = readyCountOf(n, now);
|
||||
const inCount = inCountOf(n, now);
|
||||
const isMe = username in n.participants;
|
||||
const myStatus = n.participants[username];
|
||||
const isCreator = n.creator === username;
|
||||
const isDone = n.state === 'done';
|
||||
const isStarted = n.state === 'started';
|
||||
const phase = phaseOf(n, now);
|
||||
const isSched = phase === 'scheduled' && !isDone && !isStarted;
|
||||
const isCheckin = phase === 'checkin' && !isDone && !isStarted;
|
||||
|
||||
const windowStart = n.scheduledFor ? n.scheduledFor - CHECKIN_LEAD_MS : n.createdAt;
|
||||
const totalMs = Math.max(1, n.deadline - windowStart);
|
||||
const remaining = Math.max(0, n.deadline - now);
|
||||
const pct = Math.max(0, Math.min(100, (remaining / totalMs) * 100));
|
||||
const urgency = pct < 20 ? 'high' : pct < 50 ? 'mid' : 'low';
|
||||
const peerCount = installedPeersFor_(game).length;
|
||||
|
||||
let actionsBody;
|
||||
if (isStarted) {
|
||||
actionsBody = <div className="ctp-note ctp-note-launch">Launching {game.title} for {n.maxPlayers} players…</div>;
|
||||
} else if (isSched) {
|
||||
if (isCreator) {
|
||||
actionsBody = <div className="ctp-note">Scheduled for {fmtClock(n.scheduledFor)} — check-in opens 15 min before start.</div>;
|
||||
} else if (isMe) {
|
||||
actionsBody = (
|
||||
<React.Fragment>
|
||||
<div className="ctp-me-status">You’re in — we’ll nudge you to check in 15 min before start</div>
|
||||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Can’t make it</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
actionsBody = (
|
||||
<React.Fragment>
|
||||
<button className="act-btn act-play" onClick={() => actions.rsvp(n.id)}><Icon_.check/><span>I’m in</span></button>
|
||||
<div className="ctp-note">Check-in opens 15 min before start</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
} else if (isCreator) {
|
||||
if (isDone) {
|
||||
actionsBody = (
|
||||
<React.Fragment>
|
||||
<button className="act-btn act-play" onClick={() => actions.startNow(n.id)}><Icon_.play/><span>Start now</span></button>
|
||||
<button className="ghost-btn" onClick={() => actions.addTime(n.id, 5)}>Add 5 more minutes</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else if (myStatus && myStatus.status === 'in') {
|
||||
actionsBody = (
|
||||
<React.Fragment>
|
||||
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
|
||||
<div className="ctp-buffer-group">
|
||||
{[5, 10, 15].map(min => (
|
||||
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
actionsBody = <div className="ctp-note">Waiting for players to ready up…</div>;
|
||||
}
|
||||
} else if (isDone) {
|
||||
actionsBody = <div className="ctp-note">{readyCount >= n.maxPlayers ? 'Everyone’s ready.' : `It’s time — waiting for ${n.creator} to start.`}</div>;
|
||||
} else if (isMe) {
|
||||
if (myStatus.status === 'in') {
|
||||
actionsBody = (
|
||||
<React.Fragment>
|
||||
<div className="ctp-me-status">You said you’re in — check in:</div>
|
||||
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
|
||||
<div className="ctp-buffer-group">
|
||||
{[5, 10, 15].map(min => (
|
||||
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Leave</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
const ready = isReady(myStatus, now);
|
||||
actionsBody = (
|
||||
<React.Fragment>
|
||||
<div className="ctp-me-status">{ready ? 'You’re in — ready' : `You: ready in ${fmtCountdown(myStatus.readyAt - now)}`}</div>
|
||||
{!ready && <button className="ghost-btn" onClick={() => actions.respond(n.id, 'ready')}>I’m ready now</button>}
|
||||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(n.id)}>Leave</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
actionsBody = (
|
||||
<React.Fragment>
|
||||
<button className="act-btn act-play" onClick={() => actions.respond(n.id, 'ready')}><Icon_.play/><span>Ready now</span></button>
|
||||
<div className="ctp-buffer-group">
|
||||
{[5, 10, 15, 30].map(min => (
|
||||
<button key={min} className="ctp-buffer-btn" onClick={() => actions.respond(n.id, min)}>+{min}m</button>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const timerEl = isStarted ? <div className="ctp-card-timer" data-urgency="off">Launching…</div>
|
||||
: isDone ? <div className="ctp-card-timer" data-urgency="off">Ready</div>
|
||||
: isSched ? (
|
||||
<div className="ctp-card-timer is-sched" data-urgency="off">
|
||||
<span className="ctp-card-clock">{fmtClock(n.scheduledFor)}</span>
|
||||
<span className="ctp-card-until">{fmtUntil(n.scheduledFor - now)}</span>
|
||||
</div>
|
||||
)
|
||||
: isCheckin ? (
|
||||
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
|
||||
<span className="ctp-card-clock">{fmtCountdown(remaining)}</span>
|
||||
<span className="ctp-card-until">starts {fmtClock(n.scheduledFor)}</span>
|
||||
</div>
|
||||
)
|
||||
: <div className="ctp-card-timer" data-urgency={urgency}>{fmtCountdown(remaining)}</div>;
|
||||
|
||||
const subEl = n.scheduledFor
|
||||
? <React.Fragment>Scheduled by <strong>{n.creator}</strong> · starts at {fmtClock(n.scheduledFor)} · {peerCount}/{PEERS_.length} peers have it installed</React.Fragment>
|
||||
: <React.Fragment>Called by <strong>{n.creator}</strong> · {peerCount}/{PEERS_.length} peers have it installed</React.Fragment>;
|
||||
|
||||
const rosterLabel = isSched
|
||||
? `${entries.length} in · up to ${n.maxPlayers} players`
|
||||
: isCheckin && inCount > 0
|
||||
? `${readyCount}/${n.maxPlayers} ready · ${inCount} not checked in yet`
|
||||
: `${readyCount}/${n.maxPlayers} ready`;
|
||||
|
||||
return (
|
||||
<div 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"/></div>
|
||||
<div className="ctp-card-info">
|
||||
<div className="ctp-card-title">{game.title}</div>
|
||||
<div className="ctp-card-sub">{subEl}</div>
|
||||
</div>
|
||||
{timerEl}
|
||||
</div>
|
||||
|
||||
{isCheckin && (
|
||||
<div className="ctp-checkin-note">
|
||||
<Icon_.clock/>
|
||||
<span>{isMe && myStatus && myStatus.status === 'in'
|
||||
? 'Starting soon — you said you’re in. Check in below.'
|
||||
: 'Starting soon — check-in is open.'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSched && (
|
||||
<div className="ctp-progress">
|
||||
<div className="ctp-progress-fill"
|
||||
style={{ width: `${isStarted || isDone ? 100 : pct}%`, background: isDone || isStarted ? 'var(--ok)' : accent }}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctp-roster">
|
||||
<div className="ctp-roster-count">{rosterLabel}</div>
|
||||
<div className="ctp-avatars">
|
||||
{entries.map(([name, p]) => <AvatarChip key={name} name={name} participant={p} now={now}/>)}
|
||||
{Array.from({ length: Math.max(0, n.maxPlayers - entries.length) }).map((_, i) => (
|
||||
<div key={`empty${i}`} className="ctp-avatar ctp-avatar-empty"/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ctp-actions">{actionsBody}</div>
|
||||
|
||||
<CtpChat_ nomination={n} username={username} disabled={isStarted}
|
||||
onSend={(text) => actions.sendMessage(n.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(n.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>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Create-nomination form — "Now" or a scheduled clock time
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
function CreateNominationForm({ accent, onCancel, onCreate }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [gameId, setGameId] = useState(null);
|
||||
const [maxPlayers, setMaxPlayers] = useState(8);
|
||||
const [duration, setDuration] = useState(10);
|
||||
const [when, setWhen] = useState('now');
|
||||
const [dayOffset, setDayOffset] = useState(0);
|
||||
const nextSlot = () => {
|
||||
const d = new Date(Date.now() + 40 * 60000);
|
||||
d.setMinutes(d.getMinutes() <= 30 ? 30 : 60, 0, 0);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
const [timeStr, setTimeStr] = useState(nextSlot); // committed "HH:MM" (24h)
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [timeDraft, setTimeDraft] = useState(timeStr);
|
||||
const timeInputRef = useRef(null);
|
||||
|
||||
// Select-all when the type-a-time field appears, so the user can overtype
|
||||
// immediately. Done in a layout effect (not onFocus+autoFocus) so it fires
|
||||
// after the browser places the autofocus caret, and re-asserts if the
|
||||
// per-second nominations tick re-renders the overlay.
|
||||
useLayoutEffect(() => {
|
||||
if (editing && timeInputRef.current) {
|
||||
timeInputRef.current.focus();
|
||||
timeInputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const [hStr, mStr] = timeStr.split(':');
|
||||
|
||||
// Nudge the time by whole hours or minute-steps, wrapping within 0–23:59.
|
||||
const bump = (field, delta) => {
|
||||
let [h, m] = timeStr.split(':').map(Number);
|
||||
if (field === 'h') h = (h + delta + 24) % 24;
|
||||
else {
|
||||
let total = h * 60 + m + delta;
|
||||
total = ((total % 1440) + 1440) % 1440;
|
||||
h = Math.floor(total / 60); m = total % 60;
|
||||
}
|
||||
setTimeStr(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
// Constrain free-text entry: digits + at most one colon, ≤2 digits per side
|
||||
// (≤5 chars with a colon), ≤4 digits without one. Anything else is rejected
|
||||
// (returns the previous draft) — so a colon can't be typed after 3 digits.
|
||||
const sanitizeTime = (raw, prev) => {
|
||||
const v = String(raw).replace(/[^\d:]/g, '');
|
||||
if ((v.match(/:/g) || []).length > 1) return prev;
|
||||
const ci = v.indexOf(':');
|
||||
if (ci !== -1) {
|
||||
if (v.slice(0, ci).length > 2 || v.slice(ci + 1).length > 2) return prev;
|
||||
return v;
|
||||
}
|
||||
return v.length > 4 ? prev : v;
|
||||
};
|
||||
|
||||
// Accepts "20:00", "2000", "20", "9:30" — normalizes or reverts.
|
||||
const commitDraft = () => {
|
||||
const mm = String(timeDraft).trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/);
|
||||
if (mm) {
|
||||
const h = Number(mm[1]), min = Number(mm[2] || 0);
|
||||
if (h <= 23 && min <= 59) setTimeStr(`${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const DE_DAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
const dayChips = [0, 1, 2].map(off => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + off);
|
||||
const label = off === 0 ? 'Today' : DE_DAYS[d.getDay()];
|
||||
return { off, label };
|
||||
});
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
const q = query.toLowerCase();
|
||||
return GAMES_.filter(g => g.title.toLowerCase().includes(q)).slice(0, 6);
|
||||
}, [query]);
|
||||
|
||||
const selected = GAMES_.find(g => g.id === gameId);
|
||||
|
||||
const pickGame = (g) => {
|
||||
setGameId(g.id);
|
||||
setMaxPlayers(parseMaxPlayers_(g.players));
|
||||
setQuery(g.title);
|
||||
};
|
||||
|
||||
const scheduledTs = () => {
|
||||
const [h, m] = timeStr.split(':').map(Number);
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + dayOffset);
|
||||
d.setHours(h || 0, m || 0, 0, 0);
|
||||
if (d.getTime() < Date.now() + 60000) d.setDate(d.getDate() + 1);
|
||||
return d.getTime();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctp-create">
|
||||
<div className="ctp-create-row">
|
||||
<label className="ctp-create-label">Game</label>
|
||||
<div className="search ctp-create-search" style={{ '--accent': accent }}>
|
||||
<Icon_.search/>
|
||||
<input type="text" placeholder="Search the catalog…" value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setGameId(null); }}/>
|
||||
</div>
|
||||
{!selected && query.trim() && (
|
||||
<div className="ctp-create-matches">
|
||||
{matches.map(g => (
|
||||
<button key={g.id} onClick={() => pickGame(g)}>
|
||||
<span>{g.title}</span>
|
||||
<span className="ctp-create-match-meta">{g.players} players</span>
|
||||
</button>
|
||||
))}
|
||||
{matches.length === 0 && <div className="ctp-create-nomatch">No games match "{query}"</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<React.Fragment>
|
||||
<div className="ctp-create-row ctp-create-row-inline">
|
||||
<label className="ctp-create-label">Max players</label>
|
||||
<input type="number" className="ctp-create-num" min={2} max={64} value={maxPlayers}
|
||||
onChange={(e) => setMaxPlayers(Math.max(2, Number(e.target.value) || 2))}/>
|
||||
<span className="ctp-create-hint">Suggested for {selected.title}: {parseMaxPlayers_(selected.players)}</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' : ''}`}
|
||||
style={when === 'now' ? { background: accent, borderColor: accent } : undefined}
|
||||
onClick={() => setWhen('now')}>Now</button>
|
||||
<button className={`ctp-duration-btn ${when === 'later' ? 'is-active' : ''}`}
|
||||
style={when === 'later' ? { background: accent, borderColor: accent } : undefined}
|
||||
onClick={() => setWhen('later')}>Schedule</button>
|
||||
</div>
|
||||
{when === 'later' && (
|
||||
<React.Fragment>
|
||||
<div className="ctp-timepick">
|
||||
{editing ? (
|
||||
<input ref={timeInputRef} type="text" inputMode="numeric" maxLength={5} placeholder="20:00"
|
||||
className="ctp-time-editinput" value={timeDraft}
|
||||
onChange={(e) => setTimeDraft(prev => sanitizeTime(e.target.value, prev))}
|
||||
onBlur={commitDraft}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitDraft(); if (e.key === 'Escape') setEditing(false); }}/>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<div className="ctp-timepick-field">
|
||||
<button type="button" className="ctp-time-step" aria-label="Hour up" onClick={() => bump('h', 1)}><Icon_.caretUp/></button>
|
||||
<span className="ctp-time-cell">{hStr}</span>
|
||||
<button type="button" className="ctp-time-step" aria-label="Hour down" onClick={() => bump('h', -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={() => bump('m', 15)}><Icon_.caretUp/></button>
|
||||
<span className="ctp-time-cell">{mStr}</span>
|
||||
<button type="button" className="ctp-time-step" aria-label="Minute down" onClick={() => bump('m', -15)}><Icon_.caretDown/></button>
|
||||
</div>
|
||||
<button type="button" className="ctp-time-type"
|
||||
onClick={() => { setTimeDraft(timeStr); setEditing(true); }}>Type a time</button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
<div className="ctp-time-row ctp-day-row">
|
||||
<span className="ctp-day-label">Day</span>
|
||||
{dayChips.map(c => (
|
||||
<button key={c.off} className={`ctp-duration-btn ctp-day-btn ${dayOffset === c.off ? 'is-active' : ''}`}
|
||||
style={dayOffset === c.off ? { background: accent, borderColor: accent } : undefined}
|
||||
onClick={() => setDayOffset(c.off)}>{c.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ctp-create-hint">24-hour clock. People RSVP with “I’m in” — check-in to ready up opens 15 min before start.</span>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</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(min => (
|
||||
<button key={min} className={`ctp-duration-btn ${duration === min ? 'is-active' : ''}`}
|
||||
style={duration === min ? { background: accent, borderColor: accent } : undefined}
|
||||
onClick={() => setDuration(min)}>{min}m</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="ctp-create-foot">
|
||||
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
|
||||
<button className="act-btn act-play"
|
||||
onClick={() => onCreate(selected.id, maxPlayers, duration, when === 'later' ? scheduledTs() : null)}>
|
||||
{when === 'later'
|
||||
? `Schedule it — ${selected.title} · ${dayOffset > 0 ? `${dayChips[dayOffset].label} ` : ''}${timeStr}`
|
||||
: `Call it — ${selected.title}`}
|
||||
</button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{!selected && (
|
||||
<div className="ctp-create-foot">
|
||||
<button className="ghost-btn" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Full overlay
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
function CallToPlayOverlay({ nominations, username, accent, onClose, actions, focusId }) {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const sorted = [...nominations].sort((a, b) => (a.state === 'started' ? 1 : 0) - (b.state === 'started' ? 1 : 0) || a.deadline - b.deadline);
|
||||
|
||||
return (
|
||||
<div className="modal-scrim" onClick={onClose}>
|
||||
<div className="modal ctp-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<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" onClick={() => setShowCreate(true)}>
|
||||
<Icon_.flag/><span>Call a new match</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="ctp-body">
|
||||
{showCreate && (
|
||||
<CreateNominationForm accent={accent}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onCreate={(gid, max, dur, sched) => { actions.createNomination(gid, max, dur, sched); setShowCreate(false); }}/>
|
||||
)}
|
||||
{sorted.length === 0 && !showCreate && (
|
||||
<div className="ctp-empty">No active calls right now — be the one to start something.</div>
|
||||
)}
|
||||
{sorted.map(n => (
|
||||
<NominationCard key={n.id} nomination={n} username={username} accent={accent} actions={actions}
|
||||
focused={n.id === focusId}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
useNominations, makeSeedNomination,
|
||||
CallToPlayButton, CallToPlayTicker, CallToPlayOverlay,
|
||||
NominationCard, CreateNominationForm,
|
||||
});
|
||||
Reference in New Issue
Block a user