@nano133/nanoterra 0.6.1 → 0.7.1
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/CHANGELOG.md +15 -0
- package/README.md +35 -1
- package/dist/admin/panel/icons.d.ts +18 -0
- package/dist/admin/panel/icons.js +3 -0
- package/dist/admin/panel/index.d.ts +2 -0
- package/dist/admin/panel/index.js +1 -0
- package/dist/admin/panel/mount.d.ts +5 -0
- package/dist/admin/panel/mount.js +11 -5
- package/dist/admin/panel/theme.d.ts +40 -0
- package/dist/admin/panel/theme.js +142 -0
- package/dist/friends/panel/mount.d.ts +3 -3
- package/dist/friends/panel/mount.js +46 -12
- package/package.json +1 -1
- package/src/admin/panel/icons.ts +3 -0
- package/src/admin/panel/index.ts +2 -0
- package/src/admin/panel/mount.ts +15 -5
- package/src/admin/panel/style.css +222 -62
- package/src/admin/panel/theme.ts +179 -0
- package/src/friends/panel/mount.ts +47 -13
- package/src/friends/panel/style.css +26 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The panel's light and dark themes. The owner picks Light, Dark or Device (the device's own setting, `prefers-color-scheme`, followed live); Device is the
|
|
2
|
+
// default. The choice is kept in this browser only (localStorage, `n133-admin-theme`): no server call, no admin setting. A private window, or a browser that
|
|
3
|
+
// refuses storage, still works: it reads Device and keeps a choice for the life of the page.
|
|
4
|
+
//
|
|
5
|
+
// The resolved theme is set as data-theme="light" | "dark" on the panel's root (style.css reads it), and as data-n133-theme on the page's <html> while the panel
|
|
6
|
+
// is mounted, so the page around the panel can follow it (README, "Light and dark").
|
|
7
|
+
|
|
8
|
+
import { h } from "./dom.js";
|
|
9
|
+
import { icon, type IconName } from "./icons.js";
|
|
10
|
+
|
|
11
|
+
export type ThemeChoice = "device" | "light" | "dark";
|
|
12
|
+
export type Theme = "light" | "dark";
|
|
13
|
+
|
|
14
|
+
/** The localStorage key of the choice. */
|
|
15
|
+
export const THEME_KEY = "n133-admin-theme";
|
|
16
|
+
export const THEME_CHOICES: readonly ThemeChoice[] = ["device", "light", "dark"];
|
|
17
|
+
const QUERY = "(prefers-color-scheme: dark)";
|
|
18
|
+
|
|
19
|
+
/** The theme a choice gives: Device follows the device's setting. */
|
|
20
|
+
export const resolveTheme = (choice: ThemeChoice, deviceDark: boolean): Theme => (choice === "device" ? (deviceDark ? "dark" : "light") : choice);
|
|
21
|
+
|
|
22
|
+
const isChoice = (v: unknown): v is ThemeChoice => typeof v === "string" && (THEME_CHOICES as readonly string[]).includes(v);
|
|
23
|
+
|
|
24
|
+
type Store = Pick<Storage, "getItem" | "setItem">;
|
|
25
|
+
|
|
26
|
+
/** The page's localStorage, or null where reading it throws (some private windows, a blocked site). */
|
|
27
|
+
function pageStorage(): Store | null {
|
|
28
|
+
try {
|
|
29
|
+
return globalThis.localStorage ?? null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The kept choice; Device when there is none, it is not a choice, or the storage cannot be read. */
|
|
36
|
+
export function readThemeChoice(storage: Store | null = pageStorage()): ThemeChoice {
|
|
37
|
+
try {
|
|
38
|
+
const v = storage?.getItem(THEME_KEY);
|
|
39
|
+
return isChoice(v) ? v : "device";
|
|
40
|
+
} catch {
|
|
41
|
+
return "device";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ThemeOptions = {
|
|
46
|
+
/** Where the choice is kept (default: the page's localStorage). null keeps it only for the life of the page. */
|
|
47
|
+
storage?: Store | null;
|
|
48
|
+
/** The device's setting (default: window.matchMedia). */
|
|
49
|
+
matchMedia?: ((query: string) => Pick<MediaQueryList, "matches" | "addEventListener" | "removeEventListener">) | null;
|
|
50
|
+
/** The page's root, which gets data-n133-theme (default: document.documentElement). null sets nothing outside the panel. */
|
|
51
|
+
host?: HTMLElement | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ThemeControl = {
|
|
55
|
+
/** What the owner chose. */
|
|
56
|
+
readonly choice: ThemeChoice;
|
|
57
|
+
/** The theme drawn now. */
|
|
58
|
+
readonly resolved: Theme;
|
|
59
|
+
/** Picks a theme, keeps it in this browser and draws it. */
|
|
60
|
+
set(choice: ThemeChoice): void;
|
|
61
|
+
/** Hears every change of the choice or the drawn theme (the device's setting too). Returns the call that stops it. */
|
|
62
|
+
onChange(listen: (resolved: Theme, choice: ThemeChoice) => void): () => void;
|
|
63
|
+
/** Stops following the device and takes data-n133-theme off the page. */
|
|
64
|
+
destroy(): void;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Reads the kept choice and draws it on `root` at once (data-theme), and on the page's <html> (data-n133-theme). */
|
|
68
|
+
export function createTheme(root: HTMLElement, options: ThemeOptions = {}): ThemeControl {
|
|
69
|
+
const storage = options.storage === undefined ? pageStorage() : options.storage;
|
|
70
|
+
const host = options.host === undefined ? (globalThis.document?.documentElement ?? null) : options.host;
|
|
71
|
+
let media: ReturnType<NonNullable<ThemeOptions["matchMedia"]>> | null = null;
|
|
72
|
+
try {
|
|
73
|
+
const mm = options.matchMedia === undefined ? (typeof globalThis.matchMedia === "function" ? globalThis.matchMedia.bind(globalThis) : null) : options.matchMedia;
|
|
74
|
+
media = mm ? mm(QUERY) : null;
|
|
75
|
+
} catch {
|
|
76
|
+
media = null;
|
|
77
|
+
}
|
|
78
|
+
let choice = readThemeChoice(storage);
|
|
79
|
+
let resolved: Theme = "light";
|
|
80
|
+
const listeners = new Set<(resolved: Theme, choice: ThemeChoice) => void>();
|
|
81
|
+
|
|
82
|
+
function draw() {
|
|
83
|
+
resolved = resolveTheme(choice, media?.matches === true);
|
|
84
|
+
root.setAttribute("data-theme", resolved);
|
|
85
|
+
host?.setAttribute("data-n133-theme", resolved);
|
|
86
|
+
for (const l of listeners) l(resolved, choice);
|
|
87
|
+
}
|
|
88
|
+
const onDevice = () => {
|
|
89
|
+
if (choice === "device") draw();
|
|
90
|
+
};
|
|
91
|
+
media?.addEventListener?.("change", onDevice);
|
|
92
|
+
draw();
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
get choice() {
|
|
96
|
+
return choice;
|
|
97
|
+
},
|
|
98
|
+
get resolved() {
|
|
99
|
+
return resolved;
|
|
100
|
+
},
|
|
101
|
+
set(next) {
|
|
102
|
+
if (!isChoice(next)) return;
|
|
103
|
+
choice = next;
|
|
104
|
+
try {
|
|
105
|
+
storage?.setItem(THEME_KEY, next);
|
|
106
|
+
} catch {
|
|
107
|
+
// A full or refused storage: the choice holds for this page only.
|
|
108
|
+
}
|
|
109
|
+
draw();
|
|
110
|
+
},
|
|
111
|
+
onChange(listen) {
|
|
112
|
+
listeners.add(listen);
|
|
113
|
+
return () => listeners.delete(listen);
|
|
114
|
+
},
|
|
115
|
+
destroy() {
|
|
116
|
+
media?.removeEventListener?.("change", onDevice);
|
|
117
|
+
listeners.clear();
|
|
118
|
+
host?.removeAttribute("data-n133-theme");
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const WORDS: Record<ThemeChoice, string> = { device: "Device", light: "Light", dark: "Dark" };
|
|
124
|
+
const ICONS: Record<ThemeChoice, IconName> = { device: "device", light: "sun", dark: "moon" };
|
|
125
|
+
|
|
126
|
+
/** "Theme: Dark", or "Theme: Device (dark now)". */
|
|
127
|
+
export const themeLabel = (choice: ThemeChoice, resolved: Theme): string => (choice === "device" ? `Theme: Device (${resolved} now)` : `Theme: ${WORDS[choice]}`);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The theme key: a 44 px round key with the choice's icon (a sun, a moon, a half circle for Device). A press opens a small menu of the three choices, each a
|
|
131
|
+
* 44 px row with its icon and word; the chosen one is pressed (aria-pressed). A choice, Escape or a press outside closes it.
|
|
132
|
+
*/
|
|
133
|
+
export function themeKey(theme: ThemeControl): HTMLElement {
|
|
134
|
+
const summary = h("summary", { class: "btn btn-quiet theme-summary" });
|
|
135
|
+
const rows = THEME_CHOICES.map((c) =>
|
|
136
|
+
h(
|
|
137
|
+
"button",
|
|
138
|
+
{
|
|
139
|
+
class: "theme-choice",
|
|
140
|
+
type: "button",
|
|
141
|
+
"data-choice": c,
|
|
142
|
+
onclick: () => {
|
|
143
|
+
theme.set(c);
|
|
144
|
+
box.open = false;
|
|
145
|
+
summary.focus();
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
icon(ICONS[c], 18),
|
|
149
|
+
WORDS[c],
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
const menu = h("div", { class: "theme-menu", role: "group", "aria-label": "Theme" }, ...rows);
|
|
153
|
+
const box = h("details", { class: "theme-key" }, summary, menu);
|
|
154
|
+
|
|
155
|
+
const outside = (e: Event) => {
|
|
156
|
+
if (!box.contains(e.target as Node)) box.open = false;
|
|
157
|
+
};
|
|
158
|
+
box.addEventListener("toggle", () => {
|
|
159
|
+
if (box.open) document.addEventListener("pointerdown", outside, true);
|
|
160
|
+
else document.removeEventListener("pointerdown", outside, true);
|
|
161
|
+
});
|
|
162
|
+
box.addEventListener("keydown", (e) => {
|
|
163
|
+
if ((e as KeyboardEvent).key === "Escape" && box.open) {
|
|
164
|
+
box.open = false;
|
|
165
|
+
summary.focus();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const show = (resolved: Theme, choice: ThemeChoice) => {
|
|
170
|
+
const label = themeLabel(choice, resolved);
|
|
171
|
+
summary.replaceChildren(icon(ICONS[choice], 20));
|
|
172
|
+
summary.title = label;
|
|
173
|
+
summary.setAttribute("aria-label", label);
|
|
174
|
+
for (const r of rows) r.setAttribute("aria-pressed", String(r.dataset.choice === choice));
|
|
175
|
+
};
|
|
176
|
+
show(theme.resolved, theme.choice);
|
|
177
|
+
theme.onChange(show);
|
|
178
|
+
return box;
|
|
179
|
+
}
|
|
@@ -32,13 +32,13 @@ export type FriendsMountOptions = {
|
|
|
32
32
|
/**
|
|
33
33
|
* The player chose to join a friend. "nanoterra": `target` is the friend's https address (the kit has asked the player already) and there is no
|
|
34
34
|
* `friend` (the window sends no name); with no `onJoin` the page goes there. "own": `target` is the `where` of the friend's invite, for the game to
|
|
35
|
-
* move the player, and `friend` names them (with no `onJoin`, there is no Join key).
|
|
35
|
+
* move the player, and `friend` names them (with no `onJoin`, there is no Join key and no Invite key: an invite would lead nowhere).
|
|
36
36
|
*/
|
|
37
37
|
onJoin?: (target: string, friend?: { name: string }) => void;
|
|
38
38
|
/** The `fetch` to use (default: the page's). */
|
|
39
39
|
fetch?: typeof fetch;
|
|
40
|
-
/** Asks the player a yes or no question (default:
|
|
41
|
-
confirm?: (question: string) => boolean
|
|
40
|
+
/** Asks the player a yes or no question (default: a question in the panel, with a key for each answer; never a browser dialog). */
|
|
41
|
+
confirm?: (question: string) => boolean | Promise<boolean>;
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
export type MountedFriends = {
|
|
@@ -60,7 +60,6 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
60
60
|
if (options.mode !== "nanoterra" && options.mode !== "own") return NOTHING;
|
|
61
61
|
const n = ++mounts;
|
|
62
62
|
const origin = (options.origin ?? "https://nano133.com").replace(/\/+$/, "");
|
|
63
|
-
const ask = options.confirm ?? ((q: string) => window.confirm(q));
|
|
64
63
|
let where = options.where === undefined ? null : whereText(options.where);
|
|
65
64
|
let isOpen = false;
|
|
66
65
|
let destroyed = false;
|
|
@@ -73,7 +72,12 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
73
72
|
const title = h("h2", { class: "n133f-title", id: `n133f-title-${n}` }, "Friends");
|
|
74
73
|
const closeKey = h("button", { class: "n133f-close", type: "button", "aria-label": "Close friends" }, icon("close", 20));
|
|
75
74
|
const body = h("div", { class: "n133f-body" });
|
|
76
|
-
const
|
|
75
|
+
const question = h("p", { class: "n133f-ask-q", id: `n133f-ask-${n}` });
|
|
76
|
+
const yesKey = h("button", { class: "n133f-btn n133f-primary", type: "button" });
|
|
77
|
+
const noKey = h("button", { class: "n133f-btn", type: "button" }, "Cancel");
|
|
78
|
+
const askBox = h("div", { class: "n133f-ask", role: "alertdialog", "aria-labelledby": `n133f-ask-${n}` }, question, h("div", { class: "n133f-ask-keys" }, yesKey, noKey));
|
|
79
|
+
askBox.hidden = true;
|
|
80
|
+
const panel = h("section", { class: `n133f-panel n133f-${options.mode}`, id: `n133f-panel-${n}`, role: "dialog", "aria-labelledby": `n133f-title-${n}`, "data-open": "false" }, h("header", { class: "n133f-head" }, title, closeKey), askBox, body);
|
|
77
81
|
const wrap = h("div", { class: "n133-friends" }, key, panel);
|
|
78
82
|
root.append(wrap);
|
|
79
83
|
|
|
@@ -99,6 +103,7 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
99
103
|
};
|
|
100
104
|
const close = () => {
|
|
101
105
|
if (destroyed || !isOpen) return;
|
|
106
|
+
answer?.(false);
|
|
102
107
|
isOpen = false;
|
|
103
108
|
panel.dataset.open = "false";
|
|
104
109
|
key.setAttribute("aria-expanded", "false");
|
|
@@ -107,8 +112,34 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
107
112
|
};
|
|
108
113
|
key.addEventListener("click", () => (isOpen ? close() : open()));
|
|
109
114
|
closeKey.addEventListener("click", close);
|
|
115
|
+
|
|
116
|
+
/** The question in the panel: one at a time (a new one answers the old with no), and closing the panel answers no. */
|
|
117
|
+
let answer: ((yes: boolean) => void) | null = null;
|
|
118
|
+
const askInPanel = (q: string, yesWord: string) =>
|
|
119
|
+
new Promise<boolean>((resolve) => {
|
|
120
|
+
answer?.(false);
|
|
121
|
+
question.textContent = q;
|
|
122
|
+
yesKey.textContent = yesWord;
|
|
123
|
+
askBox.hidden = false;
|
|
124
|
+
answer = (yes) => {
|
|
125
|
+
answer = null;
|
|
126
|
+
askBox.hidden = true;
|
|
127
|
+
resolve(yes);
|
|
128
|
+
if (isOpen && !destroyed) closeKey.focus();
|
|
129
|
+
};
|
|
130
|
+
open();
|
|
131
|
+
yesKey.focus();
|
|
132
|
+
});
|
|
133
|
+
yesKey.addEventListener("click", () => answer?.(true));
|
|
134
|
+
noKey.addEventListener("click", () => answer?.(false));
|
|
135
|
+
const ask = (q: string, yesWord: string): Promise<boolean> => (options.confirm ? Promise.resolve(options.confirm(q)) : askInPanel(q, yesWord));
|
|
136
|
+
cleanup.push(() => answer?.(false));
|
|
137
|
+
|
|
110
138
|
const onKey = (e: KeyboardEvent) => {
|
|
111
|
-
if (e.key
|
|
139
|
+
if (e.key !== "Escape" || !isOpen) return;
|
|
140
|
+
// Escape answers an open question first, then closes the panel.
|
|
141
|
+
if (answer) answer(false);
|
|
142
|
+
else close();
|
|
112
143
|
};
|
|
113
144
|
document.addEventListener("keydown", onKey);
|
|
114
145
|
cleanup.push(() => document.removeEventListener("keydown", onKey));
|
|
@@ -165,9 +196,12 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
165
196
|
frame.style.height = `${room > 0 ? Math.min(m.height, room) : m.height}px`;
|
|
166
197
|
} else if (m.type === "badge") setBadge(m);
|
|
167
198
|
else if (m.type === "join") {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
199
|
+
const url = m.url;
|
|
200
|
+
void ask("Leave this game to join your friend?", "Join").then((yes) => {
|
|
201
|
+
if (!yes || destroyed) return;
|
|
202
|
+
if (options.onJoin) options.onJoin(url);
|
|
203
|
+
else window.location.assign(url);
|
|
204
|
+
});
|
|
171
205
|
}
|
|
172
206
|
// "signin": the window needs a tap (Storage Access) inside itself. The panel stays as it is; the kit does nothing else.
|
|
173
207
|
};
|
|
@@ -198,7 +232,7 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
198
232
|
}
|
|
199
233
|
|
|
200
234
|
/** The game's own list in `body`. */
|
|
201
|
-
function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () => string | null, setBadge: (n: { requests: number; online: number }) => void, ask: (q: string) => boolean) {
|
|
235
|
+
function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () => string | null, setBadge: (n: { requests: number; online: number }) => void, ask: (q: string, yesWord: string) => Promise<boolean>) {
|
|
202
236
|
const api: Api = createApi(options.base ?? "/friends", options.fetch);
|
|
203
237
|
const lease = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
204
238
|
const status = h("p", { class: "n133f-status", role: "status", "aria-live": "polite" });
|
|
@@ -253,9 +287,9 @@ function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () =
|
|
|
253
287
|
if (p.state === "friend") {
|
|
254
288
|
if (p.invite && options.onJoin) keys.push(btn("Join", () => options.onJoin!(p.invite!.where, { name: p.name }), "n133f-primary"));
|
|
255
289
|
const here = whereNow();
|
|
256
|
-
if (here) keys.push(btn("Invite", () => void act({ op: "invite", edge: p.edge, where: here }, `Invited ${p.name}.`)));
|
|
257
|
-
keys.push(btn("Remove", () => ask(`Remove ${p.name} from your friends
|
|
258
|
-
keys.push(btn("Block", () => ask(`Block ${p.name}? They will not see you or be able to ask you again
|
|
290
|
+
if (here && options.onJoin) keys.push(btn("Invite", () => void act({ op: "invite", edge: p.edge, where: here }, `Invited ${p.name}.`)));
|
|
291
|
+
keys.push(btn("Remove", () => void ask(`Remove ${p.name} from your friends?`, "Remove").then((yes) => (yes ? act({ op: "remove", edge: p.edge }) : undefined))));
|
|
292
|
+
keys.push(btn("Block", () => void ask(`Block ${p.name}? They will not see you or be able to ask you again.`, "Block").then((yes) => (yes ? act({ op: "block", edge: p.edge }) : undefined)), "n133f-quiet"));
|
|
259
293
|
}
|
|
260
294
|
if (p.state === "outgoing") keys.push(btn("Cancel", () => void act({ op: "decline", edge: p.edge })));
|
|
261
295
|
if (p.state === "blocked") keys.push(btn("Unblock", () => void act({ op: "unblock", edge: p.edge })));
|
|
@@ -202,6 +202,32 @@
|
|
|
202
202
|
outline-offset: 2px;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/* The panel's own question (Remove, Block, Join): never a browser dialog. */
|
|
206
|
+
.n133-friends .n133f-ask {
|
|
207
|
+
flex: 0 0 auto;
|
|
208
|
+
margin: 12px 16px 0;
|
|
209
|
+
padding: 12px;
|
|
210
|
+
border: 1px solid var(--gem-dark);
|
|
211
|
+
border-radius: 14px;
|
|
212
|
+
background: var(--paper);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.n133-friends .n133f-ask[hidden] {
|
|
216
|
+
display: none;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.n133-friends .n133f-ask-q {
|
|
220
|
+
margin: 0 0 10px;
|
|
221
|
+
font-weight: 650;
|
|
222
|
+
overflow-wrap: anywhere;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.n133-friends .n133f-ask-keys {
|
|
226
|
+
display: flex;
|
|
227
|
+
flex-wrap: wrap;
|
|
228
|
+
gap: 8px;
|
|
229
|
+
}
|
|
230
|
+
|
|
205
231
|
.n133-friends .n133f-status:empty {
|
|
206
232
|
display: none;
|
|
207
233
|
}
|