@conduction/docusaurus-preset 3.38.0 → 3.39.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/MISSING_COMPONENTS.md +1 -0
- package/package.json +1 -1
- package/src/__tests__/no-icu-messages.test.js +57 -0
- package/src/components/BlueprintRush/BlueprintRush.jsx +238 -0
- package/src/components/BlueprintRush/BlueprintRush.module.css +186 -0
- package/src/components/BlueprintRush/__tests__/engine.test.js +154 -0
- package/src/components/BlueprintRush/engine.js +172 -0
- package/src/components/DeadlineDefender/DeadlineDefender.jsx +237 -0
- package/src/components/DeadlineDefender/DeadlineDefender.module.css +193 -0
- package/src/components/DeadlineDefender/__tests__/engine.test.js +163 -0
- package/src/components/DeadlineDefender/engine.js +188 -0
- package/src/components/DetailHero/DetailHero.jsx +11 -4
- package/src/components/DetailHero/__tests__/DetailHero.downloads.test.js +160 -0
- package/src/components/FeaturedCard/FeaturedCard.jsx +14 -1
- package/src/components/FeaturedCard/FeaturedCard.module.css +5 -0
- package/src/components/FeaturedCard/__tests__/FeaturedCard.visual.test.js +110 -0
- package/src/components/GameModal/GameModal.jsx +255 -50
- package/src/components/GameModal/GameModal.module.css +103 -0
- package/src/components/GameModal/__tests__/scores.test.js +123 -0
- package/src/components/GameModal/__tests__/share.test.js +83 -0
- package/src/components/GameModal/scores.js +149 -0
- package/src/components/GameModal/share.js +97 -0
- package/src/components/RecordRun/RecordRun.jsx +245 -0
- package/src/components/RecordRun/RecordRun.module.css +208 -0
- package/src/components/RecordRun/__tests__/engine.test.js +191 -0
- package/src/components/RecordRun/engine.js +180 -0
- package/src/components/StampRush/StampRush.jsx +232 -0
- package/src/components/StampRush/StampRush.module.css +188 -0
- package/src/components/StampRush/__tests__/engine.test.js +182 -0
- package/src/components/StampRush/engine.js +185 -0
- package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
- package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
- package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
- package/src/components/index.js +5 -0
- package/src/data/app-downloads.js +21 -0
- package/src/index.js +10 -0
- package/src/theme/Footer/index.jsx +10 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FeaturedCard.visual.test.js — the `visual` prop puts a node in the
|
|
3
|
+
* card's right-hand column instead of the hex thumbnail.
|
|
4
|
+
*
|
|
5
|
+
* The regression half matters more than the feature half: every
|
|
6
|
+
* academy card and every featured post renders through this
|
|
7
|
+
* component, so a card that passes no `visual` has to render exactly
|
|
8
|
+
* what it rendered before, satellites and all.
|
|
9
|
+
*
|
|
10
|
+
* Same esbuild-bundle-then-renderToStaticMarkup technique as the
|
|
11
|
+
* other render tests; CSS modules are stubbed with an identity proxy.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test');
|
|
17
|
+
const {before, after} = test;
|
|
18
|
+
const assert = require('node:assert/strict');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const fs = require('node:fs/promises');
|
|
21
|
+
const {build} = require('esbuild');
|
|
22
|
+
const React = require('react');
|
|
23
|
+
const {renderToStaticMarkup} = require('react-dom/server');
|
|
24
|
+
|
|
25
|
+
const COMPONENT = path.resolve(__dirname, '..', 'FeaturedCard.jsx');
|
|
26
|
+
const PRESET_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
|
27
|
+
const SCRATCH = path.join(PRESET_ROOT, '.tmp-featured-card-test');
|
|
28
|
+
|
|
29
|
+
const cssModuleStub = {
|
|
30
|
+
name: 'css-module-stub',
|
|
31
|
+
setup(b) {
|
|
32
|
+
b.onResolve({filter: /\.module\.css$/}, (args) => ({path: args.path, namespace: 'css-stub'}));
|
|
33
|
+
b.onLoad({filter: /.*/, namespace: 'css-stub'}, () => ({
|
|
34
|
+
contents: 'export default new Proxy({}, {get: (_, p) => p});',
|
|
35
|
+
loader: 'js',
|
|
36
|
+
}));
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const docusaurusStub = {
|
|
41
|
+
name: 'docusaurus-stub',
|
|
42
|
+
setup(b) {
|
|
43
|
+
b.onResolve({filter: /^@docusaurus\/Translate$/}, () => ({path: 'docusaurus-translate', namespace: 'docusaurus-stub'}));
|
|
44
|
+
b.onLoad({filter: /^docusaurus-translate$/, namespace: 'docusaurus-stub'}, () => ({
|
|
45
|
+
contents: `import React from 'react';
|
|
46
|
+
export function translate(o, v) {
|
|
47
|
+
return String(o.message).replace(/\\{(\\w+)\\}/g, (_, k) => (v && v[k] !== undefined ? v[k] : ''));
|
|
48
|
+
}
|
|
49
|
+
export default function Translate({children}) { return React.createElement(React.Fragment, null, children); }`,
|
|
50
|
+
loader: 'jsx',
|
|
51
|
+
}));
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let FeaturedCard;
|
|
56
|
+
|
|
57
|
+
before(async () => {
|
|
58
|
+
await fs.mkdir(SCRATCH, {recursive: true});
|
|
59
|
+
const tmpDir = await fs.mkdtemp(path.join(SCRATCH, 'run-'));
|
|
60
|
+
const outFile = path.join(tmpDir, 'bundle.cjs');
|
|
61
|
+
await build({
|
|
62
|
+
entryPoints: [COMPONENT],
|
|
63
|
+
outfile: outFile,
|
|
64
|
+
bundle: true,
|
|
65
|
+
format: 'cjs',
|
|
66
|
+
jsx: 'automatic',
|
|
67
|
+
jsxImportSource: 'react',
|
|
68
|
+
tsconfigRaw: {compilerOptions: {jsx: 'react-jsx', jsxImportSource: 'react'}},
|
|
69
|
+
platform: 'node',
|
|
70
|
+
external: ['react'],
|
|
71
|
+
plugins: [cssModuleStub, docusaurusStub],
|
|
72
|
+
logLevel: 'warning',
|
|
73
|
+
});
|
|
74
|
+
delete require.cache[require.resolve(outFile)];
|
|
75
|
+
FeaturedCard = require(outFile).default;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
after(async () => {
|
|
79
|
+
await fs.rm(SCRATCH, {recursive: true, force: true});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const BASE = {title: 'La Frankendesk', lede: 'One finish, mixed parts.', href: '/academy/x'};
|
|
83
|
+
|
|
84
|
+
function render(props) {
|
|
85
|
+
return renderToStaticMarkup(React.createElement(FeaturedCard, {...BASE, ...props}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const marker = React.createElement('div', {'data-visual': 'seam'}, 'scene');
|
|
89
|
+
|
|
90
|
+
test('without a visual the card renders the hex thumbnail and its satellites, as before', () => {
|
|
91
|
+
const html = render({thumbnail: {icon: React.createElement('svg', null), tone: 'cobalt'}});
|
|
92
|
+
assert.match(html, /thumb/, 'no hex thumbnail rendered');
|
|
93
|
+
assert.equal((html.match(/satellite/g) || []).length > 0, true, 'satellites missing');
|
|
94
|
+
assert.doesNotMatch(html, /visualNode/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a visual takes the column and stands the satellites down', () => {
|
|
98
|
+
const html = render({visual: marker});
|
|
99
|
+
assert.match(html, /data-visual="seam"/, 'the passed node did not render');
|
|
100
|
+
assert.match(html, /visualNode/);
|
|
101
|
+
assert.doesNotMatch(html, /satellite/, 'satellites still frame a node that is not a hex');
|
|
102
|
+
assert.doesNotMatch(html, /thumb/, 'the hex thumbnail rendered alongside the visual');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the copy column is untouched by the visual', () => {
|
|
106
|
+
const html = render({visual: marker, eyebrow: 'blog', durationMinutes: 20});
|
|
107
|
+
assert.match(html, /La Frankendesk/);
|
|
108
|
+
assert.match(html, /One finish, mixed parts\./);
|
|
109
|
+
assert.match(html, /20 min read/);
|
|
110
|
+
});
|
|
@@ -42,60 +42,66 @@
|
|
|
42
42
|
|
|
43
43
|
import React, {useEffect, useState, useCallback, useMemo} from 'react';
|
|
44
44
|
import useIsBrowser from '@docusaurus/useIsBrowser';
|
|
45
|
+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
45
46
|
import Translate, {translate} from '@docusaurus/Translate';
|
|
47
|
+
import {
|
|
48
|
+
readScores, writeScores, recordResult, bestFor, foundCount as countFound,
|
|
49
|
+
totalScore, formatScore,
|
|
50
|
+
} from './scores';
|
|
51
|
+
import {buildShareText, scoreLines, mastodonShareUrl, linkedInShareUrl, normaliseInstance} from './share';
|
|
46
52
|
import styles from './GameModal.module.css';
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
{id: 'hexrain', label: 'Twelve apps · hex rain'},
|
|
52
|
-
{id: 'boats', label: 'Sink the boats · footer canal'},
|
|
53
|
-
{id: 'invaders', label: 'Hex-vaders · cookie CLI'},
|
|
54
|
-
{id: 'logo-memory', label: 'Logo memory · clients marquee'},
|
|
55
|
-
{id: 'kade-cyclist', label: 'Kade cyclist · footer kade'},
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
function readFound() {
|
|
59
|
-
if (typeof window === 'undefined') return {};
|
|
60
|
-
try {
|
|
61
|
-
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
62
|
-
return raw ? JSON.parse(raw) : {};
|
|
63
|
-
} catch (e) { return {}; }
|
|
64
|
-
}
|
|
54
|
+
/* The player's Mastodon instance, remembered so the second share does
|
|
55
|
+
not ask again. Per-viewer convenience only; nothing else reads it. */
|
|
56
|
+
const INSTANCE_KEY = 'conduction:mastodon-instance';
|
|
65
57
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
58
|
+
/* The labels are read by every player, so they are translated like any
|
|
59
|
+
other user-facing string. Each one is "the game · where it hides";
|
|
60
|
+
the share text keeps only the part before the separator. Built in a
|
|
61
|
+
function rather than at module scope, because translate() must run
|
|
62
|
+
inside the render for the active locale to apply. */
|
|
63
|
+
function defaultGames() {
|
|
64
|
+
return [
|
|
65
|
+
{id: 'hexrain', label: translate({id: 'preset.gameModal.game.hexrain', message: 'Twelve apps · hex rain', description: 'Name of the hex-rain mini-game and where it hides'})},
|
|
66
|
+
{id: 'boats', label: translate({id: 'preset.gameModal.game.boats', message: 'Sink the boats · footer canal', description: 'Name of the boat-sinking mini-game and where it hides'})},
|
|
67
|
+
{id: 'invaders', label: translate({id: 'preset.gameModal.game.invaders', message: 'Hex-vaders · cookie CLI', description: 'Name of the invaders mini-game and where it hides'})},
|
|
68
|
+
{id: 'logo-memory', label: translate({id: 'preset.gameModal.game.logoMemory', message: 'Logo memory · clients marquee', description: 'Name of the logo-memory mini-game and where it hides'})},
|
|
69
|
+
{id: 'kade-cyclist', label: translate({id: 'preset.gameModal.game.kadeCyclist', message: 'Kade cyclist · footer kade', description: 'Name of the kade-cyclist mini-game and where it hides'})},
|
|
70
|
+
];
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export default function GameModal({games
|
|
73
|
+
export default function GameModal({games: gamesProp, share: shareConfig, className}) {
|
|
74
74
|
const isBrowser = useIsBrowser();
|
|
75
|
+
const {siteConfig, i18n} = useDocusaurusContext();
|
|
75
76
|
const [open, setOpen] = useState(false);
|
|
76
77
|
const [event, setEvent] = useState(null);
|
|
77
|
-
const [
|
|
78
|
+
const [scores, setScores] = useState(() => ({version: 2, games: {}}));
|
|
79
|
+
/* Share UI state: which network is mid-flow, the remembered Mastodon
|
|
80
|
+
instance, and the "copied" acknowledgement. */
|
|
81
|
+
const [instance, setInstance] = useState('');
|
|
82
|
+
const [askInstance, setAskInstance] = useState(false);
|
|
83
|
+
const [copied, setCopied] = useState(false);
|
|
78
84
|
|
|
79
|
-
/* On mount: read
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
/* On mount: read the score table from localStorage and subscribe to
|
|
86
|
+
the `connext:gameend` event. Each event opens the modal with the
|
|
87
|
+
supplied copy and folds the result into the table. */
|
|
82
88
|
useEffect(() => {
|
|
83
89
|
if (!isBrowser) return;
|
|
84
|
-
|
|
90
|
+
setScores(readScores());
|
|
91
|
+
try {
|
|
92
|
+
setInstance(window.localStorage.getItem(INSTANCE_KEY) || '');
|
|
93
|
+
} catch (e) {/* blocked storage: the player types it again */}
|
|
85
94
|
|
|
86
95
|
function onEnd(e) {
|
|
87
96
|
const detail = e.detail || {};
|
|
88
97
|
setEvent(detail);
|
|
89
98
|
setOpen(true);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
runners) never reach a clean win state. The scoreboard pill
|
|
93
|
-
still reflects the actual performance for that round. */
|
|
99
|
+
setCopied(false);
|
|
100
|
+
setAskInstance(false);
|
|
94
101
|
if (detail.id) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
writeFound(next);
|
|
102
|
+
setScores((prev) => {
|
|
103
|
+
const next = recordResult(prev, detail);
|
|
104
|
+
writeScores(next);
|
|
99
105
|
return next;
|
|
100
106
|
});
|
|
101
107
|
}
|
|
@@ -137,9 +143,88 @@ export default function GameModal({games = DEFAULT_GAMES, className}) {
|
|
|
137
143
|
setOpen(false);
|
|
138
144
|
}, [event]);
|
|
139
145
|
|
|
140
|
-
const
|
|
146
|
+
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
147
|
+
|
|
148
|
+
/* Copy that reaches this component from a site's themeConfig can be
|
|
149
|
+
either a string or a per-locale map, because Docusaurus does not
|
|
150
|
+
translate themeConfig at all. A string is used as-is. */
|
|
151
|
+
const pickLocale = useCallback((value) => {
|
|
152
|
+
if (!value || typeof value === 'string') return value;
|
|
153
|
+
return value[locale] || value.en || Object.values(value)[0];
|
|
154
|
+
}, [locale]);
|
|
155
|
+
|
|
156
|
+
/* The roster: which games this site actually ships. The preset's own
|
|
157
|
+
five are the default, and a site that hides more of them passes
|
|
158
|
+
its own list (with per-locale labels, same reason as above). A
|
|
159
|
+
roster that named a game the site does not ship would leave every
|
|
160
|
+
player permanently short of "all found". */
|
|
161
|
+
const games = useMemo(
|
|
162
|
+
() => (gamesProp || defaultGames()).map((g) => ({...g, label: pickLocale(g.label)})),
|
|
163
|
+
[gamesProp, pickLocale],
|
|
164
|
+
);
|
|
165
|
+
const rosterIds = useMemo(() => games.map((g) => g.id), [games]);
|
|
166
|
+
const foundCount = useMemo(() => countFound(scores, rosterIds), [scores, rosterIds]);
|
|
141
167
|
const total = games.length;
|
|
142
168
|
const percent = total > 0 ? Math.round((foundCount / total) * 100) : 0;
|
|
169
|
+
const grandTotal = useMemo(() => totalScore(scores, rosterIds), [scores, rosterIds]);
|
|
170
|
+
const lines = useMemo(
|
|
171
|
+
() => scoreLines(games, (id) => bestFor(scores, id), locale),
|
|
172
|
+
[games, scores, locale],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
/* The post the player publishes. Built here so the copy button, the
|
|
176
|
+
Mastodon link and the LinkedIn link cannot drift apart. */
|
|
177
|
+
const shareText = useMemo(() => buildShareText({
|
|
178
|
+
total: grandTotal,
|
|
179
|
+
lines,
|
|
180
|
+
foundCount,
|
|
181
|
+
totalGames: total,
|
|
182
|
+
hashtag: (shareConfig && shareConfig.hashtag) || '#IReadTheKit',
|
|
183
|
+
url: (shareConfig && shareConfig.url) || (siteConfig && siteConfig.url) || undefined,
|
|
184
|
+
locale,
|
|
185
|
+
}), [grandTotal, lines, foundCount, total, shareConfig, siteConfig, locale]);
|
|
186
|
+
|
|
187
|
+
const copyShareText = useCallback(async () => {
|
|
188
|
+
try {
|
|
189
|
+
await navigator.clipboard.writeText(shareText);
|
|
190
|
+
setCopied(true);
|
|
191
|
+
return true;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
/* No clipboard permission (or no clipboard): fall back to a
|
|
194
|
+
hidden textarea, which works everywhere that still supports
|
|
195
|
+
execCommand, and give up quietly if that fails too. */
|
|
196
|
+
try {
|
|
197
|
+
const ta = document.createElement('textarea');
|
|
198
|
+
ta.value = shareText;
|
|
199
|
+
ta.setAttribute('readonly', '');
|
|
200
|
+
ta.style.position = 'fixed';
|
|
201
|
+
ta.style.opacity = '0';
|
|
202
|
+
document.body.appendChild(ta);
|
|
203
|
+
ta.select();
|
|
204
|
+
const ok = document.execCommand('copy');
|
|
205
|
+
document.body.removeChild(ta);
|
|
206
|
+
setCopied(ok);
|
|
207
|
+
return ok;
|
|
208
|
+
} catch (e2) { return false; }
|
|
209
|
+
}
|
|
210
|
+
}, [shareText]);
|
|
211
|
+
|
|
212
|
+
const shareOnMastodon = useCallback((raw) => {
|
|
213
|
+
const url = mastodonShareUrl(raw, shareText);
|
|
214
|
+
if (!url) { setAskInstance(true); return; }
|
|
215
|
+
const host = normaliseInstance(raw);
|
|
216
|
+
setInstance(host);
|
|
217
|
+
setAskInstance(false);
|
|
218
|
+
try { window.localStorage.setItem(INSTANCE_KEY, host); } catch (e) {/* fine */}
|
|
219
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
220
|
+
}, [shareText]);
|
|
221
|
+
|
|
222
|
+
const shareOnLinkedIn = useCallback(async () => {
|
|
223
|
+
/* LinkedIn stopped honouring prefilled text reliably, so the post
|
|
224
|
+
goes to the clipboard first and the composer opens for a paste. */
|
|
225
|
+
await copyShareText();
|
|
226
|
+
window.open(linkedInShareUrl(shareText), '_blank', 'noopener,noreferrer');
|
|
227
|
+
}, [copyShareText, shareText]);
|
|
143
228
|
|
|
144
229
|
if (!isBrowser || !open || !event) return null;
|
|
145
230
|
|
|
@@ -188,27 +273,147 @@ export default function GameModal({games = DEFAULT_GAMES, className}) {
|
|
|
188
273
|
</div>
|
|
189
274
|
|
|
190
275
|
<ul className={styles.grid}>
|
|
191
|
-
{games.map((g) =>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
276
|
+
{games.map((g) => {
|
|
277
|
+
const best = bestFor(scores, g.id);
|
|
278
|
+
const isFound = Boolean(scores.games[g.id] && scores.games[g.id].found);
|
|
279
|
+
return (
|
|
280
|
+
<li key={g.id} className={isFound ? styles.gridItemFound : styles.gridItem}>
|
|
281
|
+
<span className={styles.gridHex} aria-hidden="true" />
|
|
282
|
+
<span className={styles.gridLabel}>{g.label}</span>
|
|
283
|
+
{best !== null && (
|
|
284
|
+
<span className={styles.gridScore}>{formatScore(best, locale)}</span>
|
|
285
|
+
)}
|
|
286
|
+
</li>
|
|
287
|
+
);
|
|
288
|
+
})}
|
|
197
289
|
</ul>
|
|
198
290
|
|
|
291
|
+
{grandTotal > 0 && (
|
|
292
|
+
<p className={styles.total}>
|
|
293
|
+
<Translate
|
|
294
|
+
id="preset.gameModal.totalScore"
|
|
295
|
+
description="Total score line under the games list. {score} is the sum of the player's best score in every game."
|
|
296
|
+
values={{score: <strong>{formatScore(grandTotal, locale)}</strong>}}>
|
|
297
|
+
{'Total score {score}'}
|
|
298
|
+
</Translate>
|
|
299
|
+
</p>
|
|
300
|
+
)}
|
|
301
|
+
|
|
199
302
|
<p className={styles.cta}>
|
|
303
|
+
{/* Two messages picked here rather than one ICU plural.
|
|
304
|
+
Docusaurus's translate() only substitutes {placeholder};
|
|
305
|
+
it does not expand plurals, so an ICU string renders to
|
|
306
|
+
the reader verbatim, braces and all, in every locale. */}
|
|
200
307
|
{foundCount < total
|
|
201
|
-
?
|
|
308
|
+
? (total - foundCount === 1
|
|
309
|
+
? translate({
|
|
310
|
+
id: 'preset.gameModal.cta.remaining.one',
|
|
311
|
+
message: 'One more game hidden somewhere. Keep clicking.',
|
|
312
|
+
description: 'CTA on the game-over modal when exactly one mini-game is still hidden.',
|
|
313
|
+
})
|
|
314
|
+
: translate(
|
|
315
|
+
{
|
|
316
|
+
id: 'preset.gameModal.cta.remaining.other',
|
|
317
|
+
message: '{remaining} more games hidden somewhere. Keep clicking.',
|
|
318
|
+
description: 'CTA on the game-over modal when several mini-games are still hidden. {remaining} is how many.',
|
|
319
|
+
},
|
|
320
|
+
{remaining: total - foundCount},
|
|
321
|
+
))
|
|
322
|
+
: translate(
|
|
202
323
|
{
|
|
203
|
-
id: 'preset.gameModal.cta.
|
|
204
|
-
message: '
|
|
205
|
-
description: 'CTA
|
|
324
|
+
id: 'preset.gameModal.cta.allFound',
|
|
325
|
+
message: 'All {total} found. You read the kit.',
|
|
326
|
+
description: 'CTA on the game-over modal when every mini-game has been discovered. {total} is how many games there are.',
|
|
206
327
|
},
|
|
207
|
-
{
|
|
208
|
-
)
|
|
209
|
-
: translate({id: 'preset.gameModal.cta.allFound', message: 'All five found. You read the kit.', description: 'CTA text on the game-over modal when the player has discovered every mini-game.'})}
|
|
328
|
+
{total},
|
|
329
|
+
)}
|
|
210
330
|
</p>
|
|
211
331
|
|
|
332
|
+
{/* Posting a score is the whole competition: there is no
|
|
333
|
+
leaderboard to submit to, so the share block is where a run
|
|
334
|
+
turns into something other people can see. */}
|
|
335
|
+
<div className={styles.share}>
|
|
336
|
+
<p className={styles.shareHead}>
|
|
337
|
+
<Translate id="preset.gameModal.share.head" description="Heading above the share buttons on the game-over modal">
|
|
338
|
+
Post your score
|
|
339
|
+
</Translate>
|
|
340
|
+
</p>
|
|
341
|
+
<p className={styles.shareHint}>
|
|
342
|
+
<Translate id="preset.gameModal.share.hint" description="Line under the share heading telling the player to attach a screenshot of the modal">
|
|
343
|
+
Add a screenshot of this card, so people can see the run behind the number.
|
|
344
|
+
</Translate>
|
|
345
|
+
</p>
|
|
346
|
+
|
|
347
|
+
<div className={styles.shareButtons}>
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
className={styles.shareBtn}
|
|
351
|
+
onClick={() => (instance ? shareOnMastodon(instance) : setAskInstance(true))}>
|
|
352
|
+
<Translate id="preset.gameModal.share.mastodon" description="Share-on-Mastodon button label">Mastodon</Translate>
|
|
353
|
+
</button>
|
|
354
|
+
<button type="button" className={styles.shareBtn} onClick={shareOnLinkedIn}>
|
|
355
|
+
<Translate id="preset.gameModal.share.linkedin" description="Share-on-LinkedIn button label">LinkedIn</Translate>
|
|
356
|
+
</button>
|
|
357
|
+
<button type="button" className={styles.shareBtn} onClick={copyShareText}>
|
|
358
|
+
<Translate id="preset.gameModal.share.copy" description="Copy-the-post-text button label">Copy the post</Translate>
|
|
359
|
+
</button>
|
|
360
|
+
<span className={styles.shareStatus} role="status" aria-live="polite">
|
|
361
|
+
{copied && (
|
|
362
|
+
<Translate id="preset.gameModal.share.copied" description="Confirmation shown after the post text is copied to the clipboard">
|
|
363
|
+
Copied. Paste it with your screenshot.
|
|
364
|
+
</Translate>
|
|
365
|
+
)}
|
|
366
|
+
</span>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
{askInstance && (
|
|
370
|
+
/* Mastodon has no central share endpoint, so the post can
|
|
371
|
+
only be opened on the player's own instance. Asked once,
|
|
372
|
+
then remembered. */
|
|
373
|
+
<form
|
|
374
|
+
className={styles.instanceRow}
|
|
375
|
+
onSubmit={(e) => { e.preventDefault(); shareOnMastodon(e.target.elements.instance.value); }}>
|
|
376
|
+
<label className={styles.instanceLabel} htmlFor="gm-instance">
|
|
377
|
+
<Translate id="preset.gameModal.share.instanceLabel" description="Label for the input asking which Mastodon instance the player is on">
|
|
378
|
+
Your Mastodon instance
|
|
379
|
+
</Translate>
|
|
380
|
+
</label>
|
|
381
|
+
<input
|
|
382
|
+
id="gm-instance"
|
|
383
|
+
name="instance"
|
|
384
|
+
className={styles.instanceInput}
|
|
385
|
+
defaultValue={instance}
|
|
386
|
+
placeholder="mastodon.nl"
|
|
387
|
+
autoComplete="off"
|
|
388
|
+
/>
|
|
389
|
+
<button type="submit" className={styles.shareBtn}>
|
|
390
|
+
<Translate id="preset.gameModal.share.instanceGo" description="Submit button next to the Mastodon instance input">Open</Translate>
|
|
391
|
+
</button>
|
|
392
|
+
</form>
|
|
393
|
+
)}
|
|
394
|
+
|
|
395
|
+
{shareConfig && pickLocale(shareConfig.prize) && (
|
|
396
|
+
/* The prize sentence stays text and only the rules link is
|
|
397
|
+
a link: a whole underlined paragraph reads as one long
|
|
398
|
+
link and hides where it goes. */
|
|
399
|
+
<p className={styles.prize}>
|
|
400
|
+
{pickLocale(shareConfig.prize)}
|
|
401
|
+
{shareConfig.prizeHref && (
|
|
402
|
+
<>
|
|
403
|
+
{' '}
|
|
404
|
+
<a href={shareConfig.prizeHref}>
|
|
405
|
+
{pickLocale(shareConfig.prizeLinkLabel) || translate({
|
|
406
|
+
id: 'preset.gameModal.share.rules',
|
|
407
|
+
message: 'Read the rules',
|
|
408
|
+
description: 'Link to the giveaway rules, shown after the prize line in the share block',
|
|
409
|
+
})}
|
|
410
|
+
</a>
|
|
411
|
+
</>
|
|
412
|
+
)}
|
|
413
|
+
</p>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
|
|
212
417
|
<div className={styles.actions}>
|
|
213
418
|
<button type="button" className={styles.btnSecondary} onClick={close}>
|
|
214
419
|
<Translate id="preset.gameModal.action.close" description="Close button label on the game-over modal">Close</Translate>
|
|
@@ -142,6 +142,109 @@
|
|
|
142
142
|
}
|
|
143
143
|
.gridItemFound .gridHex { background: var(--c-mint-500); }
|
|
144
144
|
|
|
145
|
+
/* Per-game best, right-aligned against the game's label. */
|
|
146
|
+
.gridLabel { flex: 1; }
|
|
147
|
+
.gridScore {
|
|
148
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
149
|
+
font-size: 12px;
|
|
150
|
+
color: var(--c-cobalt-700);
|
|
151
|
+
font-variant-numeric: tabular-nums;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.total {
|
|
155
|
+
margin: 12px 0 0;
|
|
156
|
+
font-size: 15px;
|
|
157
|
+
color: var(--c-cobalt-700);
|
|
158
|
+
}
|
|
159
|
+
.total strong {
|
|
160
|
+
color: var(--c-cobalt-900);
|
|
161
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
162
|
+
font-variant-numeric: tabular-nums;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ======================================================================
|
|
166
|
+
Share block
|
|
167
|
+
====================================================================== */
|
|
168
|
+
|
|
169
|
+
.share {
|
|
170
|
+
margin: 20px 0 0;
|
|
171
|
+
padding: 16px;
|
|
172
|
+
background: var(--c-cobalt-50);
|
|
173
|
+
border-radius: var(--radius-md);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.shareHead {
|
|
177
|
+
margin: 0 0 4px;
|
|
178
|
+
font-weight: 600;
|
|
179
|
+
font-size: 14px;
|
|
180
|
+
color: var(--c-cobalt-900);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.shareHint {
|
|
184
|
+
margin: 0 0 12px;
|
|
185
|
+
font-size: 13px;
|
|
186
|
+
color: var(--c-cobalt-400);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.shareButtons {
|
|
190
|
+
display: flex;
|
|
191
|
+
flex-wrap: wrap;
|
|
192
|
+
align-items: center;
|
|
193
|
+
gap: 8px;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.shareBtn {
|
|
197
|
+
background: white;
|
|
198
|
+
color: var(--c-cobalt-700);
|
|
199
|
+
border: 1px solid var(--c-cobalt-200);
|
|
200
|
+
padding: 8px 14px;
|
|
201
|
+
border-radius: var(--radius-pill);
|
|
202
|
+
font-weight: 500;
|
|
203
|
+
font-size: 13px;
|
|
204
|
+
cursor: pointer;
|
|
205
|
+
font-family: inherit;
|
|
206
|
+
transition: border-color 120ms ease, color 120ms ease;
|
|
207
|
+
}
|
|
208
|
+
.shareBtn:hover { border-color: var(--c-blue-cobalt); color: var(--c-blue-cobalt); }
|
|
209
|
+
|
|
210
|
+
.shareStatus {
|
|
211
|
+
font-size: 12px;
|
|
212
|
+
color: var(--c-mint-500);
|
|
213
|
+
min-height: 1em;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.instanceRow {
|
|
217
|
+
display: flex;
|
|
218
|
+
flex-wrap: wrap;
|
|
219
|
+
align-items: center;
|
|
220
|
+
gap: 8px;
|
|
221
|
+
margin-top: 12px;
|
|
222
|
+
}
|
|
223
|
+
.instanceLabel { font-size: 12px; color: var(--c-cobalt-700); }
|
|
224
|
+
.instanceInput {
|
|
225
|
+
flex: 1;
|
|
226
|
+
min-width: 160px;
|
|
227
|
+
padding: 7px 10px;
|
|
228
|
+
border: 1px solid var(--c-cobalt-200);
|
|
229
|
+
border-radius: var(--radius-md);
|
|
230
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
231
|
+
font-size: 13px;
|
|
232
|
+
color: var(--c-cobalt-900);
|
|
233
|
+
background: white;
|
|
234
|
+
}
|
|
235
|
+
.instanceInput:focus {
|
|
236
|
+
outline: 2px solid var(--c-blue-cobalt);
|
|
237
|
+
outline-offset: 1px;
|
|
238
|
+
border-color: var(--c-blue-cobalt);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.prize {
|
|
242
|
+
margin: 12px 0 0;
|
|
243
|
+
font-size: 13px;
|
|
244
|
+
color: var(--c-cobalt-700);
|
|
245
|
+
}
|
|
246
|
+
.prize a { color: var(--c-blue-cobalt); }
|
|
247
|
+
|
|
145
248
|
.cta {
|
|
146
249
|
font-size: 13px;
|
|
147
250
|
color: var(--c-cobalt-400);
|