mlv2boii 1.0.9 → 1.0.10

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 +449 -187
  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)); }
@@ -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,21 @@
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
+
105
120
  /* ---------- motion ---------- */
106
121
  .w { display: inline-block; opacity: 0; }
107
122
  .glowwrap { display: inline-block; filter: drop-shadow(0 0 22px rgba(240,26,54,0.55)); }
@@ -145,6 +160,8 @@
145
160
 
146
161
  <body class="min-h-screen bg-ink-950 font-sans text-ink-50 antialiased">
147
162
 
163
+ <a href="#games-heading" class="skip">Skip to games</a>
164
+
148
165
  <div class="ambient" aria-hidden="true"></div>
149
166
 
150
167
  <!-- shared gradients for the hero shapes -->
@@ -152,8 +169,6 @@
152
169
  <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
170
  <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
171
  <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
172
  </defs></svg>
158
173
 
159
174
  <!-- ================= HEADER ================= -->
@@ -164,10 +179,11 @@
164
179
  <span class="orb"></span>
165
180
  <span class="text-[17px] font-semibold tracking-[-0.02em]">mlv2</span>
166
181
  </a>
167
- <div class="ml-auto flex min-w-0 flex-1 items-center justify-end">
182
+ <div role="search" class="ml-auto flex min-w-0 flex-1 items-center justify-end">
168
183
  <div id="search-wrap" class="relative">
169
184
  <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" />
185
+ <input id="search" type="search" placeholder="Search games" aria-label="Search games" autocomplete="off" enterkeyhint="search" class="field rounded-full pl-9 pr-9" />
186
+ <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
187
  </div>
172
188
  </div>
173
189
  </div>
@@ -184,26 +200,29 @@
184
200
 
185
201
  <!-- floating bubbly shapes (large screens) -->
186
202
  <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">
203
+ <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
204
  <rect x="8" y="30" width="104" height="62" rx="31" fill="url(#gP)"/>
189
205
  <path d="M34 50v24M22 62h24" stroke="#fff" stroke-width="9" stroke-linecap="round" opacity=".92"/>
190
206
  <circle cx="82" cy="54" r="6" fill="#fff" opacity=".92"/><circle cx="96" cy="68" r="6" fill="#fff" opacity=".92"/>
191
207
  <ellipse cx="44" cy="40" rx="28" ry="7" fill="#fff" opacity=".45"/>
192
208
  </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">
209
+ <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
210
  <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
211
  <ellipse cx="48" cy="40" rx="14" ry="5" fill="#fff" opacity=".5" transform="rotate(-25 48 40)"/>
196
212
  </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)"/>
213
+ <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">
214
+ <path d="M44 34 L90 60 L44 86 Z" fill="url(#gL)" stroke="url(#gL)" stroke-width="16" stroke-linejoin="round"/>
215
+ <ellipse cx="50" cy="48" rx="8" ry="4" fill="#fff" opacity=".55" transform="rotate(35 50 48)"/>
200
216
  </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"/>
217
+ <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">
218
+ <path d="M60 26v68M26 60h68" fill="none" stroke="url(#gP)" stroke-width="30" stroke-linecap="round"/>
203
219
  <ellipse cx="52" cy="30" rx="6" ry="3" fill="#fff" opacity=".55"/>
204
220
  </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)"/>
221
+ <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">
222
+ <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)"/>
223
+ </svg></div></div>
224
+ <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">
225
+ <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
226
  </svg></div></div>
208
227
  </div>
209
228
  </section>
@@ -215,11 +234,14 @@
215
234
  <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
235
  </div>
217
236
 
237
+ <h2 id="games-heading" tabindex="-1" class="sr-only">Games</h2>
238
+ <p id="grid-status" role="status" class="sr-only"></p>
218
239
  <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
240
 
220
- <div id="empty-state" class="hidden flex-col items-center justify-center py-24 text-center">
241
+ <div id="empty-state" aria-live="polite" class="hidden flex-col items-center justify-center py-24 text-center">
221
242
  <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>
243
+ <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>
244
+ <button id="retry" type="button" class="btn btn-ghost mt-5 hidden">Try again</button>
223
245
  </div>
224
246
 
225
247
  <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 +251,14 @@
229
251
  <footer class="relative z-10 border-t border-white/[0.08]">
230
252
  <div class="page-x mx-auto flex max-w-5xl items-center justify-between py-4 text-[13px] text-ink-400">
231
253
  <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>
254
+ <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
255
  </div>
234
256
  </footer>
235
257
 
236
258
  <!-- ================= PLAYER MODAL ================= -->
237
259
  <div id="player-modal" class="fixed inset-0 z-50 hidden">
238
260
  <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">
261
+ <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
262
  <div class="border-b border-white/[0.08] bg-black pt-[env(safe-area-inset-top)]">
241
263
  <div class="flex h-14 items-center justify-between gap-3 px-3 sm:px-4">
242
264
  <div class="flex min-w-0 items-center gap-3">
@@ -244,6 +266,9 @@
244
266
  <p id="player-title" class="truncate text-[15px] font-semibold"></p>
245
267
  </div>
246
268
  <div class="flex shrink-0 items-center gap-2">
269
+ <button id="player-fs" type="button" title="Fullscreen" aria-label="Fullscreen" class="glass-lite icon-btn">
270
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
271
+ </button>
247
272
  <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
273
  <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
274
  </a>
@@ -264,10 +289,10 @@
264
289
  <div id="admin-login-modal" class="fixed inset-0 z-50 hidden">
265
290
  <div class="absolute inset-0 bg-black/50 backdrop-blur-md" data-admin-login-close></div>
266
291
  <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>
292
+ <form id="admin-login-form" role="dialog" aria-modal="true" aria-labelledby="pin-title" class="glass glass-sheet relative w-full max-w-[340px] rounded-[28px] p-6 opacity-0" data-fx>
293
+ <h2 id="pin-title" class="text-[19px] font-semibold">Enter PIN</h2>
269
294
  <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>
295
+ <div id="pin-boxes" role="group" aria-labelledby="pin-title" class="mt-5 grid grid-cols-6 gap-2"></div>
271
296
  <input id="admin-pin" type="hidden" />
272
297
  <p id="admin-login-error" class="mt-3 hidden text-[14px] text-red-400" role="alert"></p>
273
298
  <button type="submit" id="admin-login-submit" class="btn btn-primary mt-5 w-full">Unlock</button>
@@ -278,47 +303,38 @@
278
303
 
279
304
  <!-- ================= ADMIN: PANEL ================= -->
280
305
  <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>
306
+ <div id="admin-backdrop" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
282
307
  <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>
308
+ <div id="admin-panel-card" role="dialog" aria-modal="true" aria-labelledby="admin-title" class="glass glass-sheet relative flex h-full w-full flex-col opacity-0 sm:h-[85vh] sm:max-w-2xl sm:rounded-[32px]">
309
+ <div class="flex shrink-0 items-center justify-between gap-3 border-b border-white/[0.08] px-5 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
310
+ <div class="min-w-0">
311
+ <h2 id="admin-title" tabindex="-1" class="text-[17px] font-semibold leading-tight">Manage games</h2>
312
+ <p id="admin-summary" class="mt-0.5 text-[13px] text-ink-300"></p>
288
313
  </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">
314
+ <div class="flex shrink-0 items-center gap-2">
315
+ <button id="admin-logout" type="button" class="btn btn-ghost">Lock</button>
316
+ <button id="admin-close" type="button" aria-label="Close" class="glass-lite icon-btn">
292
317
  <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
293
318
  </button>
294
319
  </div>
295
320
  </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>
308
- </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>
312
- </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>
321
+ <div class="flex shrink-0 gap-2 px-5 pt-4">
322
+ <div class="min-w-0 flex-1">
323
+ <label for="admin-filter" class="sr-only">Filter games</label>
324
+ <input id="admin-filter" type="search" placeholder="Filter games" autocomplete="off" class="field" />
325
+ </div>
326
+ <button id="admin-add" type="button" class="btn btn-primary shrink-0">Add game</button>
327
+ </div>
328
+ <div id="admin-body" class="flex-1 overflow-y-auto px-5 py-4">
329
+ <div id="admin-form-slot"></div>
330
+ <ul id="admin-list" class="space-y-2" aria-label="Games"></ul>
316
331
  </div>
332
+ <p id="admin-live" role="status" class="sr-only"></p>
317
333
  </div>
318
334
  </div>
319
335
  </div>
320
336
 
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>
337
+ <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
338
 
323
339
  <script src="https://cdn.jsdelivr.net/npm/appwrite@26.2.0"></script>
324
340
  <script type="module">
@@ -361,7 +377,7 @@ let allGames = [];
361
377
  let activeCategory = "All";
362
378
  let searchTerm = "";
363
379
  const BRAND = "#f01a36";
364
- const PALETTE = ["#f01a36", "#ff6b7d", "#ff98a5"];
380
+ const PALETTE = ["#f01a36"];
365
381
  let catColors = {};
366
382
  let barShown = false;
367
383
 
@@ -395,7 +411,7 @@ function toast(message, tone = "default") {
395
411
  const root = el("toast-root");
396
412
  const node = document.createElement("div");
397
413
  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`;
414
+ node.className = `glass glass-sheet pointer-events-auto relative rounded-full ${toneClasses} px-4 py-2.5 text-[14px] opacity-0`;
399
415
  node.textContent = message;
400
416
  root.appendChild(node);
401
417
  pop(node, { y: 16, scale: 0.9, stiff: 420, damp: 24 });
@@ -455,6 +471,7 @@ let gridShown = false;
455
471
  function renderGrid() {
456
472
  const grid = el("grid");
457
473
  const games = currentFiltered();
474
+ 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
475
  el("skeleton-grid").classList.add("hidden");
459
476
 
460
477
  if (allGames.length === 0) {
@@ -525,6 +542,8 @@ async function loadGames() {
525
542
  renderGrid();
526
543
  } catch (err) {
527
544
  console.error("Failed to load games:", err);
545
+ if (allGames.length) { toast("Couldn't refresh the game list", "error"); return; }
546
+ el("retry").classList.remove("hidden");
528
547
  el("skeleton-grid").classList.add("hidden");
529
548
  el("empty-title").textContent = "Couldn't load games";
530
549
  el("empty-sub").textContent = err?.message
@@ -537,84 +556,119 @@ async function loadGames() {
537
556
  loadGames();
538
557
 
539
558
  /* --------------------------- search / filter --------------------------- */
559
+ const searchEl = el("search"), clearBtn = el("search-clear");
540
560
  let searchDebounce;
541
- el("search").addEventListener("input", (e) => {
561
+ function applySearch() { searchTerm = searchEl.value.trim().toLowerCase(); renderGrid(); }
562
+ searchEl.addEventListener("input", () => {
563
+ clearBtn.classList.toggle("hidden", !searchEl.value);
564
+ clearBtn.classList.toggle("grid", !!searchEl.value);
542
565
  clearTimeout(searchDebounce);
543
- searchDebounce = setTimeout(() => {
544
- searchTerm = e.target.value.trim().toLowerCase();
545
- renderGrid();
546
- }, 120);
566
+ searchDebounce = setTimeout(applySearch, 120);
567
+ });
568
+ clearBtn.addEventListener("click", () => {
569
+ searchEl.value = "";
570
+ clearBtn.classList.add("hidden");
571
+ clearBtn.classList.remove("grid");
572
+ applySearch();
573
+ searchEl.focus();
574
+ });
575
+ searchEl.addEventListener("keydown", (e) => { if (e.key === "Escape" && searchEl.value) clearBtn.click(); });
576
+ el("retry").addEventListener("click", () => {
577
+ el("retry").classList.add("hidden");
578
+ el("empty-state").classList.add("hidden");
579
+ el("empty-state").classList.remove("flex");
580
+ el("skeleton-grid").classList.remove("hidden");
581
+ loadGames();
582
+ });
583
+
584
+ /* ------------------------------- dialogs ------------------------------- */
585
+ const isHidden = (id) => el(id).classList.contains("hidden");
586
+ let opener = null;
587
+ function lockBg(on) { document.querySelectorAll("body > header, body > main, body > footer").forEach((n) => n.toggleAttribute("inert", on)); }
588
+ function beginDialog() { opener = document.activeElement; lockBg(true); }
589
+ function endDialog() { lockBg(false); if (opener && document.contains(opener)) opener.focus(); opener = null; }
590
+ const focusSoon = (node) => requestAnimationFrame(() => node && node.focus());
591
+
592
+ document.addEventListener("keydown", (e) => {
593
+ if (e.key === "Escape") {
594
+ if (!isHidden("player-modal")) closePlayer();
595
+ else if (!isHidden("admin-login-modal")) closeAdminLogin();
596
+ else if (!isHidden("admin-panel")) {
597
+ if (confirmingId) { const id = confirmingId; confirmingId = null; renderAdmin([`[data-act="delete"][data-id="${id}"]`]); }
598
+ else if (editingId) cancelForm();
599
+ else closeAdminPanel();
600
+ }
601
+ } else if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey && isHidden("player-modal") && isHidden("admin-login-modal") && isHidden("admin-panel")
602
+ && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || "")) {
603
+ e.preventDefault();
604
+ el("search").focus();
605
+ }
547
606
  });
548
607
 
549
608
  /* ------------------------------- player ------------------------------- */
550
609
  function openPlayer(game) {
551
610
  if (!game) return;
611
+ beginDialog();
552
612
  el("player-title").textContent = game.name;
613
+ el("player-frame").title = game.name;
553
614
  el("player-icon").src = iconSrcFor(game) || monogramSrc(game.name);
554
615
  el("player-newtab").href = game.url;
555
- el("player-frame").src = game.url;
556
616
  el("player-loading").style.display = "grid";
617
+ el("player-frame").src = game.url;
557
618
  el("player-modal").classList.remove("hidden");
558
619
  document.documentElement.style.overflow = "hidden";
559
620
  pop("#player-panel", { y: 20, scale: 0.94, stiff: 260, damp: 26 });
621
+ focusSoon(el("player-close"));
560
622
  }
561
623
  function closePlayer() {
562
624
  fx("#player-panel", { opacity: [1, 0], scale: [1, 0.98] }, { duration: 0.18, ease: "easeIn" }).finished.then(() => {
563
625
  el("player-modal").classList.add("hidden");
564
- el("player-frame").src = "";
626
+ el("player-frame").src = "about:blank";
565
627
  document.documentElement.style.overflow = "";
628
+ endDialog();
566
629
  });
567
630
  }
568
631
  el("player-close").addEventListener("click", closePlayer);
569
632
  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
- });
633
+ el("player-fs").addEventListener("click", () => { const f = el("player-frame"); (f.requestFullscreen || f.webkitRequestFullscreen)?.call(f); });
577
634
 
578
- /* -------------------------------- admin -------------------------------- */
635
+ /* ------------------------------ admin: PIN ------------------------------ */
636
+ function isUnlocked() { return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1"; }
579
637
  function openAdminLogin() {
638
+ beginDialog();
580
639
  el("admin-login-modal").classList.remove("hidden");
581
640
  pop("#admin-login-form", { y: 16, scale: 0.92, stiff: 380, damp: 24 });
582
641
  resetPinBoxes();
583
- pinInputs[0]?.focus();
642
+ focusSoon(pinInputs[0]);
584
643
  }
585
644
  function closeAdminLogin() {
586
645
  el("admin-login-modal").classList.add("hidden");
587
646
  el("admin-login-error").classList.add("hidden");
647
+ endDialog();
588
648
  }
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
649
  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);
650
+ document.querySelectorAll("[data-admin-login-close]").forEach((n) => n.addEventListener("click", closeAdminLogin));
600
651
 
601
652
  const pinBoxesEl = el("pin-boxes");
602
653
  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}"
654
+ .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
655
  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
656
  .join("");
606
657
  const pinInputs = Array.from(pinBoxesEl.querySelectorAll("[data-pin-idx]"));
607
-
608
658
  function resetPinBoxes() { pinInputs.forEach((i) => (i.value = "")); el("admin-pin").value = ""; }
609
659
  function syncPinValue() { el("admin-pin").value = pinInputs.map((i) => i.value).join(""); }
660
+ function submitIfFull() { if (el("admin-pin").value.length === 6) el("admin-login-form").requestSubmit(); }
610
661
  pinInputs.forEach((input, idx) => {
611
662
  input.addEventListener("input", () => {
612
663
  input.value = input.value.replace(/[^0-9]/g, "").slice(0, 1);
613
664
  syncPinValue();
614
665
  if (input.value && idx < pinInputs.length - 1) pinInputs[idx + 1].focus();
666
+ submitIfFull();
615
667
  });
616
668
  input.addEventListener("keydown", (e) => {
617
669
  if (e.key === "Backspace" && !input.value && idx > 0) pinInputs[idx - 1].focus();
670
+ if (e.key === "ArrowLeft" && idx > 0) pinInputs[idx - 1].focus();
671
+ if (e.key === "ArrowRight" && idx < pinInputs.length - 1) pinInputs[idx + 1].focus();
618
672
  });
619
673
  input.addEventListener("paste", (e) => {
620
674
  e.preventDefault();
@@ -622,140 +676,348 @@ pinInputs.forEach((input, idx) => {
622
676
  digits.forEach((d, i) => pinInputs[i] && (pinInputs[i].value = d));
623
677
  syncPinValue();
624
678
  pinInputs[Math.min(digits.length, pinInputs.length - 1)]?.focus();
679
+ submitIfFull();
625
680
  });
626
681
  });
627
-
628
682
  el("admin-login-form").addEventListener("submit", (e) => {
629
683
  e.preventDefault();
630
684
  const errBox = el("admin-login-error");
631
685
  syncPinValue();
632
686
  if (el("admin-pin").value === ADMIN_PIN) {
633
687
  sessionStorage.setItem(ADMIN_SESSION_KEY, "1");
634
- errBox.classList.add("hidden");
635
688
  closeAdminLogin();
636
689
  openAdminPanel();
637
690
  } else {
638
- errBox.textContent = "Wrong PIN.";
639
- if (!reduceMotion) animate("#pin-boxes", { x: [0, -10, 10, -8, 8, -4, 0] }, { duration: 0.45 });
691
+ errBox.textContent = "That PIN isn't right. Try again.";
640
692
  errBox.classList.remove("hidden");
693
+ if (!reduceMotion) animate("#pin-boxes", { x: [0, -10, 10, -8, 8, -4, 0] }, { duration: 0.45 });
641
694
  resetPinBoxes();
642
695
  pinInputs[0]?.focus();
643
696
  }
644
697
  });
645
698
 
699
+ /* ---------------------------- admin: manage games ---------------------------- */
700
+ let adminRows = [], adminState = "loading", adminError = "";
701
+ let editingId = null, confirmingId = null, adminFilter = "", previewUrl = null;
702
+
703
+ const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0);
704
+ const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return u; } };
705
+ const isHttp = (v) => { try { const p = new URL(v).protocol; return p === "http:" || p === "https:"; } catch { return false; } };
706
+ const sr = (t) => `<span class="sr-only">${escapeHtml(t)}</span>`;
707
+ const rowIcon = (g) => iconSrcFor(g) || monogramSrc(g.name);
708
+ const adminSay = (msg) => { const n = el("admin-live"); n.textContent = ""; setTimeout(() => (n.textContent = msg), 60); };
709
+ const dbArgs = { databaseId: CONFIG.databaseId, tableId: CONFIG.gamesTableId };
710
+
711
+ function openAdminPanel() {
712
+ beginDialog();
713
+ editingId = null; confirmingId = null; adminFilter = ""; el("admin-filter").value = "";
714
+ el("admin-panel").classList.remove("hidden");
715
+ pop("#admin-panel-card", { y: 24, scale: 0.97, stiff: 300, damp: 28 });
716
+ focusSoon(el("admin-title"));
717
+ loadAdmin();
718
+ }
719
+ function closeAdminPanel() { el("admin-panel").classList.add("hidden"); endDialog(); }
720
+ el("admin-close").addEventListener("click", closeAdminPanel);
721
+ el("admin-backdrop").addEventListener("click", () => { if (!editingId) closeAdminPanel(); });
646
722
  el("admin-logout").addEventListener("click", () => {
647
723
  sessionStorage.removeItem(ADMIN_SESSION_KEY);
648
724
  closeAdminPanel();
649
- toast("Locked");
725
+ toast("Admin locked");
650
726
  });
651
727
 
652
- async function renderAdminList() {
653
- const list = el("admin-list");
654
- list.innerHTML = `<p class="text-[14px] text-ink-400">Loading…</p>`;
728
+ async function loadAdmin() {
729
+ adminState = "loading";
730
+ renderAdmin();
655
731
  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" />
732
+ const res = await tablesDB.listRows({ ...dbArgs, queries: [Query.limit(200), Query.orderAsc("order")] });
733
+ adminRows = res.rows.sort(byOrder);
734
+ adminState = "ready";
735
+ } catch (err) {
736
+ adminState = "error";
737
+ adminError = err?.message || "Couldn't load your games.";
738
+ }
739
+ renderAdmin();
740
+ }
741
+
742
+ function formHtml(g) {
743
+ const isEdit = !!g;
744
+ const v = (x) => escapeHtml(x ?? "");
745
+ const cats = [...new Set(adminRows.map((r) => r.category).filter(Boolean))];
746
+ const hasIcon = isEdit && (g.iconFileId || g.iconUrl);
747
+ const base = escapeHtml(isEdit ? rowIcon(g) : monogramSrc("+"));
748
+ return `<form id="game-form" novalidate aria-labelledby="game-form-title" class="glass-lite mb-4 rounded-2xl p-4">
749
+ <h3 id="game-form-title" class="text-[16px] font-semibold">${isEdit ? `Edit ${v(g.name)}` : "Add a game"}</h3>
750
+ <div class="mt-4 grid gap-4 sm:grid-cols-2">
751
+ <div>
752
+ <label for="gf-name" class="text-[13px] font-medium text-ink-200">Name</label>
753
+ <input id="gf-name" required autocomplete="off" aria-describedby="gf-name-err" value="${v(g?.name)}" class="field mt-1.5" />
754
+ <p id="gf-name-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
755
+ </div>
756
+ <div>
757
+ <label for="gf-category" class="text-[13px] font-medium text-ink-200">Category <span class="font-normal text-ink-300">(optional)</span></label>
758
+ <input id="gf-category" list="category-options" autocomplete="off" value="${v(g?.category)}" class="field mt-1.5" />
759
+ <datalist id="category-options">${cats.map((c) => `<option value="${v(c)}"></option>`).join("")}</datalist>
760
+ </div>
761
+ <div class="sm:col-span-2">
762
+ <label for="gf-url" class="text-[13px] font-medium text-ink-200">Game link</label>
763
+ <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" />
764
+ <p id="gf-url-hint" class="mt-1.5 text-[13px] text-ink-300">The page that opens when someone taps the game.</p>
765
+ <p id="gf-url-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
766
+ </div>
767
+ </div>
768
+ <fieldset class="mt-4 min-w-0">
769
+ <legend class="text-[13px] font-medium text-ink-200">Icon</legend>
770
+ <div class="mt-2 flex items-start gap-4">
771
+ <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" />
772
+ <div class="grid min-w-0 flex-1 gap-3">
773
+ <div>
774
+ <label for="gf-file" class="text-[13px] text-ink-300">Upload an image</label>
775
+ <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" />
776
+ <p id="gf-file-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
777
+ </div>
778
+ <div>
779
+ <label for="gf-iconurl" class="text-[13px] text-ink-300">…or paste an image link</label>
780
+ <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" />
781
+ <p id="gf-iconurl-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
782
+ </div>
783
+ ${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>` : ""}
784
+ </div>
785
+ </div>
786
+ </fieldset>
787
+ <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>
788
+ <div class="mt-5 flex flex-wrap items-center gap-2">
789
+ <button type="submit" id="gf-submit" class="btn btn-primary">${isEdit ? "Save changes" : "Add game"}</button>
790
+ <button type="button" data-act="cancel-form" class="btn btn-ghost">Cancel</button>
791
+ </div>
792
+ </form>`;
793
+ }
794
+
795
+ function rowHtml(g, idx, canMove) {
796
+ if (editingId === g.$id) return `<li data-row="${g.$id}">${formHtml(g)}</li>`;
797
+ const n = escapeHtml(g.name);
798
+ const off = g.enabled === false;
799
+ const dis = editingId ? "disabled" : "";
800
+ const meta = [g.category, hostOf(g.url)].filter(Boolean).map(escapeHtml).join(" · ");
801
+ 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>`;
802
+ const actions = confirmingId === g.$id
803
+ ? `<div role="group" aria-label="Confirm delete" class="mt-3 rounded-xl bg-red-500/10 p-3 ring-1 ring-red-500/30">
804
+ <p class="text-[14px]">Delete <strong>${n}</strong>? This can't be undone.</p>
805
+ <div class="mt-3 flex gap-2">
806
+ <button type="button" id="cancel-delete" data-act="cancel-delete" data-id="${g.$id}" class="btn btn-ghost">Cancel</button>
807
+ <button type="button" data-act="confirm-delete" data-id="${g.$id}" class="btn btn-danger">Delete</button>
673
808
  </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
809
  </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
- });
810
+ : `<div class="mt-3 flex flex-wrap items-center gap-2">
811
+ ${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) : ""}
812
+ <button type="button" data-act="edit" data-id="${g.$id}" ${dis} class="btn btn-ghost">Edit${sr(" " + g.name)}</button>
813
+ <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>
814
+ </div>`;
815
+ return `<li data-row="${g.$id}" class="glass-lite rounded-2xl p-3">
816
+ <div class="flex items-center gap-3">
817
+ <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)}'" />
818
+ <div class="min-w-0 flex-1">
819
+ <p class="truncate text-[15px] font-medium">${n}${off ? '<span class="tag">Hidden</span>' : ""}</p>
820
+ <p class="truncate text-[13px] text-ink-300">${meta}</p>
821
+ </div>
822
+ <label class="flex shrink-0 cursor-pointer items-center gap-2 text-[13px] text-ink-200">
823
+ <input type="checkbox" role="switch" class="switch" data-toggle="${g.$id}" ${off ? "" : "checked"} ${dis} />
824
+ <span>Visible${sr(" on site: " + g.name)}</span>
825
+ </label>
826
+ </div>
827
+ ${actions}
828
+ </li>`;
829
+ }
701
830
 
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>`;
831
+ function renderAdmin(focusSels = []) {
832
+ el("admin-form-slot").innerHTML = editingId === "new" ? formHtml(null) : "";
833
+ const list = el("admin-list"), sum = el("admin-summary");
834
+ el("admin-add").disabled = !!editingId;
835
+ el("admin-filter").disabled = !!editingId;
836
+ if (adminState === "loading") { sum.textContent = ""; list.innerHTML = `<li class="py-8 text-center text-[14px] text-ink-300">Loading games…</li>`; return; }
837
+ if (adminState === "error") {
838
+ sum.textContent = "";
839
+ 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>`;
840
+ return;
841
+ }
842
+ const q = adminFilter;
843
+ const rows = q ? adminRows.filter((g) => `${g.name} ${g.category || ""}`.toLowerCase().includes(q)) : adminRows;
844
+ const visible = adminRows.filter((g) => g.enabled !== false).length;
845
+ sum.textContent = q ? `Showing ${rows.length} of ${adminRows.length}` : `${adminRows.length} ${adminRows.length === 1 ? "game" : "games"} · ${visible} visible`;
846
+ list.innerHTML = rows.length
847
+ ? rows.map((g) => rowHtml(g, adminRows.indexOf(g), !q)).join("")
848
+ : `<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>`;
849
+ if (editingId) { const n = el("gf-name"); n.focus(); n.scrollIntoView({ block: "nearest" }); return; }
850
+ for (const sel of focusSels) {
851
+ const t = el("admin-panel-card").querySelector(sel);
852
+ if (t && !t.disabled) { t.focus(); break; }
717
853
  }
718
854
  }
719
855
 
720
- el("add-game-form").addEventListener("submit", async (e) => {
856
+ function cancelForm() {
857
+ const id = editingId;
858
+ editingId = null;
859
+ renderAdmin([id && id !== "new" ? `[data-act="edit"][data-id="${id}"]` : "#admin-add"]);
860
+ }
861
+
862
+ el("admin-add").addEventListener("click", () => { editingId = "new"; confirmingId = null; renderAdmin(); });
863
+ el("admin-filter").addEventListener("input", (e) => { adminFilter = e.target.value.trim().toLowerCase(); renderAdmin(); });
864
+
865
+ el("admin-body").addEventListener("click", (e) => {
866
+ const b = e.target.closest("[data-act]");
867
+ if (!b || b.disabled) return;
868
+ const { act, id } = b.dataset;
869
+ if (act === "retry") loadAdmin();
870
+ else if (act === "edit") { editingId = id; confirmingId = null; renderAdmin(); }
871
+ else if (act === "cancel-form") cancelForm();
872
+ else if (act === "delete") { confirmingId = id; renderAdmin(["#cancel-delete"]); }
873
+ else if (act === "cancel-delete") { confirmingId = null; renderAdmin([`[data-act="delete"][data-id="${id}"]`]); }
874
+ else if (act === "confirm-delete") { b.disabled = true; b.textContent = "Deleting…"; deleteGame(id); }
875
+ else if (act === "up") moveGame(id, -1);
876
+ else if (act === "down") moveGame(id, 1);
877
+ });
878
+ el("admin-body").addEventListener("change", (e) => {
879
+ const t = e.target;
880
+ if (t.matches("[data-toggle]")) toggleGame(t.dataset.toggle, t.checked);
881
+ else if (t.id === "gf-file" || t.id === "gf-remove") updatePreview();
882
+ });
883
+ el("admin-body").addEventListener("input", (e) => {
884
+ const t = e.target;
885
+ if (t.getAttribute?.("aria-invalid")) setErr(t.id, "");
886
+ if (t.id === "gf-iconurl") updatePreview();
887
+ });
888
+ el("admin-body").addEventListener("submit", (e) => { if (e.target.id === "game-form") submitGameForm(e); });
889
+
890
+ function updatePreview() {
891
+ const img = el("gf-preview"), file = el("gf-file").files[0], url = el("gf-iconurl").value.trim();
892
+ if (previewUrl) { URL.revokeObjectURL(previewUrl); previewUrl = null; }
893
+ if (el("gf-remove")?.checked) img.src = monogramSrc(el("gf-name").value || "?");
894
+ else if (file && file.type.startsWith("image/")) img.src = previewUrl = URL.createObjectURL(file);
895
+ else if (isHttp(url)) img.src = url;
896
+ else img.src = img.dataset.base;
897
+ }
898
+ function setErr(id, msg) {
899
+ const inp = el(id), p = el(id + "-err");
900
+ p.textContent = msg || "";
901
+ p.classList.toggle("hidden", !msg);
902
+ if (msg) inp.setAttribute("aria-invalid", "true"); else inp.removeAttribute("aria-invalid");
903
+ }
904
+ function validateGameForm() {
905
+ 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];
906
+ const checks = [
907
+ ["gf-name", name ? "" : "Enter a name."],
908
+ ["gf-url", !url ? "Enter the game link." : isHttp(url) ? "" : "Enter a full link that starts with https://"],
909
+ ["gf-iconurl", !iu || isHttp(iu) ? "" : "Enter a full link that starts with https://"],
910
+ ["gf-file", !f || f.type.startsWith("image/") ? "" : "Choose an image file."],
911
+ ];
912
+ let first = null;
913
+ checks.forEach(([id, msg]) => { setErr(id, msg); if (msg && !first) first = id; });
914
+ if (first) el(first).focus();
915
+ return !first;
916
+ }
917
+
918
+ function flashRow(id) {
919
+ const li = el("admin-list").querySelector(`[data-row="${id}"]`);
920
+ if (!li) return;
921
+ li.scrollIntoView({ block: "nearest" });
922
+ if (!reduceMotion) animate(li, { backgroundColor: ["rgba(240,26,54,0.4)", "rgba(255,255,255,0.07)"] }, { duration: 1.2 });
923
+ }
924
+
925
+ async function submitGameForm(e) {
721
926
  e.preventDefault();
722
- const statusEl = el("add-game-status");
723
- const submitBtn = el("add-game-submit");
724
- submitBtn.disabled = true;
725
- statusEl.textContent = "Saving…";
927
+ if (!validateGameForm()) return;
928
+ const isEdit = editingId && editingId !== "new";
929
+ const existing = isEdit ? adminRows.find((r) => r.$id === editingId) : null;
930
+ const name = el("gf-name").value.trim(), url = el("gf-url").value.trim(), category = el("gf-category").value.trim();
931
+ const iconField = el("gf-iconurl").value.trim(), file = el("gf-file").files[0], enabled = el("gf-enabled").checked, remove = !!el("gf-remove")?.checked;
932
+ const btn = el("gf-submit"), label = btn.textContent;
933
+ btn.disabled = true; btn.textContent = "Saving…";
934
+ let uploadedId = null, oldFile = null;
726
935
  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;
936
+ let iconFileId = existing?.iconFileId || "", iconUrl = existing?.iconUrl || "";
937
+ if (remove) { oldFile = iconFileId; iconFileId = ""; iconUrl = ""; }
938
+ else if (file) {
939
+ uploadedId = (await storage.createFile({ bucketId: CONFIG.iconsBucketId, fileId: ID.unique(), file })).$id;
940
+ oldFile = iconFileId; iconFileId = uploadedId; iconUrl = "";
941
+ } else if (iconField && iconField !== iconUrl) { oldFile = iconFileId; iconFileId = ""; iconUrl = iconField; }
942
+ else if (!iconField && iconUrl && !iconFileId) iconUrl = "";
943
+ const data = { name, url, category, iconFileId, iconUrl, enabled };
944
+ let row;
945
+ if (isEdit) {
946
+ row = await tablesDB.updateRow({ ...dbArgs, rowId: editingId, data });
947
+ adminRows = adminRows.map((r) => (r.$id === row.$id ? row : r));
948
+ } else {
949
+ data.order = adminRows.reduce((m, r) => Math.max(m, r.order ?? -1), -1) + 1;
950
+ row = await tablesDB.createRow({ ...dbArgs, rowId: ID.unique(), data });
951
+ adminRows = [...adminRows, row].sort(byOrder);
737
952
  }
953
+ if (oldFile) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: oldFile }).catch(() => {});
954
+ editingId = null;
955
+ renderAdmin([`[data-act="edit"][data-id="${row.$id}"]`]);
956
+ flashRow(row.$id);
957
+ toast(isEdit ? `Saved ${name}` : `Added ${name}`);
958
+ loadGames();
959
+ } catch (err) {
960
+ if (uploadedId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: uploadedId }).catch(() => {});
961
+ btn.disabled = false; btn.textContent = label;
962
+ toast(err?.message || "Couldn't save. Check your connection and try again.", "error");
963
+ }
964
+ }
738
965
 
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 },
744
- });
966
+ async function toggleGame(id, on) {
967
+ const row = adminRows.find((r) => r.$id === id);
968
+ if (!row) return;
969
+ const prev = row.enabled !== false;
970
+ row.enabled = on;
971
+ renderAdmin([`[data-toggle="${id}"]`]);
972
+ try {
973
+ await tablesDB.updateRow({ ...dbArgs, rowId: id, data: { enabled: on } });
974
+ adminSay(`${row.name} is now ${on ? "visible" : "hidden"}`);
975
+ loadGames();
976
+ } catch (err) {
977
+ row.enabled = prev;
978
+ renderAdmin([`[data-toggle="${id}"]`]);
979
+ toast(err?.message || "Couldn't update visibility", "error");
980
+ }
981
+ }
745
982
 
746
- statusEl.textContent = "Added.";
747
- el("add-game-form").reset();
748
- await renderAdminList();
749
- await loadGames();
750
- toast(`${name} added`);
983
+ async function moveGame(id, dir) {
984
+ const list = [...adminRows].sort(byOrder);
985
+ const i = list.findIndex((r) => r.$id === id), j = i + dir;
986
+ if (i < 0 || j < 0 || j >= list.length) return;
987
+ const prev = list.map((r) => ({ ...r }));
988
+ [list[i], list[j]] = [list[j], list[i]];
989
+ const changes = [];
990
+ list.forEach((r, k) => { if (r.order !== k) { r.order = k; changes.push(r); } });
991
+ adminRows = list;
992
+ const name = list[j].name;
993
+ renderAdmin([`[data-act="${dir < 0 ? "up" : "down"}"][data-id="${id}"]`, `[data-act="${dir < 0 ? "down" : "up"}"][data-id="${id}"]`]);
994
+ adminSay(`${name} moved to position ${j + 1} of ${list.length}`);
995
+ try {
996
+ await Promise.all(changes.map((r) => tablesDB.updateRow({ ...dbArgs, rowId: r.$id, data: { order: r.order } })));
997
+ loadGames();
751
998
  } 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);
999
+ adminRows = prev;
1000
+ renderAdmin();
1001
+ toast(err?.message || "Couldn't change the order", "error");
757
1002
  }
758
- });
1003
+ }
1004
+
1005
+ async function deleteGame(id) {
1006
+ const row = adminRows.find((r) => r.$id === id);
1007
+ if (!row) return;
1008
+ try {
1009
+ await tablesDB.deleteRow({ ...dbArgs, rowId: id });
1010
+ if (row.iconFileId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: row.iconFileId }).catch(() => {});
1011
+ adminRows = adminRows.filter((r) => r.$id !== id);
1012
+ confirmingId = null;
1013
+ renderAdmin(["#admin-add"]);
1014
+ toast(`Deleted ${row.name}`);
1015
+ loadGames();
1016
+ } catch (err) {
1017
+ renderAdmin(["#cancel-delete"]);
1018
+ toast(err?.message || "Couldn't delete. Try again.", "error");
1019
+ }
1020
+ }
759
1021
 
760
1022
  /* ---------------------------- tile hover FX ---------------------------- */
761
1023
  const gridEl = el("grid");
@@ -781,7 +1043,7 @@ gridEl.addEventListener("pointerout", (e) => {
781
1043
  });
782
1044
 
783
1045
  el("player-frame").addEventListener("load", () => {
784
- if (el("player-frame").getAttribute("src")) el("player-loading").style.display = "none";
1046
+ if (el("player-frame").getAttribute("src") !== "about:blank") el("player-loading").style.display = "none";
785
1047
  });
786
1048
 
787
1049
  /* ------------------------------ hero motion ------------------------------ */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlv2boii",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {