mlv2boii 1.0.0
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 +734 -0
- package/package.json +11 -0
package/index.html
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>mlv2</title>
|
|
7
|
+
<meta name="description" content="mlv2 — a small, fast library of games." />
|
|
8
|
+
<meta name="color-scheme" content="dark" />
|
|
9
|
+
<meta name="theme-color" content="#09090b" />
|
|
10
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2309090b'/%3E%3Ccircle cx='16' cy='16' r='6' fill='%23e8142f'/%3E%3C/svg%3E" />
|
|
11
|
+
|
|
12
|
+
<!-- Fonts -->
|
|
13
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
|
+
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
16
|
+
|
|
17
|
+
<!-- Tailwind CSS v4 (Play CDN, mirrored on jsDelivr) -->
|
|
18
|
+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
19
|
+
<style type="text/tailwindcss">
|
|
20
|
+
@theme {
|
|
21
|
+
--font-display: "Sora", ui-sans-serif, system-ui, sans-serif;
|
|
22
|
+
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
|
|
23
|
+
--font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", monospace;
|
|
24
|
+
|
|
25
|
+
--color-ink-950: #09090b;
|
|
26
|
+
--color-ink-900: #0e0e11;
|
|
27
|
+
--color-ink-850: #131316;
|
|
28
|
+
--color-ink-800: #18181c;
|
|
29
|
+
--color-ink-700: #202024;
|
|
30
|
+
--color-ink-600: #2a2a30;
|
|
31
|
+
--color-ink-500: #3f3f47;
|
|
32
|
+
--color-ink-400: #6e6e78;
|
|
33
|
+
--color-ink-300: #98979f;
|
|
34
|
+
--color-ink-200: #c2c1c7;
|
|
35
|
+
--color-ink-100: #e2e1e6;
|
|
36
|
+
--color-ink-50: #f6f5f7;
|
|
37
|
+
|
|
38
|
+
--color-red-700: #8f0e20;
|
|
39
|
+
--color-red-600: #c11228;
|
|
40
|
+
--color-red-500: #e8142f;
|
|
41
|
+
--color-red-400: #ff4d61;
|
|
42
|
+
--color-red-300: #ff8b96;
|
|
43
|
+
}
|
|
44
|
+
</style>
|
|
45
|
+
|
|
46
|
+
<style>
|
|
47
|
+
/* ---------------------------------------------------------------
|
|
48
|
+
Base layer — kept outside the Tailwind @theme block so it works
|
|
49
|
+
regardless of Tailwind's preflight timing.
|
|
50
|
+
----------------------------------------------------------------*/
|
|
51
|
+
html, body { background: var(--color-ink-950); }
|
|
52
|
+
body { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
|
53
|
+
::selection { background: rgba(232,20,47,0.35); color: #fff; }
|
|
54
|
+
|
|
55
|
+
/* thin, quiet scrollbars */
|
|
56
|
+
* { scrollbar-width: thin; scrollbar-color: #2a2a30 transparent; }
|
|
57
|
+
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
58
|
+
*::-webkit-scrollbar-track { background: transparent; }
|
|
59
|
+
*::-webkit-scrollbar-thumb { background: #26262b; border-radius: 999px; }
|
|
60
|
+
*::-webkit-scrollbar-thumb:hover { background: #3a3a42; }
|
|
61
|
+
|
|
62
|
+
/* film-grain texture, very subtle, for depth without gradients */
|
|
63
|
+
.grain { position: fixed; inset: 0; pointer-events: none; z-index: 0; opacity: 0.035; mix-blend-mode: overlay;
|
|
64
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
|
|
65
|
+
|
|
66
|
+
.spotlight { position: fixed; top: -20%; left: 50%; width: 1100px; height: 600px; transform: translateX(-50%);
|
|
67
|
+
background: radial-gradient(closest-side, rgba(232,20,47,0.16), transparent 70%); pointer-events: none; z-index: 0; }
|
|
68
|
+
|
|
69
|
+
/* skeleton shimmer */
|
|
70
|
+
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
|
|
71
|
+
.skel { background: linear-gradient(100deg, #18181c 30%, #202024 45%, #18181c 60%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
|
|
72
|
+
|
|
73
|
+
/* respect the OS "reduce motion" flag everywhere, unconditionally */
|
|
74
|
+
@media (prefers-reduced-motion: reduce) {
|
|
75
|
+
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
[data-fx] { will-change: transform, opacity; }
|
|
79
|
+
</style>
|
|
80
|
+
</head>
|
|
81
|
+
|
|
82
|
+
<body class="bg-ink-950 text-ink-100 font-sans antialiased min-h-screen">
|
|
83
|
+
<div class="grain"></div>
|
|
84
|
+
<div class="spotlight"></div>
|
|
85
|
+
|
|
86
|
+
<!-- ================= HEADER ================= -->
|
|
87
|
+
<header class="sticky top-0 z-40 border-b border-ink-800/80 bg-ink-950/75 backdrop-blur-md">
|
|
88
|
+
<div class="mx-auto max-w-6xl px-4 sm:px-6 h-14 flex items-center gap-3">
|
|
89
|
+
<a href="#" class="flex items-center gap-2 shrink-0 group">
|
|
90
|
+
<span class="relative h-2 w-2">
|
|
91
|
+
<span class="absolute inset-0 rounded-full bg-red-500"></span>
|
|
92
|
+
<span class="absolute inset-0 rounded-full bg-red-500 motion-safe:animate-ping opacity-60"></span>
|
|
93
|
+
</span>
|
|
94
|
+
<span class="font-display font-semibold tracking-tight text-[15px] leading-none">mlv2</span>
|
|
95
|
+
</a>
|
|
96
|
+
|
|
97
|
+
<div class="flex-1 max-w-xs sm:max-w-sm relative">
|
|
98
|
+
<svg class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 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>
|
|
99
|
+
<input id="search" type="text" placeholder="Search games…" autocomplete="off"
|
|
100
|
+
class="w-full rounded-lg border border-ink-700 bg-ink-900/70 py-1.5 pl-8 pr-3 text-[13px] placeholder:text-ink-400 outline-none transition-colors focus:border-red-500/60 focus:bg-ink-900" />
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<span id="count-badge" class="hidden sm:flex items-center gap-1.5 shrink-0 text-[11px] font-mono uppercase tracking-wider text-ink-400">
|
|
104
|
+
<span id="count-num">0</span> games
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div class="border-t border-ink-800/60">
|
|
109
|
+
<div id="category-bar" class="mx-auto max-w-6xl px-4 sm:px-6 py-2.5 flex items-center gap-1.5 overflow-x-auto"></div>
|
|
110
|
+
</div>
|
|
111
|
+
</header>
|
|
112
|
+
|
|
113
|
+
<!-- ================= MAIN ================= -->
|
|
114
|
+
<main class="relative z-10 mx-auto max-w-6xl px-4 sm:px-6 py-8 sm:py-10">
|
|
115
|
+
|
|
116
|
+
<!-- setup banner, shown only while CONFIG still has placeholder values -->
|
|
117
|
+
<div id="setup-banner" class="hidden mb-8 rounded-xl border border-red-500/25 bg-red-500/[0.06] px-4 py-3.5">
|
|
118
|
+
<p class="text-[13px] font-medium text-red-300">Not connected yet</p>
|
|
119
|
+
<p class="mt-1 text-[13px] leading-relaxed text-ink-300">Open this file and fill in the <code class="font-mono text-ink-100">CONFIG</code> object near the bottom with your Appwrite endpoint, project, database, table and bucket IDs.</p>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<!-- skeleton loading state -->
|
|
123
|
+
<div id="skeleton-grid" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3.5 sm:gap-4">
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<!-- empty state -->
|
|
127
|
+
<div id="empty-state" class="hidden flex-col items-center justify-center py-24 text-center">
|
|
128
|
+
<div class="h-10 w-10 rounded-full border border-ink-700 flex items-center justify-center mb-4">
|
|
129
|
+
<svg class="h-4 w-4 text-ink-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 12h12M12 6v12"/></svg>
|
|
130
|
+
</div>
|
|
131
|
+
<p id="empty-title" class="font-display text-[15px] font-medium text-ink-100">No games yet</p>
|
|
132
|
+
<p id="empty-sub" class="mt-1.5 text-[13px] text-ink-400 max-w-xs">Games you add in Appwrite with a valid link will show up here automatically.</p>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<!-- game grid -->
|
|
136
|
+
<div id="grid" class="hidden grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3.5 sm:gap-4"></div>
|
|
137
|
+
</main>
|
|
138
|
+
|
|
139
|
+
<!-- ================= FOOTER ================= -->
|
|
140
|
+
<footer class="relative z-10 border-t border-ink-800/70">
|
|
141
|
+
<div class="mx-auto max-w-6xl px-4 sm:px-6 py-6 flex items-center justify-between text-[11px] text-ink-500">
|
|
142
|
+
<p class="font-mono">mlv2 © <span id="year"></span></p>
|
|
143
|
+
<button id="admin-trigger" class="font-mono uppercase tracking-wider text-ink-500 hover:text-ink-200 transition-colors">Admin</button>
|
|
144
|
+
</div>
|
|
145
|
+
</footer>
|
|
146
|
+
|
|
147
|
+
<!-- ================= PLAYER MODAL ================= -->
|
|
148
|
+
<div id="player-modal" class="fixed inset-0 z-50 hidden">
|
|
149
|
+
<div class="absolute inset-0 bg-ink-950/92 backdrop-blur-sm" data-player-close></div>
|
|
150
|
+
<div id="player-panel" class="relative h-full w-full flex flex-col opacity-0">
|
|
151
|
+
<div class="flex items-center justify-between gap-3 px-3 sm:px-4 h-14 border-b border-ink-800 bg-ink-950">
|
|
152
|
+
<div class="flex items-center gap-2.5 min-w-0">
|
|
153
|
+
<img id="player-icon" class="h-7 w-7 rounded-md object-cover border border-ink-700" alt="" />
|
|
154
|
+
<p id="player-title" class="truncate font-display text-[14px] font-medium"></p>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="flex items-center gap-1.5 shrink-0">
|
|
157
|
+
<a id="player-newtab" target="_blank" rel="noopener" title="Open in new tab"
|
|
158
|
+
class="h-8 w-8 grid place-items-center rounded-lg border border-ink-700 text-ink-300 hover:text-ink-50 hover:border-ink-500 transition-colors">
|
|
159
|
+
<svg class="h-3.5 w-3.5" 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>
|
|
160
|
+
</a>
|
|
161
|
+
<button id="player-close" title="Close" class="h-8 w-8 grid place-items-center rounded-lg border border-ink-700 text-ink-300 hover:text-red-400 hover:border-red-500/50 transition-colors">
|
|
162
|
+
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="relative flex-1 bg-black">
|
|
167
|
+
<iframe id="player-frame" class="h-full w-full" allow="autoplay; fullscreen; gamepad; pointer-lock; cross-origin-isolated" allowfullscreen loading="eager"></iframe>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<!-- ================= ADMIN: PIN GATE ================= -->
|
|
173
|
+
<div id="admin-login-modal" class="fixed inset-0 z-50 hidden">
|
|
174
|
+
<div class="absolute inset-0 bg-ink-950/85 backdrop-blur-sm" data-admin-login-close></div>
|
|
175
|
+
<div class="absolute inset-0 flex items-center justify-center p-4">
|
|
176
|
+
<form id="admin-login-form" class="w-full max-w-[320px] rounded-2xl border border-ink-700 bg-ink-900 p-5 opacity-0" data-fx>
|
|
177
|
+
<p class="font-display text-[15px] font-medium">Enter PIN</p>
|
|
178
|
+
<p class="mt-1 text-[12.5px] text-ink-400">This only unlocks the admin UI on this device — real write access is controlled by your Appwrite permissions.</p>
|
|
179
|
+
|
|
180
|
+
<div id="pin-boxes" class="mt-4 grid grid-cols-6 gap-2">
|
|
181
|
+
<!-- 6 single-digit inputs, filled in by JS -->
|
|
182
|
+
</div>
|
|
183
|
+
<input id="admin-pin" type="hidden" />
|
|
184
|
+
|
|
185
|
+
<p id="admin-login-error" class="hidden mt-3 text-[12.5px] text-red-400"></p>
|
|
186
|
+
|
|
187
|
+
<button type="submit" id="admin-login-submit" class="mt-4 w-full rounded-lg bg-red-500 hover:bg-red-400 transition-colors text-ink-950 font-medium text-[13px] py-2">Unlock</button>
|
|
188
|
+
<button type="button" data-admin-login-close class="mt-2 w-full rounded-lg border border-ink-700 hover:border-ink-500 transition-colors text-[13px] py-2 text-ink-300">Cancel</button>
|
|
189
|
+
</form>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<!-- ================= ADMIN: PANEL ================= -->
|
|
194
|
+
<div id="admin-panel" class="fixed inset-0 z-50 hidden">
|
|
195
|
+
<div class="absolute inset-0 bg-ink-950/90 backdrop-blur-sm" data-admin-panel-close></div>
|
|
196
|
+
<div class="absolute inset-0 flex items-start sm:items-center justify-center p-0 sm:p-4">
|
|
197
|
+
<div id="admin-panel-card" class="relative w-full sm:max-w-2xl h-full sm:h-[85vh] sm:rounded-2xl border-0 sm:border border-ink-700 bg-ink-900 flex flex-col opacity-0" data-fx>
|
|
198
|
+
|
|
199
|
+
<div class="flex items-center justify-between px-5 h-14 border-b border-ink-800 shrink-0">
|
|
200
|
+
<div>
|
|
201
|
+
<p class="font-display text-[14px] font-medium leading-none">Admin panel</p>
|
|
202
|
+
<p class="mt-1 text-[11px] font-mono text-ink-400">Unlocked with PIN</p>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="flex items-center gap-1.5">
|
|
205
|
+
<button id="admin-logout" class="text-[11.5px] font-mono uppercase tracking-wider text-ink-400 hover:text-ink-100 transition-colors px-2 py-1">Lock</button>
|
|
206
|
+
<button data-admin-panel-close class="h-8 w-8 grid place-items-center rounded-lg border border-ink-700 text-ink-300 hover:text-red-400 hover:border-red-500/50 transition-colors">
|
|
207
|
+
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="flex-1 overflow-y-auto px-5 py-5">
|
|
213
|
+
|
|
214
|
+
<!-- add game form -->
|
|
215
|
+
<form id="add-game-form" class="rounded-xl border border-ink-700 p-4">
|
|
216
|
+
<p class="text-[11px] font-mono uppercase tracking-wider text-ink-400">Add game</p>
|
|
217
|
+
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
218
|
+
<div>
|
|
219
|
+
<label class="text-[11px] text-ink-400">Name</label>
|
|
220
|
+
<input id="new-name" required class="mt-1 w-full rounded-lg border border-ink-700 bg-ink-850 px-3 py-2 text-[13px] outline-none focus:border-red-500/60" />
|
|
221
|
+
</div>
|
|
222
|
+
<div>
|
|
223
|
+
<label class="text-[11px] text-ink-400">Category (optional)</label>
|
|
224
|
+
<input id="new-category" class="mt-1 w-full rounded-lg border border-ink-700 bg-ink-850 px-3 py-2 text-[13px] outline-none focus:border-red-500/60" />
|
|
225
|
+
</div>
|
|
226
|
+
<div class="sm:col-span-2">
|
|
227
|
+
<label class="text-[11px] text-ink-400">Game URL</label>
|
|
228
|
+
<input id="new-url" required type="url" placeholder="https://…" class="mt-1 w-full rounded-lg border border-ink-700 bg-ink-850 px-3 py-2 text-[13px] outline-none focus:border-red-500/60" />
|
|
229
|
+
</div>
|
|
230
|
+
<div>
|
|
231
|
+
<label class="text-[11px] text-ink-400">Icon upload</label>
|
|
232
|
+
<input id="new-icon-file" type="file" accept="image/*" class="mt-1 w-full text-[12px] text-ink-400 file:mr-2 file:rounded-md file:border-0 file:bg-ink-700 file:px-2.5 file:py-1.5 file:text-[11.5px] file:text-ink-100" />
|
|
233
|
+
</div>
|
|
234
|
+
<div>
|
|
235
|
+
<label class="text-[11px] text-ink-400">or external icon URL</label>
|
|
236
|
+
<input id="new-icon-url" type="url" placeholder="https://…" class="mt-1 w-full rounded-lg border border-ink-700 bg-ink-850 px-3 py-2 text-[13px] outline-none focus:border-red-500/60" />
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
<button type="submit" id="add-game-submit" class="mt-3.5 rounded-lg bg-red-500 hover:bg-red-400 transition-colors text-ink-950 font-medium text-[13px] px-4 py-2">Add game</button>
|
|
240
|
+
<span id="add-game-status" class="ml-3 text-[12px] text-ink-400"></span>
|
|
241
|
+
</form>
|
|
242
|
+
|
|
243
|
+
<!-- existing games -->
|
|
244
|
+
<p class="mt-6 mb-2 text-[11px] font-mono uppercase tracking-wider text-ink-400">Existing games</p>
|
|
245
|
+
<div id="admin-list" class="space-y-2"></div>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<!-- ================= TOASTS ================= -->
|
|
252
|
+
<div id="toast-root" class="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 items-end"></div>
|
|
253
|
+
|
|
254
|
+
<script src="https://cdn.jsdelivr.net/npm/appwrite@26.2.0"></script>
|
|
255
|
+
<script type="module">
|
|
256
|
+
import { animate, stagger, inView } from "https://cdn.jsdelivr.net/npm/motion@12/+esm";
|
|
257
|
+
|
|
258
|
+
/* =====================================================================
|
|
259
|
+
mlv2 — single-file game directory
|
|
260
|
+
---------------------------------------------------------------------
|
|
261
|
+
SETUP — fill these in, that's the only thing this file needs.
|
|
262
|
+
Uses the current Appwrite TablesDB API (Appwrite console ≥ 1.8 calls
|
|
263
|
+
these "tables" / "columns" / "rows" — the old "collections" naming is
|
|
264
|
+
deprecated but this matches what you'll see when you create one today).
|
|
265
|
+
|
|
266
|
+
Expected columns on your "games" table:
|
|
267
|
+
name string required
|
|
268
|
+
url string required (game link — no link, no card)
|
|
269
|
+
category string optional
|
|
270
|
+
iconFileId string optional (file ID inside the icons bucket)
|
|
271
|
+
iconUrl string optional (used if iconFileId is empty)
|
|
272
|
+
order integer optional (lower shows first — needs an index
|
|
273
|
+
on this column for ordering to work)
|
|
274
|
+
enabled boolean optional (defaults to true if omitted)
|
|
275
|
+
|
|
276
|
+
Permissions — the admin panel is gated by a PIN in this page, not by an
|
|
277
|
+
Appwrite login. That PIN cannot be enforced by Appwrite itself, so:
|
|
278
|
+
- "games" table → Read: Any. Create/Update/Delete: Any.
|
|
279
|
+
- icons storage bucket → Read: Any. Create: Any.
|
|
280
|
+
- Add this site's origin as a Web platform in your Appwrite project.
|
|
281
|
+
This means anyone who finds your Appwrite endpoint + project ID could
|
|
282
|
+
write to these resources directly, bypassing the page entirely — the
|
|
283
|
+
PIN only stops casual clicking-around on the site. See the setup guide
|
|
284
|
+
for a tighter option (an Appwrite Function that checks a server-side
|
|
285
|
+
secret) if that risk matters for your case.
|
|
286
|
+
=====================================================================*/
|
|
287
|
+
const CONFIG = {
|
|
288
|
+
endpoint: "https://nyc.cloud.appwrite.io/v1",
|
|
289
|
+
projectId: "6a9b8a1b001b9c61537f",
|
|
290
|
+
databaseId: "6a9b8b23003de0ec5cf7",
|
|
291
|
+
gamesTableId: "6a9b8bb3000013a3fd80", // ← must be this exact key
|
|
292
|
+
iconsBucketId: "6a9b8b36002565ac5987",
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// This PIN only gates the admin UI on this page — it is visible to anyone
|
|
296
|
+
// who views source, and it has no bearing on the Appwrite API itself.
|
|
297
|
+
// The real access control is whatever Read/Write permissions you set on
|
|
298
|
+
// the "games" table and the icons bucket in the Appwrite console.
|
|
299
|
+
const ADMIN_PIN = "614888";
|
|
300
|
+
const ADMIN_SESSION_KEY = "mlv2_admin_unlocked";
|
|
301
|
+
|
|
302
|
+
const { Client, TablesDB, Storage, Query, ID } = window.Appwrite;
|
|
303
|
+
|
|
304
|
+
const client = new Client().setEndpoint(CONFIG.endpoint).setProject(CONFIG.projectId);
|
|
305
|
+
const tablesDB = new TablesDB(client);
|
|
306
|
+
const storage = new Storage(client);
|
|
307
|
+
|
|
308
|
+
const isConfigured = () =>
|
|
309
|
+
!CONFIG.endpoint.includes("YOUR_") &&
|
|
310
|
+
!CONFIG.projectId.includes("YOUR_") &&
|
|
311
|
+
!CONFIG.databaseId.includes("YOUR_");
|
|
312
|
+
|
|
313
|
+
/* ---------------------------------------------------------------------
|
|
314
|
+
Reduced-motion handling — the "bypass animations" flag.
|
|
315
|
+
Mirrors the Windows / OS "disable animation effects" setting via the
|
|
316
|
+
prefers-reduced-motion media query. Every Motion call below funnels
|
|
317
|
+
through fx(), which collapses to an instant, durationless jump when
|
|
318
|
+
the flag is set — and re-checks live if the user flips it mid-session.
|
|
319
|
+
--------------------------------------------------------------------- */
|
|
320
|
+
const reduceMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
321
|
+
let reduceMotion = reduceMotionQuery.matches;
|
|
322
|
+
reduceMotionQuery.addEventListener("change", (e) => (reduceMotion = e.matches));
|
|
323
|
+
|
|
324
|
+
function fx(targets, keyframes, options = {}) {
|
|
325
|
+
if (reduceMotion) return animate(targets, keyframes, { duration: 0 });
|
|
326
|
+
return animate(targets, keyframes, options);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* ------------------------------- state ------------------------------- */
|
|
330
|
+
let allGames = [];
|
|
331
|
+
let activeCategory = "All";
|
|
332
|
+
let searchTerm = "";
|
|
333
|
+
|
|
334
|
+
const el = (id) => document.getElementById(id);
|
|
335
|
+
document.getElementById("year").textContent = new Date().getFullYear();
|
|
336
|
+
|
|
337
|
+
/* ------------------------------ helpers ------------------------------ */
|
|
338
|
+
function iconSrcFor(game) {
|
|
339
|
+
if (game.iconFileId) {
|
|
340
|
+
try { return String(storage.getFileView({ bucketId: CONFIG.iconsBucketId, fileId: game.iconFileId })); }
|
|
341
|
+
catch { /* fall through to other sources */ }
|
|
342
|
+
}
|
|
343
|
+
if (game.iconUrl) return game.iconUrl;
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function monogramSrc(name) {
|
|
348
|
+
const initials = (name || "?").trim().slice(0, 2).toUpperCase();
|
|
349
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
350
|
+
<rect width="100" height="100" fill="#18181c"/>
|
|
351
|
+
<text x="50" y="58" font-family="Sora, sans-serif" font-size="34" font-weight="600" fill="#e8142f" text-anchor="middle">${initials}</text>
|
|
352
|
+
</svg>`;
|
|
353
|
+
return "data:image/svg+xml;utf8," + encodeURIComponent(svg);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function escapeHtml(s) {
|
|
357
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function toast(message, tone = "default") {
|
|
361
|
+
const root = el("toast-root");
|
|
362
|
+
const node = document.createElement("div");
|
|
363
|
+
const toneClasses = tone === "error" ? "border-red-500/40 text-red-300" : "border-ink-700 text-ink-100";
|
|
364
|
+
node.className = `rounded-lg border ${toneClasses} bg-ink-900/95 backdrop-blur px-3.5 py-2.5 text-[12.5px] shadow-lg shadow-black/40 opacity-0`;
|
|
365
|
+
node.textContent = message;
|
|
366
|
+
root.appendChild(node);
|
|
367
|
+
fx(node, { opacity: [0, 1], y: [8, 0] }, { duration: 0.25, easing: "ease-out" });
|
|
368
|
+
setTimeout(() => {
|
|
369
|
+
fx(node, { opacity: [1, 0], y: [0, 6] }, { duration: 0.2, easing: "ease-in" }).finished.then(() => node.remove());
|
|
370
|
+
}, 2800);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* -------------------------- render: skeleton -------------------------- */
|
|
374
|
+
function renderSkeleton() {
|
|
375
|
+
el("skeleton-grid").innerHTML = Array.from({ length: 10 })
|
|
376
|
+
.map(() => `<div class="skel motion-reduce:animate-none aspect-square rounded-2xl border border-ink-800"></div>`)
|
|
377
|
+
.join("");
|
|
378
|
+
}
|
|
379
|
+
renderSkeleton();
|
|
380
|
+
|
|
381
|
+
/* ---------------------------- render: grid ---------------------------- */
|
|
382
|
+
function currentFiltered() {
|
|
383
|
+
return allGames.filter((g) => {
|
|
384
|
+
const matchesCategory = activeCategory === "All" || (g.category || "Uncategorized") === activeCategory;
|
|
385
|
+
const matchesSearch = !searchTerm || g.name.toLowerCase().includes(searchTerm);
|
|
386
|
+
return matchesCategory && matchesSearch;
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function renderCategoryBar() {
|
|
391
|
+
const cats = ["All", ...new Set(allGames.map((g) => g.category || "Uncategorized"))];
|
|
392
|
+
const bar = el("category-bar");
|
|
393
|
+
bar.innerHTML = cats
|
|
394
|
+
.map((c) => {
|
|
395
|
+
const active = c === activeCategory;
|
|
396
|
+
return `<button data-cat="${escapeHtml(c)}" class="shrink-0 rounded-full border px-3 py-1 text-[12px] font-medium transition-colors ${
|
|
397
|
+
active ? "border-red-500/60 bg-red-500/10 text-red-300" : "border-ink-700 text-ink-300 hover:border-ink-500 hover:text-ink-100"
|
|
398
|
+
}">${escapeHtml(c)}</button>`;
|
|
399
|
+
})
|
|
400
|
+
.join("");
|
|
401
|
+
bar.querySelectorAll("[data-cat]").forEach((btn) =>
|
|
402
|
+
btn.addEventListener("click", () => {
|
|
403
|
+
activeCategory = btn.dataset.cat;
|
|
404
|
+
renderCategoryBar();
|
|
405
|
+
renderGrid();
|
|
406
|
+
})
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function renderGrid() {
|
|
411
|
+
const grid = el("grid");
|
|
412
|
+
const games = currentFiltered();
|
|
413
|
+
el("skeleton-grid").classList.add("hidden");
|
|
414
|
+
|
|
415
|
+
if (allGames.length === 0) {
|
|
416
|
+
grid.classList.add("hidden");
|
|
417
|
+
el("empty-state").classList.remove("hidden");
|
|
418
|
+
el("empty-state").classList.add("flex");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (games.length === 0) {
|
|
422
|
+
grid.classList.add("hidden");
|
|
423
|
+
el("empty-title").textContent = "Nothing matches";
|
|
424
|
+
el("empty-sub").textContent = "Try a different search term or category.";
|
|
425
|
+
el("empty-state").classList.remove("hidden");
|
|
426
|
+
el("empty-state").classList.add("flex");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
el("empty-state").classList.add("hidden");
|
|
431
|
+
el("empty-state").classList.remove("flex");
|
|
432
|
+
grid.classList.remove("hidden");
|
|
433
|
+
|
|
434
|
+
grid.innerHTML = games
|
|
435
|
+
.map((g) => {
|
|
436
|
+
const src = iconSrcFor(g) || monogramSrc(g.name);
|
|
437
|
+
return `<button data-fx data-id="${g.$id}" class="group relative aspect-square rounded-2xl border border-ink-700/70 bg-ink-900 overflow-hidden text-left opacity-0 motion-safe:transition-[border-color,box-shadow] motion-safe:duration-300 hover:border-red-500/55 hover:shadow-[0_0_0_1px_rgba(232,20,47,0.35),0_10px_28px_-10px_rgba(232,20,47,0.4)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60">
|
|
438
|
+
<img src="${src}" alt="${escapeHtml(g.name)}" loading="lazy" class="h-full w-full object-cover motion-safe:transition-transform motion-safe:duration-500 group-hover:scale-[1.07]" onerror="this.onerror=null;this.src='${monogramSrc(g.name)}'" />
|
|
439
|
+
<div class="absolute inset-x-0 bottom-0 p-2.5 bg-gradient-to-t from-ink-950/95 via-ink-950/55 to-transparent">
|
|
440
|
+
<p class="truncate text-[12.5px] font-medium text-ink-50 leading-tight">${escapeHtml(g.name)}</p>
|
|
441
|
+
${g.category ? `<p class="truncate mt-0.5 text-[9.5px] font-mono uppercase tracking-wide text-ink-400">${escapeHtml(g.category)}</p>` : ""}
|
|
442
|
+
</div>
|
|
443
|
+
</button>`;
|
|
444
|
+
})
|
|
445
|
+
.join("");
|
|
446
|
+
|
|
447
|
+
grid.querySelectorAll("[data-id]").forEach((card) =>
|
|
448
|
+
card.addEventListener("click", () => openPlayer(games.find((g) => g.$id === card.dataset.id)))
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
fx("#grid [data-fx]", { opacity: [0, 1], y: [10, 0] }, { duration: 0.35, delay: stagger(0.035), easing: "ease-out" });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
el("count-badge") && (el("count-num").textContent = "0");
|
|
455
|
+
|
|
456
|
+
/* ------------------------------ fetching ------------------------------ */
|
|
457
|
+
async function loadGames() {
|
|
458
|
+
if (!isConfigured()) {
|
|
459
|
+
el("setup-banner").classList.remove("hidden");
|
|
460
|
+
el("skeleton-grid").classList.add("hidden");
|
|
461
|
+
el("empty-title").textContent = "Not connected yet";
|
|
462
|
+
el("empty-sub").textContent = "Fill in CONFIG at the bottom of this file with your Appwrite project details.";
|
|
463
|
+
el("empty-state").classList.remove("hidden");
|
|
464
|
+
el("empty-state").classList.add("flex");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const res = await tablesDB.listRows({
|
|
469
|
+
databaseId: CONFIG.databaseId,
|
|
470
|
+
tableId: CONFIG.gamesTableId,
|
|
471
|
+
queries: [Query.limit(200), Query.orderAsc("order")],
|
|
472
|
+
});
|
|
473
|
+
allGames = res.rows.filter((g) => g.url && g.enabled !== false);
|
|
474
|
+
el("count-num").textContent = allGames.length;
|
|
475
|
+
renderCategoryBar();
|
|
476
|
+
renderGrid();
|
|
477
|
+
} catch (err) {
|
|
478
|
+
console.error("Failed to load games:", err);
|
|
479
|
+
el("skeleton-grid").classList.add("hidden");
|
|
480
|
+
el("empty-title").textContent = "Couldn't load games";
|
|
481
|
+
el("empty-sub").textContent = err?.message
|
|
482
|
+
? `Appwrite said: "${err.message}". Check the browser console (F12) for the full error.`
|
|
483
|
+
: "Check your Appwrite endpoint, project ID, table ID and permissions.";
|
|
484
|
+
el("empty-state").classList.remove("hidden");
|
|
485
|
+
el("empty-state").classList.add("flex");
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
loadGames();
|
|
489
|
+
|
|
490
|
+
/* --------------------------- search / filter --------------------------- */
|
|
491
|
+
let searchDebounce;
|
|
492
|
+
el("search").addEventListener("input", (e) => {
|
|
493
|
+
clearTimeout(searchDebounce);
|
|
494
|
+
searchDebounce = setTimeout(() => {
|
|
495
|
+
searchTerm = e.target.value.trim().toLowerCase();
|
|
496
|
+
renderGrid();
|
|
497
|
+
}, 120);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
/* ------------------------------- player ------------------------------- */
|
|
501
|
+
function openPlayer(game) {
|
|
502
|
+
if (!game) return;
|
|
503
|
+
el("player-title").textContent = game.name;
|
|
504
|
+
el("player-icon").src = iconSrcFor(game) || monogramSrc(game.name);
|
|
505
|
+
el("player-newtab").href = game.url;
|
|
506
|
+
el("player-frame").src = game.url;
|
|
507
|
+
const modal = el("player-modal");
|
|
508
|
+
modal.classList.remove("hidden");
|
|
509
|
+
document.documentElement.style.overflow = "hidden";
|
|
510
|
+
fx("#player-panel", { opacity: [0, 1], scale: [0.98, 1] }, { duration: 0.28, easing: "ease-out" });
|
|
511
|
+
}
|
|
512
|
+
function closePlayer() {
|
|
513
|
+
fx("#player-panel", { opacity: [1, 0], scale: [1, 0.98] }, { duration: 0.18, easing: "ease-in" }).finished.then(() => {
|
|
514
|
+
el("player-modal").classList.add("hidden");
|
|
515
|
+
el("player-frame").src = "";
|
|
516
|
+
document.documentElement.style.overflow = "";
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
el("player-close").addEventListener("click", closePlayer);
|
|
520
|
+
document.querySelector("[data-player-close]").addEventListener("click", closePlayer);
|
|
521
|
+
document.addEventListener("keydown", (e) => {
|
|
522
|
+
if (e.key === "Escape") {
|
|
523
|
+
if (!el("player-modal").classList.contains("hidden")) closePlayer();
|
|
524
|
+
if (!el("admin-panel").classList.contains("hidden")) closeAdminPanel();
|
|
525
|
+
if (!el("admin-login-modal").classList.contains("hidden")) closeAdminLogin();
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
/* -------------------------------- admin -------------------------------- */
|
|
530
|
+
function openAdminLogin() {
|
|
531
|
+
el("admin-login-modal").classList.remove("hidden");
|
|
532
|
+
fx("#admin-login-form", { opacity: [0, 1], y: [8, 0] }, { duration: 0.25, easing: "ease-out" });
|
|
533
|
+
resetPinBoxes();
|
|
534
|
+
pinInputs[0]?.focus();
|
|
535
|
+
}
|
|
536
|
+
function closeAdminLogin() {
|
|
537
|
+
el("admin-login-modal").classList.add("hidden");
|
|
538
|
+
el("admin-login-error").classList.add("hidden");
|
|
539
|
+
}
|
|
540
|
+
function openAdminPanel() {
|
|
541
|
+
el("admin-panel").classList.remove("hidden");
|
|
542
|
+
fx("#admin-panel-card", { opacity: [0, 1], y: [12, 0] }, { duration: 0.28, easing: "ease-out" });
|
|
543
|
+
renderAdminList();
|
|
544
|
+
}
|
|
545
|
+
function closeAdminPanel() {
|
|
546
|
+
el("admin-panel").classList.add("hidden");
|
|
547
|
+
}
|
|
548
|
+
function isUnlocked() {
|
|
549
|
+
return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
el("admin-trigger").addEventListener("click", () => {
|
|
553
|
+
if (isUnlocked()) openAdminPanel();
|
|
554
|
+
else openAdminLogin();
|
|
555
|
+
});
|
|
556
|
+
document.querySelector("[data-admin-login-close]").addEventListener("click", closeAdminLogin);
|
|
557
|
+
document.querySelector("[data-admin-panel-close]").addEventListener("click", closeAdminPanel);
|
|
558
|
+
|
|
559
|
+
/* ------- 6-digit PIN pad: build boxes, wire auto-advance + paste ------- */
|
|
560
|
+
const pinBoxesEl = el("pin-boxes");
|
|
561
|
+
pinBoxesEl.innerHTML = Array.from({ length: 6 })
|
|
562
|
+
.map(
|
|
563
|
+
(_, i) =>
|
|
564
|
+
`<input data-pin-idx="${i}" type="password" inputmode="numeric" pattern="[0-9]*" maxlength="1"
|
|
565
|
+
class="h-12 w-full rounded-lg border border-ink-700 bg-ink-850 text-center text-[18px] font-mono outline-none focus:border-red-500/60" />`
|
|
566
|
+
)
|
|
567
|
+
.join("");
|
|
568
|
+
const pinInputs = Array.from(pinBoxesEl.querySelectorAll("[data-pin-idx]"));
|
|
569
|
+
|
|
570
|
+
function resetPinBoxes() {
|
|
571
|
+
pinInputs.forEach((i) => (i.value = ""));
|
|
572
|
+
el("admin-pin").value = "";
|
|
573
|
+
}
|
|
574
|
+
function syncPinValue() {
|
|
575
|
+
el("admin-pin").value = pinInputs.map((i) => i.value).join("");
|
|
576
|
+
}
|
|
577
|
+
pinInputs.forEach((input, idx) => {
|
|
578
|
+
input.addEventListener("input", () => {
|
|
579
|
+
input.value = input.value.replace(/[^0-9]/g, "").slice(0, 1);
|
|
580
|
+
syncPinValue();
|
|
581
|
+
if (input.value && idx < pinInputs.length - 1) pinInputs[idx + 1].focus();
|
|
582
|
+
});
|
|
583
|
+
input.addEventListener("keydown", (e) => {
|
|
584
|
+
if (e.key === "Backspace" && !input.value && idx > 0) pinInputs[idx - 1].focus();
|
|
585
|
+
});
|
|
586
|
+
input.addEventListener("paste", (e) => {
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
const digits = (e.clipboardData.getData("text") || "").replace(/[^0-9]/g, "").slice(0, 6).split("");
|
|
589
|
+
digits.forEach((d, i) => pinInputs[i] && (pinInputs[i].value = d));
|
|
590
|
+
syncPinValue();
|
|
591
|
+
pinInputs[Math.min(digits.length, pinInputs.length - 1)]?.focus();
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
el("admin-login-form").addEventListener("submit", (e) => {
|
|
596
|
+
e.preventDefault();
|
|
597
|
+
const errBox = el("admin-login-error");
|
|
598
|
+
syncPinValue();
|
|
599
|
+
if (el("admin-pin").value === ADMIN_PIN) {
|
|
600
|
+
sessionStorage.setItem(ADMIN_SESSION_KEY, "1");
|
|
601
|
+
errBox.classList.add("hidden");
|
|
602
|
+
closeAdminLogin();
|
|
603
|
+
openAdminPanel();
|
|
604
|
+
} else {
|
|
605
|
+
errBox.textContent = "Wrong PIN.";
|
|
606
|
+
errBox.classList.remove("hidden");
|
|
607
|
+
resetPinBoxes();
|
|
608
|
+
pinInputs[0]?.focus();
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
el("admin-logout").addEventListener("click", () => {
|
|
613
|
+
sessionStorage.removeItem(ADMIN_SESSION_KEY);
|
|
614
|
+
closeAdminPanel();
|
|
615
|
+
toast("Locked");
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
async function renderAdminList() {
|
|
619
|
+
const list = el("admin-list");
|
|
620
|
+
list.innerHTML = `<p class="text-[12.5px] text-ink-400">Loading…</p>`;
|
|
621
|
+
try {
|
|
622
|
+
const res = await tablesDB.listRows({
|
|
623
|
+
databaseId: CONFIG.databaseId,
|
|
624
|
+
tableId: CONFIG.gamesTableId,
|
|
625
|
+
queries: [Query.limit(200), Query.orderAsc("order")],
|
|
626
|
+
});
|
|
627
|
+
if (res.rows.length === 0) {
|
|
628
|
+
list.innerHTML = `<p class="text-[12.5px] text-ink-400">No games added yet.</p>`;
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
list.innerHTML = res.rows
|
|
632
|
+
.map(
|
|
633
|
+
(g) => `
|
|
634
|
+
<div class="flex items-center gap-3 rounded-xl border border-ink-700 p-2.5" data-row="${g.$id}">
|
|
635
|
+
<img src="${iconSrcFor(g) || monogramSrc(g.name)}" class="h-9 w-9 rounded-lg object-cover shrink-0 border border-ink-700" onerror="this.onerror=null;this.src='${monogramSrc(g.name)}'" />
|
|
636
|
+
<div class="min-w-0 flex-1">
|
|
637
|
+
<input data-field="name" data-id="${g.$id}" value="${escapeHtml(g.name)}" class="w-full bg-transparent text-[13px] font-medium outline-none border-b border-transparent focus:border-red-500/50" />
|
|
638
|
+
<input data-field="url" data-id="${g.$id}" value="${escapeHtml(g.url)}" class="mt-0.5 w-full bg-transparent text-[11.5px] text-ink-400 outline-none border-b border-transparent focus:border-red-500/50" />
|
|
639
|
+
</div>
|
|
640
|
+
<input data-field="category" data-id="${g.$id}" value="${escapeHtml(g.category || "")}" placeholder="category" class="hidden sm:block w-24 bg-ink-850 border border-ink-700 rounded-md px-2 py-1 text-[11.5px] outline-none focus:border-red-500/50" />
|
|
641
|
+
<label class="hidden sm:flex items-center gap-1 text-[11px] text-ink-400 shrink-0">
|
|
642
|
+
<input type="checkbox" data-field="enabled" data-id="${g.$id}" ${g.enabled === false ? "" : "checked"} class="accent-red-500" /> on
|
|
643
|
+
</label>
|
|
644
|
+
<button data-delete="${g.$id}" title="Delete" class="h-8 w-8 shrink-0 grid place-items-center rounded-lg border border-ink-700 text-ink-400 hover:text-red-400 hover:border-red-500/50 transition-colors">
|
|
645
|
+
<svg class="h-3.5 w-3.5" 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>
|
|
646
|
+
</button>
|
|
647
|
+
</div>`
|
|
648
|
+
)
|
|
649
|
+
.join("");
|
|
650
|
+
|
|
651
|
+
list.querySelectorAll("input[data-field]").forEach((input) => {
|
|
652
|
+
const commit = async () => {
|
|
653
|
+
const id = input.dataset.id;
|
|
654
|
+
const field = input.dataset.field;
|
|
655
|
+
const value = input.type === "checkbox" ? input.checked : input.value.trim();
|
|
656
|
+
try {
|
|
657
|
+
await tablesDB.updateRow({ databaseId: CONFIG.databaseId, tableId: CONFIG.gamesTableId, rowId: id, data: { [field]: value } });
|
|
658
|
+
toast("Saved");
|
|
659
|
+
await loadGames();
|
|
660
|
+
} catch (err) {
|
|
661
|
+
toast(err?.message || "Update failed", "error");
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
input.addEventListener(input.type === "checkbox" ? "change" : "blur", commit);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
list.querySelectorAll("[data-delete]").forEach((btn) =>
|
|
668
|
+
btn.addEventListener("click", async () => {
|
|
669
|
+
if (!confirm("Delete this game?")) return;
|
|
670
|
+
try {
|
|
671
|
+
await tablesDB.deleteRow({ databaseId: CONFIG.databaseId, tableId: CONFIG.gamesTableId, rowId: btn.dataset.delete });
|
|
672
|
+
toast("Deleted");
|
|
673
|
+
await renderAdminList();
|
|
674
|
+
await loadGames();
|
|
675
|
+
} catch (err) {
|
|
676
|
+
toast(err?.message || "Delete failed", "error");
|
|
677
|
+
}
|
|
678
|
+
})
|
|
679
|
+
);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
list.innerHTML = `<p class="text-[12.5px] text-red-400">${escapeHtml(err?.message || "Failed to load games.")}</p>`;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
el("add-game-form").addEventListener("submit", async (e) => {
|
|
686
|
+
e.preventDefault();
|
|
687
|
+
const statusEl = el("add-game-status");
|
|
688
|
+
const submitBtn = el("add-game-submit");
|
|
689
|
+
submitBtn.disabled = true;
|
|
690
|
+
statusEl.textContent = "Saving…";
|
|
691
|
+
try {
|
|
692
|
+
const name = el("new-name").value.trim();
|
|
693
|
+
const url = el("new-url").value.trim();
|
|
694
|
+
const category = el("new-category").value.trim();
|
|
695
|
+
const iconUrlField = el("new-icon-url").value.trim();
|
|
696
|
+
const iconFile = el("new-icon-file").files[0];
|
|
697
|
+
|
|
698
|
+
let iconFileId = "";
|
|
699
|
+
if (iconFile) {
|
|
700
|
+
const uploaded = await storage.createFile({ bucketId: CONFIG.iconsBucketId, fileId: ID.unique(), file: iconFile });
|
|
701
|
+
iconFileId = uploaded.$id;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
await tablesDB.createRow({
|
|
705
|
+
databaseId: CONFIG.databaseId,
|
|
706
|
+
tableId: CONFIG.gamesTableId,
|
|
707
|
+
rowId: ID.unique(),
|
|
708
|
+
data: {
|
|
709
|
+
name,
|
|
710
|
+
url,
|
|
711
|
+
category,
|
|
712
|
+
iconFileId,
|
|
713
|
+
iconUrl: iconFileId ? "" : iconUrlField,
|
|
714
|
+
order: allGames.length,
|
|
715
|
+
enabled: true,
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
statusEl.textContent = "Added.";
|
|
720
|
+
el("add-game-form").reset();
|
|
721
|
+
await renderAdminList();
|
|
722
|
+
await loadGames();
|
|
723
|
+
toast(`${name} added`);
|
|
724
|
+
} catch (err) {
|
|
725
|
+
statusEl.textContent = "";
|
|
726
|
+
toast(err?.message || "Couldn't add game", "error");
|
|
727
|
+
} finally {
|
|
728
|
+
submitBtn.disabled = false;
|
|
729
|
+
setTimeout(() => (statusEl.textContent = ""), 2500);
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
</script>
|
|
733
|
+
</body>
|
|
734
|
+
</html>
|