@homie-rocks/studio 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -8
- package/bin/homie-studio.mjs +90 -13
- package/lib/build.mjs +38 -1
- package/lib/check.mjs +109 -48
- package/lib/chrome.mjs +126 -0
- package/lib/cloudflare.mjs +85 -16
- package/lib/look.mjs +8 -12
- package/lib/media.mjs +8 -1
- package/lib/port-check.mjs +8 -13
- package/lib/progress.mjs +115 -6
- package/lib/scaffold.mjs +131 -22
- package/lib/setup.mjs +90 -0
- package/lib/site.mjs +8 -1
- package/lib/studio.mjs +11 -0
- package/lib/template-history.json +180 -0
- package/lib/upgrade.mjs +285 -0
- package/lib/version.mjs +7 -3
- package/media/MEDIA.md +13 -0
- package/package.json +1 -1
- package/site/SITE.md +51 -6
- package/worker/index.mjs +125 -25
- package/worker/pages.mjs +67 -11
- package/worker/site.mjs +64 -19
- package/worker/stats.mjs +17 -0
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
* /<game>/__net?room= the room's netplay socket (Table Durable Object)
|
|
21
21
|
* /<game>/__watch?room= the room's facts, for the shell
|
|
22
22
|
* /<game>/api/lobby which public room to join (Lobby Durable Object)
|
|
23
|
-
* /api/games, /api/rooms the catalogue plus live counts; every public room playing now
|
|
23
|
+
* /api/games, /api/rooms the catalogue plus live counts; every public room playing now (/api/rooms may be
|
|
24
|
+
* cached for 15 s)
|
|
25
|
+
* /__homie/..., /<game>/__homie/... `not-a-homie`: the answer a game with Homie's arcade controls gets when it
|
|
26
|
+
* knocks for a Homie box, so it stops knocking (a studio site is not a box)
|
|
24
27
|
* /.well-known/homie-studio.json what the homie.rocks directory reads
|
|
25
28
|
* /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
|
|
26
29
|
* /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
|
|
@@ -49,10 +52,10 @@ import { ROOM_ID, badRoomPage, frameAncestors, playPage } from './pages.mjs';
|
|
|
49
52
|
import { qrSvg } from './qr.mjs';
|
|
50
53
|
import { SEAT_MAX, perAddress, seatsOf } from './seats.mjs';
|
|
51
54
|
import {
|
|
52
|
-
SITE_JS, atomFeed, creditsPage, customPage, gameLanding, gamesPage, homePage, jsonFeed,
|
|
53
|
-
postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage,
|
|
55
|
+
SITE_JS, atomFeed, creditsPage, customPage, gameCover, gameLanding, gamesPage, homePage, jsonFeed, mediaArt, mediaIndexPage,
|
|
56
|
+
notFoundPage, postPage, postsPage, roomView, roomsPage, sectionsOf, songPage, videoPage,
|
|
54
57
|
} from './site.mjs';
|
|
55
|
-
import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
|
|
58
|
+
import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
|
|
56
59
|
import { ownerRoutes } from './stats-page.mjs';
|
|
57
60
|
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
58
61
|
|
|
@@ -87,9 +90,71 @@ async function postsOf(env, origin) {
|
|
|
87
90
|
} catch { return []; }
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
/*
|
|
94
|
+
* THE SITE CLAIMS ITSELF IN THE DIRECTORY (0.10.0). The homie.rocks directory lists a studio only when its site
|
|
95
|
+
* serves the claim the directory handed out for that exact address, which proves the studio controls the site.
|
|
96
|
+
* Before 0.10.0 `homie-studio deploy` fetched the claim and stored it in D1 from the person's computer; a site built
|
|
97
|
+
* by Cloudflare's Workers Builds has no such step. So the site asks for its own claim the first time its manifest
|
|
98
|
+
* is read (by the directory publishing it, or `deploy` reading it once), for the address it is being read at, and
|
|
99
|
+
* keeps it in its D1 (`meta`, `homie_claim:<origin>`).
|
|
100
|
+
*
|
|
101
|
+
* It never asks from a Preview (HOMIE_PREVIEW: a Preview is a branch under review, not the studio), from a studio
|
|
102
|
+
* whose studio.json says `homie.directory: false`, or without its D1. A directory that does not answer is asked
|
|
103
|
+
* again at most once a minute per address; the page answers without a claim meanwhile.
|
|
104
|
+
*/
|
|
105
|
+
const CLAIM = /^[a-f0-9]{16,128}$/;
|
|
106
|
+
const claimed = new Map();
|
|
107
|
+
const claimMissed = new Map();
|
|
108
|
+
|
|
109
|
+
async function storedClaim(env, origin) {
|
|
91
110
|
if (!env.DB) return null;
|
|
92
|
-
try {
|
|
111
|
+
try {
|
|
112
|
+
const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2)').bind(`homie_claim:${origin}`, 'homie_claim').all()).results ?? [];
|
|
113
|
+
return { own: rows.find((r) => r.key === `homie_claim:${origin}`)?.value ?? null, older: rows.find((r) => r.key === 'homie_claim')?.value ?? null };
|
|
114
|
+
} catch { return null; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function directoryOf(cat) {
|
|
118
|
+
const d = cat?.studio?.directory;
|
|
119
|
+
if (d === null || d === false) return null;
|
|
120
|
+
const at = String(d ?? 'https://homie.rocks').replace(/\/+$/, '');
|
|
121
|
+
try {
|
|
122
|
+
const u = new URL(at);
|
|
123
|
+
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname)) ? u.origin : null;
|
|
124
|
+
} catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
|
|
128
|
+
async function claimOf(env, cat, url) {
|
|
129
|
+
const origin = url.origin;
|
|
130
|
+
if (claimed.has(origin)) return claimed.get(origin);
|
|
131
|
+
const kept = await storedClaim(env, origin);
|
|
132
|
+
if (kept?.own) { claimed.set(origin, kept.own); return kept.own; }
|
|
133
|
+
const directory = directoryOf(cat);
|
|
134
|
+
const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
|
|
135
|
+
const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
|
|
136
|
+
&& Date.now() - (claimMissed.get(origin) ?? 0) > 60_000;
|
|
137
|
+
if (may) {
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {
|
|
140
|
+
headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION_TAG} (site claim)` }, signal: AbortSignal.timeout(5000),
|
|
141
|
+
});
|
|
142
|
+
const body = await res.json().catch(() => null);
|
|
143
|
+
if (res.ok && CLAIM.test(String(body?.claim ?? ''))) {
|
|
144
|
+
await env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim).run();
|
|
145
|
+
claimed.set(origin, body.claim);
|
|
146
|
+
return body.claim;
|
|
147
|
+
}
|
|
148
|
+
} catch { /* the directory is optional for a live site */ }
|
|
149
|
+
claimMissed.set(origin, Date.now());
|
|
150
|
+
}
|
|
151
|
+
return kept?.older ?? null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A copy of the public template nobody has named yet shows the name typed into Cloudflare's form (STUDIO_NAME). */
|
|
155
|
+
function named(cat, env) {
|
|
156
|
+
if (!cat?.studio?.template || !env.STUDIO_NAME) return cat;
|
|
157
|
+
return { ...cat, studio: { ...cat.studio, name: String(env.STUDIO_NAME).slice(0, 60) } };
|
|
93
158
|
}
|
|
94
159
|
|
|
95
160
|
/** Every public room with people in it, per game (the Lobby's list), and the number playing per game. */
|
|
@@ -163,18 +228,34 @@ async function mediaObject(request, env, key) {
|
|
|
163
228
|
return new Response(obj.body, { headers });
|
|
164
229
|
}
|
|
165
230
|
|
|
166
|
-
/**
|
|
167
|
-
|
|
231
|
+
/**
|
|
232
|
+
* A catalogue media row as the directory reads it (absolute addresses). A song's cover (a video's poster) is its
|
|
233
|
+
* own, else the manifest's default, else the landing still of the game it was made for (site.mjs mediaArt).
|
|
234
|
+
*/
|
|
235
|
+
function mediaRow(e, origin, kind, cat) {
|
|
168
236
|
const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u ?? null);
|
|
169
237
|
const main = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'audio' : 'video'));
|
|
170
|
-
const art = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'cover' : 'poster'));
|
|
171
238
|
return {
|
|
172
239
|
slug: e.slug, kind: e.kind, title: e.title, blurb: e.blurb ?? '', duration: e.duration ?? null,
|
|
173
|
-
page: `${origin}/${kind}/${e.slug}/`, [kind === 'music' ? 'audio' : 'video']: abs(main?.url), [kind === 'music' ? 'cover' : 'poster']: abs(
|
|
240
|
+
page: `${origin}/${kind}/${e.slug}/`, [kind === 'music' ? 'audio' : 'video']: abs(main?.url), [kind === 'music' ? 'cover' : 'poster']: abs(mediaArt(e, kind, cat)),
|
|
174
241
|
credits: e.credits ?? null, rights: e.rights ?? null, for: e.for ?? null,
|
|
175
242
|
};
|
|
176
243
|
}
|
|
177
244
|
|
|
245
|
+
/**
|
|
246
|
+
* THE ARCADE KNOCK, ANSWERED AT THE ROOT. A game made with Homie's arcade controls (@homie-rocks/arcade, and the
|
|
247
|
+
* older builds of it that Homie's own games carry) asks its page's own origin for a Homie box with a GET on
|
|
248
|
+
* /__homie/call, and a score goes there as a POST. The game's frame is /<game>/__game/, so that absolute path lands
|
|
249
|
+
* on the site's ROOT, not under the game. `not-a-homie` (JSON, CORS-open, because the frame is an opaque origin) makes the knock stop for
|
|
250
|
+
* good and refuses a call in words; it answers 200, so no browser logs a failed load for it, and a studio needs no
|
|
251
|
+
* wrapper Worker of its own for this. A preflight (a POST's content-type) is answered too.
|
|
252
|
+
*/
|
|
253
|
+
const KNOCK_CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400' };
|
|
254
|
+
function notAHomie(request) {
|
|
255
|
+
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...KNOCK_CORS, 'cache-control': 'no-store' } });
|
|
256
|
+
return json({ ok: false, error: 'not-a-homie', message: 'this is a studio site, not a Homie box; a game here plays on its own' }, 200, KNOCK_CORS);
|
|
257
|
+
}
|
|
258
|
+
|
|
178
259
|
/** The game's index.html with HOMIE_NET ahead of its modules, in the site's sandbox (an opaque origin). */
|
|
179
260
|
async function gameDocument(request, env, url, game, meta, cat) {
|
|
180
261
|
const asked = url.searchParams.get('room');
|
|
@@ -284,9 +365,16 @@ async function route(request, env, ctx) {
|
|
|
284
365
|
const parts = path.split('/').filter(Boolean);
|
|
285
366
|
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
286
367
|
let catP = null;
|
|
287
|
-
const getCat = () => (catP ??= catalogue(env, url.origin));
|
|
368
|
+
const getCat = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
|
|
288
369
|
|
|
370
|
+
// "Connect to Claude" (the template's first-run band): the directory's setup page, for this site's address.
|
|
371
|
+
if (path === '/_studio/connect' && read) {
|
|
372
|
+
const directory = directoryOf(await getCat());
|
|
373
|
+
if (!directory || env.HOMIE_PREVIEW === '1') return json({ ok: false, error: 'no-directory', message: 'This studio is not connected to a directory.' }, 404);
|
|
374
|
+
return Response.redirect(`${directory}/studio/setup/connect?site=${encodeURIComponent(url.origin)}`, 302);
|
|
375
|
+
}
|
|
289
376
|
if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
|
|
377
|
+
if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
|
|
290
378
|
if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
291
379
|
if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
|
|
292
380
|
if (path === '/api/stats') {
|
|
@@ -304,7 +392,10 @@ async function route(request, env, ctx) {
|
|
|
304
392
|
}
|
|
305
393
|
if (path === '/.well-known/homie-studio.json') {
|
|
306
394
|
const cat = await getCat();
|
|
307
|
-
const
|
|
395
|
+
const sharing = Boolean(cat.studio?.stats?.share);
|
|
396
|
+
const [played, byGame] = sharing ? await Promise.all([playedThisWeek(env), playedByGame(env)]) : [null, null];
|
|
397
|
+
// studio.json `"rooms": { "share": false }` keeps the rooms off the hub: a manifest that names no `rooms` shares none.
|
|
398
|
+
const shareRooms = cat.studio?.rooms?.share !== false;
|
|
308
399
|
return json({
|
|
309
400
|
v: 1,
|
|
310
401
|
kind: 'homie-studio',
|
|
@@ -313,16 +404,25 @@ async function route(request, env, ctx) {
|
|
|
313
404
|
...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
|
|
314
405
|
site: url.origin,
|
|
315
406
|
studio: cat.studio?.version ?? null,
|
|
316
|
-
claim: await claimOf(env),
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
407
|
+
claim: await claimOf(env, cat, url),
|
|
408
|
+
// What is deployed: the commit, when, and the marks of the newest changes (a merged pull request is live when
|
|
409
|
+
// its mark is here). A Preview says it is one.
|
|
410
|
+
...(cat.studio?.build ? { build: { commit: cat.studio.build.commit ?? null, branch: cat.studio.build.branch ?? null, at: cat.studio.build.at ?? null, changes: (cat.studio.build.changes ?? []).slice(0, 50), ...(env.HOMIE_PREVIEW === '1' ? { preview: true } : {}) } } : {}),
|
|
411
|
+
games: (cat.games ?? []).map((g) => {
|
|
412
|
+
// The card picture is the landing's hero still (what the landing leads with), else the game's cover.
|
|
413
|
+
const cover = gameCover(g);
|
|
414
|
+
return {
|
|
415
|
+
id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
|
|
416
|
+
page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
|
|
417
|
+
// Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
|
|
418
|
+
...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
|
|
419
|
+
};
|
|
420
|
+
}),
|
|
421
|
+
songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music', cat)),
|
|
422
|
+
videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos', cat)),
|
|
323
423
|
// The studio's posts, for the hub (the full text is in /posts/feed.json).
|
|
324
424
|
posts: (cat.posts ?? []).slice(0, 20).map((p) => ({ slug: p.slug, title: p.title, date: p.date, summary: p.summary, page: `${url.origin}/posts/${p.slug}/`, image: p.image ? (p.image.startsWith('/') ? `${url.origin}${p.image}` : p.image) : null, links: p.links ?? {} })),
|
|
325
|
-
rooms: `${url.origin}/api/rooms
|
|
425
|
+
...(shareRooms ? { rooms: `${url.origin}/api/rooms` } : {}),
|
|
326
426
|
// Shared only when studio.json says `stats.share`: two numbers for the whole studio, for the hub.
|
|
327
427
|
...(played ? { played } : {}),
|
|
328
428
|
}, 200, { 'access-control-allow-origin': '*' });
|
|
@@ -334,7 +434,9 @@ async function route(request, env, ctx) {
|
|
|
334
434
|
if (path === '/api/rooms') {
|
|
335
435
|
const cat = await getCat();
|
|
336
436
|
const { rooms } = await roomsOf(env, cat.games ?? []);
|
|
337
|
-
|
|
437
|
+
// Short-lived and cacheable: the hub reads every studio's rooms at most every 45 s, and a room's count is a
|
|
438
|
+
// few seconds stale at worst. (The site's own pages ask with cache: 'no-store'.)
|
|
439
|
+
return json({ ok: true, playing: rooms.reduce((n, r) => n + r.players, 0), rooms }, 200, { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=15' });
|
|
338
440
|
}
|
|
339
441
|
if (path.startsWith('/media/')) {
|
|
340
442
|
if (!env.MEDIA) return new Response('this studio keeps no media in R2 yet', { status: 404 });
|
|
@@ -473,10 +575,8 @@ async function route(request, env, ctx) {
|
|
|
473
575
|
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
|
|
474
576
|
return stub.fetch(new Request(target, request));
|
|
475
577
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
return json({ ok: false, error: 'not-a-homie' }, 200, { 'access-control-allow-origin': '*' });
|
|
479
|
-
}
|
|
578
|
+
// The same knock, relative to the game's own page: the same answer (see notAHomie).
|
|
579
|
+
if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
|
|
480
580
|
if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') return gameDocument(request, env, url, game, meta, cat);
|
|
481
581
|
if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
|
|
482
582
|
}
|
package/worker/pages.mjs
CHANGED
|
@@ -31,16 +31,49 @@ export function badRoomPage(cat, g, raw, { screen = false } = {}) {
|
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Where the room button may sit: a corner, or the middle of the top edge. */
|
|
35
|
+
export const SHARE_PLACES = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-right'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where the room button sits on each device, from game.json `screen.share`, so it never covers the game's own
|
|
39
|
+
* HUD (a scoreboard in the top right, a fuel bar across a phone's top):
|
|
40
|
+
*
|
|
41
|
+
* "share": "top-left" every device (a place from SHARE_PLACES)
|
|
42
|
+
* "share": { "at": "top-right", "y": 64 } moved 64 px in from its edge (x: from its side)
|
|
43
|
+
* "share": { "desk": "bottom-left", "phone": { "at": "top-left", "y": 56 }, "sideways": "top-center" }
|
|
44
|
+
* "share": { "at": "top-right", "label": false } the button stays a small round icon (the room code is
|
|
45
|
+
* in its sheet), for a corner with little room
|
|
46
|
+
*
|
|
47
|
+
* `desk` is a computer, `phone` a phone held upright, `sideways` a phone turned sideways (else as `phone`). For a
|
|
48
|
+
* corner, `x` and `y` move the button in from its side and its edge (0 to 600 px); for top-center, `x` moves it
|
|
49
|
+
* right (or left, negative) and `y` down. Anything else is the default: the top right, as before.
|
|
50
|
+
*/
|
|
51
|
+
export function sharePlaces(value) {
|
|
52
|
+
const one = (v) => {
|
|
53
|
+
const o = typeof v === 'string' ? { at: v } : v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
54
|
+
if (!o || !SHARE_PLACES.includes(o.at)) return null;
|
|
55
|
+
const num = (n, lo, hi) => (Number.isFinite(Number(n)) ? Math.max(lo, Math.min(hi, Math.round(Number(n)))) : 0);
|
|
56
|
+
return { at: o.at, x: num(o.x, o.at === 'top-center' ? -600 : 0, 600), y: num(o.y, 0, 600), label: o.label !== false };
|
|
57
|
+
};
|
|
58
|
+
// A place for every device (a string, or an object with `at`), then any device's own over it.
|
|
59
|
+
const all = one(value) ?? { at: 'top-right', x: 0, y: 0, label: true };
|
|
60
|
+
const per = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
61
|
+
const desk = one(per.desk) ?? all;
|
|
62
|
+
const phone = one(per.phone) ?? all;
|
|
63
|
+
return { desk, phone, sideways: one(per.sideways) ?? phone };
|
|
64
|
+
}
|
|
65
|
+
|
|
34
66
|
/**
|
|
35
67
|
* The play shell. It asks the Lobby for a public room, boots the game frame at once (seated, no waiting), keeps
|
|
36
68
|
* the seat token for a reload, writes the room into the address (so a reload or a copied address comes back to
|
|
37
69
|
* it), and keeps the room's facts in `window.__shell` (what a test or a big screen reads). A small room button at
|
|
38
|
-
* the edge (game.json `screen.share
|
|
39
|
-
* the middle of the screen or a thumb.
|
|
70
|
+
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
71
|
+
* covers the middle of the screen or a thumb.
|
|
40
72
|
*/
|
|
41
73
|
export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null } = {}) {
|
|
42
74
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
43
75
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
76
|
+
const places = sharePlaces(g.screen?.share);
|
|
44
77
|
// The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
|
|
45
78
|
// (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
|
|
46
79
|
const css = `:root{--hot:${/^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a'}}
|
|
@@ -59,17 +92,22 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
59
92
|
.join .qr svg { width: 100%; height: 100%; display: block; }
|
|
60
93
|
.join b { display: block; color: var(--hot); font-size: 1.15em; margin-bottom: 4px; }
|
|
61
94
|
.join span { opacity: .85; word-break: break-all; }
|
|
62
|
-
.room { position: fixed; z-index: 7; display: flex; flex-direction: column; gap: 8px; font: 600 13px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; color: #eef1f8; }
|
|
63
|
-
.room.at-top-right { top: max(8px, env(safe-area-inset-top)); right: max(8px, env(safe-area-inset-right)); align-items: flex-end; }
|
|
64
|
-
.room.at-top-left { top: max(8px, env(safe-area-inset-top)); left: max(8px, env(safe-area-inset-left)); align-items: flex-start; }
|
|
65
|
-
.room.at-
|
|
66
|
-
.room.at-bottom-
|
|
95
|
+
.room { --dx: 0px; --dy: 0px; position: fixed; z-index: 7; display: flex; flex-direction: column; gap: 8px; font: 600 13px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; color: #eef1f8; }
|
|
96
|
+
.room.at-top-right { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; }
|
|
97
|
+
.room.at-top-left { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; }
|
|
98
|
+
.room.at-top-center { top: calc(max(8px, env(safe-area-inset-top)) + var(--dy)); left: calc(50% + var(--dx)); transform: translateX(-50%); align-items: center; }
|
|
99
|
+
.room.at-bottom-right { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); right: calc(max(8px, env(safe-area-inset-right)) + var(--dx)); align-items: flex-end; flex-direction: column-reverse; }
|
|
100
|
+
.room.at-bottom-left { bottom: calc(max(8px, env(safe-area-inset-bottom)) + var(--dy)); left: calc(max(8px, env(safe-area-inset-left)) + var(--dx)); align-items: flex-start; flex-direction: column-reverse; }
|
|
101
|
+
.chip.chip-right { left: auto; right: max(10px, env(safe-area-inset-right)); }
|
|
67
102
|
.pill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 11px 0 9px; border-radius: 999px; border: 1px solid rgba(255,255,255,.18); background: rgba(6,9,16,.62); color: inherit; font: inherit; cursor: pointer; touch-action: manipulation; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transition: opacity .5s; }
|
|
68
103
|
.pill svg { width: 15px; height: 15px; flex: none; }
|
|
69
104
|
.pill.dim { opacity: .38; width: 34px; padding: 0; justify-content: center; }
|
|
70
105
|
.pill.dim span { display: none; }
|
|
71
106
|
.pill:hover, .pill:focus-visible, .pill[aria-expanded="true"] { opacity: 1; width: auto; padding: 0 11px 0 9px; }
|
|
72
107
|
.pill:hover span, .pill:focus-visible span, .pill[aria-expanded="true"] span { display: inline; }
|
|
108
|
+
/* game.json screen.share "label": false: always the small round icon, even while it is open (the code is in the sheet). */
|
|
109
|
+
.pill.icon, .pill.icon:hover, .pill.icon:focus-visible, .pill.icon[aria-expanded="true"] { width: 34px; padding: 0; justify-content: center; }
|
|
110
|
+
.pill.icon span, .pill.icon:hover span, .pill.icon:focus-visible span, .pill.icon[aria-expanded="true"] span { display: none; }
|
|
73
111
|
.sheet { box-sizing: border-box; width: min(300px, calc(100vw - 16px)); padding: 12px; border-radius: 16px; background: rgba(8,12,22,.94); border: 1px solid rgba(255,255,255,.16); box-shadow: 0 18px 50px rgba(0,0,0,.5); -webkit-user-select: text; user-select: text; touch-action: manipulation; }
|
|
74
112
|
.sheet .code { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 2px 2px 10px; }
|
|
75
113
|
.sheet .code b { font: 800 20px/1.1 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
|
|
@@ -84,13 +122,14 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
84
122
|
.sheet .foot a:hover, .sheet .foot button:hover { color: #fff; }
|
|
85
123
|
.toast { position: fixed; left: 50%; top: max(12px, env(safe-area-inset-top)); z-index: 8; transform: translateX(-50%); padding: 8px 14px; border-radius: 999px; background: rgba(8,12,22,.9); color: #fff; font: 600 13px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
|
|
86
124
|
[hidden] { display: none !important; }`;
|
|
87
|
-
|
|
125
|
+
// The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
|
|
126
|
+
const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}) };
|
|
88
127
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
89
128
|
const joinCorner = corner('join', 'bottom-right');
|
|
90
|
-
const
|
|
129
|
+
const first = places.desk;
|
|
91
130
|
const joinCard = screen && joinUrl ? `<div class="join join-${joinCorner}" data-join>${qr ? `<div class="qr">${qr}</div>` : ''}<div><b>Scan to play</b><span>${esc(joinUrl.replace(/^https?:\/\//, ''))}</span></div></div>` : '';
|
|
92
|
-
const share = screen ? '' : `<div class="room at-${
|
|
93
|
-
<button class="pill" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
|
|
131
|
+
const share = screen ? '' : `<div class="room at-${first.at}" style="--dx:${first.x}px;--dy:${first.y}px" data-room-ui>
|
|
132
|
+
<button class="pill${first.label ? '' : ' icon'}" type="button" data-share-toggle aria-expanded="false" aria-controls="share-sheet" aria-label="Room, invite and big screen"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="m8.2 10.8 7.6-4.1M8.2 13.2l7.6 4.1"/></svg><span data-room-code>Room</span></button>
|
|
94
133
|
<div class="sheet" id="share-sheet" role="dialog" aria-label="This room" data-share-sheet hidden>
|
|
95
134
|
<div class="code"><b data-room-label>This room</b><span data-room-count></span></div>
|
|
96
135
|
<div class="acts">
|
|
@@ -163,6 +202,23 @@ const SHELL_JS = String.raw`(function () {
|
|
|
163
202
|
var sheet = ui && ui.querySelector('[data-share-sheet]');
|
|
164
203
|
var toast = document.querySelector('[data-toast]');
|
|
165
204
|
var dimTimer = null;
|
|
205
|
+
// Where the button sits on THIS device (game.json screen.share, per computer, phone and phone turned sideways: a
|
|
206
|
+
// corner or the top's middle, moved in by x / y), so it never sits on the game's own scoreboard or fuel bar.
|
|
207
|
+
function place() {
|
|
208
|
+
if (!ui || !boot.share) return;
|
|
209
|
+
var key = device === 'phone' ? (innerWidth > innerHeight ? 'sideways' : 'phone') : 'desk';
|
|
210
|
+
var p = boot.share[key] || boot.share.desk;
|
|
211
|
+
if (!p) return;
|
|
212
|
+
ui.className = 'room at-' + p.at;
|
|
213
|
+
ui.style.setProperty('--dx', (p.x || 0) + 'px');
|
|
214
|
+
ui.style.setProperty('--dy', (p.y || 0) + 'px');
|
|
215
|
+
if (toggle) { if (p.label === false) toggle.classList.add('icon'); else toggle.classList.remove('icon'); }
|
|
216
|
+
// The status chip lives at the bottom left: a button there sends it to the bottom right.
|
|
217
|
+
if (p.at === 'bottom-left') chip.classList.add('chip-right'); else chip.classList.remove('chip-right');
|
|
218
|
+
state.share = { device: key, at: p.at, x: p.x || 0, y: p.y || 0, label: p.label !== false };
|
|
219
|
+
}
|
|
220
|
+
place();
|
|
221
|
+
addEventListener('resize', place);
|
|
166
222
|
function labelOf(room) { var m = /^pub-(\d+)$/.exec(room); return m ? 'Room ' + m[1] : room; }
|
|
167
223
|
function flash(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { toast.hidden = true; }, 1800); }
|
|
168
224
|
function wake() { if (!toggle) return; toggle.classList.remove('dim'); clearTimeout(dimTimer); dimTimer = setTimeout(function () { if (sheet.hidden) toggle.classList.add('dim'); }, 6000); }
|
package/worker/site.mjs
CHANGED
|
@@ -69,7 +69,7 @@ img,video{display:block;max-width:100%}
|
|
|
69
69
|
.ico{width:18px;height:18px;flex:none}
|
|
70
70
|
.skip{position:absolute;left:-9999px;top:12px;z-index:40;padding:10px 14px;border-radius:10px;background:var(--fg);color:var(--bg)}
|
|
71
71
|
.skip:focus{left:12px}
|
|
72
|
-
.kicker{margin:0 0 12px;font:600 12px/1.5 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--glow)}
|
|
72
|
+
.kicker{margin:0 0 12px;font:600 12px/1.5 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--glow-ink)}
|
|
73
73
|
:focus-visible{outline:3px solid var(--fg);outline-offset:3px}
|
|
74
74
|
@media (max-width:559px){.k-opt{display:none}}
|
|
75
75
|
|
|
@@ -255,7 +255,7 @@ img,video{display:block;max-width:100%}
|
|
|
255
255
|
.credits{display:grid;gap:18px;margin-top:30px}
|
|
256
256
|
@media (min-width:760px){.credits{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}}
|
|
257
257
|
.credit{overflow-wrap:anywhere;padding:20px 22px;border-radius:var(--r);background:var(--panel);border:1px solid var(--line);color:var(--soft);font-size:15px}
|
|
258
|
-
.credit h3{margin:0 0 10px;font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow)}
|
|
258
|
+
.credit h3{margin:0 0 10px;font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow-ink)}
|
|
259
259
|
.credit p{margin:0 0 8px}
|
|
260
260
|
.credit ul{margin:0;padding-left:18px}
|
|
261
261
|
.credit li{margin:4px 0}
|
|
@@ -310,7 +310,7 @@ img,video{display:block;max-width:100%}
|
|
|
310
310
|
.plist{list-style:none;margin:28px 0 0;padding:0;display:grid;gap:14px}
|
|
311
311
|
.pitem>a{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:center;padding:20px 22px;border-radius:var(--r);background:var(--panel);border:1px solid var(--line);text-decoration:none;transition:border-color .2s ease,transform .2s ease}
|
|
312
312
|
.pitem>a:hover{border-color:color-mix(in srgb,var(--hot) 45%,transparent);transform:translateY(-2px)}
|
|
313
|
-
.pitem time,.post-meta{font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow)}
|
|
313
|
+
.pitem time,.post-meta{font:600 12px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow-ink)}
|
|
314
314
|
.pitem h3{margin:6px 0 6px;font:800 clamp(22px,2.6vw,30px)/1.1 var(--display);letter-spacing:-.02em}
|
|
315
315
|
.pitem p{margin:0;color:var(--soft)}
|
|
316
316
|
.pitem img{width:168px;aspect-ratio:16/10;object-fit:cover;border-radius:12px}
|
|
@@ -444,21 +444,45 @@ if(src){
|
|
|
444
444
|
/* ------------------------------------------------------------------ the shell */
|
|
445
445
|
|
|
446
446
|
const TOKEN_DEFAULTS = { bg: '#0b0c12', fg: '#f1f3f9', accent: '#ffcf5a', glow: '#7dffb0' };
|
|
447
|
+
/**
|
|
448
|
+
* A game landing's own scheme (game.json `landing.scheme`), for a game whose picture is the other way round from
|
|
449
|
+
* the studio's look: a white or cream arena (a light hero) under a dark studio's tint turns grey, so a light
|
|
450
|
+
* landing draws the whole page light, its hero tinted and shaded with the light background and its words dark.
|
|
451
|
+
* `landing.theme` colours still win over these.
|
|
452
|
+
*/
|
|
453
|
+
export const SCHEME_TOKENS = {
|
|
454
|
+
light: { bg: '#f7f6f1', fg: '#15161d', panel: 'color-mix(in srgb,var(--fg) 5%,var(--bg))' },
|
|
455
|
+
dark: { bg: '#0b0c12', fg: '#f1f3f9', panel: 'color-mix(in srgb,var(--fg) 5%,var(--bg))' },
|
|
456
|
+
};
|
|
447
457
|
|
|
448
|
-
/**
|
|
458
|
+
/**
|
|
459
|
+
* The studio's tokens as CSS custom properties (site/theme.json, checked at build), plus its fonts. `over` is a
|
|
460
|
+
* page's own (a game landing's `landing.theme`, and its `scheme`). `--glow-ink` is the glow as small text: the
|
|
461
|
+
* glow itself on a dark page, and a darker mix of it on a light one (a pale glow on white cannot be read).
|
|
462
|
+
*/
|
|
449
463
|
function tokensCss(theme = {}, over = null) {
|
|
450
464
|
const t = { ...TOKEN_DEFAULTS, ...theme, ...(over ?? {}) };
|
|
465
|
+
const scheme = (over?.scheme ?? theme.scheme) === 'light' ? 'light' : 'dark';
|
|
451
466
|
const fonts = (theme.fonts ?? []).map((f) => `@font-face{font-family:"${f.family}";src:url("${f.src}");font-weight:${f.weight};font-style:${f.style};font-display:swap}`).join('');
|
|
452
467
|
const display = theme.display ?? 'ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif';
|
|
453
468
|
const text = theme.text ?? 'ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif';
|
|
454
469
|
const mono = theme.mono ?? 'ui-monospace,"SF Mono",Menlo,Consolas,monospace';
|
|
455
470
|
return `${fonts}:root{--bg:${t.bg};--fg:${t.fg};--hot:${t.accent};--glow:${t.glow};--hot-ink:${t.accentInk ?? t.bg};
|
|
471
|
+
--glow-ink:${scheme === 'light' ? 'color-mix(in srgb,var(--glow) 45%,var(--fg))' : 'var(--glow)'};
|
|
456
472
|
--soft:color-mix(in srgb,var(--fg) 80%,transparent);--dim:color-mix(in srgb,var(--fg) 62%,transparent);--line:color-mix(in srgb,var(--fg) 14%,transparent);
|
|
457
473
|
--panel:${t.panel ?? 'color-mix(in srgb,var(--fg) 5%,var(--bg))'};--display:${display};--text:${text};--mono:${mono};--r:${Number.isFinite(t.radius) ? t.radius : 18}px;
|
|
458
|
-
--gutter:clamp(18px,5vw,72px);--top-h:72px;color-scheme:${
|
|
474
|
+
--gutter:clamp(18px,5vw,72px);--top-h:72px;color-scheme:${scheme}${theme.uppercase === false ? ';--mark-case:none;--mark-track:.02em' : ''}}
|
|
459
475
|
@media (max-width:719px){:root{--top-h:112px}}`;
|
|
460
476
|
}
|
|
461
477
|
|
|
478
|
+
/** A landing's `over` tokens: its scheme's (light or dark, when it differs from the studio's), then its own colours. */
|
|
479
|
+
function landingTokens(cat, L) {
|
|
480
|
+
const studio = cat.studio?.theme?.scheme === 'light' ? 'light' : 'dark';
|
|
481
|
+
const want = L.scheme === 'light' || L.scheme === 'dark' ? L.scheme : null;
|
|
482
|
+
if (!want || want === studio) return L.theme ?? null;
|
|
483
|
+
return { ...SCHEME_TOKENS[want], ...(L.theme ?? {}), scheme: want };
|
|
484
|
+
}
|
|
485
|
+
|
|
462
486
|
const studioName = (cat) => cat.studio?.name ?? 'Studio';
|
|
463
487
|
|
|
464
488
|
/** Which sections this studio has: a section with nothing in it has no tab (and its page answers 404). */
|
|
@@ -533,7 +557,7 @@ ${icon}${feeds}${head}
|
|
|
533
557
|
<style>${tokensCss(theme, over)}${BASE_CSS}${cat.site?.css ?? ''}</style>
|
|
534
558
|
${partial(cat, 'head') ?? ''}
|
|
535
559
|
</head>
|
|
536
|
-
<body class="${hero ? 'has-hero' : ''}" data-page="${esc(page)}">
|
|
560
|
+
<body class="${hero ? 'has-hero' : ''}" data-page="${esc(page)}" data-scheme="${(over?.scheme ?? theme.scheme) === 'light' ? 'light' : 'dark'}">
|
|
537
561
|
<a class="skip" href="#main">Skip to content</a>
|
|
538
562
|
${header(cat, active)}
|
|
539
563
|
<main id="main">
|
|
@@ -566,7 +590,13 @@ export function customPage(cat, html, { active = null } = {}) {
|
|
|
566
590
|
const minutes = (s) => (Number.isFinite(s) && s > 0 ? (s % 60 ? `${s}\u00a0s rounds` : `${s / 60}\u2011minute rounds`) : null);
|
|
567
591
|
const playersText = (g) => (g.players?.max ? (g.players.max === 1 ? 'one player' : `1–${g.players.max} players`) : 'multiplayer');
|
|
568
592
|
const titleClass = (name) => { const n = String(name ?? '').length; return n > 22 ? 't-xlong' : n > 12 ? 't-long' : ''; };
|
|
569
|
-
|
|
593
|
+
/**
|
|
594
|
+
* A game's picture on every card (Home, Games, Rooms, a post), a room's row and the directory's manifest: the
|
|
595
|
+
* landing's hero still (hero/wide.jpg or game.json landing.hero.image, the picture the landing leads with), else
|
|
596
|
+
* a trailer's poster, else the game's cover. An old game.json cover never outranks the landing's own still.
|
|
597
|
+
*/
|
|
598
|
+
export const gameCover = (g) => g.landing?.hero?.wideImage ?? g.landing?.hero?.tallImage ?? g.landing?.cover ?? (g.cover ? `/games/${g.id}/${g.cover}` : null);
|
|
599
|
+
const coverOf = gameCover;
|
|
570
600
|
|
|
571
601
|
/** Hero media: the game's footage (wide and tall, AV1 first), else its art with slow motion, else the studio's colours. */
|
|
572
602
|
function heroMedia(h = {}, { alt = '', word = '' } = {}) {
|
|
@@ -626,9 +656,22 @@ const KIND_WORD = { song: 'Song', score: 'Score', loop: 'Loop', stem: 'Stem', sf
|
|
|
626
656
|
const fmtTime = (s) => (Number.isFinite(s) ? `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}` : '');
|
|
627
657
|
const fileOf = (e, role) => (e.files ?? []).find((f) => f.role === role) ?? null;
|
|
628
658
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
659
|
+
/**
|
|
660
|
+
* A song's cover (a video's poster), wherever it is drawn (its page, a card, the directory's manifest): its own
|
|
661
|
+
* file, else the manifest's default (music/manifest.json `cover`, videos/manifest.json `poster`, which the build
|
|
662
|
+
* gives every entry without one), else the landing still of the game it was made for (`for.game`), else the
|
|
663
|
+
* studio's share picture (site/theme.json `social`). Null when there is none of these.
|
|
664
|
+
*/
|
|
665
|
+
export function mediaArt(e, kind, cat = null) {
|
|
666
|
+
const own = fileOf(e, kind === 'music' ? 'cover' : 'poster');
|
|
667
|
+
if (own?.url) return own.url;
|
|
668
|
+
const game = e.for?.game ? (cat?.games ?? []).find((g) => g.id === e.for.game) : null;
|
|
669
|
+
return (game ? gameCover(game) : null) ?? cat?.studio?.theme?.social ?? null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function mediaCard(e, kind, cat = null) {
|
|
673
|
+
const art = mediaArt(e, kind, cat);
|
|
674
|
+
return `<a class="media-card reveal" href="/${kind}/${esc(e.slug)}/"><span class="art">${art ? `<img src="${esc(art)}" alt="" loading="lazy" decoding="async">` : ''}<span class="big">${PLAY_ICON}</span></span><span class="cap"><b>${esc(e.title)}</b><span>${esc(KIND_WORD[e.kind] ?? (kind === 'music' ? 'Music' : 'Video'))}${e.duration ? ` · ${esc(fmtTime(e.duration))}` : ''}</span></span></a>`;
|
|
632
675
|
}
|
|
633
676
|
|
|
634
677
|
/* ------------------------------------------------------------------ Home */
|
|
@@ -663,7 +706,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
|
663
706
|
</section>`;
|
|
664
707
|
} else {
|
|
665
708
|
const lead = videos[0] ?? songs[0] ?? null;
|
|
666
|
-
const art = lead ?
|
|
709
|
+
const art = lead ? mediaArt(lead, videos[0] ? 'videos' : 'music', cat) : null;
|
|
667
710
|
hero = `<section class="hero home" aria-labelledby="hero-title">
|
|
668
711
|
${heroMedia(art ? { wideImage: art } : {}, { word: name })}
|
|
669
712
|
<div class="hero-copy">
|
|
@@ -688,7 +731,7 @@ export function homePage(cat, { origin = '', rooms = [], live = {} } = {}) {
|
|
|
688
731
|
</div></section>` : '';
|
|
689
732
|
const mediaBand = (list, kind, title) => (list.length ? `<section class="band" aria-labelledby="${kind}-title"><div class="band-in">
|
|
690
733
|
<div class="head-row reveal"><div><p class="kicker">${kind === 'music' ? 'Music' : 'Videos'}</p><h2 id="${kind}-title">${esc(title)}</h2></div><a class="more" href="/${kind}/">All ${kind === 'music' ? 'music' : 'videos'} ${icon('arrow')}</a></div>
|
|
691
|
-
<div class="cards">${list.slice(0, 3).map((e) => mediaCard(e, kind)).join('')}</div>
|
|
734
|
+
<div class="cards">${list.slice(0, 3).map((e) => mediaCard(e, kind, cat)).join('')}</div>
|
|
692
735
|
</div></section>` : '');
|
|
693
736
|
const extra = partial(cat, 'home');
|
|
694
737
|
return layout(cat, {
|
|
@@ -751,7 +794,7 @@ export function postPage(cat, p, { origin = '' } = {}) {
|
|
|
751
794
|
const video = p.links?.video ? (cat.videos ?? []).find((x) => x.slug === p.links.video) : null;
|
|
752
795
|
const others = (cat.posts ?? []).filter((x) => x.slug !== p.slug).slice(0, 2);
|
|
753
796
|
const extra = partial(cat, 'post', { 'post.title': p.title, 'post.slug': p.slug });
|
|
754
|
-
const linked = [g ? gameCard(g) : '', video ? mediaCard(video, 'videos') : '', song ? mediaCard(song, 'music') : ''].filter(Boolean).join('');
|
|
797
|
+
const linked = [g ? gameCard(g) : '', video ? mediaCard(video, 'videos', cat) : '', song ? mediaCard(song, 'music', cat) : ''].filter(Boolean).join('');
|
|
755
798
|
return layout(cat, {
|
|
756
799
|
title: `${p.title} · ${name}`, description: p.summary, origin, path: `/posts/${p.slug}/`, image: p.image ?? (g ? coverOf(g) : null), type: 'article', page: 'post', active: 'posts',
|
|
757
800
|
head: `<meta property="article:published_time" content="${esc(p.date)}">${p.updated ? `<meta property="article:modified_time" content="${esc(p.updated)}">` : ''}`,
|
|
@@ -898,7 +941,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
898
941
|
</div></section>`;
|
|
899
942
|
|
|
900
943
|
const facts = ['free, in the browser', 'bots fill empty seats', 'join any time: take a bot’s place', 'rounds restart on their own', ...(tvOn ? ['phones as controllers on a TV'] : [])];
|
|
901
|
-
const side = trailer ? mediaCard(trailer, 'videos') : song ? mediaCard(song, 'music') : (L.cover ? `<figure class="media-card reveal" style="margin:0"><span class="art"><img src="${esc(L.cover)}" alt="${esc(g.name)}, a moment of play" loading="lazy"></span><figcaption class="cap"><b>${esc(g.name)}</b><span>${esc(playersText(g))}${minutes(g.roundSeconds) ? ` · ${esc(minutes(g.roundSeconds))}` : ''}</span></figcaption></figure>` : '');
|
|
944
|
+
const side = trailer ? mediaCard(trailer, 'videos', cat) : song ? mediaCard(song, 'music', cat) : (L.cover ? `<figure class="media-card reveal" style="margin:0"><span class="art"><img src="${esc(L.cover)}" alt="${esc(g.name)}, a moment of play" loading="lazy"></span><figcaption class="cap"><b>${esc(g.name)}</b><span>${esc(playersText(g))}${minutes(g.roundSeconds) ? ` · ${esc(minutes(g.roundSeconds))}` : ''}</span></figcaption></figure>` : '');
|
|
902
945
|
const howBand = `<section class="band hotband" aria-labelledby="how-title"><div class="band-in split">
|
|
903
946
|
<div class="reveal">
|
|
904
947
|
<p class="kicker">How to play</p>
|
|
@@ -907,7 +950,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
907
950
|
${(L.how ?? []).length ? `<ul class="howto">${L.how.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : ''}
|
|
908
951
|
<ul class="facts">${facts.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>
|
|
909
952
|
</div>
|
|
910
|
-
${side ? `<div>${side}${trailer && song ? `<div style="margin-top:14px">${mediaCard(song, 'music')}</div>` : ''}</div>` : ''}
|
|
953
|
+
${side ? `<div>${side}${trailer && song ? `<div style="margin-top:14px">${mediaCard(song, 'music', cat)}</div>` : ''}</div>` : ''}
|
|
911
954
|
</div></section>`;
|
|
912
955
|
|
|
913
956
|
const C = L.credits ?? {};
|
|
@@ -944,7 +987,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
944
987
|
return layout(cat, {
|
|
945
988
|
title: `${g.name} — play free in your browser`,
|
|
946
989
|
description: `${g.name}: ${L.pitch ?? g.blurb ?? ''} Free in your browser, nothing to download; a TV or laptop browser can be the big screen, with phones as controllers.`.replace(/\s+/g, ' ').trim(),
|
|
947
|
-
origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: L
|
|
990
|
+
origin, path: `/${g.id}/`, image: h.wideImage ?? L.cover ?? null, page: 'landing', hero: true, active: 'games', over: landingTokens(cat, L),
|
|
948
991
|
head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}<script type="application/ld+json">${jsonScript(ld)}</script>`,
|
|
949
992
|
main: `${hero}${ways}${liveBand}${howBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}${makeBand}`,
|
|
950
993
|
});
|
|
@@ -986,14 +1029,15 @@ export function mediaIndexPage(cat, kind, { origin = '' } = {}) {
|
|
|
986
1029
|
return layout(cat, {
|
|
987
1030
|
title: `${title} · ${name}`, description: `${title} from ${name}.`, origin, path: `/${kind}/`, page: kind, active: kind,
|
|
988
1031
|
main: `${pageHead(name, title, kind === 'music' ? 'Songs, scores and loops from the studio.' : 'Trailers, music videos and clips from the studio.')}
|
|
989
|
-
<div class="wrap"><div class="cards">${list.map((e) => mediaCard(e, kind)).join('')}</div></div>`,
|
|
1032
|
+
<div class="wrap"><div class="cards">${list.map((e) => mediaCard(e, kind, cat)).join('')}</div></div>`,
|
|
990
1033
|
});
|
|
991
1034
|
}
|
|
992
1035
|
|
|
993
1036
|
export function songPage(cat, e, origin = '') {
|
|
994
1037
|
const name = studioName(cat);
|
|
995
1038
|
const audio = fileOf(e, 'audio');
|
|
996
|
-
const
|
|
1039
|
+
const coverUrl = mediaArt(e, 'music', cat);
|
|
1040
|
+
const cover = coverUrl ? { url: coverUrl } : null;
|
|
997
1041
|
const extras = (e.files ?? []).filter((f) => f.role === 'loop' || f.role === 'stem');
|
|
998
1042
|
const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
|
|
999
1043
|
const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u);
|
|
@@ -1023,7 +1067,8 @@ export function videoPage(cat, e, origin = '') {
|
|
|
1023
1067
|
const name = studioName(cat);
|
|
1024
1068
|
const video = fileOf(e, 'video');
|
|
1025
1069
|
const vertical = fileOf(e, 'vertical');
|
|
1026
|
-
const
|
|
1070
|
+
const posterUrl = mediaArt(e, 'videos', cat);
|
|
1071
|
+
const poster = posterUrl ? { url: posterUrl } : null;
|
|
1027
1072
|
const captions = fileOf(e, 'captions');
|
|
1028
1073
|
const game = e.for?.game && (cat.games ?? []).find((g) => g.id === e.for.game);
|
|
1029
1074
|
const song = e.for?.song && (cat.songs ?? []).find((m) => m.slug === e.for.song);
|
package/worker/stats.mjs
CHANGED
|
@@ -304,6 +304,23 @@ export async function playedThisWeek(env, game = null) {
|
|
|
304
304
|
} catch { return null; }
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/**
|
|
308
|
+
* "Played this week" for every game at once (one query, grouped by game), for the manifest's `games[].played`
|
|
309
|
+
* when the studio shares its numbers: `{ [game]: { days: 7, plays, rounds } }`, or null when the counters cannot be
|
|
310
|
+
* read (before migration 0002, or no D1). A game nobody played this week is absent: its numbers are 0.
|
|
311
|
+
*/
|
|
312
|
+
export async function playedByGame(env) {
|
|
313
|
+
if (!env?.DB) return null;
|
|
314
|
+
const to = today();
|
|
315
|
+
try {
|
|
316
|
+
const { results } = await env.DB.prepare(`SELECT subject, SUM(CASE WHEN metric = 'play' THEN n ELSE 0 END) AS plays, SUM(CASE WHEN metric = 'round' AND source = 'people' THEN n ELSE 0 END) AS rounds FROM stats_daily WHERE day >= ?1 AND day <= ?2 AND metric IN ('play', 'round') GROUP BY subject`)
|
|
317
|
+
.bind(dayOffset(to, -6), to).all();
|
|
318
|
+
const out = {};
|
|
319
|
+
for (const row of results ?? []) if (SLUG.test(String(row.subject ?? ''))) out[row.subject] = { days: 7, plays: Number(row.plays) || 0, rounds: Number(row.rounds) || 0 };
|
|
320
|
+
return out;
|
|
321
|
+
} catch { return null; }
|
|
322
|
+
}
|
|
323
|
+
|
|
307
324
|
/** Parse ?game= / ?song= / ?video= into one narrowing, or null. */
|
|
308
325
|
export function onlyOf(params) {
|
|
309
326
|
for (const kind of ['game', 'song', 'video']) {
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.10.0';
|