Room invite links: /join/<CODE> recruits with a card of its own
A living room's link unfurls as an invitation (waiting rooms beckon a seat, started games a gallery view) via the shared ogPage baker. The client follows the path: a held seat resumes, a waiting room offers join-or-watch from the lobby, a started game is watched at once. The address bar carries the invite link at any table, with a copy button beside the roster. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
e93bcc15a2
commit
691f44d858
@@ -147,19 +147,26 @@ function shareData(id: string): ShareData | null {
|
|||||||
const escapeHtml = (t: string) =>
|
const escapeHtml = (t: string) =>
|
||||||
t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
|
||||||
/** index.html with this share's OpenGraph card folded into its head —
|
/** index.html with a bespoke OpenGraph card folded into its head —
|
||||||
* crawlers never run the app, so the unfurl must arrive pre-baked. */
|
* crawlers never run the app, so the unfurl must arrive pre-baked. The
|
||||||
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
|
* stock page carries its own generic card; strip it, or crawlers (which
|
||||||
// Host and proto arrive from request headers — attacker-writable text
|
* take the FIRST tag they meet) never see this page's. */
|
||||||
// that must never reach an HTML attribute raw.
|
function ogPage(metas: string[]): string {
|
||||||
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
|
return readFileSync(join(staticRoot!, "index.html"), "utf8")
|
||||||
const host = escapeHtml(rawHost);
|
|
||||||
// The stock page carries its own generic card; strip it, or crawlers
|
|
||||||
// (which take the FIRST tag they meet) never see this turn's.
|
|
||||||
const html = readFileSync(join(staticRoot!, "index.html"), "utf8")
|
|
||||||
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
|
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
|
||||||
.replace(/<title>[^<]*<\/title>\s*/, "");
|
.replace(/<title>[^<]*<\/title>\s*/, "")
|
||||||
const base = `${proto}://${host}`;
|
.replace("</head>", ` ${metas.join("\n ")}\n </head>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host and proto arrive from request headers — attacker-writable text
|
||||||
|
* that must never reach an HTML attribute raw. */
|
||||||
|
function safeBase(rawHost: string, rawProto: string): string {
|
||||||
|
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
|
||||||
|
return `${proto}://${escapeHtml(rawHost)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
|
||||||
|
const base = safeBase(rawHost, rawProto);
|
||||||
const title = data.whole
|
const title = data.whole
|
||||||
? "The whole tale — a game of Wiz-War, replayed"
|
? "The whole tale — a game of Wiz-War, replayed"
|
||||||
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
|
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
|
||||||
@@ -179,8 +186,34 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin
|
|||||||
`<meta property="og:image:height" content="630"/>`,
|
`<meta property="og:image:height" content="630"/>`,
|
||||||
`<meta name="twitter:card" content="summary_large_image"/>`,
|
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||||
`<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`,
|
`<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`,
|
||||||
].join("\n ");
|
];
|
||||||
return html.replace("</head>", ` ${metas}\n </head>`);
|
return ogPage(metas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recruiting card for a /join/<code> link: an invitation while the
|
||||||
|
* room waits to start, a summons to the gallery once it has. */
|
||||||
|
function inviteHtml(room: Room, rawHost: string, rawProto: string): string {
|
||||||
|
const base = safeBase(rawHost, rawProto);
|
||||||
|
const seats = room.players.length;
|
||||||
|
const wizards = `${seats} wizard${seats === 1 ? "" : "s"}`;
|
||||||
|
const title = `You're summoned — Wiz-War room ${room.id}`;
|
||||||
|
const desc = room.state
|
||||||
|
? `The duel is underway, ${wizards} in the labyrinth. Follow the link to watch it live from the Peanut Gallery.`
|
||||||
|
: `${wizards} at the table, waiting to flip the boards. Follow the link, pick a name, and take a seat.`;
|
||||||
|
const metas = [
|
||||||
|
`<title>${title}</title>`,
|
||||||
|
`<meta property="og:type" content="website"/>`,
|
||||||
|
`<meta property="og:site_name" content="Wiz-War"/>`,
|
||||||
|
`<meta property="og:title" content="${title}"/>`,
|
||||||
|
`<meta property="og:description" content="${desc}"/>`,
|
||||||
|
`<meta property="og:url" content="${base}/join/${room.id}"/>`,
|
||||||
|
`<meta property="og:image" content="${base}/og.png"/>`,
|
||||||
|
`<meta property="og:image:width" content="1200"/>`,
|
||||||
|
`<meta property="og:image:height" content="630"/>`,
|
||||||
|
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||||
|
`<meta name="twitter:image" content="${base}/og.png"/>`,
|
||||||
|
];
|
||||||
|
return ogPage(metas);
|
||||||
}
|
}
|
||||||
const httpServer = createServer((req, res) => {
|
const httpServer = createServer((req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -233,6 +266,19 @@ const httpServer = createServer((req, res) => {
|
|||||||
res.end(shareHtml(watch[1]!, data, hostname, proto));
|
res.end(shareHtml(watch[1]!, data, hostname, proto));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Room invitations: a living room gets its recruiting card; a dead
|
||||||
|
// code falls through to the app, which reports it in the lobby.
|
||||||
|
const invite = url.match(/^\/join\/([A-Za-z0-9]{4})$/);
|
||||||
|
if (invite) {
|
||||||
|
const room = getRoom(invite[1]!);
|
||||||
|
if (room) {
|
||||||
|
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
|
||||||
|
const hostname = String(req.headers.host ?? `localhost:${port}`);
|
||||||
|
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
|
||||||
|
res.end(inviteHtml(room, hostname, proto));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
|
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
|
||||||
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
|
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
|
||||||
res.writeHead(403).end();
|
res.writeHead(403).end();
|
||||||
|
|||||||
@@ -23,6 +23,33 @@
|
|||||||
let name = $state(prefs.wizardName);
|
let name = $state(prefs.wizardName);
|
||||||
let joinCode = $state("");
|
let joinCode = $state("");
|
||||||
let claimPhrase = $state("");
|
let claimPhrase = $state("");
|
||||||
|
/** A /join/<CODE> recruiting link. The visitor lands looking at the
|
||||||
|
* room: a held seat walks them back to it, a waiting room offers a
|
||||||
|
* seat or the gallery, a started game is watched from the gallery. */
|
||||||
|
const inviteCode = location.pathname.match(/^\/join\/([A-Za-z0-9]{4})$/)?.[1]?.toUpperCase() ?? null;
|
||||||
|
if (inviteCode) joinCode = inviteCode;
|
||||||
|
let inviteFollowed = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (!inviteCode || inviteFollowed || net.status !== "connected" || net.roomId) return;
|
||||||
|
inviteFollowed = true;
|
||||||
|
const seat = net.seats.find((s) => s.roomId === inviteCode);
|
||||||
|
if (seat) net.resume(seat);
|
||||||
|
else net.watch(inviteCode);
|
||||||
|
});
|
||||||
|
/** The address bar holds the table's invite link while you sit at one,
|
||||||
|
* so recruiting is a copy away. */
|
||||||
|
$effect(() => {
|
||||||
|
const path = net.roomId ? `/join/${net.roomId}` : "/";
|
||||||
|
if (location.pathname !== path) history.replaceState(null, "", path + location.search);
|
||||||
|
});
|
||||||
|
const inviteLink = $derived(net.roomId ? `${location.origin}/join/${net.roomId}` : "");
|
||||||
|
let inviteCopied = $state(false);
|
||||||
|
function copyInvite() {
|
||||||
|
navigator.clipboard?.writeText(inviteLink).then(() => {
|
||||||
|
inviteCopied = true;
|
||||||
|
setTimeout(() => (inviteCopied = false), 1600);
|
||||||
|
});
|
||||||
|
}
|
||||||
let showHelp = $state(false);
|
let showHelp = $state(false);
|
||||||
/** The finished game whose victory fanfare has been dismissed. */
|
/** The finished game whose victory fanfare has been dismissed. */
|
||||||
let victorySeen = $state(false);
|
let victorySeen = $state(false);
|
||||||
@@ -1827,7 +1854,11 @@
|
|||||||
<section class="boxlid">
|
<section class="boxlid">
|
||||||
<div class="boxlid-inner">
|
<div class="boxlid-inner">
|
||||||
<div class="boxlid-title small">Room {net.roomId}</div>
|
<div class="boxlid-title small">Room {net.roomId}</div>
|
||||||
<div class="boxlid-tag">Share the code. Two to six wizards enter the maze.</div>
|
<div class="boxlid-tag">Share the code — or the link. Two to six wizards enter the maze.</div>
|
||||||
|
<div class="invite-row">
|
||||||
|
<code class="invite-link">{inviteLink}</code>
|
||||||
|
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy link"}</button>
|
||||||
|
</div>
|
||||||
<ul class="roster">
|
<ul class="roster">
|
||||||
{#each net.players as p (p)}
|
{#each net.players as p (p)}
|
||||||
{@const chosen = net.roomColors[p]}
|
{@const chosen = net.roomColors[p]}
|
||||||
@@ -1847,6 +1878,15 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{#if net.spectating}
|
{#if net.spectating}
|
||||||
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
|
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
|
||||||
|
{#if net.players.length < 6}
|
||||||
|
<div class="gallery-join">
|
||||||
|
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
|
||||||
|
<button class="stamp" disabled={!name.trim()}
|
||||||
|
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
|
||||||
|
…or take a seat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="standee-row" role="group" aria-label="choose your wizard">
|
<div class="standee-row" role="group" aria-label="choose your wizard">
|
||||||
{#each [0, 1, 2, 3, 4, 5] as c (c)}
|
{#each [0, 1, 2, 3, 4, 5] as c (c)}
|
||||||
@@ -2716,6 +2756,31 @@
|
|||||||
.roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
|
.roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
|
||||||
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
|
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
|
||||||
.waiting { color: #6b5a41; font-style: italic; }
|
.waiting { color: #6b5a41; font-style: italic; }
|
||||||
|
.invite-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.invite-link {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b5a41;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
.gallery-join {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.gallery-join input {
|
||||||
|
width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
.transfer-slip {
|
.transfer-slip {
|
||||||
max-width: 30rem;
|
max-width: 30rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user