Wording throughout the app, the server's messages and the engine's refusals trades its stacked dashes for sentences, while the maze still trusts you to honor its curses, the gallery still listens, and the clockwork still learns its best tricks from the humans at the table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
8.6 KiB
TypeScript
160 lines
8.6 KiB
TypeScript
// The public clip gallery: authored screenplay scenes recorded through
|
|
// the reel, each in two takes — through the wizard's eyes and from the
|
|
// board above. Standalone pages, no app bundle: a clip link must play
|
|
// anywhere a browser lands, and unfurl anywhere it's pasted.
|
|
import type { ClipMeta } from "./store.js";
|
|
|
|
const esc = (s: string) =>
|
|
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
|
|
const PAGE_CSS = `
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: #171a20; color: #d8d2c0; font-family: "Archivo Narrow", sans-serif; }
|
|
a { color: #e0b34a; }
|
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 1.4rem 1.25rem 3rem; }
|
|
.mast { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 1.6rem;
|
|
border-bottom: 1px solid rgba(233, 225, 203, 0.18); padding-bottom: 0.7rem; }
|
|
.mast a { font-family: "Oswald", sans-serif; text-transform: uppercase; letter-spacing: 0.18em;
|
|
font-size: 0.95rem; color: #e9e1cb; text-decoration: none; }
|
|
.mast .crumb { color: #8d8672; font-size: 0.85rem; letter-spacing: 0.06em; }
|
|
.mast .crumb a { font: inherit; letter-spacing: inherit; text-transform: none; color: inherit; }
|
|
.mast .deal { margin-left: auto; font-size: 0.8rem; letter-spacing: 0.12em; color: #e0b34a; }
|
|
h1 { font-family: "Oswald", sans-serif; font-weight: 500; font-size: 1.7rem; letter-spacing: 0.06em;
|
|
text-transform: uppercase; margin: 0 0 0.3rem; color: #e9e1cb; }
|
|
h1 a { color: inherit; text-decoration: none; }
|
|
p.sub { color: #a49c86; margin: 0 0 1.6rem; font-size: 1.05rem; max-width: 46rem; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.4rem; }
|
|
.card { background: #1f232d; border: 1px solid rgba(233, 225, 203, 0.18); border-radius: 8px;
|
|
overflow: hidden; text-decoration: none; color: inherit; display: block; }
|
|
.card:hover { border-color: #e0b34a; }
|
|
.card img { width: 100%; display: block; aspect-ratio: 736 / 577; object-fit: cover; }
|
|
.card .meta { padding: 0.7rem 0.9rem 0.9rem; }
|
|
.card .t { font-family: "Oswald", sans-serif; color: #e0b34a; letter-spacing: 0.06em; text-transform: uppercase; font-size: 0.95rem; }
|
|
.card .b { font-size: 0.95rem; line-height: 1.4; margin-top: 0.3rem; color: #b5b0a1; }
|
|
.card .s { float: right; color: #8d8672; font-size: 0.85rem; font-family: "Courier Prime", monospace; }
|
|
video { width: 100%; border-radius: 6px; display: block; background: #000; }
|
|
.takes { display: grid; gap: 1.5rem; margin-top: 1.4rem; }
|
|
.take h2 { font-family: "Oswald", sans-serif; font-size: 0.9rem; color: #e0b34a; font-weight: 500;
|
|
letter-spacing: 0.14em; text-transform: uppercase; margin: 0 0 0.5rem; }
|
|
.foot { margin-top: 2.2rem; color: #8d8672; font-size: 0.95rem; }
|
|
.share { font: inherit; font-size: 0.85rem; letter-spacing: 0.12em; text-transform: uppercase; cursor: pointer;
|
|
font-family: "Oswald", sans-serif; color: #43331f; background: #e9e1cb; border: 1.5px solid #43331f;
|
|
border-radius: 3px; padding: 0.4rem 1rem; margin: -0.6rem 0 0.4rem; }
|
|
.share:hover { background: #f2d27a; }
|
|
.share.done { background: #d8d2c0; }
|
|
`;
|
|
|
|
// The share button: the phone's own share sheet where there is one,
|
|
// the clipboard everywhere else. The page URL is the canonical link.
|
|
const SHARE_JS = `
|
|
var b = document.querySelector(".share");
|
|
b.addEventListener("click", function () {
|
|
var data = { title: b.dataset.title, text: b.dataset.text, url: location.origin + location.pathname };
|
|
if (navigator.share) { navigator.share(data).catch(function () {}); return; }
|
|
navigator.clipboard.writeText(data.url).then(function () {
|
|
b.textContent = "Link copied"; b.classList.add("done");
|
|
setTimeout(function () { b.textContent = "Share this clip"; b.classList.remove("done"); }, 1800);
|
|
});
|
|
});
|
|
`;
|
|
|
|
const shell = (metas: string[], body: string) => `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600&family=Archivo+Narrow:wght@400;600&family=Courier+Prime&display=swap" rel="stylesheet">
|
|
${metas.join("\n")}
|
|
<style>${PAGE_CSS}</style>
|
|
</head>
|
|
<body><div class="wrap">${body}</div></body>
|
|
</html>`;
|
|
|
|
/** The 1200x630 share card cut by deploy/clips-prep.mjs, with the
|
|
* dimensions that let Facebook show it on the very first share. Falls
|
|
* back to the poster on a set that was never prepped. */
|
|
function cardImageMetas(clip: ClipMeta, base: string): string[] {
|
|
const prepped = clip.width !== undefined;
|
|
const image = `${base}/clips/${clip.name}${prepped ? "-card" : ""}.jpg`;
|
|
return [
|
|
`<meta property="og:image" content="${image}"/>`,
|
|
...(prepped ? [
|
|
`<meta property="og:image:width" content="1200"/>`,
|
|
`<meta property="og:image:height" content="630"/>`,
|
|
] : []),
|
|
`<meta name="twitter:image" content="${image}"/>`,
|
|
];
|
|
}
|
|
|
|
export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
|
|
const metas = [
|
|
`<title>Wiz-War clips from the labyrinth</title>`,
|
|
`<meta property="og:type" content="website"/>`,
|
|
`<meta property="og:site_name" content="Wiz-War"/>`,
|
|
`<meta property="og:title" content="Wiz-War clips from the labyrinth"/>`,
|
|
`<meta property="og:description" content="Short scenes of magical combat in a stone maze, from wormhole heists and warp snipers to ambushes and falling walls, each seen through the wizard's own eyes and from the board above."/>`,
|
|
`<meta property="og:url" content="${base}/clips"/>`,
|
|
...(clips[0] ? cardImageMetas(clips[0], base) : []),
|
|
`<meta name="twitter:card" content="summary_large_image"/>`,
|
|
];
|
|
const cards = clips.map((c) => `
|
|
<a class="card" href="/clips/${c.name}">
|
|
<img src="/clips/${c.name}.jpg" alt="${esc(c.title)}" loading="lazy">
|
|
<div class="meta">
|
|
<span class="s">${c.seconds}s</span>
|
|
<span class="t">${esc(c.title)}</span>
|
|
<div class="b">${esc(c.blurb)}</div>
|
|
</div>
|
|
</a>`).join("\n");
|
|
const body = `
|
|
<nav class="mast"><a href="/">Wiz-War</a><span class="crumb">clips</span><a class="deal" href="/">deal yourself in →</a></nav>
|
|
<h1>Clips from the labyrinth</h1>
|
|
<p class="sub">Scenes of Wiz-War, played by the rules and filmed through the wizards' own eyes.
|
|
Each clip has a first-person take and a board take of the same moves.</p>
|
|
<div class="grid">${cards}</div>
|
|
<p class="foot">Wiz-War is Tom Jolly's classic of magical combat.
|
|
This is a fan-built table, and you can <a href="/">deal yourself in</a>.</p>`;
|
|
return shell(metas, body);
|
|
}
|
|
|
|
export function clipPageHtml(clip: ClipMeta, base: string): string {
|
|
const metas = [
|
|
`<title>${esc(clip.title)} · Wiz-War clip</title>`,
|
|
`<meta property="og:type" content="video.other"/>`,
|
|
`<meta property="og:site_name" content="Wiz-War"/>`,
|
|
`<meta property="og:title" content="${esc(clip.title)}"/>`,
|
|
`<meta property="og:description" content="${esc(clip.blurb)}"/>`,
|
|
`<meta property="og:url" content="${base}/clips/${clip.name}"/>`,
|
|
...cardImageMetas(clip, base),
|
|
`<meta property="og:video" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
|
|
`<meta property="og:video:secure_url" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
|
|
`<meta property="og:video:type" content="video/mp4"/>`,
|
|
...(clip.width && clip.height ? [
|
|
`<meta property="og:video:width" content="${clip.width}"/>`,
|
|
`<meta property="og:video:height" content="${clip.height}"/>`,
|
|
] : []),
|
|
`<meta name="twitter:card" content="summary_large_image"/>`,
|
|
];
|
|
const body = `
|
|
<nav class="mast"><a href="/">Wiz-War</a><span class="crumb"><a href="/clips">clips</a> / ${esc(clip.title)}</span><a class="deal" href="/">deal yourself in →</a></nav>
|
|
<h1><a href="/clips">${esc(clip.title)}</a></h1>
|
|
<p class="sub">${esc(clip.blurb)}</p>
|
|
<button class="share" type="button" data-title="${esc(clip.title)}" data-text="${esc(clip.blurb)}">Share this clip</button>
|
|
<div class="takes">
|
|
<div class="take">
|
|
<h2>Through the wizard's eyes</h2>
|
|
<video src="/clips/${clip.name}-fpv.mp4" poster="/clips/${clip.name}.jpg" controls playsinline></video>
|
|
</div>
|
|
<div class="take">
|
|
<h2>The same scene, from the board</h2>
|
|
<video src="/clips/${clip.name}-board.mp4" controls playsinline preload="metadata"></video>
|
|
</div>
|
|
</div>
|
|
<p class="foot">Played by the real rules engine, so every move on screen is a legal move.
|
|
<a href="/">Deal yourself in</a>.</p>
|
|
<script>${SHARE_JS}</script>`;
|
|
return shell(metas, body);
|
|
}
|