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.
- package/index.html +449 -187
- 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: #
|
|
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
|
-
|
|
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
|
|
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: #
|
|
86
|
-
box-shadow: inset 0 1px 0 rgba(255,255,255,0.
|
|
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:
|
|
94
|
-
|
|
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="
|
|
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:
|
|
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:
|
|
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:
|
|
198
|
-
<path d="M44 34 L90 60 L44 86 Z" fill="url(#
|
|
199
|
-
<ellipse cx="50" cy="48" rx="8" ry="4" fill="#fff" opacity=".
|
|
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:
|
|
202
|
-
<path d="M60 26v68M26 60h68" fill="none" stroke="url(#
|
|
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:
|
|
206
|
-
<circle cx="60" cy="60" r="
|
|
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-
|
|
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
|
-
<
|
|
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"
|
|
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]"
|
|
284
|
-
<div class="flex
|
|
285
|
-
<div>
|
|
286
|
-
<
|
|
287
|
-
<p class="mt-
|
|
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
|
|
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-
|
|
297
|
-
<
|
|
298
|
-
<
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
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]
|
|
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.
|
|
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 = "
|
|
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("
|
|
725
|
+
toast("Admin locked");
|
|
650
726
|
});
|
|
651
727
|
|
|
652
|
-
async function
|
|
653
|
-
|
|
654
|
-
|
|
728
|
+
async function loadAdmin() {
|
|
729
|
+
adminState = "loading";
|
|
730
|
+
renderAdmin();
|
|
655
731
|
try {
|
|
656
|
-
const res = await tablesDB.listRows({
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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 ------------------------------ */
|