mlv2boii 1.0.9 → 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 +1308 -195
  2. package/package.json +1 -1
package/index.html CHANGED
@@ -24,7 +24,7 @@
24
24
  --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", ui-sans-serif, system-ui, sans-serif;
25
25
  --font-mono: ui-monospace, "SF Mono", Menlo, monospace;
26
26
  --color-ink-950: #000000; --color-ink-900: #0a0a0b; --color-ink-850: #101012; --color-ink-800: #161618;
27
- --color-ink-700: #232326; --color-ink-600: #2c2c30; --color-ink-500: #48484c; --color-ink-400: #76767b;
27
+ --color-ink-700: #232326; --color-ink-600: #2c2c30; --color-ink-500: #48484c; --color-ink-400: #8e8e93;
28
28
  --color-ink-300: #a1a1a6; --color-ink-200: #c7c7cc; --color-ink-100: #e5e5ea; --color-ink-50: #f5f5f7;
29
29
  --color-red-700: #8f0e20; --color-red-600: #c11228; --color-red-500: #f01a36; --color-red-400: #ff4d63; --color-red-300: #ff8b96;
30
30
  }
@@ -42,7 +42,8 @@
42
42
  ::selection { background: rgba(240,26,54,0.4); color: #fff; }
43
43
  input { caret-color: var(--color-red-400); }
44
44
  :focus-visible { outline: 2px solid var(--color-red-400); outline-offset: 2px; }
45
- input:focus-visible { outline: none; }
45
+ .field:focus-visible, [data-pin-idx]:focus-visible { outline: none; }
46
+ [tabindex="-1"]:focus { outline: none; }
46
47
  .tile:focus-visible { outline: none; }
47
48
  .tile:focus-visible .tile-icon { outline: 2px solid var(--color-red-400); outline-offset: 3px; }
48
49
  .page-x { padding-left: max(1.25rem, env(safe-area-inset-left)); padding-right: max(1.25rem, env(safe-area-inset-right)); }
@@ -55,11 +56,11 @@
55
56
  radial-gradient(720px 520px at 8% 108%, rgba(240,26,54,0.06), transparent 70%); }
56
57
 
57
58
  /* ---------- liquid glass ---------- */
58
- .glass { background: rgba(255,255,255,0.06); -webkit-backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
59
- 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); }
60
61
  .glass-sheet { background: rgba(28,28,31,0.55); -webkit-backdrop-filter: blur(40px) saturate(180%); backdrop-filter: blur(40px) saturate(180%); }
61
62
  .glass::before { content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; pointer-events: none;
62
- 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));
63
64
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; }
64
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); }
65
66
  .glass-lite:hover { background: rgba(255,255,255,0.12); }
@@ -73,7 +74,7 @@
73
74
  .badge { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 14px 0 10px; border-radius: 999px; font-size: 13px; font-weight: 600; color: #fff;
74
75
  background: linear-gradient(180deg, #ff6b7d, #f01a36 60%, #c80f27);
75
76
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.55), inset 0 -2px 0 rgba(0,0,0,0.16), 0 6px 22px -2px rgba(240,26,54,0.7); }
76
- .glow-line { display: inline-block; padding: 0 0.04em 0.1em;
77
+ .glow-line { display: inline-block; padding: 0 0 0.1em;
77
78
  background: linear-gradient(180deg, #ffb8c0 0%, #ff4d63 48%, #f01a36 100%); -webkit-background-clip: text; background-clip: text; color: transparent; }
78
79
  .btn-primary { color: #fff; background: linear-gradient(180deg, #ff6b7d, #f01a36 55%, #c80f27);
79
80
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.5), inset 0 -2px 0 rgba(0,0,0,0.18), 0 8px 22px -6px rgba(240,26,54,0.7); }
@@ -82,17 +83,16 @@
82
83
  .btn-ghost:hover { background: rgba(255,255,255,0.14); }
83
84
  .chip-on { background: linear-gradient(180deg, #ff6b7d, #f01a36 60%, #c80f27);
84
85
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.5), inset 0 -2px 0 rgba(0,0,0,0.16), 0 6px 20px -4px rgba(240,26,54,0.65); }
85
- .chip-count { display: grid; place-items: center; min-width: 26px; height: 26px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 700; color: #111; background: var(--c);
86
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.6), 0 0 14px color-mix(in srgb, var(--c) 55%, transparent); }
86
+ .chip-count { display: grid; place-items: center; min-width: 26px; height: 26px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 700; color: #fff; background: #c11228;
87
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.3); }
87
88
  .chip-on .chip-count { background: #fff; color: #b50e28; box-shadow: none; }
88
89
 
89
90
  /* game tiles: glass card, glossy icon, glow in the category colour */
90
91
  .tile { background: rgba(255,255,255,0.05); border-radius: 28px; padding: 10px 10px 12px;
91
92
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.16), inset 0 0 0 1px rgba(255,255,255,0.07);
92
93
  transition: background-color .3s ease-out, box-shadow .3s ease-out; }
93
- .tile:hover { background: color-mix(in srgb, var(--c) 13%, rgba(255,255,255,0.05));
94
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.28), inset 0 0 0 1px color-mix(in srgb, var(--c) 55%, transparent), 0 16px 40px -10px color-mix(in srgb, var(--c) 60%, transparent); }
95
- .tile-icon { position: relative; box-shadow: 0 10px 24px -8px color-mix(in srgb, var(--c) 28%, transparent); }
94
+ .tile:hover { background: rgba(255,255,255,0.09); box-shadow: inset 0 1px 0 rgba(255,255,255,0.28), inset 0 0 0 1px rgba(240,26,54,0.55); }
95
+ .tile-icon { position: relative; }
96
96
  .tile-icon::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
97
97
  background: linear-gradient(160deg, rgba(255,255,255,0.3), rgba(255,255,255,0) 42%);
98
98
  box-shadow: inset 0 2px 0 rgba(255,255,255,0.35), inset 0 -8px 14px rgba(0,0,0,0.25); }
@@ -102,6 +102,47 @@
102
102
  animation: float 6s ease-in-out infinite; animation-delay: var(--d, 0s); }
103
103
  @keyframes float { 0%, 100% { transform: translateY(0) rotate(var(--r, 0deg)); } 50% { transform: translateY(-12px) rotate(calc(var(--r, 0deg) + 4deg)); } }
104
104
 
105
+ /* ---------- accessibility + admin controls ---------- */
106
+ .skip { position: fixed; left: 12px; top: -64px; z-index: 100; padding: 10px 16px; border-radius: 999px; background: #fff; color: #000; font-size: 14px; font-weight: 600; transition: top .2s; }
107
+ .skip:focus { top: calc(env(safe-area-inset-top) + 12px); }
108
+ input[type="search"]::-webkit-search-cancel-button { display: none; }
109
+ input[type="file"]:focus-visible, input[type="checkbox"]:focus-visible { outline: 2px solid var(--color-red-400); outline-offset: 2px; }
110
+ .btn-danger { color: #fff; background: #c11228; box-shadow: inset 0 1px 0 rgba(255,255,255,0.25); }
111
+ .btn-danger:hover { background: #d61a40; }
112
+ .tag { display: inline-block; margin-left: 6px; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; color: var(--color-ink-200); background: rgba(255,255,255,0.1); vertical-align: 1px; }
113
+ .switch { appearance: none; -webkit-appearance: none; position: relative; flex-shrink: 0; width: 44px; height: 26px; border-radius: 999px; cursor: pointer;
114
+ background: rgba(255,255,255,0.16); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12); transition: background-color .2s; }
115
+ .switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.4); transition: transform .25s cubic-bezier(.34,1.56,.64,1); }
116
+ .switch:checked { background: #f01a36; }
117
+ .switch:checked::after { transform: translateX(18px); }
118
+ .switch:disabled, button:disabled { opacity: 0.45; cursor: not-allowed; }
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
+
105
146
  /* ---------- motion ---------- */
106
147
  .w { display: inline-block; opacity: 0; }
107
148
  .glowwrap { display: inline-block; filter: drop-shadow(0 0 22px rgba(240,26,54,0.55)); }
@@ -145,31 +186,45 @@
145
186
 
146
187
  <body class="min-h-screen bg-ink-950 font-sans text-ink-50 antialiased">
147
188
 
189
+ <a href="#games-heading" class="skip">Skip to games</a>
190
+
148
191
  <div class="ambient" aria-hidden="true"></div>
149
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
+
150
197
  <!-- shared gradients for the hero shapes -->
151
198
  <svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
152
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>
153
200
  <radialGradient id="gO" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffb8c0"/><stop offset=".38" stop-color="#e8142f"/><stop offset="1" stop-color="#7d0a1c"/></radialGradient>
154
201
  <radialGradient id="gL" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffd0d5"/><stop offset=".38" stop-color="#ff5c70"/><stop offset="1" stop-color="#c11228"/></radialGradient>
155
- <radialGradient id="gB" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffffff"/><stop offset=".4" stop-color="#e5e5ea"/><stop offset="1" stop-color="#9a9aa2"/></radialGradient>
156
- <radialGradient id="gV" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#85858d"/><stop offset=".4" stop-color="#2c2c30"/><stop offset="1" stop-color="#0a0a0b"/></radialGradient>
157
202
  </defs></svg>
158
203
 
159
204
  <!-- ================= HEADER ================= -->
160
205
  <header class="pointer-events-none sticky top-0 z-40 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
161
206
  <div class="page-x mx-auto max-w-5xl">
162
- <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">
163
208
  <a href="#" class="flex shrink-0 items-center gap-2.5" aria-label="mlv2 home">
164
209
  <span class="orb"></span>
165
210
  <span class="text-[17px] font-semibold tracking-[-0.02em]">mlv2</span>
166
211
  </a>
167
- <div class="ml-auto flex min-w-0 flex-1 items-center justify-end">
212
+ <div role="search" class="ml-auto flex min-w-0 flex-1 items-center justify-end">
168
213
  <div id="search-wrap" class="relative">
169
214
  <svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
170
- <input id="search" type="text" placeholder="Search games" aria-label="Search games" autocomplete="off" enterkeyhint="search" class="field rounded-full pl-9" />
215
+ <input id="search" type="search" placeholder="Search games" aria-label="Search games" autocomplete="off" enterkeyhint="search" class="field rounded-full pl-9 pr-9" />
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>
171
217
  </div>
172
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>
173
228
  </div>
174
229
  </div>
175
230
  </header>
@@ -184,26 +239,29 @@
184
239
 
185
240
  <!-- floating bubbly shapes (large screens) -->
186
241
  <div id="shapes" class="pointer-events-none absolute right-2 top-4 hidden h-[360px] w-[400px] lg:block" aria-hidden="true">
187
- <div class="shape" style="left:20px;top:120px;width:190px"><div class="par" data-depth="1"><svg viewBox="0 0 120 120" class="float" style="--r:-8deg;--g:rgba(240,26,54,.4);--d:-1s">
242
+ <div class="shape" style="left:30px;top:90px;width:200px"><div class="par" data-depth="1"><svg viewBox="0 0 120 120" class="float" style="--r:-8deg;--g:rgba(240,26,54,.4);--d:-1s">
188
243
  <rect x="8" y="30" width="104" height="62" rx="31" fill="url(#gP)"/>
189
244
  <path d="M34 50v24M22 62h24" stroke="#fff" stroke-width="9" stroke-linecap="round" opacity=".92"/>
190
245
  <circle cx="82" cy="54" r="6" fill="#fff" opacity=".92"/><circle cx="96" cy="68" r="6" fill="#fff" opacity=".92"/>
191
246
  <ellipse cx="44" cy="40" rx="28" ry="7" fill="#fff" opacity=".45"/>
192
247
  </svg></div></div>
193
- <div class="shape" style="left:250px;top:0;width:120px"><div class="par" data-depth="1.6"><svg viewBox="0 0 120 120" class="float" style="--r:10deg;--g:rgba(240,26,54,.35);--d:-3s">
248
+ <div class="shape" style="left:255px;top:10px;width:120px"><div class="par" data-depth="1.6"><svg viewBox="0 0 120 120" class="float" style="--r:10deg;--g:rgba(240,26,54,.35);--d:-3s">
194
249
  <polygon points="60,16 71.8,43.8 101.9,46.4 79,66.2 85.9,95.6 60,80 34.1,95.6 41,66.2 18.2,46.4 48.2,43.8" fill="url(#gO)" stroke="url(#gO)" stroke-width="14" stroke-linejoin="round"/>
195
250
  <ellipse cx="48" cy="40" rx="14" ry="5" fill="#fff" opacity=".5" transform="rotate(-25 48 40)"/>
196
251
  </svg></div></div>
197
- <div class="shape" style="left:272px;top:170px;width:120px"><div class="par" data-depth="0.7"><svg viewBox="0 0 120 120" class="float" style="--r:-6deg;--g:rgba(255,255,255,.14);--d:-2s">
198
- <path d="M44 34 L90 60 L44 86 Z" fill="url(#gB)" stroke="url(#gB)" stroke-width="16" stroke-linejoin="round"/>
199
- <ellipse cx="50" cy="48" rx="8" ry="4" fill="#fff" opacity=".5" transform="rotate(35 50 48)"/>
252
+ <div class="shape" style="left:255px;top:175px;width:105px"><div class="par" data-depth="0.8"><svg viewBox="0 0 120 120" class="float" style="--r:-6deg;--g:rgba(240,26,54,.35);--d:-2s">
253
+ <path d="M44 34 L90 60 L44 86 Z" fill="url(#gL)" stroke="url(#gL)" stroke-width="16" stroke-linejoin="round"/>
254
+ <ellipse cx="50" cy="48" rx="8" ry="4" fill="#fff" opacity=".55" transform="rotate(35 50 48)"/>
200
255
  </svg></div></div>
201
- <div class="shape" style="left:60px;top:270px;width:86px"><div class="par" data-depth="1.3"><svg viewBox="0 0 120 120" class="float" style="--r:8deg;--g:rgba(240,26,54,.3);--d:-4s">
202
- <path d="M60 26v68M26 60h68" fill="none" stroke="url(#gL)" stroke-width="30" stroke-linecap="round"/>
256
+ <div class="shape" style="left:55px;top:268px;width:84px"><div class="par" data-depth="1.3"><svg viewBox="0 0 120 120" class="float" style="--r:8deg;--g:rgba(240,26,54,.3);--d:-4s">
257
+ <path d="M60 26v68M26 60h68" fill="none" stroke="url(#gP)" stroke-width="30" stroke-linecap="round"/>
203
258
  <ellipse cx="52" cy="30" rx="6" ry="3" fill="#fff" opacity=".55"/>
204
259
  </svg></div></div>
205
- <div class="shape" style="left:150px;top:20px;width:64px"><div class="par" data-depth="2"><svg viewBox="0 0 120 120" class="float" style="--r:0deg;--g:rgba(255,255,255,.12);--d:-5s">
206
- <circle cx="60" cy="60" r="40" fill="url(#gV)"/><ellipse cx="46" cy="42" rx="16" ry="8" fill="#fff" opacity=".5" transform="rotate(-30 46 42)"/>
260
+ <div class="shape" style="left:165px;top:14px;width:52px"><div class="par" data-depth="2"><svg viewBox="0 0 120 120" class="float" style="--r:0deg;--g:rgba(240,26,54,.35);--d:-5s">
261
+ <circle cx="60" cy="60" r="42" fill="url(#gP)"/><ellipse cx="46" cy="42" rx="16" ry="8" fill="#fff" opacity=".55" transform="rotate(-30 46 42)"/>
262
+ </svg></div></div>
263
+ <div class="shape" style="left:175px;top:300px;width:40px"><div class="par" data-depth="1.8"><svg viewBox="0 0 120 120" class="float" style="--r:0deg;--g:rgba(240,26,54,.3);--d:-2.5s">
264
+ <circle cx="60" cy="60" r="42" fill="url(#gL)"/><ellipse cx="46" cy="42" rx="16" ry="8" fill="#fff" opacity=".55" transform="rotate(-30 46 42)"/>
207
265
  </svg></div></div>
208
266
  </div>
209
267
  </section>
@@ -215,11 +273,14 @@
215
273
  <p class="mt-1 text-[14px] leading-relaxed text-ink-300">Open this file and fill in the <code class="font-mono text-ink-50">CONFIG</code> object near the bottom with your Appwrite endpoint, project, database, table and bucket IDs.</p>
216
274
  </div>
217
275
 
276
+ <h2 id="games-heading" tabindex="-1" class="sr-only">Games</h2>
277
+ <p id="grid-status" role="status" class="sr-only"></p>
218
278
  <div id="skeleton-grid" class="grid grid-cols-3 gap-3 sm:grid-cols-4 sm:gap-4 md:grid-cols-5 lg:grid-cols-6"></div>
219
279
 
220
- <div id="empty-state" class="hidden flex-col items-center justify-center py-24 text-center">
280
+ <div id="empty-state" aria-live="polite" class="hidden flex-col items-center justify-center py-24 text-center">
221
281
  <p id="empty-title" class="text-[19px] font-semibold">No games yet</p>
222
- <p id="empty-sub" class="mt-2 max-w-xs text-pretty text-[15px] text-ink-400">Games you add in Appwrite with a valid link will show up here automatically.</p>
282
+ <p id="empty-sub" class="mt-2 max-w-xs text-pretty text-[15px] text-ink-300">Games you add in Appwrite with a valid link will show up here automatically.</p>
283
+ <button id="retry" type="button" class="btn btn-ghost mt-5 hidden">Try again</button>
223
284
  </div>
224
285
 
225
286
  <div id="grid" class="hidden grid grid-cols-3 gap-3 sm:grid-cols-4 sm:gap-4 md:grid-cols-5 lg:grid-cols-6"></div>
@@ -229,14 +290,14 @@
229
290
  <footer class="relative z-10 border-t border-white/[0.08]">
230
291
  <div class="page-x mx-auto flex max-w-5xl items-center justify-between py-4 text-[13px] text-ink-400">
231
292
  <p>mlv2 © <span id="year"></span></p>
232
- <button id="admin-trigger" class="glass-lite rounded-full px-4 py-2 transition-colors duration-200 hover:text-ink-50">Admin</button>
293
+ <button id="admin-trigger" aria-haspopup="dialog" class="glass-lite rounded-full px-4 py-2 transition-colors duration-200 hover:text-ink-50">Admin</button>
233
294
  </div>
234
295
  </footer>
235
296
 
236
297
  <!-- ================= PLAYER MODAL ================= -->
237
298
  <div id="player-modal" class="fixed inset-0 z-50 hidden">
238
299
  <div class="absolute inset-0 bg-black" data-player-close></div>
239
- <div id="player-panel" class="relative flex h-full w-full flex-col opacity-0">
300
+ <div id="player-panel" role="dialog" aria-modal="true" aria-labelledby="player-title" class="relative flex h-full w-full flex-col opacity-0">
240
301
  <div class="border-b border-white/[0.08] bg-black pt-[env(safe-area-inset-top)]">
241
302
  <div class="flex h-14 items-center justify-between gap-3 px-3 sm:px-4">
242
303
  <div class="flex min-w-0 items-center gap-3">
@@ -244,6 +305,12 @@
244
305
  <p id="player-title" class="truncate text-[15px] font-semibold"></p>
245
306
  </div>
246
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>
311
+ <button id="player-fs" type="button" title="Fullscreen" aria-label="Fullscreen" class="glass-lite icon-btn">
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>
313
+ </button>
247
314
  <a id="player-newtab" target="_blank" rel="noopener" title="Open in new tab" aria-label="Open in new tab" class="glass-lite icon-btn">
248
315
  <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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
249
316
  </a>
@@ -261,13 +328,13 @@
261
328
  </div>
262
329
 
263
330
  <!-- ================= ADMIN: PIN GATE ================= -->
264
- <div id="admin-login-modal" class="fixed inset-0 z-50 hidden">
265
- <div class="absolute inset-0 bg-black/50 backdrop-blur-md" data-admin-login-close></div>
266
- <div class="absolute inset-0 flex items-center justify-center p-4">
267
- <form id="admin-login-form" class="glass glass-sheet relative w-full max-w-[340px] rounded-[28px] p-6 opacity-0" data-fx>
268
- <p class="text-[19px] font-semibold">Enter PIN</p>
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>
335
+ <h2 id="pin-title" class="text-[19px] font-semibold">Enter PIN</h2>
269
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>
270
- <div id="pin-boxes" class="mt-5 grid grid-cols-6 gap-2"></div>
337
+ <div id="pin-boxes" role="group" aria-labelledby="pin-title" class="mt-5 grid grid-cols-6 gap-2"></div>
271
338
  <input id="admin-pin" type="hidden" />
272
339
  <p id="admin-login-error" class="mt-3 hidden text-[14px] text-red-400" role="alert"></p>
273
340
  <button type="submit" id="admin-login-submit" class="btn btn-primary mt-5 w-full">Unlock</button>
@@ -277,48 +344,145 @@
277
344
  </div>
278
345
 
279
346
  <!-- ================= ADMIN: PANEL ================= -->
280
- <div id="admin-panel" class="fixed inset-0 z-50 hidden">
281
- <div class="absolute inset-0 bg-black/50 backdrop-blur-md" data-admin-panel-close></div>
282
- <div class="absolute inset-0 flex items-start justify-center p-0 sm:items-center sm:p-4">
283
- <div id="admin-panel-card" class="glass glass-sheet relative flex h-full w-full flex-col opacity-0 sm:h-[85vh] sm:max-w-2xl sm:rounded-[32px]" data-fx>
284
- <div class="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.08] px-5 pt-[env(safe-area-inset-top)] box-content">
285
- <div>
286
- <p class="text-[17px] font-semibold leading-none">Admin</p>
287
- <p class="mt-1 text-[13px] text-ink-400">Unlocked with PIN</p>
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)]">
352
+ <div class="min-w-0">
353
+ <h2 id="admin-title" tabindex="-1" class="text-[17px] font-semibold leading-tight">Admin</h2>
354
+ <p id="admin-summary" class="mt-0.5 text-[13px] text-ink-300"></p>
288
355
  </div>
289
- <div class="flex items-center gap-2">
290
- <button id="admin-logout" class="btn btn-ghost">Lock</button>
291
- <button data-admin-panel-close aria-label="Close" class="glass-lite icon-btn">
292
- <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>
293
- </button>
356
+ <div class="flex shrink-0 items-center gap-2">
357
+ <button id="admin-logout" type="button" class="btn btn-ghost">Lock</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>
359
+ </div>
360
+ </div>
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>
294
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>
377
+ </div>
378
+ </div>
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>
389
+ </div>
390
+ <p id="admin-live" role="status" class="sr-only"></p>
391
+ </div>
392
+ </div>
393
+ </div>
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>
295
418
  </div>
296
- <div class="flex-1 overflow-y-auto px-5 py-5">
297
- <form id="add-game-form" class="glass-lite rounded-2xl p-4">
298
- <p class="text-[15px] font-semibold">Add game</p>
299
- <div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
300
- <div><label for="new-name" class="text-[13px] text-ink-300">Name</label><input id="new-name" required class="field mt-1.5" /></div>
301
- <div><label for="new-category" class="text-[13px] text-ink-300">Category (optional)</label><input id="new-category" class="field mt-1.5" /></div>
302
- <div class="sm:col-span-2"><label for="new-url" class="text-[13px] text-ink-300">Game URL</label><input id="new-url" required type="url" placeholder="https://" class="field mt-1.5" /></div>
303
- <div>
304
- <label for="new-icon-file" class="text-[13px] text-ink-300">Icon upload</label>
305
- <input id="new-icon-file" type="file" accept="image/*" class="mt-2 w-full text-[13px] text-ink-400 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" />
306
- </div>
307
- <div><label for="new-icon-url" class="text-[13px] text-ink-300">Or external icon URL</label><input id="new-icon-url" type="url" placeholder="https://" class="field mt-1.5" /></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>
308
452
  </div>
309
- <div class="mt-5 flex items-center gap-3">
310
- <button type="submit" id="add-game-submit" class="btn btn-primary">Add game</button>
311
- <span id="add-game-status" class="text-[13px] text-ink-400"></span>
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>
312
476
  </div>
313
- </form>
314
- <p class="mb-3 mt-8 text-[15px] font-semibold">Games</p>
315
- <div id="admin-list" class="space-y-2"></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>
316
480
  </div>
317
481
  </div>
318
482
  </div>
319
483
  </div>
320
484
 
321
- <div id="toast-root" class="fixed bottom-4 right-4 z-[60] flex flex-col items-end gap-2 pb-[env(safe-area-inset-bottom)]"></div>
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>
322
486
 
323
487
  <script src="https://cdn.jsdelivr.net/npm/appwrite@26.2.0"></script>
324
488
  <script type="module">
@@ -330,6 +494,8 @@ const CONFIG = {
330
494
  databaseId: "6a9b8b23003de0ec5cf7",
331
495
  gamesTableId: "6a9b8bb3000013a3fd80",
332
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: "",
333
499
  };
334
500
 
335
501
  const ADMIN_PIN = "051511";
@@ -344,7 +510,51 @@ const storage = new Storage(client);
344
510
  const isConfigured = () =>
345
511
  !CONFIG.endpoint.includes("YOUR_") && !CONFIG.projectId.includes("YOUR_") && !CONFIG.databaseId.includes("YOUR_");
346
512
 
347
- 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
+
348
558
  function fx(targets, keyframes, options = {}) {
349
559
  if (reduceMotion) options = { ...options, duration: 0, delay: 0 };
350
560
  return animate(targets, keyframes, options);
@@ -361,7 +571,7 @@ let allGames = [];
361
571
  let activeCategory = "All";
362
572
  let searchTerm = "";
363
573
  const BRAND = "#f01a36";
364
- const PALETTE = ["#f01a36", "#ff6b7d", "#ff98a5"];
574
+ const PALETTE = ["#f01a36"];
365
575
  let catColors = {};
366
576
  let barShown = false;
367
577
 
@@ -395,7 +605,7 @@ function toast(message, tone = "default") {
395
605
  const root = el("toast-root");
396
606
  const node = document.createElement("div");
397
607
  const toneClasses = tone === "error" ? "text-red-300" : "text-ink-50";
398
- node.className = `glass glass-sheet relative rounded-full ${toneClasses} px-4 py-2.5 text-[14px] opacity-0`;
608
+ node.className = `glass glass-sheet pointer-events-auto relative rounded-full ${toneClasses} px-4 py-2.5 text-[14px] opacity-0`;
399
609
  node.textContent = message;
400
610
  root.appendChild(node);
401
611
  pop(node, { y: 16, scale: 0.9, stiff: 420, damp: 24 });
@@ -455,6 +665,7 @@ let gridShown = false;
455
665
  function renderGrid() {
456
666
  const grid = el("grid");
457
667
  const games = currentFiltered();
668
+ el("grid-status").textContent = allGames.length === 0 ? "No games yet" : games.length === 0 ? "No games match your search" : `${games.length} ${games.length === 1 ? "game" : "games"} shown`;
458
669
  el("skeleton-grid").classList.add("hidden");
459
670
 
460
671
  if (allGames.length === 0) {
@@ -525,6 +736,8 @@ async function loadGames() {
525
736
  renderGrid();
526
737
  } catch (err) {
527
738
  console.error("Failed to load games:", err);
739
+ if (allGames.length) { toast("Couldn't refresh the game list", "error"); return; }
740
+ el("retry").classList.remove("hidden");
528
741
  el("skeleton-grid").classList.add("hidden");
529
742
  el("empty-title").textContent = "Couldn't load games";
530
743
  el("empty-sub").textContent = err?.message
@@ -537,84 +750,117 @@ async function loadGames() {
537
750
  loadGames();
538
751
 
539
752
  /* --------------------------- search / filter --------------------------- */
753
+ const searchEl = el("search"), clearBtn = el("search-clear");
540
754
  let searchDebounce;
541
- el("search").addEventListener("input", (e) => {
755
+ function applySearch() { searchTerm = searchEl.value.trim().toLowerCase(); renderGrid(); }
756
+ searchEl.addEventListener("input", () => {
757
+ clearBtn.classList.toggle("hidden", !searchEl.value);
758
+ clearBtn.classList.toggle("grid", !!searchEl.value);
542
759
  clearTimeout(searchDebounce);
543
- searchDebounce = setTimeout(() => {
544
- searchTerm = e.target.value.trim().toLowerCase();
545
- renderGrid();
546
- }, 120);
760
+ searchDebounce = setTimeout(applySearch, 120);
761
+ });
762
+ clearBtn.addEventListener("click", () => {
763
+ searchEl.value = "";
764
+ clearBtn.classList.add("hidden");
765
+ clearBtn.classList.remove("grid");
766
+ applySearch();
767
+ searchEl.focus();
768
+ });
769
+ searchEl.addEventListener("keydown", (e) => { if (e.key === "Escape" && searchEl.value) clearBtn.click(); });
770
+ el("retry").addEventListener("click", () => {
771
+ el("retry").classList.add("hidden");
772
+ el("empty-state").classList.add("hidden");
773
+ el("empty-state").classList.remove("flex");
774
+ el("skeleton-grid").classList.remove("hidden");
775
+ loadGames();
776
+ });
777
+
778
+ /* ------------------------------- dialogs ------------------------------- */
779
+ const isHidden = (id) => el(id).classList.contains("hidden");
780
+ let opener = null;
781
+ function lockBg(on) { document.querySelectorAll("body > header, body > main, body > footer, body > #panic-btn").forEach((n) => n.toggleAttribute("inert", on)); }
782
+ function beginDialog() { opener = document.activeElement; lockBg(true); }
783
+ function endDialog() { lockBg(false); if (opener && document.contains(opener)) opener.focus(); opener = null; }
784
+ const focusSoon = (node) => requestAnimationFrame(() => node && node.focus());
785
+
786
+ document.addEventListener("keydown", (e) => {
787
+ if (e.key === "Escape") {
788
+ if (!isHidden("player-modal")) closePlayer();
789
+ else if (!isHidden("admin-login-modal")) closeAdminLogin();
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()
794
+ && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || "")) {
795
+ e.preventDefault();
796
+ el("search").focus();
797
+ }
547
798
  });
548
799
 
549
800
  /* ------------------------------- player ------------------------------- */
550
801
  function openPlayer(game) {
551
802
  if (!game) return;
803
+ beginDialog();
552
804
  el("player-title").textContent = game.name;
805
+ el("player-frame").title = game.name;
553
806
  el("player-icon").src = iconSrcFor(game) || monogramSrc(game.name);
554
807
  el("player-newtab").href = game.url;
555
- el("player-frame").src = game.url;
556
808
  el("player-loading").style.display = "grid";
809
+ el("player-frame").src = game.url;
557
810
  el("player-modal").classList.remove("hidden");
558
811
  document.documentElement.style.overflow = "hidden";
559
812
  pop("#player-panel", { y: 20, scale: 0.94, stiff: 260, damp: 26 });
813
+ focusSoon(el("player-close"));
560
814
  }
561
815
  function closePlayer() {
562
816
  fx("#player-panel", { opacity: [1, 0], scale: [1, 0.98] }, { duration: 0.18, ease: "easeIn" }).finished.then(() => {
563
817
  el("player-modal").classList.add("hidden");
564
- el("player-frame").src = "";
818
+ el("player-frame").src = "about:blank";
565
819
  document.documentElement.style.overflow = "";
820
+ endDialog();
566
821
  });
567
822
  }
568
823
  el("player-close").addEventListener("click", closePlayer);
569
824
  document.querySelector("[data-player-close]").addEventListener("click", closePlayer);
570
- document.addEventListener("keydown", (e) => {
571
- if (e.key === "Escape") {
572
- if (!el("player-modal").classList.contains("hidden")) closePlayer();
573
- if (!el("admin-panel").classList.contains("hidden")) closeAdminPanel();
574
- if (!el("admin-login-modal").classList.contains("hidden")) closeAdminLogin();
575
- }
576
- });
825
+ el("player-fs").addEventListener("click", () => { const f = el("player-frame"); (f.requestFullscreen || f.webkitRequestFullscreen)?.call(f); });
577
826
 
578
- /* -------------------------------- admin -------------------------------- */
827
+ /* ------------------------------ admin: PIN ------------------------------ */
828
+ function isUnlocked() { return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1"; }
579
829
  function openAdminLogin() {
830
+ beginDialog();
580
831
  el("admin-login-modal").classList.remove("hidden");
581
832
  pop("#admin-login-form", { y: 16, scale: 0.92, stiff: 380, damp: 24 });
582
833
  resetPinBoxes();
583
- pinInputs[0]?.focus();
834
+ focusSoon(pinInputs[0]);
584
835
  }
585
836
  function closeAdminLogin() {
586
837
  el("admin-login-modal").classList.add("hidden");
587
838
  el("admin-login-error").classList.add("hidden");
839
+ endDialog();
588
840
  }
589
- function openAdminPanel() {
590
- el("admin-panel").classList.remove("hidden");
591
- pop("#admin-panel-card", { y: 24, scale: 0.97, stiff: 300, damp: 28 });
592
- renderAdminList();
593
- }
594
- function closeAdminPanel() { el("admin-panel").classList.add("hidden"); }
595
- function isUnlocked() { return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1"; }
596
-
597
841
  el("admin-trigger").addEventListener("click", () => { if (isUnlocked()) openAdminPanel(); else openAdminLogin(); });
598
- document.querySelector("[data-admin-login-close]").addEventListener("click", closeAdminLogin);
599
- document.querySelector("[data-admin-panel-close]").addEventListener("click", closeAdminPanel);
842
+ document.querySelectorAll("[data-admin-login-close]").forEach((n) => n.addEventListener("click", closeAdminLogin));
600
843
 
601
844
  const pinBoxesEl = el("pin-boxes");
602
845
  pinBoxesEl.innerHTML = Array.from({ length: 6 })
603
- .map((_, i) => `<input data-pin-idx="${i}" type="password" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit ${i + 1}"
846
+ .map((_, i) => `<input data-pin-idx="${i}" type="password" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit ${i + 1} of 6"
604
847
  class="h-12 w-full rounded-[12px] bg-white/[0.07] text-center text-[20px] tabular-nums outline-none ring-1 ring-inset ring-white/10 transition-shadow focus:ring-2 focus:ring-red-500" />`)
605
848
  .join("");
606
849
  const pinInputs = Array.from(pinBoxesEl.querySelectorAll("[data-pin-idx]"));
607
-
608
850
  function resetPinBoxes() { pinInputs.forEach((i) => (i.value = "")); el("admin-pin").value = ""; }
609
851
  function syncPinValue() { el("admin-pin").value = pinInputs.map((i) => i.value).join(""); }
852
+ function submitIfFull() { if (el("admin-pin").value.length === 6) el("admin-login-form").requestSubmit(); }
610
853
  pinInputs.forEach((input, idx) => {
611
854
  input.addEventListener("input", () => {
612
855
  input.value = input.value.replace(/[^0-9]/g, "").slice(0, 1);
613
856
  syncPinValue();
614
857
  if (input.value && idx < pinInputs.length - 1) pinInputs[idx + 1].focus();
858
+ submitIfFull();
615
859
  });
616
860
  input.addEventListener("keydown", (e) => {
617
861
  if (e.key === "Backspace" && !input.value && idx > 0) pinInputs[idx - 1].focus();
862
+ if (e.key === "ArrowLeft" && idx > 0) pinInputs[idx - 1].focus();
863
+ if (e.key === "ArrowRight" && idx < pinInputs.length - 1) pinInputs[idx + 1].focus();
618
864
  });
619
865
  input.addEventListener("paste", (e) => {
620
866
  e.preventDefault();
@@ -622,140 +868,1007 @@ pinInputs.forEach((input, idx) => {
622
868
  digits.forEach((d, i) => pinInputs[i] && (pinInputs[i].value = d));
623
869
  syncPinValue();
624
870
  pinInputs[Math.min(digits.length, pinInputs.length - 1)]?.focus();
871
+ submitIfFull();
625
872
  });
626
873
  });
627
-
628
874
  el("admin-login-form").addEventListener("submit", (e) => {
629
875
  e.preventDefault();
630
876
  const errBox = el("admin-login-error");
631
877
  syncPinValue();
632
878
  if (el("admin-pin").value === ADMIN_PIN) {
633
879
  sessionStorage.setItem(ADMIN_SESSION_KEY, "1");
634
- errBox.classList.add("hidden");
635
880
  closeAdminLogin();
636
881
  openAdminPanel();
637
882
  } else {
638
- errBox.textContent = "Wrong PIN.";
639
- if (!reduceMotion) animate("#pin-boxes", { x: [0, -10, 10, -8, 8, -4, 0] }, { duration: 0.45 });
883
+ errBox.textContent = "That PIN isn't right. Try again.";
640
884
  errBox.classList.remove("hidden");
885
+ if (!reduceMotion) animate("#pin-boxes", { x: [0, -10, 10, -8, 8, -4, 0] }, { duration: 0.45 });
641
886
  resetPinBoxes();
642
887
  pinInputs[0]?.focus();
643
888
  }
644
889
  });
645
890
 
891
+ /* ---------------------------- admin: manage games ---------------------------- */
892
+ let adminRows = [], adminState = "loading", adminError = "";
893
+ let editingId = null, confirmingId = null, adminFilter = "", previewUrl = null;
894
+
895
+ const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0);
896
+ const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return u; } };
897
+ const sr = (t) => `<span class="sr-only">${escapeHtml(t)}</span>`;
898
+ const rowIcon = (g) => iconSrcFor(g) || monogramSrc(g.name);
899
+ const adminSay = (msg) => { const n = el("admin-live"); n.textContent = ""; setTimeout(() => (n.textContent = msg), 60); };
900
+ const dbArgs = { databaseId: CONFIG.databaseId, tableId: CONFIG.gamesTableId };
901
+
902
+ function openAdminPanel() {
903
+ beginDialog();
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");
907
+ el("admin-panel").classList.remove("hidden");
908
+ pop("#admin-panel-card", { y: 24, scale: 0.97, stiff: 300, damp: 28 });
909
+ focusSoon(el("admin-title"));
910
+ loadAdmin();
911
+ }
912
+ function closeAdminPanel() { el("admin-panel").classList.add("hidden"); endDialog(); }
913
+ el("admin-close").addEventListener("click", closeAdminPanel);
646
914
  el("admin-logout").addEventListener("click", () => {
647
915
  sessionStorage.removeItem(ADMIN_SESSION_KEY);
648
916
  closeAdminPanel();
649
- toast("Locked");
917
+ toast("Admin locked");
650
918
  });
651
919
 
652
- async function renderAdminList() {
653
- const list = el("admin-list");
654
- list.innerHTML = `<p class="text-[14px] text-ink-400">Loading…</p>`;
920
+ async function loadAdmin() {
921
+ adminState = "loading";
922
+ renderAdmin();
655
923
  try {
656
- const res = await tablesDB.listRows({
657
- databaseId: CONFIG.databaseId,
658
- tableId: CONFIG.gamesTableId,
659
- queries: [Query.limit(200), Query.orderAsc("order")],
660
- });
661
- if (res.rows.length === 0) {
662
- list.innerHTML = `<p class="text-[14px] text-ink-400">No games added yet.</p>`;
663
- return;
664
- }
665
- list.innerHTML = res.rows
666
- .map(
667
- (g) => `
668
- <div class="glass-lite flex items-center gap-3 rounded-2xl p-3" data-row="${g.$id}">
669
- <img src="${iconSrcFor(g) || monogramSrc(g.name)}" alt="" class="tile-img h-10 w-10 shrink-0 rounded-[22.37%] object-cover" onerror="this.onerror=null;this.src='${monogramSrc(g.name)}'" />
670
- <div class="min-w-0 flex-1">
671
- <input data-field="name" data-id="${g.$id}" value="${escapeHtml(g.name)}" aria-label="Name" class="w-full border-b border-transparent bg-transparent text-[15px] font-medium outline-none focus:border-red-500" />
672
- <input data-field="url" data-id="${g.$id}" value="${escapeHtml(g.url)}" aria-label="URL" class="mt-0.5 w-full border-b border-transparent bg-transparent text-[13px] text-ink-400 outline-none focus:border-red-500" />
924
+ const res = await tablesDB.listRows({ ...dbArgs, queries: [Query.limit(200), Query.orderAsc("order")] });
925
+ adminRows = res.rows.sort(byOrder);
926
+ adminState = "ready";
927
+ } catch (err) {
928
+ adminState = "error";
929
+ adminError = err?.message || "Couldn't load your games.";
930
+ }
931
+ renderAdmin();
932
+ }
933
+
934
+ function formHtml(g) {
935
+ const isEdit = !!g;
936
+ const v = (x) => escapeHtml(x ?? "");
937
+ const cats = [...new Set(adminRows.map((r) => r.category).filter(Boolean))];
938
+ const hasIcon = isEdit && (g.iconFileId || g.iconUrl);
939
+ const base = escapeHtml(isEdit ? rowIcon(g) : monogramSrc("+"));
940
+ return `<form id="game-form" novalidate aria-labelledby="game-form-title" class="glass-lite mb-4 rounded-2xl p-4">
941
+ <h3 id="game-form-title" class="text-[16px] font-semibold">${isEdit ? `Edit ${v(g.name)}` : "Add a game"}</h3>
942
+ <div class="mt-4 grid gap-4 sm:grid-cols-2">
943
+ <div>
944
+ <label for="gf-name" class="text-[13px] font-medium text-ink-200">Name</label>
945
+ <input id="gf-name" required autocomplete="off" aria-describedby="gf-name-err" value="${v(g?.name)}" class="field mt-1.5" />
946
+ <p id="gf-name-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
947
+ </div>
948
+ <div>
949
+ <label for="gf-category" class="text-[13px] font-medium text-ink-200">Category <span class="font-normal text-ink-300">(optional)</span></label>
950
+ <input id="gf-category" list="category-options" autocomplete="off" value="${v(g?.category)}" class="field mt-1.5" />
951
+ <datalist id="category-options">${cats.map((c) => `<option value="${v(c)}"></option>`).join("")}</datalist>
952
+ </div>
953
+ <div class="sm:col-span-2">
954
+ <label for="gf-url" class="text-[13px] font-medium text-ink-200">Game link</label>
955
+ <input id="gf-url" type="url" inputmode="url" required autocomplete="off" spellcheck="false" placeholder="https://" aria-describedby="gf-url-hint gf-url-err" value="${v(g?.url)}" class="field mt-1.5" />
956
+ <p id="gf-url-hint" class="mt-1.5 text-[13px] text-ink-300">The page that opens when someone taps the game.</p>
957
+ <p id="gf-url-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
958
+ </div>
959
+ </div>
960
+ <fieldset class="mt-4 min-w-0">
961
+ <legend class="text-[13px] font-medium text-ink-200">Icon</legend>
962
+ <div class="mt-2 flex items-start gap-4">
963
+ <img id="gf-preview" src="${base}" data-base="${base}" alt="Icon preview" class="tile-img h-16 w-16 shrink-0 rounded-[22.37%] object-cover" onerror="this.onerror=null;this.src=this.dataset.base" />
964
+ <div class="grid min-w-0 flex-1 gap-3">
965
+ <div>
966
+ <label for="gf-file" class="text-[13px] text-ink-300">Upload an image</label>
967
+ <input id="gf-file" type="file" accept="image/*" aria-describedby="gf-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" />
968
+ <p id="gf-file-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
969
+ </div>
970
+ <div>
971
+ <label for="gf-iconurl" class="text-[13px] text-ink-300">…or paste an image link</label>
972
+ <input id="gf-iconurl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://" aria-describedby="gf-iconurl-err" value="${v(g?.iconUrl)}" class="field mt-1.5" />
973
+ <p id="gf-iconurl-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
974
+ </div>
975
+ ${hasIcon ? `<label class="flex items-center gap-2 text-[13px] text-ink-200"><input id="gf-remove" type="checkbox" class="h-4 w-4 accent-red-500" /> Remove the current icon (shows initials instead)</label>` : ""}
976
+ </div>
977
+ </div>
978
+ </fieldset>
979
+ <label class="mt-4 flex cursor-pointer items-center gap-3 text-[14px]"><input id="gf-enabled" type="checkbox" role="switch" class="switch" ${g?.enabled === false ? "" : "checked"} /> Show on the site</label>
980
+ <div class="mt-5 flex flex-wrap items-center gap-2">
981
+ <button type="submit" id="gf-submit" class="btn btn-primary">${isEdit ? "Save changes" : "Add game"}</button>
982
+ <button type="button" data-act="cancel-form" class="btn btn-ghost">Cancel</button>
983
+ </div>
984
+ </form>`;
985
+ }
986
+
987
+ function rowHtml(g, idx, canMove) {
988
+ if (editingId === g.$id) return `<li data-row="${g.$id}">${formHtml(g)}</li>`;
989
+ const n = escapeHtml(g.name);
990
+ const off = g.enabled === false;
991
+ const dis = editingId ? "disabled" : "";
992
+ const meta = [g.category, hostOf(g.url)].filter(Boolean).map(escapeHtml).join(" · ");
993
+ const arrow = (dir, d, label, disabled) => `<button type="button" data-act="${dir}" data-id="${g.$id}" aria-label="${label} ${n}" ${disabled || dis ? "disabled" : ""} class="glass-lite icon-btn"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg></button>`;
994
+ const actions = confirmingId === g.$id
995
+ ? `<div role="group" aria-label="Confirm delete" class="mt-3 rounded-xl bg-red-500/10 p-3 ring-1 ring-red-500/30">
996
+ <p class="text-[14px]">Delete <strong>${n}</strong>? This can't be undone.</p>
997
+ <div class="mt-3 flex gap-2">
998
+ <button type="button" id="cancel-delete" data-act="cancel-delete" data-id="${g.$id}" class="btn btn-ghost">Cancel</button>
999
+ <button type="button" data-act="confirm-delete" data-id="${g.$id}" class="btn btn-danger">Delete</button>
673
1000
  </div>
674
- <input data-field="category" data-id="${g.$id}" value="${escapeHtml(g.category || "")}" placeholder="Category" aria-label="Category" class="field hidden !w-28 sm:block" />
675
- <label class="hidden shrink-0 items-center gap-2 text-[13px] text-ink-300 sm:flex">
676
- <input type="checkbox" data-field="enabled" data-id="${g.$id}" ${g.enabled === false ? "" : "checked"} class="h-4 w-4 accent-red-500" /> On
677
- </label>
678
- <button data-delete="${g.$id}" title="Delete" aria-label="Delete" class="glass-lite icon-btn hover:!text-red-400">
679
- <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="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2L4 6"/></svg>
680
- </button>
681
1001
  </div>`
682
- )
683
- .join("");
684
- if (!reduceMotion) animate("#admin-list > div", { opacity: [0, 1], y: [8, 0] }, { duration: 0.3, delay: stagger(0.03), ease: "easeOut" });
685
-
686
- list.querySelectorAll("input[data-field]").forEach((input) => {
687
- const commit = async () => {
688
- const id = input.dataset.id;
689
- const field = input.dataset.field;
690
- const value = input.type === "checkbox" ? input.checked : input.value.trim();
691
- try {
692
- await tablesDB.updateRow({ databaseId: CONFIG.databaseId, tableId: CONFIG.gamesTableId, rowId: id, data: { [field]: value } });
693
- toast("Saved");
694
- await loadGames();
695
- } catch (err) {
696
- toast(err?.message || "Update failed", "error");
697
- }
698
- };
699
- input.addEventListener(input.type === "checkbox" ? "change" : "blur", commit);
700
- });
1002
+ : `<div class="mt-3 flex flex-wrap items-center gap-2">
1003
+ ${canMove ? arrow("up", "m6 15 6-6 6 6", "Move up:", idx === 0) + arrow("down", "m6 9 6 6 6-6", "Move down:", idx === adminRows.length - 1) : ""}
1004
+ <button type="button" data-act="edit" data-id="${g.$id}" ${dis} class="btn btn-ghost">Edit${sr(" " + g.name)}</button>
1005
+ <button type="button" data-act="delete" data-id="${g.$id}" ${dis} class="btn btn-ghost ml-auto hover:!text-red-300">Delete${sr(" " + g.name)}</button>
1006
+ </div>`;
1007
+ return `<li data-row="${g.$id}" class="glass-lite rounded-2xl p-3">
1008
+ <div class="flex items-center gap-3">
1009
+ <img src="${escapeHtml(rowIcon(g))}" alt="" class="tile-img h-12 w-12 shrink-0 rounded-[22.37%] object-cover ${off ? "opacity-50" : ""}" onerror="this.onerror=null;this.src='${monogramSrc(g.name)}'" />
1010
+ <div class="min-w-0 flex-1">
1011
+ <p class="truncate text-[15px] font-medium">${n}${off ? '<span class="tag">Hidden</span>' : ""}</p>
1012
+ <p class="truncate text-[13px] text-ink-300">${meta}</p>
1013
+ </div>
1014
+ <label class="flex shrink-0 cursor-pointer items-center gap-2 text-[13px] text-ink-200">
1015
+ <input type="checkbox" role="switch" class="switch" data-toggle="${g.$id}" ${off ? "" : "checked"} ${dis} />
1016
+ <span>Visible${sr(" on site: " + g.name)}</span>
1017
+ </label>
1018
+ </div>
1019
+ ${actions}
1020
+ </li>`;
1021
+ }
701
1022
 
702
- list.querySelectorAll("[data-delete]").forEach((btn) =>
703
- btn.addEventListener("click", async () => {
704
- if (!confirm("Delete this game?")) return;
705
- try {
706
- await tablesDB.deleteRow({ databaseId: CONFIG.databaseId, tableId: CONFIG.gamesTableId, rowId: btn.dataset.delete });
707
- toast("Deleted");
708
- await renderAdminList();
709
- await loadGames();
710
- } catch (err) {
711
- toast(err?.message || "Delete failed", "error");
712
- }
713
- })
714
- );
715
- } catch (err) {
716
- list.innerHTML = `<p class="text-[14px] text-red-400">${escapeHtml(err?.message || "Failed to load games.")}</p>`;
1023
+ function renderAdmin(focusSels = []) {
1024
+ dirty.games = false;
1025
+ el("admin-form-slot").innerHTML = editingId === "new" ? formHtml(null) : "";
1026
+ const list = el("admin-list"), sum = gamesSum;
1027
+ el("admin-add").disabled = !!editingId;
1028
+ el("admin-filter").disabled = !!editingId;
1029
+ if (adminState === "loading") { sum.textContent = ""; list.innerHTML = `<li class="py-8 text-center text-[14px] text-ink-300">Loading games…</li>`; return; }
1030
+ if (adminState === "error") {
1031
+ sum.textContent = "";
1032
+ list.innerHTML = `<li class="py-8 text-center"><p class="text-[14px] text-red-300">${escapeHtml(adminError)}</p><button type="button" data-act="retry" class="btn btn-ghost mt-3">Try again</button></li>`;
1033
+ return;
1034
+ }
1035
+ const q = adminFilter;
1036
+ const rows = q ? adminRows.filter((g) => `${g.name} ${g.category || ""}`.toLowerCase().includes(q)) : adminRows;
1037
+ const visible = adminRows.filter((g) => g.enabled !== false).length;
1038
+ sum.textContent = q ? `Showing ${rows.length} of ${adminRows.length}` : `${adminRows.length} ${adminRows.length === 1 ? "game" : "games"} · ${visible} visible`;
1039
+ list.innerHTML = rows.length
1040
+ ? rows.map((g) => rowHtml(g, adminRows.indexOf(g), !q)).join("")
1041
+ : `<li class="py-8 text-center text-[14px] text-ink-300">${adminRows.length ? `No games match “${escapeHtml(adminFilter)}”.` : "No games yet. Choose “Add game” to add your first one."}</li>`;
1042
+ if (editingId) { const n = el("gf-name"); n.focus(); n.scrollIntoView({ block: "nearest" }); return; }
1043
+ for (const sel of focusSels) {
1044
+ const t = el("admin-panel-card").querySelector(sel);
1045
+ if (t && !t.disabled) { t.focus(); break; }
717
1046
  }
718
1047
  }
719
1048
 
720
- el("add-game-form").addEventListener("submit", async (e) => {
1049
+ function cancelForm() {
1050
+ const id = editingId;
1051
+ editingId = null;
1052
+ renderAdmin([id && id !== "new" ? `[data-act="edit"][data-id="${id}"]` : "#admin-add"]);
1053
+ }
1054
+
1055
+ el("admin-add").addEventListener("click", () => { editingId = "new"; confirmingId = null; renderAdmin(); });
1056
+ el("admin-filter").addEventListener("input", (e) => { adminFilter = e.target.value.trim().toLowerCase(); renderAdmin(); });
1057
+
1058
+ el("admin-body").addEventListener("click", (e) => {
1059
+ const b = e.target.closest("[data-act]");
1060
+ if (!b || b.disabled) return;
1061
+ const { act, id } = b.dataset;
1062
+ if (act === "retry") loadAdmin();
1063
+ else if (act === "edit") { editingId = id; confirmingId = null; renderAdmin(); }
1064
+ else if (act === "cancel-form") cancelForm();
1065
+ else if (act === "delete") { confirmingId = id; renderAdmin(["#cancel-delete"]); }
1066
+ else if (act === "cancel-delete") { confirmingId = null; renderAdmin([`[data-act="delete"][data-id="${id}"]`]); }
1067
+ else if (act === "confirm-delete") { b.disabled = true; b.textContent = "Deleting…"; deleteGame(id); }
1068
+ else if (act === "up") moveGame(id, -1);
1069
+ else if (act === "down") moveGame(id, 1);
1070
+ });
1071
+ el("admin-body").addEventListener("change", (e) => {
1072
+ const t = e.target;
1073
+ if (t.matches("[data-toggle]")) toggleGame(t.dataset.toggle, t.checked);
1074
+ else if (t.id === "gf-file" || t.id === "gf-remove") updatePreview();
1075
+ });
1076
+ el("admin-body").addEventListener("input", (e) => {
1077
+ const t = e.target;
1078
+ if (t.getAttribute?.("aria-invalid")) setErr(t.id, "");
1079
+ if (t.id === "gf-iconurl") updatePreview();
1080
+ });
1081
+ el("admin-body").addEventListener("submit", (e) => { if (e.target.id === "game-form") submitGameForm(e); });
1082
+
1083
+ function updatePreview() {
1084
+ const img = el("gf-preview"), file = el("gf-file").files[0], url = el("gf-iconurl").value.trim();
1085
+ if (previewUrl) { URL.revokeObjectURL(previewUrl); previewUrl = null; }
1086
+ if (el("gf-remove")?.checked) img.src = monogramSrc(el("gf-name").value || "?");
1087
+ else if (file && file.type.startsWith("image/")) img.src = previewUrl = URL.createObjectURL(file);
1088
+ else if (isHttp(url)) img.src = url;
1089
+ else img.src = img.dataset.base;
1090
+ }
1091
+ function setErr(id, msg) {
1092
+ const inp = el(id), p = el(id + "-err");
1093
+ p.textContent = msg || "";
1094
+ p.classList.toggle("hidden", !msg);
1095
+ if (msg) inp.setAttribute("aria-invalid", "true"); else inp.removeAttribute("aria-invalid");
1096
+ }
1097
+ function validateGameForm() {
1098
+ const name = el("gf-name").value.trim(), url = el("gf-url").value.trim(), iu = el("gf-iconurl").value.trim(), f = el("gf-file").files[0];
1099
+ const checks = [
1100
+ ["gf-name", name ? "" : "Enter a name."],
1101
+ ["gf-url", !url ? "Enter the game link." : isHttp(url) ? "" : "Enter a full link that starts with https://"],
1102
+ ["gf-iconurl", !iu || isHttp(iu) ? "" : "Enter a full link that starts with https://"],
1103
+ ["gf-file", !f || f.type.startsWith("image/") ? "" : "Choose an image file."],
1104
+ ];
1105
+ let first = null;
1106
+ checks.forEach(([id, msg]) => { setErr(id, msg); if (msg && !first) first = id; });
1107
+ if (first) el(first).focus();
1108
+ return !first;
1109
+ }
1110
+
1111
+ function flashRow(id) {
1112
+ const li = el("admin-list").querySelector(`[data-row="${id}"]`);
1113
+ if (!li) return;
1114
+ li.scrollIntoView({ block: "nearest" });
1115
+ if (!reduceMotion) animate(li, { backgroundColor: ["rgba(240,26,54,0.4)", "rgba(255,255,255,0.07)"] }, { duration: 1.2 });
1116
+ }
1117
+
1118
+ async function submitGameForm(e) {
721
1119
  e.preventDefault();
722
- const statusEl = el("add-game-status");
723
- const submitBtn = el("add-game-submit");
724
- submitBtn.disabled = true;
725
- statusEl.textContent = "Saving…";
1120
+ if (!validateGameForm()) return;
1121
+ const isEdit = editingId && editingId !== "new";
1122
+ const existing = isEdit ? adminRows.find((r) => r.$id === editingId) : null;
1123
+ const name = el("gf-name").value.trim(), url = el("gf-url").value.trim(), category = el("gf-category").value.trim();
1124
+ const iconField = el("gf-iconurl").value.trim(), file = el("gf-file").files[0], enabled = el("gf-enabled").checked, remove = !!el("gf-remove")?.checked;
1125
+ const btn = el("gf-submit"), label = btn.textContent;
1126
+ btn.disabled = true; btn.textContent = "Saving…";
1127
+ let uploadedId = null, oldFile = null;
726
1128
  try {
727
- const name = el("new-name").value.trim();
728
- const url = el("new-url").value.trim();
729
- const category = el("new-category").value.trim();
730
- const iconUrlField = el("new-icon-url").value.trim();
731
- const iconFile = el("new-icon-file").files[0];
732
-
733
- let iconFileId = "";
734
- if (iconFile) {
735
- const uploaded = await storage.createFile({ bucketId: CONFIG.iconsBucketId, fileId: ID.unique(), file: iconFile });
736
- iconFileId = uploaded.$id;
1129
+ let iconFileId = existing?.iconFileId || "", iconUrl = existing?.iconUrl || "";
1130
+ if (remove) { oldFile = iconFileId; iconFileId = ""; iconUrl = ""; }
1131
+ else if (file) {
1132
+ uploadedId = (await storage.createFile({ bucketId: CONFIG.iconsBucketId, fileId: ID.unique(), file })).$id;
1133
+ oldFile = iconFileId; iconFileId = uploadedId; iconUrl = "";
1134
+ } else if (iconField && iconField !== iconUrl) { oldFile = iconFileId; iconFileId = ""; iconUrl = iconField; }
1135
+ else if (!iconField && iconUrl && !iconFileId) iconUrl = "";
1136
+ const data = { name, url, category, iconFileId, iconUrl, enabled };
1137
+ let row;
1138
+ if (isEdit) {
1139
+ row = await tablesDB.updateRow({ ...dbArgs, rowId: editingId, data });
1140
+ adminRows = adminRows.map((r) => (r.$id === row.$id ? row : r));
1141
+ } else {
1142
+ data.order = adminRows.reduce((m, r) => Math.max(m, r.order ?? -1), -1) + 1;
1143
+ row = await tablesDB.createRow({ ...dbArgs, rowId: ID.unique(), data });
1144
+ adminRows = [...adminRows, row].sort(byOrder);
737
1145
  }
1146
+ if (oldFile) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: oldFile }).catch(() => {});
1147
+ editingId = null;
1148
+ renderAdmin([`[data-act="edit"][data-id="${row.$id}"]`]);
1149
+ flashRow(row.$id);
1150
+ toast(isEdit ? `Saved ${name}` : `Added ${name}`);
1151
+ loadGames();
1152
+ } catch (err) {
1153
+ if (uploadedId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: uploadedId }).catch(() => {});
1154
+ btn.disabled = false; btn.textContent = label;
1155
+ toast(err?.message || "Couldn't save. Check your connection and try again.", "error");
1156
+ }
1157
+ }
738
1158
 
739
- await tablesDB.createRow({
740
- databaseId: CONFIG.databaseId,
741
- tableId: CONFIG.gamesTableId,
742
- rowId: ID.unique(),
743
- data: { name, url, category, iconFileId, iconUrl: iconFileId ? "" : iconUrlField, order: allGames.length, enabled: true },
1159
+ async function toggleGame(id, on) {
1160
+ const row = adminRows.find((r) => r.$id === id);
1161
+ if (!row) return;
1162
+ const prev = row.enabled !== false;
1163
+ row.enabled = on;
1164
+ renderAdmin([`[data-toggle="${id}"]`]);
1165
+ try {
1166
+ await tablesDB.updateRow({ ...dbArgs, rowId: id, data: { enabled: on } });
1167
+ adminSay(`${row.name} is now ${on ? "visible" : "hidden"}`);
1168
+ loadGames();
1169
+ } catch (err) {
1170
+ row.enabled = prev;
1171
+ renderAdmin([`[data-toggle="${id}"]`]);
1172
+ toast(err?.message || "Couldn't update visibility", "error");
1173
+ }
1174
+ }
1175
+
1176
+ async function moveGame(id, dir) {
1177
+ const list = [...adminRows].sort(byOrder);
1178
+ const i = list.findIndex((r) => r.$id === id), j = i + dir;
1179
+ if (i < 0 || j < 0 || j >= list.length) return;
1180
+ const prev = list.map((r) => ({ ...r }));
1181
+ [list[i], list[j]] = [list[j], list[i]];
1182
+ const changes = [];
1183
+ list.forEach((r, k) => { if (r.order !== k) { r.order = k; changes.push(r); } });
1184
+ adminRows = list;
1185
+ const name = list[j].name;
1186
+ renderAdmin([`[data-act="${dir < 0 ? "up" : "down"}"][data-id="${id}"]`, `[data-act="${dir < 0 ? "down" : "up"}"][data-id="${id}"]`]);
1187
+ adminSay(`${name} moved to position ${j + 1} of ${list.length}`);
1188
+ try {
1189
+ await Promise.all(changes.map((r) => tablesDB.updateRow({ ...dbArgs, rowId: r.$id, data: { order: r.order } })));
1190
+ loadGames();
1191
+ } catch (err) {
1192
+ adminRows = prev;
1193
+ renderAdmin();
1194
+ toast(err?.message || "Couldn't change the order", "error");
1195
+ }
1196
+ }
1197
+
1198
+ async function deleteGame(id) {
1199
+ const row = adminRows.find((r) => r.$id === id);
1200
+ if (!row) return;
1201
+ try {
1202
+ await tablesDB.deleteRow({ ...dbArgs, rowId: id });
1203
+ if (row.iconFileId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: row.iconFileId }).catch(() => {});
1204
+ adminRows = adminRows.filter((r) => r.$id !== id);
1205
+ confirmingId = null;
1206
+ renderAdmin(["#admin-add"]);
1207
+ toast(`Deleted ${row.name}`);
1208
+ loadGames();
1209
+ } catch (err) {
1210
+ renderAdmin(["#cancel-delete"]);
1211
+ toast(err?.message || "Couldn't delete. Try again.", "error");
1212
+ }
1213
+ }
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;
744
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
+ }
745
1294
 
746
- statusEl.textContent = "Added.";
747
- el("add-game-form").reset();
748
- await renderAdminList();
749
- await loadGames();
750
- toast(`${name} added`);
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();
751
1568
  } catch (err) {
752
- statusEl.textContent = "";
753
- toast(err?.message || "Couldn't add game", "error");
754
- } finally {
755
- submitBtn.disabled = false;
756
- setTimeout(() => (statusEl.textContent = ""), 2500);
1569
+ renderAnnAdmin(["#ann-cancel-delete"]);
1570
+ toast(err?.message || "Couldn't delete. Try again.", "error");
757
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();
758
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();
759
1872
 
760
1873
  /* ---------------------------- tile hover FX ---------------------------- */
761
1874
  const gridEl = el("grid");
@@ -781,7 +1894,7 @@ gridEl.addEventListener("pointerout", (e) => {
781
1894
  });
782
1895
 
783
1896
  el("player-frame").addEventListener("load", () => {
784
- if (el("player-frame").getAttribute("src")) el("player-loading").style.display = "none";
1897
+ if (el("player-frame").getAttribute("src") !== "about:blank") el("player-loading").style.display = "none";
785
1898
  });
786
1899
 
787
1900
  /* ------------------------------ hero motion ------------------------------ */