mlv2boii 1.0.10 → 1.0.11

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.
Files changed (2) hide show
  1. package/index.html +887 -36
  2. package/package.json +1 -1
package/index.html CHANGED
@@ -56,11 +56,11 @@
56
56
  radial-gradient(720px 520px at 8% 108%, rgba(240,26,54,0.06), transparent 70%); }
57
57
 
58
58
  /* ---------- liquid glass ---------- */
59
- .glass { background: rgba(255,255,255,0.06); -webkit-backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
60
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.22), inset 0 -1px 0 rgba(255,255,255,0.04), 0 10px 32px rgba(0,0,0,0.45); }
59
+ .glass { background: rgba(255,255,255,0.05); -webkit-backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
60
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.12), 0 10px 32px rgba(0,0,0,0.4); }
61
61
  .glass-sheet { background: rgba(28,28,31,0.55); -webkit-backdrop-filter: blur(40px) saturate(180%); backdrop-filter: blur(40px) saturate(180%); }
62
62
  .glass::before { content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; pointer-events: none;
63
- background: linear-gradient(135deg, rgba(255,255,255,0.4), rgba(255,255,255,0.05) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.22));
63
+ background: linear-gradient(135deg, rgba(255,255,255,0.16), rgba(255,255,255,0.03) 40%, rgba(255,255,255,0.03) 60%, rgba(255,255,255,0.09));
64
64
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; }
65
65
  .glass-lite { background: rgba(255,255,255,0.07); box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), inset 0 0 0 1px rgba(255,255,255,0.08); }
66
66
  .glass-lite:hover { background: rgba(255,255,255,0.12); }
@@ -117,6 +117,32 @@
117
117
  .switch:checked::after { transform: translateX(18px); }
118
118
  .switch:disabled, button:disabled { opacity: 0.45; cursor: not-allowed; }
119
119
 
120
+ /* ---------- top bar: same recipe as the game cards so it blends in ---------- */
121
+ .topbar { background: rgba(255,255,255,0.045); -webkit-backdrop-filter: blur(20px) saturate(160%); backdrop-filter: blur(20px) saturate(160%);
122
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.09); }
123
+ .topbar .field { background: rgba(255,255,255,0.05); }
124
+
125
+ /* ---------- settings + tabs ---------- */
126
+ .tab { height: 36px; padding: 0 14px; border-radius: 999px; font-size: 14px; font-weight: 500; color: var(--color-ink-300); transition: background-color .2s, color .2s; }
127
+ .tab:hover { color: #fff; }
128
+ .tab[aria-selected="true"] { background: rgba(255,255,255,0.14); color: #fff; }
129
+ .shead { margin: 22px 4px 8px; font-size: 13px; font-weight: 600; color: var(--color-ink-300); }
130
+ .shead:first-child { margin-top: 6px; }
131
+ .sgroup { border-radius: 20px; padding: 2px 16px; background: rgba(255,255,255,0.05); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08); }
132
+ .srow { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 0; border-bottom: 1px solid rgba(255,255,255,0.07); cursor: pointer; }
133
+ .srow.plain { cursor: default; }
134
+ .sgroup > .srow:last-child, .sgroup > div:last-child { border-bottom: 0; }
135
+ .opt { display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 18px; cursor: pointer; background: rgba(255,255,255,0.05); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08); }
136
+ .opt:hover { background: rgba(255,255,255,0.08); }
137
+ .opt:has(input:checked) { background: rgba(240,26,54,0.16); box-shadow: inset 0 0 0 1.5px rgba(240,26,54,0.75); }
138
+ .opt:has(input:focus-visible), .pill-opt:has(input:focus-visible) { outline: 2px solid var(--color-red-400); outline-offset: 2px; }
139
+ .pill-opt { cursor: pointer; border-radius: 999px; font-size: 13px; font-weight: 500; color: var(--color-ink-200); }
140
+ .pill-opt span { display: block; padding: 7px 12px; border-radius: 999px; }
141
+ .pill-opt:has(input:checked) span { background: rgba(255,255,255,0.16); color: #fff; }
142
+ kbd { font-family: var(--font-mono); font-size: 13px; }
143
+ html.rm *, html.rm *::before, html.rm *::after { animation: none !important; transition-duration: 0.01ms !important; }
144
+ html.rm .tile-icon { transform: none; }
145
+
120
146
  /* ---------- motion ---------- */
121
147
  .w { display: inline-block; opacity: 0; }
122
148
  .glowwrap { display: inline-block; filter: drop-shadow(0 0 22px rgba(240,26,54,0.55)); }
@@ -164,6 +190,10 @@
164
190
 
165
191
  <div class="ambient" aria-hidden="true"></div>
166
192
 
193
+ <button id="panic-btn" type="button" hidden aria-label="Panic: leave this page now" title="Panic: leave this page now" class="glass-lite icon-btn fixed bottom-4 left-4 z-[45] opacity-70 hover:opacity-100">
194
+ <svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
195
+ </button>
196
+
167
197
  <!-- shared gradients for the hero shapes -->
168
198
  <svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
169
199
  <radialGradient id="gP" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffc0c7"/><stop offset=".38" stop-color="#ff3347"/><stop offset="1" stop-color="#b30d25"/></radialGradient>
@@ -174,7 +204,7 @@
174
204
  <!-- ================= HEADER ================= -->
175
205
  <header class="pointer-events-none sticky top-0 z-40 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
176
206
  <div class="page-x mx-auto max-w-5xl">
177
- <div class="glass pointer-events-auto relative flex h-14 items-center gap-4 rounded-full pl-5 pr-2">
207
+ <div class="topbar pointer-events-auto relative flex h-14 items-center gap-3 rounded-full pl-5 pr-2">
178
208
  <a href="#" class="flex shrink-0 items-center gap-2.5" aria-label="mlv2 home">
179
209
  <span class="orb"></span>
180
210
  <span class="text-[17px] font-semibold tracking-[-0.02em]">mlv2</span>
@@ -186,6 +216,15 @@
186
216
  <button id="search-clear" type="button" aria-label="Clear search" class="absolute right-1.5 top-1/2 hidden h-7 w-7 -translate-y-1/2 place-items-center rounded-full text-ink-300 hover:text-white"><svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
187
217
  </div>
188
218
  </div>
219
+ <div class="flex shrink-0 items-center gap-1.5">
220
+ <button id="bell-btn" type="button" hidden aria-label="Announcements" class="glass-lite icon-btn relative">
221
+ <svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
222
+ <span id="bell-dot" hidden class="absolute right-2 top-2 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-[#111113]"></span>
223
+ </button>
224
+ <button id="settings-btn" type="button" aria-label="Settings" aria-haspopup="dialog" class="glass-lite icon-btn">
225
+ <svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
226
+ </button>
227
+ </div>
189
228
  </div>
190
229
  </div>
191
230
  </header>
@@ -266,6 +305,9 @@
266
305
  <p id="player-title" class="truncate text-[15px] font-semibold"></p>
267
306
  </div>
268
307
  <div class="flex shrink-0 items-center gap-2">
308
+ <button id="player-panic" type="button" hidden aria-label="Panic: leave this page now" title="Panic: leave this page now" class="glass-lite icon-btn">
309
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
310
+ </button>
269
311
  <button id="player-fs" type="button" title="Fullscreen" aria-label="Fullscreen" class="glass-lite icon-btn">
270
312
  <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
271
313
  </button>
@@ -286,10 +328,10 @@
286
328
  </div>
287
329
 
288
330
  <!-- ================= ADMIN: PIN GATE ================= -->
289
- <div id="admin-login-modal" class="fixed inset-0 z-50 hidden">
290
- <div class="absolute inset-0 bg-black/50 backdrop-blur-md" data-admin-login-close></div>
291
- <div class="absolute inset-0 flex items-center justify-center p-4">
292
- <form id="admin-login-form" role="dialog" aria-modal="true" aria-labelledby="pin-title" class="glass glass-sheet relative w-full max-w-[340px] rounded-[28px] p-6 opacity-0" data-fx>
331
+ <div id="admin-login-modal" data-nomask class="fixed inset-0 z-50 hidden">
332
+ <div data-backdrop="login" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
333
+ <div class="pointer-events-none absolute inset-0 flex items-center justify-center p-4">
334
+ <form id="admin-login-form" role="dialog" aria-modal="true" aria-labelledby="pin-title" class="glass glass-sheet pointer-events-auto relative w-full max-w-[340px] rounded-[28px] p-6 opacity-0" data-fx>
293
335
  <h2 id="pin-title" class="text-[19px] font-semibold">Enter PIN</h2>
294
336
  <p class="mt-1.5 text-pretty text-[14px] text-ink-400">This unlocks the admin screen on this device. Write access is controlled by your Appwrite permissions.</p>
295
337
  <div id="pin-boxes" role="group" aria-labelledby="pin-title" class="mt-5 grid grid-cols-6 gap-2"></div>
@@ -302,38 +344,144 @@
302
344
  </div>
303
345
 
304
346
  <!-- ================= ADMIN: PANEL ================= -->
305
- <div id="admin-panel" class="fixed inset-0 z-50 hidden">
306
- <div id="admin-backdrop" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
307
- <div class="absolute inset-0 flex items-start justify-center p-0 sm:items-center sm:p-4">
308
- <div id="admin-panel-card" role="dialog" aria-modal="true" aria-labelledby="admin-title" class="glass glass-sheet relative flex h-full w-full flex-col opacity-0 sm:h-[85vh] sm:max-w-2xl sm:rounded-[32px]">
309
- <div class="flex shrink-0 items-center justify-between gap-3 border-b border-white/[0.08] px-5 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
347
+ <div id="admin-panel" data-nomask class="fixed inset-0 z-50 hidden">
348
+ <div data-backdrop="admin" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
349
+ <div class="pointer-events-none absolute inset-0 flex items-start justify-center p-0 sm:items-center sm:p-4">
350
+ <div id="admin-panel-card" role="dialog" aria-modal="true" aria-labelledby="admin-title" class="glass glass-sheet pointer-events-auto relative flex h-full w-full flex-col opacity-0 sm:h-[85vh] sm:max-w-2xl sm:rounded-[32px]">
351
+ <div class="flex shrink-0 items-center justify-between gap-3 px-5 pb-2 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
310
352
  <div class="min-w-0">
311
- <h2 id="admin-title" tabindex="-1" class="text-[17px] font-semibold leading-tight">Manage games</h2>
353
+ <h2 id="admin-title" tabindex="-1" class="text-[17px] font-semibold leading-tight">Admin</h2>
312
354
  <p id="admin-summary" class="mt-0.5 text-[13px] text-ink-300"></p>
313
355
  </div>
314
356
  <div class="flex shrink-0 items-center gap-2">
315
357
  <button id="admin-logout" type="button" class="btn btn-ghost">Lock</button>
316
- <button id="admin-close" type="button" aria-label="Close" class="glass-lite icon-btn">
317
- <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
318
- </button>
358
+ <button id="admin-close" type="button" aria-label="Close" class="glass-lite icon-btn"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
319
359
  </div>
320
360
  </div>
321
- <div class="flex shrink-0 gap-2 px-5 pt-4">
322
- <div class="min-w-0 flex-1">
323
- <label for="admin-filter" class="sr-only">Filter games</label>
324
- <input id="admin-filter" type="search" placeholder="Filter games" autocomplete="off" class="field" />
361
+ <div id="admin-tabs" role="tablist" aria-label="Admin sections" class="flex shrink-0 gap-1 border-b border-white/[0.08] px-5 pb-3">
362
+ <button id="atab-games" role="tab" type="button" aria-selected="true" aria-controls="admin-tab-games" data-atab="games" class="tab">Games</button>
363
+ <button id="atab-ann" role="tab" type="button" aria-selected="false" tabindex="-1" aria-controls="admin-tab-ann" data-atab="ann" class="tab">Announcements</button>
364
+ </div>
365
+
366
+ <div id="admin-tab-games" role="tabpanel" aria-labelledby="atab-games" class="flex min-h-0 flex-1 flex-col">
367
+ <div class="flex shrink-0 gap-2 px-5 pt-4">
368
+ <div class="min-w-0 flex-1">
369
+ <label for="admin-filter" class="sr-only">Filter games</label>
370
+ <input id="admin-filter" type="search" placeholder="Filter games" autocomplete="off" class="field" />
371
+ </div>
372
+ <button id="admin-add" type="button" class="btn btn-primary shrink-0">Add game</button>
373
+ </div>
374
+ <div id="admin-body" class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
375
+ <div id="admin-form-slot"></div>
376
+ <ul id="admin-list" class="space-y-2" aria-label="Games"></ul>
325
377
  </div>
326
- <button id="admin-add" type="button" class="btn btn-primary shrink-0">Add game</button>
327
378
  </div>
328
- <div id="admin-body" class="flex-1 overflow-y-auto px-5 py-4">
329
- <div id="admin-form-slot"></div>
330
- <ul id="admin-list" class="space-y-2" aria-label="Games"></ul>
379
+
380
+ <div id="admin-tab-ann" role="tabpanel" aria-labelledby="atab-ann" hidden class="flex min-h-0 flex-1 flex-col">
381
+ <div class="flex shrink-0 items-center justify-between gap-3 px-5 pt-4">
382
+ <p class="text-[13px] text-ink-300">Shown to visitors when they open the site.</p>
383
+ <button id="ann-add" type="button" class="btn btn-primary shrink-0">New announcement</button>
384
+ </div>
385
+ <div id="ann-admin-body" class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
386
+ <div id="ann-form-slot"></div>
387
+ <ul id="ann-admin-list" class="space-y-2" aria-label="Announcements"></ul>
388
+ </div>
331
389
  </div>
332
390
  <p id="admin-live" role="status" class="sr-only"></p>
333
391
  </div>
334
392
  </div>
335
393
  </div>
336
394
 
395
+ <!-- ================= ANNOUNCEMENTS ================= -->
396
+ <div id="ann-modal" class="fixed inset-0 z-50 hidden">
397
+ <div data-backdrop="ann" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
398
+ <div class="pointer-events-none absolute inset-0 flex items-center justify-center p-4">
399
+ <div id="ann-card" role="dialog" aria-modal="true" aria-labelledby="ann-title" class="glass glass-sheet pointer-events-auto relative flex max-h-[85vh] w-full max-w-[480px] flex-col rounded-[32px] opacity-0">
400
+ <div class="flex shrink-0 items-center justify-between gap-3 px-6 pb-1 pt-5">
401
+ <h2 id="ann-title" tabindex="-1" class="text-[19px] font-semibold">Announcements</h2>
402
+ <button id="ann-close" type="button" aria-label="Close" class="glass-lite icon-btn"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
403
+ </div>
404
+ <div id="ann-list" class="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 pb-2 pt-3"></div>
405
+ <div class="shrink-0 px-4 pb-4 pt-2"><button id="ann-done" type="button" class="btn btn-primary w-full">Got it</button></div>
406
+ </div>
407
+ </div>
408
+ </div>
409
+
410
+ <!-- ================= SETTINGS ================= -->
411
+ <div id="settings-modal" data-nomask class="fixed inset-0 z-50 hidden">
412
+ <div data-backdrop="settings" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
413
+ <div class="pointer-events-none absolute inset-0 flex items-start justify-center p-0 sm:items-center sm:p-4">
414
+ <div id="settings-card" role="dialog" aria-modal="true" aria-labelledby="settings-title" class="glass glass-sheet pointer-events-auto relative flex h-full w-full flex-col opacity-0 sm:h-[82vh] sm:max-w-xl sm:rounded-[32px]">
415
+ <div class="flex shrink-0 items-center justify-between gap-3 px-5 pb-2 pt-[calc(env(safe-area-inset-top)+0.9rem)]">
416
+ <h2 id="settings-title" tabindex="-1" class="text-[19px] font-semibold">Settings</h2>
417
+ <button id="settings-close" type="button" aria-label="Close" class="glass-lite icon-btn"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
418
+ </div>
419
+ <div id="settings-tabs" role="tablist" aria-label="Settings sections" class="flex shrink-0 gap-1 px-5 pb-3">
420
+ <button id="stab-general" role="tab" type="button" aria-selected="true" aria-controls="spanel-general" class="tab">General</button>
421
+ <button id="stab-cloak" role="tab" type="button" aria-selected="false" tabindex="-1" aria-controls="spanel-cloak" class="tab">Cloak</button>
422
+ <button id="stab-data" role="tab" type="button" aria-selected="false" tabindex="-1" aria-controls="spanel-data" class="tab">Data</button>
423
+ </div>
424
+ <div class="min-h-0 flex-1 overflow-y-auto px-5 pb-8">
425
+
426
+ <section id="spanel-general" role="tabpanel" aria-labelledby="stab-general">
427
+ <h3 class="shead">Experience</h3>
428
+ <div class="sgroup">
429
+ <label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Show announcements when I open the site</span><span class="mt-0.5 block text-[13px] text-ink-300">You can always reopen them with the bell in the top bar.</span></span><input id="set-announce" type="checkbox" role="switch" class="switch" /></label>
430
+ <label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Reduce motion</span><span class="mt-0.5 block text-[13px] text-ink-300">Turns off animations. Already on if your device asks for it.</span></span><input id="set-reduce" type="checkbox" role="switch" class="switch" /></label>
431
+ <div class="srow plain"><span id="tile-label" class="text-[15px] font-medium">Tile size</span><div role="radiogroup" aria-labelledby="tile-label" class="flex shrink-0 gap-1"><label class="pill-opt"><input type="radio" name="tile" value="compact" class="sr-only" /><span>Compact</span></label><label class="pill-opt"><input type="radio" name="tile" value="default" class="sr-only" /><span>Default</span></label><label class="pill-opt"><input type="radio" name="tile" value="large" class="sr-only" /><span>Large</span></label></div></div>
432
+ </div>
433
+ <h3 class="shead">Admin</h3>
434
+ <div class="sgroup"><div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Admin panel</span><span class="mt-0.5 block text-[13px] text-ink-300">Add games, post announcements and change the order.</span></span><button id="open-admin" type="button" class="btn btn-ghost shrink-0">Open</button></div></div>
435
+ </section>
436
+
437
+ <section id="spanel-cloak" role="tabpanel" aria-labelledby="stab-cloak" hidden>
438
+ <h3 class="shead">Tab disguise</h3>
439
+ <p class="mb-3 px-1 text-[13px] text-ink-300">Changes the tab's title and icon so it looks like something else.</p>
440
+ <div id="cloak-options" role="radiogroup" aria-label="Tab disguise" class="grid gap-2"></div>
441
+ <div id="custom-cloak" hidden class="mt-3 space-y-3">
442
+ <div><label for="cc-title" class="text-[13px] font-medium text-ink-200">Tab title</label><input id="cc-title" maxlength="80" autocomplete="off" class="field mt-1.5" /></div>
443
+ <div><label for="cc-icon" class="text-[13px] font-medium text-ink-200">Icon link <span class="font-normal text-ink-300">(optional)</span></label><input id="cc-icon" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://" class="field mt-1.5" /></div>
444
+ </div>
445
+
446
+ <h3 class="shead">Panic button</h3>
447
+ <div class="sgroup">
448
+ <label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Enable panic button</span><span class="mt-0.5 block text-[13px] text-ink-300">Instantly leaves this page and opens the site below.</span></span><input id="panic-on" type="checkbox" role="switch" class="switch" /></label>
449
+ <div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Shortcut</span><span class="mt-0.5 block text-[13px] text-ink-300"><span id="panic-key-hint">Works while this page has focus, not inside a game.</span></span></span><button id="panic-key" type="button" aria-describedby="panic-key-hint" class="btn btn-ghost shrink-0"></button></div>
450
+ <div class="py-3.5"><label for="panic-url" class="block text-[15px] font-medium">Go to</label><input id="panic-url" type="url" inputmode="url" autocomplete="off" spellcheck="false" aria-describedby="panic-url-err" class="field mt-1.5" /><p id="panic-url-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p></div>
451
+ <label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Show an on-screen button</span><span class="mt-0.5 block text-[13px] text-ink-300">A small button in the corner and in the game window, for phones or when a game has focus.</span></span><input id="panic-btn-on" type="checkbox" role="switch" class="switch" /></label>
452
+ </div>
453
+
454
+ <h3 class="shead">Privacy</h3>
455
+ <div class="sgroup">
456
+ <label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Mask text</span><span class="mt-0.5 block text-[13px] text-ink-300">Swaps letters for look-alike characters so on-screen keyword scanners can't read them. Screen readers may read masked text oddly.</span></span><input id="set-mask" type="checkbox" role="switch" class="switch" /></label>
457
+ <label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Ask before closing this tab</span><span class="mt-0.5 block text-[13px] text-ink-300">Shows your browser's “leave site?” prompt so you don't close it by accident.</span></span><input id="set-confirm" type="checkbox" role="switch" class="switch" /></label>
458
+ <div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Open in about:blank</span><span class="mt-0.5 block text-[13px] text-ink-300">Opens the site in a blank tab and sends this tab to your panic page.</span></span><button id="launch-blank" type="button" class="btn btn-ghost shrink-0">Open</button></div>
459
+ </div>
460
+ </section>
461
+
462
+ <section id="spanel-data" role="tabpanel" aria-labelledby="stab-data" hidden>
463
+ <h3 class="shead">Your settings</h3>
464
+ <div class="sgroup">
465
+ <div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Export settings</span><span class="mt-0.5 block text-[13px] text-ink-300">Save your settings to a file.</span></span><button id="export-settings" type="button" class="btn btn-ghost shrink-0">Export</button></div>
466
+ <div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Import settings</span><span class="mt-0.5 block text-[13px] text-ink-300">Load settings from a file you exported.</span></span><button id="import-settings-btn" type="button" class="btn btn-ghost shrink-0">Import</button><input id="import-settings" type="file" accept="application/json,.json" tabindex="-1" aria-label="Settings file" class="sr-only" /></div>
467
+ <div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Show announcements again</span><span class="mt-0.5 block text-[13px] text-ink-300">Forget which announcements you've already seen.</span></span><button id="reset-seen" type="button" class="btn btn-ghost shrink-0">Reset</button></div>
468
+ <div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Reset settings</span><span class="mt-0.5 block text-[13px] text-ink-300">Put every setting back to its default.</span></span><button id="reset-settings" type="button" class="btn btn-ghost shrink-0">Reset</button></div>
469
+ </div>
470
+
471
+ <h3 class="shead">Game library</h3>
472
+ <p id="lib-lock" class="mb-3 px-1 text-[13px] text-ink-300">Admin only. Unlock the admin panel to back up or restore your games.</p>
473
+ <div class="sgroup">
474
+ <div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Export games</span><span class="mt-0.5 block text-[13px] text-ink-300">Download every game as a file.</span></span><button id="export-games" type="button" class="btn btn-ghost shrink-0">Export</button></div>
475
+ <div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Import games</span><span class="mt-0.5 block text-[13px] text-ink-300">Add games from an export. Duplicates are skipped.</span></span><button id="import-games-btn" type="button" class="btn btn-ghost shrink-0">Choose file</button><input id="import-games" type="file" accept="application/json,.json" tabindex="-1" aria-label="Games file" class="sr-only" /></div>
476
+ </div>
477
+ <p id="lib-status" role="status" class="mt-3 px-1 text-[13px] text-ink-200"></p>
478
+ <div class="mt-3 flex gap-2"><button id="import-games-go" type="button" hidden class="btn btn-primary"></button><button id="unlock-admin" type="button" hidden class="btn btn-ghost">Unlock admin</button></div>
479
+ </section>
480
+ </div>
481
+ </div>
482
+ </div>
483
+ </div>
484
+
337
485
  <div id="toast-root" role="status" aria-live="polite" class="pointer-events-none fixed inset-x-0 bottom-4 z-[60] flex flex-col items-center gap-2 px-4 pb-[env(safe-area-inset-bottom)]"></div>
338
486
 
339
487
  <script src="https://cdn.jsdelivr.net/npm/appwrite@26.2.0"></script>
@@ -346,6 +494,8 @@ const CONFIG = {
346
494
  databaseId: "6a9b8b23003de0ec5cf7",
347
495
  gamesTableId: "6a9b8bb3000013a3fd80",
348
496
  iconsBucketId: "6a9b8b36002565ac5987",
497
+ // Announcements need their own table in the same database. Paste its ID here (see Admin > Announcements for the columns).
498
+ announcementsTableId: "",
349
499
  };
350
500
 
351
501
  const ADMIN_PIN = "051511";
@@ -360,7 +510,51 @@ const storage = new Storage(client);
360
510
  const isConfigured = () =>
361
511
  !CONFIG.endpoint.includes("YOUR_") && !CONFIG.projectId.includes("YOUR_") && !CONFIG.databaseId.includes("YOUR_");
362
512
 
363
- const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
513
+ /* ------------------------------ settings model ------------------------------ */
514
+ const SETTINGS_KEY = "mlv2_settings";
515
+ const isHttp = (v) => { try { const p = new URL(v).protocol; return p === "http:" || p === "https:"; } catch { return false; } };
516
+ const ico = (svg) => "data:image/svg+xml," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">${svg}</svg>`);
517
+ const SIZES = {
518
+ compact: "grid-cols-4 sm:grid-cols-6 md:grid-cols-7 lg:grid-cols-8",
519
+ default: "grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6",
520
+ large: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5",
521
+ };
522
+ const CLOAKS = {
523
+ off: { label: "Off", sub: "Show the normal mlv2 tab", title: null, icon: null },
524
+ classroom: { label: "Google Classroom", sub: "Tab reads “Classes”", title: "Classes",
525
+ icon: ico('<rect width="32" height="32" rx="7" fill="#1e8e3e"/><rect x="6" y="9" width="20" height="14" rx="2" fill="#fff"/><circle cx="16" cy="16" r="3" fill="#1e8e3e"/><path d="M10 26h12" stroke="#fff" stroke-width="2" stroke-linecap="round"/>') },
526
+ ixl: { label: "IXL", sub: "Tab reads “IXL | Math, Language Arts, Science…”", title: "IXL | Math, Language Arts, Science, Social Studies, and Spanish",
527
+ icon: ico('<rect width="32" height="32" rx="7" fill="#fff"/><text x="16" y="21" font-family="Arial,sans-serif" font-size="12" font-weight="800" text-anchor="middle" fill="#0a74da">IXL</text>') },
528
+ github: { label: "GitHub", sub: "Tab reads “GitHub”", title: "GitHub",
529
+ icon: ico('<rect width="32" height="32" rx="16" fill="#0d1117"/><g transform="translate(4 4) scale(1.5)"><path fill="#fff" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></g>') },
530
+ blank: { label: "Blank", sub: "No visible title or icon", title: "\u00A0", icon: ico("") },
531
+ custom: { label: "Custom", sub: "Pick your own title and icon", title: null, icon: null },
532
+ };
533
+ const DEFAULTS = {
534
+ announceOnOpen: true, reduceMotion: false, tileSize: "default",
535
+ cloak: "off", customTitle: "", customIcon: "", maskText: false, confirmClose: false,
536
+ panic: { enabled: false, key: "`", url: "https://classroom.google.com", button: false },
537
+ };
538
+ function mergeSettings(base, src) {
539
+ const out = JSON.parse(JSON.stringify(base));
540
+ if (!src || typeof src !== "object") return out;
541
+ for (const k of Object.keys(base)) {
542
+ if (k === "panic") {
543
+ const p = src.panic && typeof src.panic === "object" ? src.panic : {};
544
+ for (const pk of Object.keys(base.panic)) if (typeof p[pk] === typeof base.panic[pk]) out.panic[pk] = p[pk];
545
+ } else if (typeof src[k] === typeof base[k]) out[k] = src[k];
546
+ }
547
+ if (!CLOAKS[out.cloak]) out.cloak = "off";
548
+ if (!SIZES[out.tileSize]) out.tileSize = "default";
549
+ if (!out.panic.key) out.panic.key = DEFAULTS.panic.key;
550
+ return out;
551
+ }
552
+ function loadSettings() { try { return mergeSettings(DEFAULTS, JSON.parse(localStorage.getItem(SETTINGS_KEY))); } catch { return mergeSettings(DEFAULTS, null); } }
553
+ function saveSettings() { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch { /* storage blocked */ } }
554
+ let settings = loadSettings();
555
+ const sysReduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
556
+ let reduceMotion = sysReduce || settings.reduceMotion;
557
+
364
558
  function fx(targets, keyframes, options = {}) {
365
559
  if (reduceMotion) options = { ...options, duration: 0, delay: 0 };
366
560
  return animate(targets, keyframes, options);
@@ -584,7 +778,7 @@ el("retry").addEventListener("click", () => {
584
778
  /* ------------------------------- dialogs ------------------------------- */
585
779
  const isHidden = (id) => el(id).classList.contains("hidden");
586
780
  let opener = null;
587
- function lockBg(on) { document.querySelectorAll("body > header, body > main, body > footer").forEach((n) => n.toggleAttribute("inert", on)); }
781
+ function lockBg(on) { document.querySelectorAll("body > header, body > main, body > footer, body > #panic-btn").forEach((n) => n.toggleAttribute("inert", on)); }
588
782
  function beginDialog() { opener = document.activeElement; lockBg(true); }
589
783
  function endDialog() { lockBg(false); if (opener && document.contains(opener)) opener.focus(); opener = null; }
590
784
  const focusSoon = (node) => requestAnimationFrame(() => node && node.focus());
@@ -593,12 +787,10 @@ document.addEventListener("keydown", (e) => {
593
787
  if (e.key === "Escape") {
594
788
  if (!isHidden("player-modal")) closePlayer();
595
789
  else if (!isHidden("admin-login-modal")) closeAdminLogin();
596
- else if (!isHidden("admin-panel")) {
597
- if (confirmingId) { const id = confirmingId; confirmingId = null; renderAdmin([`[data-act="delete"][data-id="${id}"]`]); }
598
- else if (editingId) cancelForm();
599
- else closeAdminPanel();
600
- }
601
- } else if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey && isHidden("player-modal") && isHidden("admin-login-modal") && isHidden("admin-panel")
790
+ else if (!isHidden("settings-modal")) closeSettings();
791
+ else if (!isHidden("ann-modal")) closeAnnouncements();
792
+ else if (!isHidden("admin-panel")) escapeAdmin();
793
+ } else if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey && allClosed()
602
794
  && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || "")) {
603
795
  e.preventDefault();
604
796
  el("search").focus();
@@ -702,7 +894,6 @@ let editingId = null, confirmingId = null, adminFilter = "", previewUrl = null;
702
894
 
703
895
  const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0);
704
896
  const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return u; } };
705
- const isHttp = (v) => { try { const p = new URL(v).protocol; return p === "http:" || p === "https:"; } catch { return false; } };
706
897
  const sr = (t) => `<span class="sr-only">${escapeHtml(t)}</span>`;
707
898
  const rowIcon = (g) => iconSrcFor(g) || monogramSrc(g.name);
708
899
  const adminSay = (msg) => { const n = el("admin-live"); n.textContent = ""; setTimeout(() => (n.textContent = msg), 60); };
@@ -711,6 +902,8 @@ const dbArgs = { databaseId: CONFIG.databaseId, tableId: CONFIG.gamesTableId };
711
902
  function openAdminPanel() {
712
903
  beginDialog();
713
904
  editingId = null; confirmingId = null; adminFilter = ""; el("admin-filter").value = "";
905
+ annEditing = null; annConfirming = null; annLoaded = false; dirtyWarned = false; dirty.games = dirty.ann = false;
906
+ selectAdminTab("atab-games");
714
907
  el("admin-panel").classList.remove("hidden");
715
908
  pop("#admin-panel-card", { y: 24, scale: 0.97, stiff: 300, damp: 28 });
716
909
  focusSoon(el("admin-title"));
@@ -718,7 +911,6 @@ function openAdminPanel() {
718
911
  }
719
912
  function closeAdminPanel() { el("admin-panel").classList.add("hidden"); endDialog(); }
720
913
  el("admin-close").addEventListener("click", closeAdminPanel);
721
- el("admin-backdrop").addEventListener("click", () => { if (!editingId) closeAdminPanel(); });
722
914
  el("admin-logout").addEventListener("click", () => {
723
915
  sessionStorage.removeItem(ADMIN_SESSION_KEY);
724
916
  closeAdminPanel();
@@ -829,8 +1021,9 @@ function rowHtml(g, idx, canMove) {
829
1021
  }
830
1022
 
831
1023
  function renderAdmin(focusSels = []) {
1024
+ dirty.games = false;
832
1025
  el("admin-form-slot").innerHTML = editingId === "new" ? formHtml(null) : "";
833
- const list = el("admin-list"), sum = el("admin-summary");
1026
+ const list = el("admin-list"), sum = gamesSum;
834
1027
  el("admin-add").disabled = !!editingId;
835
1028
  el("admin-filter").disabled = !!editingId;
836
1029
  if (adminState === "loading") { sum.textContent = ""; list.innerHTML = `<li class="py-8 text-center text-[14px] text-ink-300">Loading games…</li>`; return; }
@@ -1019,6 +1212,664 @@ async function deleteGame(id) {
1019
1212
  }
1020
1213
  }
1021
1214
 
1215
+ /* ============================ dialogs: shared helpers ============================ */
1216
+ const allClosed = () => ["player-modal", "admin-login-modal", "admin-panel", "settings-modal", "ann-modal"].every(isHidden);
1217
+ function openModal(id, card, focusId) {
1218
+ beginDialog();
1219
+ el(id).classList.remove("hidden");
1220
+ pop("#" + card, { y: 18, scale: 0.95, stiff: 320, damp: 26 });
1221
+ focusSoon(el(focusId));
1222
+ }
1223
+ function closeModal(id) { el(id).classList.add("hidden"); endDialog(); }
1224
+
1225
+ // Click (or tap) on the dimmed area outside a dialog closes it. Both the press and the release must land on the backdrop,
1226
+ // so dragging a text selection out of a dialog never closes it.
1227
+ let pressedEl = null;
1228
+ document.addEventListener("pointerdown", (e) => { pressedEl = e.target; }, true);
1229
+ const BACKDROPS = { login: () => closeAdminLogin(), admin: () => tryCloseAdmin(), settings: () => closeSettings(), ann: () => closeAnnouncements() };
1230
+ document.querySelectorAll("[data-backdrop]").forEach((b) =>
1231
+ b.addEventListener("click", (e) => { if (e.target === b && pressedEl === b) BACKDROPS[b.dataset.backdrop](); })
1232
+ );
1233
+
1234
+ /* ============================ tabs (settings + admin) ============================ */
1235
+ function wireTabs(list, onSelect) {
1236
+ const tabs = [...list.querySelectorAll('[role="tab"]')];
1237
+ const activate = (t, focus) => {
1238
+ tabs.forEach((x) => {
1239
+ const on = x === t;
1240
+ x.setAttribute("aria-selected", String(on));
1241
+ x.tabIndex = on ? 0 : -1;
1242
+ document.getElementById(x.getAttribute("aria-controls")).hidden = !on;
1243
+ });
1244
+ if (focus) t.focus();
1245
+ onSelect?.(t);
1246
+ };
1247
+ tabs.forEach((t) => t.addEventListener("click", () => activate(t)));
1248
+ list.addEventListener("keydown", (e) => {
1249
+ const i = tabs.indexOf(document.activeElement);
1250
+ if (i < 0) return;
1251
+ let n = -1;
1252
+ if (e.key === "ArrowRight") n = (i + 1) % tabs.length;
1253
+ else if (e.key === "ArrowLeft") n = (i - 1 + tabs.length) % tabs.length;
1254
+ else if (e.key === "Home") n = 0;
1255
+ else if (e.key === "End") n = tabs.length - 1;
1256
+ if (n >= 0) { e.preventDefault(); activate(tabs[n], true); }
1257
+ });
1258
+ return (id) => activate(tabs.find((t) => t.id === id));
1259
+ }
1260
+
1261
+ /* ============================ admin: tabs, unsaved-changes guard ============================ */
1262
+ let adminTab = "games", annLoaded = false, annEditing = null, annConfirming = null, dirtyWarned = false;
1263
+ const dirty = { games: false, ann: false };
1264
+ const adminSummaries = { games: "", ann: "" };
1265
+ function setSummary(tab, text) { adminSummaries[tab] = text; if (tab === adminTab) el("admin-summary").textContent = text; }
1266
+ const gamesSum = { set textContent(v) { setSummary("games", v); } };
1267
+ const selectAdminTab = wireTabs(el("admin-tabs"), (t) => {
1268
+ adminTab = t.dataset.atab;
1269
+ el("admin-summary").textContent = adminSummaries[adminTab];
1270
+ if (adminTab === "ann" && !annLoaded) { annLoaded = true; loadAnnAdmin(); }
1271
+ });
1272
+ for (const evt of ["input", "change"]) {
1273
+ el("admin-panel-card").addEventListener(evt, (e) => { if (e.target.closest?.("form")) { dirty[adminTab] = true; dirtyWarned = false; } });
1274
+ }
1275
+ function tryCloseAdmin() {
1276
+ if (dirty[adminTab] && !dirtyWarned) {
1277
+ dirtyWarned = true;
1278
+ if (!reduceMotion) animate("#admin-panel-card", { x: [0, -8, 8, -5, 5, 0] }, { duration: 0.35 });
1279
+ toast("You have unsaved changes. Click outside again to discard them.");
1280
+ return;
1281
+ }
1282
+ dirtyWarned = false;
1283
+ closeAdminPanel();
1284
+ }
1285
+ function escapeAdmin() {
1286
+ if (adminTab === "ann") {
1287
+ if (annConfirming) { const id = annConfirming; annConfirming = null; renderAnnAdmin([`[data-aact="delete"][data-id="${id}"]`]); }
1288
+ else if (annEditing) cancelAnnForm();
1289
+ else closeAdminPanel();
1290
+ } else if (confirmingId) { const id = confirmingId; confirmingId = null; renderAdmin([`[data-act="delete"][data-id="${id}"]`]); }
1291
+ else if (editingId) cancelForm();
1292
+ else closeAdminPanel();
1293
+ }
1294
+
1295
+ /* ============================ announcements: visitors ============================ */
1296
+ const SEEN_KEY = "mlv2_seen_announcements";
1297
+ const getSeen = () => { try { const v = JSON.parse(localStorage.getItem(SEEN_KEY)); return Array.isArray(v) ? v : []; } catch { return []; } };
1298
+ const markSeen = (ids) => { try { localStorage.setItem(SEEN_KEY, JSON.stringify([...new Set([...getSeen(), ...ids])].slice(-200))); } catch { /* storage blocked */ } };
1299
+ let announcements = [], annShown = [], annAutoShown = false;
1300
+ const fmtDate = (iso) => new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
1301
+ const annImage = (a) => (a.imageFileId ? String(storage.getFileView({ bucketId: CONFIG.iconsBucketId, fileId: a.imageFileId })) : a.imageUrl || "");
1302
+
1303
+ function annCard(a) {
1304
+ const img = annImage(a);
1305
+ return `<article class="rounded-[28px] bg-white/[0.06] p-2 ring-1 ring-inset ring-white/10">
1306
+ ${img ? `<img src="${escapeHtml(img)}" alt="" loading="lazy" class="aspect-[16/9] w-full rounded-[20px] object-cover" onerror="this.remove()" />` : ""}
1307
+ <div class="px-3.5 pb-3 pt-3">
1308
+ <h3 class="text-[17px] font-bold leading-snug">${escapeHtml(a.title)} <span aria-hidden="true">•</span> <time datetime="${escapeHtml(a.$createdAt)}">${escapeHtml(fmtDate(a.$createdAt))}</time></h3>
1309
+ ${a.content ? `<p class="mt-1 whitespace-pre-line break-words text-[15px] leading-relaxed text-ink-100">${escapeHtml(a.content)}</p>` : ""}
1310
+ </div>
1311
+ </article>`;
1312
+ }
1313
+ function refreshBell() {
1314
+ const seen = getSeen();
1315
+ const unseen = announcements.filter((a) => !seen.includes(a.$id)).length;
1316
+ el("bell-btn").hidden = announcements.length === 0;
1317
+ el("bell-dot").hidden = unseen === 0;
1318
+ el("bell-btn").setAttribute("aria-label", unseen ? `Announcements (${unseen} new)` : "Announcements");
1319
+ }
1320
+ function openAnnouncements(list) {
1321
+ if (!list.length) return;
1322
+ annShown = list;
1323
+ el("ann-list").innerHTML = list.map(annCard).join("");
1324
+ el("ann-list").scrollTop = 0;
1325
+ openModal("ann-modal", "ann-card", "ann-title");
1326
+ }
1327
+ function closeAnnouncements() {
1328
+ markSeen(annShown.map((a) => a.$id));
1329
+ annShown = [];
1330
+ closeModal("ann-modal");
1331
+ refreshBell();
1332
+ }
1333
+ async function loadAnnouncements() {
1334
+ if (!CONFIG.announcementsTableId) return;
1335
+ try {
1336
+ const res = await tablesDB.listRows({ databaseId: CONFIG.databaseId, tableId: CONFIG.announcementsTableId, queries: [Query.limit(20), Query.orderDesc("$createdAt")] });
1337
+ announcements = res.rows.filter((a) => a.enabled !== false && a.title);
1338
+ } catch (err) { console.warn("Announcements unavailable:", err); return; }
1339
+ refreshBell();
1340
+ const seen = getSeen();
1341
+ const unseen = announcements.filter((a) => !seen.includes(a.$id));
1342
+ if (settings.announceOnOpen && unseen.length && !annAutoShown) {
1343
+ annAutoShown = true;
1344
+ setTimeout(() => { if (allClosed()) openAnnouncements(unseen); }, 900);
1345
+ }
1346
+ }
1347
+ el("bell-btn").addEventListener("click", () => openAnnouncements(announcements));
1348
+ el("ann-close").addEventListener("click", closeAnnouncements);
1349
+ el("ann-done").addEventListener("click", closeAnnouncements);
1350
+
1351
+ /* ============================ announcements: admin ============================ */
1352
+ let annRows = [], annState = "idle", annError = "", annPreviewUrl = null;
1353
+ const annTable = () => ({ databaseId: CONFIG.databaseId, tableId: CONFIG.announcementsTableId });
1354
+
1355
+ async function loadAnnAdmin() {
1356
+ if (!CONFIG.announcementsTableId) { annState = "unconfigured"; renderAnnAdmin(); return; }
1357
+ annState = "loading";
1358
+ renderAnnAdmin();
1359
+ try {
1360
+ const res = await tablesDB.listRows({ ...annTable(), queries: [Query.limit(100), Query.orderDesc("$createdAt")] });
1361
+ annRows = res.rows;
1362
+ annState = "ready";
1363
+ } catch (err) { annState = "error"; annError = err?.message || "Couldn't load announcements."; }
1364
+ renderAnnAdmin();
1365
+ }
1366
+
1367
+ function annFormHtml(a) {
1368
+ const v = (x) => escapeHtml(x ?? "");
1369
+ const base = a ? escapeHtml(annImage(a)) : "";
1370
+ const hasImg = !!(a && (a.imageFileId || a.imageUrl));
1371
+ return `<form id="ann-form" novalidate aria-labelledby="ann-form-title" class="glass-lite mb-4 rounded-2xl p-4">
1372
+ <h3 id="ann-form-title" class="text-[16px] font-semibold">${a ? "Edit announcement" : "New announcement"}</h3>
1373
+ <div class="mt-4 space-y-4">
1374
+ <div>
1375
+ <label for="af-title" class="text-[13px] font-medium text-ink-200">Title</label>
1376
+ <input id="af-title" maxlength="120" autocomplete="off" aria-describedby="af-title-err" value="${v(a?.title)}" class="field mt-1.5" />
1377
+ <p id="af-title-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
1378
+ </div>
1379
+ <div>
1380
+ <label for="af-content" class="text-[13px] font-medium text-ink-200">Message <span class="font-normal text-ink-300">(optional)</span></label>
1381
+ <textarea id="af-content" rows="4" maxlength="2000" class="field mt-1.5 min-h-[104px] py-2.5 leading-relaxed" style="height:auto">${v(a?.content)}</textarea>
1382
+ </div>
1383
+ <fieldset class="min-w-0">
1384
+ <legend class="text-[13px] font-medium text-ink-200">Image <span class="font-normal text-ink-300">(optional)</span></legend>
1385
+ <img id="af-preview" src="${base}" data-base="${base}" alt="Image preview" ${base ? "" : "hidden"} class="mt-2 aspect-[16/9] w-full max-w-sm rounded-xl object-cover" onerror="this.hidden=true" />
1386
+ <div class="mt-3 grid gap-3">
1387
+ <div>
1388
+ <label for="af-file" class="text-[13px] text-ink-300">Upload an image</label>
1389
+ <input id="af-file" type="file" accept="image/*" aria-describedby="af-file-err" class="mt-1.5 w-full text-[13px] text-ink-300 file:mr-3 file:rounded-full file:border-0 file:bg-white/10 file:px-3.5 file:py-1.5 file:text-[13px] file:font-medium file:text-ink-50" />
1390
+ <p id="af-file-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
1391
+ </div>
1392
+ <div>
1393
+ <label for="af-imgurl" class="text-[13px] text-ink-300">…or paste an image link</label>
1394
+ <input id="af-imgurl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://" aria-describedby="af-imgurl-err" value="${v(a?.imageUrl)}" class="field mt-1.5" />
1395
+ <p id="af-imgurl-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
1396
+ </div>
1397
+ ${hasImg ? `<label class="flex items-center gap-2 text-[13px] text-ink-200"><input id="af-remove" type="checkbox" class="h-4 w-4 accent-red-500" /> Remove the current image</label>` : ""}
1398
+ </div>
1399
+ </fieldset>
1400
+ <label class="flex cursor-pointer items-center gap-3 text-[14px]"><input id="af-enabled" type="checkbox" role="switch" class="switch" ${a?.enabled === false ? "" : "checked"} /> Published (visible to visitors)</label>
1401
+ </div>
1402
+ <div class="mt-5 flex flex-wrap items-center gap-2">
1403
+ <button type="submit" id="af-submit" class="btn btn-primary">${a ? "Save changes" : "Publish"}</button>
1404
+ <button type="button" data-aact="cancel-form" class="btn btn-ghost">Cancel</button>
1405
+ </div>
1406
+ </form>`;
1407
+ }
1408
+
1409
+ function annRowHtml(a) {
1410
+ if (annEditing === a.$id) return `<li>${annFormHtml(a)}</li>`;
1411
+ const n = escapeHtml(a.title), off = a.enabled === false, dis = annEditing ? "disabled" : "";
1412
+ const snippet = escapeHtml((a.content || "").replace(/\s+/g, " ").slice(0, 70));
1413
+ const actions = annConfirming === a.$id
1414
+ ? `<div role="group" aria-label="Confirm delete" class="mt-3 rounded-xl bg-red-500/10 p-3 ring-1 ring-red-500/30">
1415
+ <p class="text-[14px]">Delete <strong>${n}</strong>? This can't be undone.</p>
1416
+ <div class="mt-3 flex gap-2">
1417
+ <button type="button" id="ann-cancel-delete" data-aact="cancel-delete" data-id="${a.$id}" class="btn btn-ghost">Cancel</button>
1418
+ <button type="button" data-aact="confirm-delete" data-id="${a.$id}" class="btn btn-danger">Delete</button>
1419
+ </div>
1420
+ </div>`
1421
+ : `<div class="mt-3 flex flex-wrap items-center gap-2">
1422
+ <button type="button" data-aact="edit" data-id="${a.$id}" ${dis} class="btn btn-ghost">Edit${sr(" " + a.title)}</button>
1423
+ <button type="button" data-aact="delete" data-id="${a.$id}" ${dis} class="btn btn-ghost ml-auto hover:!text-red-300">Delete${sr(" " + a.title)}</button>
1424
+ </div>`;
1425
+ return `<li class="glass-lite rounded-2xl p-3">
1426
+ <div class="flex items-center gap-3">
1427
+ <div class="min-w-0 flex-1">
1428
+ <p class="truncate text-[15px] font-medium">${n}${off ? '<span class="tag">Draft</span>' : ""}</p>
1429
+ <p class="truncate text-[13px] text-ink-300">${escapeHtml(fmtDate(a.$createdAt))}${snippet ? " · " + snippet : ""}</p>
1430
+ </div>
1431
+ <label class="flex shrink-0 cursor-pointer items-center gap-2 text-[13px] text-ink-200">
1432
+ <input type="checkbox" role="switch" class="switch" data-atoggle="${a.$id}" ${off ? "" : "checked"} ${dis} />
1433
+ <span>Published${sr(": " + a.title)}</span>
1434
+ </label>
1435
+ </div>
1436
+ ${actions}
1437
+ </li>`;
1438
+ }
1439
+
1440
+ function renderAnnAdmin(focusSels = []) {
1441
+ dirty.ann = false;
1442
+ const list = el("ann-admin-list");
1443
+ el("ann-form-slot").innerHTML = annEditing === "new" ? annFormHtml(null) : "";
1444
+ el("ann-add").disabled = !!annEditing || annState !== "ready";
1445
+ if (annState === "unconfigured") {
1446
+ setSummary("ann", "Not set up yet");
1447
+ list.innerHTML = `<li class="glass-lite rounded-2xl p-4 text-[14px] leading-relaxed text-ink-200">
1448
+ <p class="font-semibold text-ink-50">One-time setup</p>
1449
+ <p class="mt-2">Announcements need their own table. In Appwrite, create a table in the same database with these columns:</p>
1450
+ <ul class="mt-2 list-disc space-y-1 pl-5 text-ink-300">
1451
+ <li><code class="text-ink-50">title</code> string, size 120, required</li>
1452
+ <li><code class="text-ink-50">content</code> string, size 2000</li>
1453
+ <li><code class="text-ink-50">imageFileId</code> string, size 64</li>
1454
+ <li><code class="text-ink-50">imageUrl</code> string, size 2048</li>
1455
+ <li><code class="text-ink-50">enabled</code> boolean, default true</li>
1456
+ </ul>
1457
+ <p class="mt-2">Give it the same permissions as your games table, then paste its ID into <code class="text-ink-50">announcementsTableId</code> in the <code class="text-ink-50">CONFIG</code> block of this file.</p>
1458
+ </li>`;
1459
+ return;
1460
+ }
1461
+ if (annState === "loading") { setSummary("ann", ""); list.innerHTML = `<li class="py-8 text-center text-[14px] text-ink-300">Loading announcements…</li>`; return; }
1462
+ if (annState === "error") {
1463
+ setSummary("ann", "");
1464
+ list.innerHTML = `<li class="py-8 text-center"><p class="text-[14px] text-red-300">${escapeHtml(annError)}</p><button type="button" data-aact="retry" class="btn btn-ghost mt-3">Try again</button></li>`;
1465
+ return;
1466
+ }
1467
+ const pub = annRows.filter((a) => a.enabled !== false).length;
1468
+ setSummary("ann", `${annRows.length} ${annRows.length === 1 ? "announcement" : "announcements"} · ${pub} published`);
1469
+ list.innerHTML = annRows.length ? annRows.map(annRowHtml).join("") : `<li class="py-8 text-center text-[14px] text-ink-300">No announcements yet. Choose “New announcement” to post one.</li>`;
1470
+ if (annEditing) { const n = el("af-title"); n.focus(); n.scrollIntoView({ block: "nearest" }); return; }
1471
+ for (const sel of focusSels) {
1472
+ const t = el("admin-panel-card").querySelector(sel);
1473
+ if (t && !t.disabled) { t.focus(); break; }
1474
+ }
1475
+ }
1476
+ function cancelAnnForm() {
1477
+ const id = annEditing;
1478
+ annEditing = null;
1479
+ renderAnnAdmin([id && id !== "new" ? `[data-aact="edit"][data-id="${id}"]` : "#ann-add"]);
1480
+ }
1481
+ function updateAnnPreview() {
1482
+ const img = el("af-preview"), file = el("af-file").files[0], url = el("af-imgurl").value.trim();
1483
+ if (annPreviewUrl) { URL.revokeObjectURL(annPreviewUrl); annPreviewUrl = null; }
1484
+ let src = img.dataset.base || "";
1485
+ if (el("af-remove")?.checked) src = "";
1486
+ else if (file && file.type.startsWith("image/")) src = annPreviewUrl = URL.createObjectURL(file);
1487
+ else if (isHttp(url)) src = url;
1488
+ img.hidden = !src;
1489
+ if (src) img.src = src;
1490
+ }
1491
+ function validateAnnForm() {
1492
+ const url = el("af-imgurl").value.trim(), f = el("af-file").files[0];
1493
+ const checks = [
1494
+ ["af-title", el("af-title").value.trim() ? "" : "Enter a title."],
1495
+ ["af-imgurl", !url || isHttp(url) ? "" : "Enter a full link that starts with https://"],
1496
+ ["af-file", !f || f.type.startsWith("image/") ? "" : "Choose an image file."],
1497
+ ];
1498
+ let first = null;
1499
+ checks.forEach(([id, msg]) => { setErr(id, msg); if (msg && !first) first = id; });
1500
+ if (first) el(first).focus();
1501
+ return !first;
1502
+ }
1503
+ async function submitAnn(e) {
1504
+ e.preventDefault();
1505
+ if (!validateAnnForm()) return;
1506
+ const isEdit = annEditing && annEditing !== "new";
1507
+ const existing = isEdit ? annRows.find((r) => r.$id === annEditing) : null;
1508
+ const title = el("af-title").value.trim(), content = el("af-content").value.trim(), urlField = el("af-imgurl").value.trim();
1509
+ const file = el("af-file").files[0], enabled = el("af-enabled").checked, remove = !!el("af-remove")?.checked;
1510
+ const btn = el("af-submit"), label = btn.textContent;
1511
+ btn.disabled = true; btn.textContent = "Saving…";
1512
+ let uploadedId = null, oldFile = null;
1513
+ try {
1514
+ let imageFileId = existing?.imageFileId || "", imageUrl = existing?.imageUrl || "";
1515
+ if (remove) { oldFile = imageFileId; imageFileId = ""; imageUrl = ""; }
1516
+ else if (file) {
1517
+ uploadedId = (await storage.createFile({ bucketId: CONFIG.iconsBucketId, fileId: ID.unique(), file })).$id;
1518
+ oldFile = imageFileId; imageFileId = uploadedId; imageUrl = "";
1519
+ } else if (urlField && urlField !== imageUrl) { oldFile = imageFileId; imageFileId = ""; imageUrl = urlField; }
1520
+ else if (!urlField && imageUrl && !imageFileId) imageUrl = "";
1521
+ const data = { title, content, imageFileId, imageUrl, enabled };
1522
+ let row;
1523
+ if (isEdit) {
1524
+ row = await tablesDB.updateRow({ ...annTable(), rowId: annEditing, data });
1525
+ annRows = annRows.map((r) => (r.$id === row.$id ? row : r));
1526
+ } else {
1527
+ row = await tablesDB.createRow({ ...annTable(), rowId: ID.unique(), data });
1528
+ annRows = [row, ...annRows];
1529
+ }
1530
+ if (oldFile) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: oldFile }).catch(() => {});
1531
+ annEditing = null;
1532
+ renderAnnAdmin([`[data-aact="edit"][data-id="${row.$id}"]`]);
1533
+ toast(isEdit ? `Saved “${title}”` : `Posted “${title}”`);
1534
+ loadAnnouncements();
1535
+ } catch (err) {
1536
+ if (uploadedId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: uploadedId }).catch(() => {});
1537
+ btn.disabled = false; btn.textContent = label;
1538
+ toast(err?.message || "Couldn't save. Check your connection and try again.", "error");
1539
+ }
1540
+ }
1541
+ async function toggleAnn(id, on) {
1542
+ const row = annRows.find((r) => r.$id === id);
1543
+ if (!row) return;
1544
+ const prev = row.enabled !== false;
1545
+ row.enabled = on;
1546
+ renderAnnAdmin([`[data-atoggle="${id}"]`]);
1547
+ try {
1548
+ await tablesDB.updateRow({ ...annTable(), rowId: id, data: { enabled: on } });
1549
+ adminSay(`${row.title} is now ${on ? "published" : "a draft"}`);
1550
+ loadAnnouncements();
1551
+ } catch (err) {
1552
+ row.enabled = prev;
1553
+ renderAnnAdmin([`[data-atoggle="${id}"]`]);
1554
+ toast(err?.message || "Couldn't update", "error");
1555
+ }
1556
+ }
1557
+ async function deleteAnn(id) {
1558
+ const row = annRows.find((r) => r.$id === id);
1559
+ if (!row) return;
1560
+ try {
1561
+ await tablesDB.deleteRow({ ...annTable(), rowId: id });
1562
+ if (row.imageFileId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: row.imageFileId }).catch(() => {});
1563
+ annRows = annRows.filter((r) => r.$id !== id);
1564
+ annConfirming = null;
1565
+ renderAnnAdmin(["#ann-add"]);
1566
+ toast(`Deleted “${row.title}”`);
1567
+ loadAnnouncements();
1568
+ } catch (err) {
1569
+ renderAnnAdmin(["#ann-cancel-delete"]);
1570
+ toast(err?.message || "Couldn't delete. Try again.", "error");
1571
+ }
1572
+ }
1573
+ el("ann-add").addEventListener("click", () => { annEditing = "new"; annConfirming = null; renderAnnAdmin(); });
1574
+ el("ann-admin-body").addEventListener("click", (e) => {
1575
+ const b = e.target.closest("[data-aact]");
1576
+ if (!b || b.disabled) return;
1577
+ const { aact, id } = b.dataset;
1578
+ if (aact === "retry") loadAnnAdmin();
1579
+ else if (aact === "edit") { annEditing = id; annConfirming = null; renderAnnAdmin(); }
1580
+ else if (aact === "cancel-form") cancelAnnForm();
1581
+ else if (aact === "delete") { annConfirming = id; renderAnnAdmin(["#ann-cancel-delete"]); }
1582
+ else if (aact === "cancel-delete") { annConfirming = null; renderAnnAdmin([`[data-aact="delete"][data-id="${id}"]`]); }
1583
+ else if (aact === "confirm-delete") { b.disabled = true; b.textContent = "Deleting…"; deleteAnn(id); }
1584
+ });
1585
+ el("ann-admin-body").addEventListener("change", (e) => {
1586
+ const t = e.target;
1587
+ if (t.matches("[data-atoggle]")) toggleAnn(t.dataset.atoggle, t.checked);
1588
+ else if (t.id === "af-file" || t.id === "af-remove") updateAnnPreview();
1589
+ });
1590
+ el("ann-admin-body").addEventListener("input", (e) => {
1591
+ const t = e.target;
1592
+ if (t.getAttribute?.("aria-invalid")) setErr(t.id, "");
1593
+ if (t.id === "af-imgurl") updateAnnPreview();
1594
+ });
1595
+ el("ann-admin-body").addEventListener("submit", (e) => { if (e.target.id === "ann-form") submitAnn(e); });
1596
+
1597
+ /* ============================ settings: apply ============================ */
1598
+ const origTitle = document.title;
1599
+ const origIcon = document.querySelector('link[rel~="icon"]')?.href || "";
1600
+ function setFavicon(href) {
1601
+ document.head.querySelectorAll('link[rel~="icon"]').forEach((l) => l.remove());
1602
+ const l = document.createElement("link");
1603
+ l.rel = "icon"; l.href = href;
1604
+ document.head.appendChild(l);
1605
+ }
1606
+ function applyCloak() {
1607
+ const c = settings.cloak;
1608
+ let title = origTitle, icon = origIcon;
1609
+ if (c === "custom") { if (settings.customTitle) title = settings.customTitle; if (isHttp(settings.customIcon)) icon = settings.customIcon; }
1610
+ else if (c !== "off") { title = CLOAKS[c].title; icon = CLOAKS[c].icon; }
1611
+ document.title = title;
1612
+ setFavicon(icon);
1613
+ }
1614
+ function applyMotion() {
1615
+ reduceMotion = sysReduce || settings.reduceMotion;
1616
+ document.documentElement.classList.toggle("rm", reduceMotion);
1617
+ }
1618
+ function applyTileSize() {
1619
+ for (const g of [el("grid"), el("skeleton-grid")]) {
1620
+ Object.values(SIZES).forEach((c) => g.classList.remove(...c.split(" ")));
1621
+ g.classList.add(...SIZES[settings.tileSize].split(" "));
1622
+ }
1623
+ }
1624
+
1625
+ // "Mask text": swap letters for look-alike characters in everything on the page (not in the admin/settings dialogs).
1626
+ const MASK = { a: "\u0430", c: "\u0441", e: "\u0435", i: "\u0456", j: "\u0458", o: "\u043E", p: "\u0440", s: "\u0455", x: "\u0445", y: "\u0443",
1627
+ A: "\u0410", B: "\u0412", C: "\u0421", E: "\u0415", H: "\u041D", I: "\u0406", J: "\u0408", K: "\u041A", M: "\u041C", O: "\u041E", P: "\u0420", S: "\u0405", T: "\u0422", X: "\u0425", Y: "\u04AE" };
1628
+ const maskStr = (t) => t.replace(/[acejiopsxyABCEHIJKMOPSTXY]/g, (c) => MASK[c]);
1629
+ const maskOrig = new WeakMap();
1630
+ function maskNode(n) {
1631
+ if (n.nodeType !== 3 || !n.nodeValue.trim() || n.parentElement?.closest("script,style,textarea,[data-nomask]")) return;
1632
+ if (!maskOrig.has(n)) maskOrig.set(n, n.nodeValue);
1633
+ n.nodeValue = maskStr(maskOrig.get(n));
1634
+ }
1635
+ function walkText(root, fn) {
1636
+ const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1637
+ let n;
1638
+ while ((n = w.nextNode())) fn(n);
1639
+ }
1640
+ let maskObs = null;
1641
+ function setMask(on) {
1642
+ maskObs?.disconnect();
1643
+ maskObs = null;
1644
+ if (on) {
1645
+ walkText(document.body, maskNode);
1646
+ maskObs = new MutationObserver((muts) => muts.forEach((m) => m.addedNodes.forEach((n) => (n.nodeType === 3 ? maskNode(n) : n.nodeType === 1 && walkText(n, maskNode)))));
1647
+ maskObs.observe(document.body, { childList: true, subtree: true });
1648
+ } else walkText(document.body, (n) => { if (maskOrig.has(n)) { n.nodeValue = maskOrig.get(n); maskOrig.delete(n); } });
1649
+ }
1650
+
1651
+ /* ============================ panic button + safety ============================ */
1652
+ let panicking = false, capturingKey = false;
1653
+ function comboOf(e) {
1654
+ const k = e.key;
1655
+ if (["Control", "Shift", "Alt", "Meta"].includes(k)) return "";
1656
+ const parts = [];
1657
+ if (e.ctrlKey) parts.push("Ctrl");
1658
+ if (e.altKey) parts.push("Alt");
1659
+ if (e.metaKey) parts.push("Meta");
1660
+ if (e.shiftKey && k.length > 1) parts.push("Shift");
1661
+ parts.push(k.length === 1 ? k.toUpperCase() : k);
1662
+ return parts.join("+");
1663
+ }
1664
+ const showCombo = (c) => c.replace(/(^|\+) $/, "$1Space");
1665
+ function panic() {
1666
+ panicking = true;
1667
+ document.documentElement.style.visibility = "hidden";
1668
+ location.replace(isHttp(settings.panic.url) ? settings.panic.url : DEFAULTS.panic.url);
1669
+ setTimeout(() => { document.documentElement.style.visibility = ""; panicking = false; }, 3000); // restore if the navigation was blocked
1670
+ }
1671
+ function updatePanicUI() {
1672
+ const on = settings.panic.enabled && settings.panic.button;
1673
+ el("panic-btn").hidden = !on;
1674
+ el("player-panic").hidden = !on;
1675
+ el("panic-key").textContent = showCombo(settings.panic.key);
1676
+ }
1677
+ document.addEventListener("keydown", (e) => {
1678
+ if (capturingKey) {
1679
+ const c = comboOf(e);
1680
+ if (!c) return;
1681
+ e.preventDefault(); e.stopPropagation();
1682
+ capturingKey = false;
1683
+ if (e.key !== "Escape") { settings.panic.key = c; saveSettings(); }
1684
+ updatePanicUI();
1685
+ el("panic-key").focus();
1686
+ return;
1687
+ }
1688
+ const p = settings.panic;
1689
+ if (!p.enabled || e.repeat || comboOf(e) !== p.key) return;
1690
+ const typing = e.target.matches?.("input, textarea, select, [contenteditable]");
1691
+ if (typing && !(e.ctrlKey || e.altKey || e.metaKey) && e.key.length === 1) return;
1692
+ e.preventDefault();
1693
+ panic();
1694
+ }, true);
1695
+ el("panic-btn").addEventListener("click", panic);
1696
+ el("player-panic").addEventListener("click", panic);
1697
+ addEventListener("beforeunload", (e) => { if (settings.confirmClose && !panicking) { e.preventDefault(); e.returnValue = ""; } });
1698
+
1699
+ function launchBlank() {
1700
+ const w = window.open("about:blank", "_blank");
1701
+ if (!w) { toast("Your browser blocked the new tab. Allow pop-ups for this site and try again.", "error"); return; }
1702
+ const d = w.document;
1703
+ d.title = document.title;
1704
+ d.body.style.cssText = "margin:0;background:#000";
1705
+ const f = d.createElement("iframe");
1706
+ f.src = location.href;
1707
+ f.style.cssText = "position:fixed;inset:0;width:100%;height:100%;border:0";
1708
+ f.allow = "autoplay; fullscreen; gamepad";
1709
+ d.body.appendChild(f);
1710
+ panic();
1711
+ }
1712
+
1713
+ /* ============================ settings: dialog ============================ */
1714
+ const selectSettingsTab = wireTabs(el("settings-tabs"));
1715
+ function renderCloakOptions() {
1716
+ el("cloak-options").innerHTML = Object.entries(CLOAKS).map(([k, c]) =>
1717
+ `<label class="opt"><input type="radio" name="cloak" value="${k}" class="sr-only" /><img src="${escapeHtml(c.icon || origIcon)}" alt="" class="h-8 w-8 shrink-0 rounded-lg bg-white/10" />
1718
+ <span class="min-w-0"><span class="block text-[15px] font-medium">${escapeHtml(c.label)}</span><span class="block truncate text-[13px] text-ink-300">${escapeHtml(c.sub)}</span></span></label>`).join("");
1719
+ }
1720
+ function syncSettingsUI() {
1721
+ el("set-announce").checked = settings.announceOnOpen;
1722
+ el("set-reduce").checked = settings.reduceMotion;
1723
+ document.querySelector(`input[name="tile"][value="${settings.tileSize}"]`).checked = true;
1724
+ document.querySelector(`input[name="cloak"][value="${settings.cloak}"]`).checked = true;
1725
+ el("custom-cloak").hidden = settings.cloak !== "custom";
1726
+ el("cc-title").value = settings.customTitle;
1727
+ el("cc-icon").value = settings.customIcon;
1728
+ el("panic-on").checked = settings.panic.enabled;
1729
+ el("panic-url").value = settings.panic.url;
1730
+ el("panic-btn-on").checked = settings.panic.button;
1731
+ el("set-mask").checked = settings.maskText;
1732
+ el("set-confirm").checked = settings.confirmClose;
1733
+ updatePanicUI();
1734
+ }
1735
+ function applyAllSettings() { applyMotion(); applyTileSize(); applyCloak(); setMask(settings.maskText); updatePanicUI(); }
1736
+ function refreshLib() {
1737
+ const ok = isUnlocked();
1738
+ el("export-games").disabled = !ok;
1739
+ el("import-games-btn").disabled = !ok;
1740
+ el("lib-lock").hidden = ok;
1741
+ el("unlock-admin").hidden = ok;
1742
+ }
1743
+ function openSettings() { syncSettingsUI(); refreshLib(); openModal("settings-modal", "settings-card", "settings-title"); }
1744
+ function closeSettings() { closeModal("settings-modal"); }
1745
+ el("settings-btn").addEventListener("click", openSettings);
1746
+ el("settings-close").addEventListener("click", closeSettings);
1747
+ el("open-admin").addEventListener("click", () => { closeSettings(); if (isUnlocked()) openAdminPanel(); else openAdminLogin(); });
1748
+ el("unlock-admin").addEventListener("click", () => { closeSettings(); openAdminLogin(); });
1749
+
1750
+ const on = (id, evt, fn) => el(id).addEventListener(evt, fn);
1751
+ on("set-announce", "change", (e) => { settings.announceOnOpen = e.target.checked; saveSettings(); });
1752
+ on("set-reduce", "change", (e) => { settings.reduceMotion = e.target.checked; saveSettings(); applyMotion(); });
1753
+ document.querySelectorAll('input[name="tile"]').forEach((r) => r.addEventListener("change", () => { settings.tileSize = r.value; saveSettings(); applyTileSize(); }));
1754
+ on("cloak-options", "change", (e) => {
1755
+ settings.cloak = e.target.value; saveSettings(); applyCloak();
1756
+ el("custom-cloak").hidden = settings.cloak !== "custom";
1757
+ if (settings.cloak === "custom") focusSoon(el("cc-title"));
1758
+ });
1759
+ on("cc-title", "input", (e) => { settings.customTitle = e.target.value; saveSettings(); applyCloak(); });
1760
+ on("cc-icon", "input", (e) => { settings.customIcon = e.target.value.trim(); saveSettings(); applyCloak(); });
1761
+ on("panic-on", "change", (e) => { settings.panic.enabled = e.target.checked; saveSettings(); updatePanicUI(); });
1762
+ on("panic-btn-on", "change", (e) => { settings.panic.button = e.target.checked; saveSettings(); updatePanicUI(); });
1763
+ on("panic-key", "click", () => { capturingKey = true; el("panic-key").textContent = "Press a key…"; });
1764
+ on("panic-key", "blur", () => { if (capturingKey) { capturingKey = false; updatePanicUI(); } });
1765
+ on("panic-url", "input", (e) => {
1766
+ const v = e.target.value.trim(), err = el("panic-url-err");
1767
+ const bad = v && !isHttp(v);
1768
+ err.textContent = bad ? "Enter a full link that starts with https://" : "";
1769
+ err.classList.toggle("hidden", !bad);
1770
+ e.target.toggleAttribute("aria-invalid", !!bad);
1771
+ if (!bad) { settings.panic.url = v || DEFAULTS.panic.url; saveSettings(); }
1772
+ });
1773
+ on("set-mask", "change", (e) => { settings.maskText = e.target.checked; saveSettings(); setMask(settings.maskText); });
1774
+ on("set-confirm", "change", (e) => { settings.confirmClose = e.target.checked; saveSettings(); });
1775
+ on("launch-blank", "click", launchBlank);
1776
+
1777
+ /* ============================ settings: data ============================ */
1778
+ function download(name, obj) {
1779
+ const a = document.createElement("a");
1780
+ a.href = URL.createObjectURL(new Blob([JSON.stringify(obj, null, 2)], { type: "application/json" }));
1781
+ a.download = name;
1782
+ document.body.appendChild(a);
1783
+ a.click();
1784
+ a.remove();
1785
+ setTimeout(() => URL.revokeObjectURL(a.href), 2000);
1786
+ }
1787
+ const libStatus = (t) => { el("lib-status").textContent = t; };
1788
+ let resetTimer = null, pendingImport = null;
1789
+ on("export-settings", "click", () => { download("mlv2-settings.json", { app: "mlv2", type: "settings", version: 1, settings }); toast("Settings exported"); });
1790
+ on("import-settings-btn", "click", () => el("import-settings").click());
1791
+ on("import-settings", "change", async (e) => {
1792
+ const f = e.target.files[0];
1793
+ e.target.value = "";
1794
+ if (!f) return;
1795
+ try {
1796
+ const data = JSON.parse(await f.text());
1797
+ if (data?.type !== "settings" || typeof data.settings !== "object") throw new Error("bad");
1798
+ settings = mergeSettings(DEFAULTS, data.settings);
1799
+ saveSettings(); applyAllSettings(); syncSettingsUI();
1800
+ toast("Settings imported");
1801
+ } catch { toast("That doesn't look like a settings file.", "error"); }
1802
+ });
1803
+ on("reset-seen", "click", () => { try { localStorage.removeItem(SEEN_KEY); } catch { /* storage blocked */ } refreshBell(); toast("You'll see announcements again"); });
1804
+ on("reset-settings", "click", (e) => {
1805
+ const b = e.currentTarget;
1806
+ if (b.dataset.armed) {
1807
+ clearTimeout(resetTimer); delete b.dataset.armed; b.textContent = "Reset";
1808
+ settings = mergeSettings(DEFAULTS, null);
1809
+ saveSettings(); applyAllSettings(); syncSettingsUI();
1810
+ toast("Settings reset");
1811
+ } else {
1812
+ b.dataset.armed = "1"; b.textContent = "Click again to confirm";
1813
+ resetTimer = setTimeout(() => { delete b.dataset.armed; b.textContent = "Reset"; }, 4000);
1814
+ }
1815
+ });
1816
+
1817
+ async function fetchAllGames() {
1818
+ const res = await tablesDB.listRows({ ...dbArgs, queries: [Query.limit(200), Query.orderAsc("order")] });
1819
+ return res.rows.sort(byOrder);
1820
+ }
1821
+ on("export-games", "click", async () => {
1822
+ if (!isUnlocked()) return refreshLib();
1823
+ libStatus("Preparing export…");
1824
+ try {
1825
+ const rows = await fetchAllGames();
1826
+ const games = rows.map((g) => ({ name: g.name, url: g.url, category: g.category || "", iconUrl: g.iconFileId ? String(iconSrcFor(g) || "") : g.iconUrl || "", enabled: g.enabled !== false }));
1827
+ download("mlv2-games.json", { app: "mlv2", type: "games", version: 1, exportedAt: new Date().toISOString(), games });
1828
+ libStatus(`Exported ${games.length} ${games.length === 1 ? "game" : "games"}.`);
1829
+ } catch (err) { libStatus(err?.message || "Couldn't export your games."); }
1830
+ });
1831
+ on("import-games-btn", "click", () => el("import-games").click());
1832
+ on("import-games", "change", async (e) => {
1833
+ const f = e.target.files[0], go = el("import-games-go");
1834
+ e.target.value = "";
1835
+ if (!f || !isUnlocked()) return;
1836
+ go.hidden = true; pendingImport = null;
1837
+ try {
1838
+ const data = JSON.parse(await f.text());
1839
+ if (data?.type !== "games" || !Array.isArray(data.games)) throw new Error("That file isn't an mlv2 games export.");
1840
+ const existing = await fetchAllGames();
1841
+ const have = new Set(existing.map((g) => g.url));
1842
+ const valid = data.games.filter((g) => g && typeof g.name === "string" && g.name.trim() && isHttp(g.url));
1843
+ const fresh = valid.filter((g, i, a) => !have.has(g.url) && a.findIndex((x) => x.url === g.url) === i);
1844
+ pendingImport = { fresh, base: existing.reduce((m, r) => Math.max(m, r.order ?? -1), -1) + 1 };
1845
+ libStatus(`Found ${valid.length} valid games. ${fresh.length} ${fresh.length === 1 ? "is" : "are"} new; the rest are already in your library.`);
1846
+ if (fresh.length) { go.textContent = `Import ${fresh.length} new ${fresh.length === 1 ? "game" : "games"}`; go.hidden = false; }
1847
+ } catch (err) { libStatus(err?.message || "Couldn't read that file."); }
1848
+ });
1849
+ on("import-games-go", "click", async (e) => {
1850
+ const go = e.currentTarget;
1851
+ if (!pendingImport || !isUnlocked()) return;
1852
+ const { fresh, base } = pendingImport;
1853
+ go.disabled = true;
1854
+ let done = 0, failed = 0;
1855
+ for (const [i, g] of fresh.entries()) {
1856
+ libStatus(`Importing ${i + 1} of ${fresh.length}…`);
1857
+ try {
1858
+ await tablesDB.createRow({ ...dbArgs, rowId: ID.unique(), data: { name: g.name.trim(), url: g.url, category: String(g.category || "").trim(), iconFileId: "", iconUrl: isHttp(g.iconUrl) ? g.iconUrl : "", enabled: g.enabled !== false, order: base + i } });
1859
+ done++;
1860
+ } catch { failed++; }
1861
+ }
1862
+ go.disabled = false; go.hidden = true; pendingImport = null;
1863
+ libStatus(`Imported ${done} ${done === 1 ? "game" : "games"}${failed ? `, ${failed} failed` : ""}.`);
1864
+ toast(failed ? `Imported ${done}, ${failed} failed` : `Imported ${done} ${done === 1 ? "game" : "games"}`, failed ? "error" : "default");
1865
+ loadGames();
1866
+ });
1867
+
1868
+ /* ============================ boot ============================ */
1869
+ renderCloakOptions();
1870
+ applyAllSettings();
1871
+ loadAnnouncements();
1872
+
1022
1873
  /* ---------------------------- tile hover FX ---------------------------- */
1023
1874
  const gridEl = el("grid");
1024
1875
  gridEl.addEventListener("pointermove", (e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlv2boii",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {