mlv2boii 1.0.9 → 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +1308 -195
- 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)); }
|
|
@@ -55,11 +56,11 @@
|
|
|
55
56
|
radial-gradient(720px 520px at 8% 108%, rgba(240,26,54,0.06), transparent 70%); }
|
|
56
57
|
|
|
57
58
|
/* ---------- liquid glass ---------- */
|
|
58
|
-
.glass { background: rgba(255,255,255,0.
|
|
59
|
-
box-shadow: inset 0 1px 0 rgba(255,255,255,0.
|
|
59
|
+
.glass { background: rgba(255,255,255,0.05); -webkit-backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
|
|
60
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.12), 0 10px 32px rgba(0,0,0,0.4); }
|
|
60
61
|
.glass-sheet { background: rgba(28,28,31,0.55); -webkit-backdrop-filter: blur(40px) saturate(180%); backdrop-filter: blur(40px) saturate(180%); }
|
|
61
62
|
.glass::before { content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; pointer-events: none;
|
|
62
|
-
background: linear-gradient(135deg, rgba(255,255,255,0.
|
|
63
|
+
background: linear-gradient(135deg, rgba(255,255,255,0.16), rgba(255,255,255,0.03) 40%, rgba(255,255,255,0.03) 60%, rgba(255,255,255,0.09));
|
|
63
64
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; }
|
|
64
65
|
.glass-lite { background: rgba(255,255,255,0.07); box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), inset 0 0 0 1px rgba(255,255,255,0.08); }
|
|
65
66
|
.glass-lite:hover { background: rgba(255,255,255,0.12); }
|
|
@@ -73,7 +74,7 @@
|
|
|
73
74
|
.badge { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 14px 0 10px; border-radius: 999px; font-size: 13px; font-weight: 600; color: #fff;
|
|
74
75
|
background: linear-gradient(180deg, #ff6b7d, #f01a36 60%, #c80f27);
|
|
75
76
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.55), inset 0 -2px 0 rgba(0,0,0,0.16), 0 6px 22px -2px rgba(240,26,54,0.7); }
|
|
76
|
-
.glow-line { display: inline-block; padding: 0 0
|
|
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,47 @@
|
|
|
102
102
|
animation: float 6s ease-in-out infinite; animation-delay: var(--d, 0s); }
|
|
103
103
|
@keyframes float { 0%, 100% { transform: translateY(0) rotate(var(--r, 0deg)); } 50% { transform: translateY(-12px) rotate(calc(var(--r, 0deg) + 4deg)); } }
|
|
104
104
|
|
|
105
|
+
/* ---------- accessibility + admin controls ---------- */
|
|
106
|
+
.skip { position: fixed; left: 12px; top: -64px; z-index: 100; padding: 10px 16px; border-radius: 999px; background: #fff; color: #000; font-size: 14px; font-weight: 600; transition: top .2s; }
|
|
107
|
+
.skip:focus { top: calc(env(safe-area-inset-top) + 12px); }
|
|
108
|
+
input[type="search"]::-webkit-search-cancel-button { display: none; }
|
|
109
|
+
input[type="file"]:focus-visible, input[type="checkbox"]:focus-visible { outline: 2px solid var(--color-red-400); outline-offset: 2px; }
|
|
110
|
+
.btn-danger { color: #fff; background: #c11228; box-shadow: inset 0 1px 0 rgba(255,255,255,0.25); }
|
|
111
|
+
.btn-danger:hover { background: #d61a40; }
|
|
112
|
+
.tag { display: inline-block; margin-left: 6px; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; color: var(--color-ink-200); background: rgba(255,255,255,0.1); vertical-align: 1px; }
|
|
113
|
+
.switch { appearance: none; -webkit-appearance: none; position: relative; flex-shrink: 0; width: 44px; height: 26px; border-radius: 999px; cursor: pointer;
|
|
114
|
+
background: rgba(255,255,255,0.16); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12); transition: background-color .2s; }
|
|
115
|
+
.switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.4); transition: transform .25s cubic-bezier(.34,1.56,.64,1); }
|
|
116
|
+
.switch:checked { background: #f01a36; }
|
|
117
|
+
.switch:checked::after { transform: translateX(18px); }
|
|
118
|
+
.switch:disabled, button:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
119
|
+
|
|
120
|
+
/* ---------- top bar: same recipe as the game cards so it blends in ---------- */
|
|
121
|
+
.topbar { background: rgba(255,255,255,0.045); -webkit-backdrop-filter: blur(20px) saturate(160%); backdrop-filter: blur(20px) saturate(160%);
|
|
122
|
+
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.09); }
|
|
123
|
+
.topbar .field { background: rgba(255,255,255,0.05); }
|
|
124
|
+
|
|
125
|
+
/* ---------- settings + tabs ---------- */
|
|
126
|
+
.tab { height: 36px; padding: 0 14px; border-radius: 999px; font-size: 14px; font-weight: 500; color: var(--color-ink-300); transition: background-color .2s, color .2s; }
|
|
127
|
+
.tab:hover { color: #fff; }
|
|
128
|
+
.tab[aria-selected="true"] { background: rgba(255,255,255,0.14); color: #fff; }
|
|
129
|
+
.shead { margin: 22px 4px 8px; font-size: 13px; font-weight: 600; color: var(--color-ink-300); }
|
|
130
|
+
.shead:first-child { margin-top: 6px; }
|
|
131
|
+
.sgroup { border-radius: 20px; padding: 2px 16px; background: rgba(255,255,255,0.05); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08); }
|
|
132
|
+
.srow { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 0; border-bottom: 1px solid rgba(255,255,255,0.07); cursor: pointer; }
|
|
133
|
+
.srow.plain { cursor: default; }
|
|
134
|
+
.sgroup > .srow:last-child, .sgroup > div:last-child { border-bottom: 0; }
|
|
135
|
+
.opt { display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 18px; cursor: pointer; background: rgba(255,255,255,0.05); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08); }
|
|
136
|
+
.opt:hover { background: rgba(255,255,255,0.08); }
|
|
137
|
+
.opt:has(input:checked) { background: rgba(240,26,54,0.16); box-shadow: inset 0 0 0 1.5px rgba(240,26,54,0.75); }
|
|
138
|
+
.opt:has(input:focus-visible), .pill-opt:has(input:focus-visible) { outline: 2px solid var(--color-red-400); outline-offset: 2px; }
|
|
139
|
+
.pill-opt { cursor: pointer; border-radius: 999px; font-size: 13px; font-weight: 500; color: var(--color-ink-200); }
|
|
140
|
+
.pill-opt span { display: block; padding: 7px 12px; border-radius: 999px; }
|
|
141
|
+
.pill-opt:has(input:checked) span { background: rgba(255,255,255,0.16); color: #fff; }
|
|
142
|
+
kbd { font-family: var(--font-mono); font-size: 13px; }
|
|
143
|
+
html.rm *, html.rm *::before, html.rm *::after { animation: none !important; transition-duration: 0.01ms !important; }
|
|
144
|
+
html.rm .tile-icon { transform: none; }
|
|
145
|
+
|
|
105
146
|
/* ---------- motion ---------- */
|
|
106
147
|
.w { display: inline-block; opacity: 0; }
|
|
107
148
|
.glowwrap { display: inline-block; filter: drop-shadow(0 0 22px rgba(240,26,54,0.55)); }
|
|
@@ -145,31 +186,45 @@
|
|
|
145
186
|
|
|
146
187
|
<body class="min-h-screen bg-ink-950 font-sans text-ink-50 antialiased">
|
|
147
188
|
|
|
189
|
+
<a href="#games-heading" class="skip">Skip to games</a>
|
|
190
|
+
|
|
148
191
|
<div class="ambient" aria-hidden="true"></div>
|
|
149
192
|
|
|
193
|
+
<button id="panic-btn" type="button" hidden aria-label="Panic: leave this page now" title="Panic: leave this page now" class="glass-lite icon-btn fixed bottom-4 left-4 z-[45] opacity-70 hover:opacity-100">
|
|
194
|
+
<svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
|
|
195
|
+
</button>
|
|
196
|
+
|
|
150
197
|
<!-- shared gradients for the hero shapes -->
|
|
151
198
|
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
|
|
152
199
|
<radialGradient id="gP" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffc0c7"/><stop offset=".38" stop-color="#ff3347"/><stop offset="1" stop-color="#b30d25"/></radialGradient>
|
|
153
200
|
<radialGradient id="gO" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffb8c0"/><stop offset=".38" stop-color="#e8142f"/><stop offset="1" stop-color="#7d0a1c"/></radialGradient>
|
|
154
201
|
<radialGradient id="gL" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffd0d5"/><stop offset=".38" stop-color="#ff5c70"/><stop offset="1" stop-color="#c11228"/></radialGradient>
|
|
155
|
-
<radialGradient id="gB" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffffff"/><stop offset=".4" stop-color="#e5e5ea"/><stop offset="1" stop-color="#9a9aa2"/></radialGradient>
|
|
156
|
-
<radialGradient id="gV" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#85858d"/><stop offset=".4" stop-color="#2c2c30"/><stop offset="1" stop-color="#0a0a0b"/></radialGradient>
|
|
157
202
|
</defs></svg>
|
|
158
203
|
|
|
159
204
|
<!-- ================= HEADER ================= -->
|
|
160
205
|
<header class="pointer-events-none sticky top-0 z-40 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
|
161
206
|
<div class="page-x mx-auto max-w-5xl">
|
|
162
|
-
<div class="
|
|
207
|
+
<div class="topbar pointer-events-auto relative flex h-14 items-center gap-3 rounded-full pl-5 pr-2">
|
|
163
208
|
<a href="#" class="flex shrink-0 items-center gap-2.5" aria-label="mlv2 home">
|
|
164
209
|
<span class="orb"></span>
|
|
165
210
|
<span class="text-[17px] font-semibold tracking-[-0.02em]">mlv2</span>
|
|
166
211
|
</a>
|
|
167
|
-
<div class="ml-auto flex min-w-0 flex-1 items-center justify-end">
|
|
212
|
+
<div role="search" class="ml-auto flex min-w-0 flex-1 items-center justify-end">
|
|
168
213
|
<div id="search-wrap" class="relative">
|
|
169
214
|
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
170
|
-
<input id="search" type="
|
|
215
|
+
<input id="search" type="search" placeholder="Search games" aria-label="Search games" autocomplete="off" enterkeyhint="search" class="field rounded-full pl-9 pr-9" />
|
|
216
|
+
<button id="search-clear" type="button" aria-label="Clear search" class="absolute right-1.5 top-1/2 hidden h-7 w-7 -translate-y-1/2 place-items-center rounded-full text-ink-300 hover:text-white"><svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
|
171
217
|
</div>
|
|
172
218
|
</div>
|
|
219
|
+
<div class="flex shrink-0 items-center gap-1.5">
|
|
220
|
+
<button id="bell-btn" type="button" hidden aria-label="Announcements" class="glass-lite icon-btn relative">
|
|
221
|
+
<svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
|
222
|
+
<span id="bell-dot" hidden class="absolute right-2 top-2 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-[#111113]"></span>
|
|
223
|
+
</button>
|
|
224
|
+
<button id="settings-btn" type="button" aria-label="Settings" aria-haspopup="dialog" class="glass-lite icon-btn">
|
|
225
|
+
<svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
173
228
|
</div>
|
|
174
229
|
</div>
|
|
175
230
|
</header>
|
|
@@ -184,26 +239,29 @@
|
|
|
184
239
|
|
|
185
240
|
<!-- floating bubbly shapes (large screens) -->
|
|
186
241
|
<div id="shapes" class="pointer-events-none absolute right-2 top-4 hidden h-[360px] w-[400px] lg:block" aria-hidden="true">
|
|
187
|
-
<div class="shape" style="left:
|
|
242
|
+
<div class="shape" style="left:30px;top:90px;width:200px"><div class="par" data-depth="1"><svg viewBox="0 0 120 120" class="float" style="--r:-8deg;--g:rgba(240,26,54,.4);--d:-1s">
|
|
188
243
|
<rect x="8" y="30" width="104" height="62" rx="31" fill="url(#gP)"/>
|
|
189
244
|
<path d="M34 50v24M22 62h24" stroke="#fff" stroke-width="9" stroke-linecap="round" opacity=".92"/>
|
|
190
245
|
<circle cx="82" cy="54" r="6" fill="#fff" opacity=".92"/><circle cx="96" cy="68" r="6" fill="#fff" opacity=".92"/>
|
|
191
246
|
<ellipse cx="44" cy="40" rx="28" ry="7" fill="#fff" opacity=".45"/>
|
|
192
247
|
</svg></div></div>
|
|
193
|
-
<div class="shape" style="left:
|
|
248
|
+
<div class="shape" style="left:255px;top:10px;width:120px"><div class="par" data-depth="1.6"><svg viewBox="0 0 120 120" class="float" style="--r:10deg;--g:rgba(240,26,54,.35);--d:-3s">
|
|
194
249
|
<polygon points="60,16 71.8,43.8 101.9,46.4 79,66.2 85.9,95.6 60,80 34.1,95.6 41,66.2 18.2,46.4 48.2,43.8" fill="url(#gO)" stroke="url(#gO)" stroke-width="14" stroke-linejoin="round"/>
|
|
195
250
|
<ellipse cx="48" cy="40" rx="14" ry="5" fill="#fff" opacity=".5" transform="rotate(-25 48 40)"/>
|
|
196
251
|
</svg></div></div>
|
|
197
|
-
<div class="shape" style="left:
|
|
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=".
|
|
252
|
+
<div class="shape" style="left:255px;top:175px;width:105px"><div class="par" data-depth="0.8"><svg viewBox="0 0 120 120" class="float" style="--r:-6deg;--g:rgba(240,26,54,.35);--d:-2s">
|
|
253
|
+
<path d="M44 34 L90 60 L44 86 Z" fill="url(#gL)" stroke="url(#gL)" stroke-width="16" stroke-linejoin="round"/>
|
|
254
|
+
<ellipse cx="50" cy="48" rx="8" ry="4" fill="#fff" opacity=".55" transform="rotate(35 50 48)"/>
|
|
200
255
|
</svg></div></div>
|
|
201
|
-
<div class="shape" style="left:
|
|
202
|
-
<path d="M60 26v68M26 60h68" fill="none" stroke="url(#
|
|
256
|
+
<div class="shape" style="left:55px;top:268px;width:84px"><div class="par" data-depth="1.3"><svg viewBox="0 0 120 120" class="float" style="--r:8deg;--g:rgba(240,26,54,.3);--d:-4s">
|
|
257
|
+
<path d="M60 26v68M26 60h68" fill="none" stroke="url(#gP)" stroke-width="30" stroke-linecap="round"/>
|
|
203
258
|
<ellipse cx="52" cy="30" rx="6" ry="3" fill="#fff" opacity=".55"/>
|
|
204
259
|
</svg></div></div>
|
|
205
|
-
<div class="shape" style="left:
|
|
206
|
-
<circle cx="60" cy="60" r="
|
|
260
|
+
<div class="shape" style="left:165px;top:14px;width:52px"><div class="par" data-depth="2"><svg viewBox="0 0 120 120" class="float" style="--r:0deg;--g:rgba(240,26,54,.35);--d:-5s">
|
|
261
|
+
<circle cx="60" cy="60" r="42" fill="url(#gP)"/><ellipse cx="46" cy="42" rx="16" ry="8" fill="#fff" opacity=".55" transform="rotate(-30 46 42)"/>
|
|
262
|
+
</svg></div></div>
|
|
263
|
+
<div class="shape" style="left:175px;top:300px;width:40px"><div class="par" data-depth="1.8"><svg viewBox="0 0 120 120" class="float" style="--r:0deg;--g:rgba(240,26,54,.3);--d:-2.5s">
|
|
264
|
+
<circle cx="60" cy="60" r="42" fill="url(#gL)"/><ellipse cx="46" cy="42" rx="16" ry="8" fill="#fff" opacity=".55" transform="rotate(-30 46 42)"/>
|
|
207
265
|
</svg></div></div>
|
|
208
266
|
</div>
|
|
209
267
|
</section>
|
|
@@ -215,11 +273,14 @@
|
|
|
215
273
|
<p class="mt-1 text-[14px] leading-relaxed text-ink-300">Open this file and fill in the <code class="font-mono text-ink-50">CONFIG</code> object near the bottom with your Appwrite endpoint, project, database, table and bucket IDs.</p>
|
|
216
274
|
</div>
|
|
217
275
|
|
|
276
|
+
<h2 id="games-heading" tabindex="-1" class="sr-only">Games</h2>
|
|
277
|
+
<p id="grid-status" role="status" class="sr-only"></p>
|
|
218
278
|
<div id="skeleton-grid" class="grid grid-cols-3 gap-3 sm:grid-cols-4 sm:gap-4 md:grid-cols-5 lg:grid-cols-6"></div>
|
|
219
279
|
|
|
220
|
-
<div id="empty-state" class="hidden flex-col items-center justify-center py-24 text-center">
|
|
280
|
+
<div id="empty-state" aria-live="polite" class="hidden flex-col items-center justify-center py-24 text-center">
|
|
221
281
|
<p id="empty-title" class="text-[19px] font-semibold">No games yet</p>
|
|
222
|
-
<p id="empty-sub" class="mt-2 max-w-xs text-pretty text-[15px] text-ink-
|
|
282
|
+
<p id="empty-sub" class="mt-2 max-w-xs text-pretty text-[15px] text-ink-300">Games you add in Appwrite with a valid link will show up here automatically.</p>
|
|
283
|
+
<button id="retry" type="button" class="btn btn-ghost mt-5 hidden">Try again</button>
|
|
223
284
|
</div>
|
|
224
285
|
|
|
225
286
|
<div id="grid" class="hidden grid grid-cols-3 gap-3 sm:grid-cols-4 sm:gap-4 md:grid-cols-5 lg:grid-cols-6"></div>
|
|
@@ -229,14 +290,14 @@
|
|
|
229
290
|
<footer class="relative z-10 border-t border-white/[0.08]">
|
|
230
291
|
<div class="page-x mx-auto flex max-w-5xl items-center justify-between py-4 text-[13px] text-ink-400">
|
|
231
292
|
<p>mlv2 © <span id="year"></span></p>
|
|
232
|
-
<button id="admin-trigger" class="glass-lite rounded-full px-4 py-2 transition-colors duration-200 hover:text-ink-50">Admin</button>
|
|
293
|
+
<button id="admin-trigger" aria-haspopup="dialog" class="glass-lite rounded-full px-4 py-2 transition-colors duration-200 hover:text-ink-50">Admin</button>
|
|
233
294
|
</div>
|
|
234
295
|
</footer>
|
|
235
296
|
|
|
236
297
|
<!-- ================= PLAYER MODAL ================= -->
|
|
237
298
|
<div id="player-modal" class="fixed inset-0 z-50 hidden">
|
|
238
299
|
<div class="absolute inset-0 bg-black" data-player-close></div>
|
|
239
|
-
<div id="player-panel" class="relative flex h-full w-full flex-col opacity-0">
|
|
300
|
+
<div id="player-panel" role="dialog" aria-modal="true" aria-labelledby="player-title" class="relative flex h-full w-full flex-col opacity-0">
|
|
240
301
|
<div class="border-b border-white/[0.08] bg-black pt-[env(safe-area-inset-top)]">
|
|
241
302
|
<div class="flex h-14 items-center justify-between gap-3 px-3 sm:px-4">
|
|
242
303
|
<div class="flex min-w-0 items-center gap-3">
|
|
@@ -244,6 +305,12 @@
|
|
|
244
305
|
<p id="player-title" class="truncate text-[15px] font-semibold"></p>
|
|
245
306
|
</div>
|
|
246
307
|
<div class="flex shrink-0 items-center gap-2">
|
|
308
|
+
<button id="player-panic" type="button" hidden aria-label="Panic: leave this page now" title="Panic: leave this page now" class="glass-lite icon-btn">
|
|
309
|
+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
|
|
310
|
+
</button>
|
|
311
|
+
<button id="player-fs" type="button" title="Fullscreen" aria-label="Fullscreen" class="glass-lite icon-btn">
|
|
312
|
+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
|
|
313
|
+
</button>
|
|
247
314
|
<a id="player-newtab" target="_blank" rel="noopener" title="Open in new tab" aria-label="Open in new tab" class="glass-lite icon-btn">
|
|
248
315
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
|
|
249
316
|
</a>
|
|
@@ -261,13 +328,13 @@
|
|
|
261
328
|
</div>
|
|
262
329
|
|
|
263
330
|
<!-- ================= ADMIN: PIN GATE ================= -->
|
|
264
|
-
<div id="admin-login-modal" class="fixed inset-0 z-50 hidden">
|
|
265
|
-
<div class="absolute inset-0 bg-black/50 backdrop-blur-md"
|
|
266
|
-
<div class="absolute inset-0 flex items-center justify-center p-4">
|
|
267
|
-
<form id="admin-login-form" class="glass glass-sheet relative w-full max-w-[340px] rounded-[28px] p-6 opacity-0" data-fx>
|
|
268
|
-
<
|
|
331
|
+
<div id="admin-login-modal" data-nomask class="fixed inset-0 z-50 hidden">
|
|
332
|
+
<div data-backdrop="login" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
|
|
333
|
+
<div class="pointer-events-none absolute inset-0 flex items-center justify-center p-4">
|
|
334
|
+
<form id="admin-login-form" role="dialog" aria-modal="true" aria-labelledby="pin-title" class="glass glass-sheet pointer-events-auto relative w-full max-w-[340px] rounded-[28px] p-6 opacity-0" data-fx>
|
|
335
|
+
<h2 id="pin-title" class="text-[19px] font-semibold">Enter PIN</h2>
|
|
269
336
|
<p class="mt-1.5 text-pretty text-[14px] text-ink-400">This unlocks the admin screen on this device. Write access is controlled by your Appwrite permissions.</p>
|
|
270
|
-
<div id="pin-boxes" class="mt-5 grid grid-cols-6 gap-2"></div>
|
|
337
|
+
<div id="pin-boxes" role="group" aria-labelledby="pin-title" class="mt-5 grid grid-cols-6 gap-2"></div>
|
|
271
338
|
<input id="admin-pin" type="hidden" />
|
|
272
339
|
<p id="admin-login-error" class="mt-3 hidden text-[14px] text-red-400" role="alert"></p>
|
|
273
340
|
<button type="submit" id="admin-login-submit" class="btn btn-primary mt-5 w-full">Unlock</button>
|
|
@@ -277,48 +344,145 @@
|
|
|
277
344
|
</div>
|
|
278
345
|
|
|
279
346
|
<!-- ================= ADMIN: PANEL ================= -->
|
|
280
|
-
<div id="admin-panel" class="fixed inset-0 z-50 hidden">
|
|
281
|
-
<div class="absolute inset-0 bg-black/50 backdrop-blur-md"
|
|
282
|
-
<div class="absolute inset-0 flex items-start justify-center p-0 sm:items-center sm:p-4">
|
|
283
|
-
<div id="admin-panel-card" class="glass glass-sheet relative flex h-full w-full flex-col opacity-0 sm:h-[85vh] sm:max-w-2xl sm:rounded-[32px]"
|
|
284
|
-
<div class="flex
|
|
285
|
-
<div>
|
|
286
|
-
<
|
|
287
|
-
<p class="mt-
|
|
347
|
+
<div id="admin-panel" data-nomask class="fixed inset-0 z-50 hidden">
|
|
348
|
+
<div data-backdrop="admin" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
|
|
349
|
+
<div class="pointer-events-none absolute inset-0 flex items-start justify-center p-0 sm:items-center sm:p-4">
|
|
350
|
+
<div id="admin-panel-card" role="dialog" aria-modal="true" aria-labelledby="admin-title" class="glass glass-sheet pointer-events-auto relative flex h-full w-full flex-col opacity-0 sm:h-[85vh] sm:max-w-2xl sm:rounded-[32px]">
|
|
351
|
+
<div class="flex shrink-0 items-center justify-between gap-3 px-5 pb-2 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
|
352
|
+
<div class="min-w-0">
|
|
353
|
+
<h2 id="admin-title" tabindex="-1" class="text-[17px] font-semibold leading-tight">Admin</h2>
|
|
354
|
+
<p id="admin-summary" class="mt-0.5 text-[13px] text-ink-300"></p>
|
|
288
355
|
</div>
|
|
289
|
-
<div class="flex items-center gap-2">
|
|
290
|
-
<button id="admin-logout" class="btn btn-ghost">Lock</button>
|
|
291
|
-
<button
|
|
292
|
-
|
|
293
|
-
|
|
356
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
357
|
+
<button id="admin-logout" type="button" class="btn btn-ghost">Lock</button>
|
|
358
|
+
<button id="admin-close" type="button" aria-label="Close" class="glass-lite icon-btn"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
<div id="admin-tabs" role="tablist" aria-label="Admin sections" class="flex shrink-0 gap-1 border-b border-white/[0.08] px-5 pb-3">
|
|
362
|
+
<button id="atab-games" role="tab" type="button" aria-selected="true" aria-controls="admin-tab-games" data-atab="games" class="tab">Games</button>
|
|
363
|
+
<button id="atab-ann" role="tab" type="button" aria-selected="false" tabindex="-1" aria-controls="admin-tab-ann" data-atab="ann" class="tab">Announcements</button>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<div id="admin-tab-games" role="tabpanel" aria-labelledby="atab-games" class="flex min-h-0 flex-1 flex-col">
|
|
367
|
+
<div class="flex shrink-0 gap-2 px-5 pt-4">
|
|
368
|
+
<div class="min-w-0 flex-1">
|
|
369
|
+
<label for="admin-filter" class="sr-only">Filter games</label>
|
|
370
|
+
<input id="admin-filter" type="search" placeholder="Filter games" autocomplete="off" class="field" />
|
|
371
|
+
</div>
|
|
372
|
+
<button id="admin-add" type="button" class="btn btn-primary shrink-0">Add game</button>
|
|
294
373
|
</div>
|
|
374
|
+
<div id="admin-body" class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
375
|
+
<div id="admin-form-slot"></div>
|
|
376
|
+
<ul id="admin-list" class="space-y-2" aria-label="Games"></ul>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
<div id="admin-tab-ann" role="tabpanel" aria-labelledby="atab-ann" hidden class="flex min-h-0 flex-1 flex-col">
|
|
381
|
+
<div class="flex shrink-0 items-center justify-between gap-3 px-5 pt-4">
|
|
382
|
+
<p class="text-[13px] text-ink-300">Shown to visitors when they open the site.</p>
|
|
383
|
+
<button id="ann-add" type="button" class="btn btn-primary shrink-0">New announcement</button>
|
|
384
|
+
</div>
|
|
385
|
+
<div id="ann-admin-body" class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
386
|
+
<div id="ann-form-slot"></div>
|
|
387
|
+
<ul id="ann-admin-list" class="space-y-2" aria-label="Announcements"></ul>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
<p id="admin-live" role="status" class="sr-only"></p>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<!-- ================= ANNOUNCEMENTS ================= -->
|
|
396
|
+
<div id="ann-modal" class="fixed inset-0 z-50 hidden">
|
|
397
|
+
<div data-backdrop="ann" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
|
|
398
|
+
<div class="pointer-events-none absolute inset-0 flex items-center justify-center p-4">
|
|
399
|
+
<div id="ann-card" role="dialog" aria-modal="true" aria-labelledby="ann-title" class="glass glass-sheet pointer-events-auto relative flex max-h-[85vh] w-full max-w-[480px] flex-col rounded-[32px] opacity-0">
|
|
400
|
+
<div class="flex shrink-0 items-center justify-between gap-3 px-6 pb-1 pt-5">
|
|
401
|
+
<h2 id="ann-title" tabindex="-1" class="text-[19px] font-semibold">Announcements</h2>
|
|
402
|
+
<button id="ann-close" type="button" aria-label="Close" class="glass-lite icon-btn"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
|
403
|
+
</div>
|
|
404
|
+
<div id="ann-list" class="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 pb-2 pt-3"></div>
|
|
405
|
+
<div class="shrink-0 px-4 pb-4 pt-2"><button id="ann-done" type="button" class="btn btn-primary w-full">Got it</button></div>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
<!-- ================= SETTINGS ================= -->
|
|
411
|
+
<div id="settings-modal" data-nomask class="fixed inset-0 z-50 hidden">
|
|
412
|
+
<div data-backdrop="settings" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
|
|
413
|
+
<div class="pointer-events-none absolute inset-0 flex items-start justify-center p-0 sm:items-center sm:p-4">
|
|
414
|
+
<div id="settings-card" role="dialog" aria-modal="true" aria-labelledby="settings-title" class="glass glass-sheet pointer-events-auto relative flex h-full w-full flex-col opacity-0 sm:h-[82vh] sm:max-w-xl sm:rounded-[32px]">
|
|
415
|
+
<div class="flex shrink-0 items-center justify-between gap-3 px-5 pb-2 pt-[calc(env(safe-area-inset-top)+0.9rem)]">
|
|
416
|
+
<h2 id="settings-title" tabindex="-1" class="text-[19px] font-semibold">Settings</h2>
|
|
417
|
+
<button id="settings-close" type="button" aria-label="Close" class="glass-lite icon-btn"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
|
295
418
|
</div>
|
|
296
|
-
<div class="flex-
|
|
297
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
</
|
|
307
|
-
<
|
|
419
|
+
<div id="settings-tabs" role="tablist" aria-label="Settings sections" class="flex shrink-0 gap-1 px-5 pb-3">
|
|
420
|
+
<button id="stab-general" role="tab" type="button" aria-selected="true" aria-controls="spanel-general" class="tab">General</button>
|
|
421
|
+
<button id="stab-cloak" role="tab" type="button" aria-selected="false" tabindex="-1" aria-controls="spanel-cloak" class="tab">Cloak</button>
|
|
422
|
+
<button id="stab-data" role="tab" type="button" aria-selected="false" tabindex="-1" aria-controls="spanel-data" class="tab">Data</button>
|
|
423
|
+
</div>
|
|
424
|
+
<div class="min-h-0 flex-1 overflow-y-auto px-5 pb-8">
|
|
425
|
+
|
|
426
|
+
<section id="spanel-general" role="tabpanel" aria-labelledby="stab-general">
|
|
427
|
+
<h3 class="shead">Experience</h3>
|
|
428
|
+
<div class="sgroup">
|
|
429
|
+
<label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Show announcements when I open the site</span><span class="mt-0.5 block text-[13px] text-ink-300">You can always reopen them with the bell in the top bar.</span></span><input id="set-announce" type="checkbox" role="switch" class="switch" /></label>
|
|
430
|
+
<label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Reduce motion</span><span class="mt-0.5 block text-[13px] text-ink-300">Turns off animations. Already on if your device asks for it.</span></span><input id="set-reduce" type="checkbox" role="switch" class="switch" /></label>
|
|
431
|
+
<div class="srow plain"><span id="tile-label" class="text-[15px] font-medium">Tile size</span><div role="radiogroup" aria-labelledby="tile-label" class="flex shrink-0 gap-1"><label class="pill-opt"><input type="radio" name="tile" value="compact" class="sr-only" /><span>Compact</span></label><label class="pill-opt"><input type="radio" name="tile" value="default" class="sr-only" /><span>Default</span></label><label class="pill-opt"><input type="radio" name="tile" value="large" class="sr-only" /><span>Large</span></label></div></div>
|
|
432
|
+
</div>
|
|
433
|
+
<h3 class="shead">Admin</h3>
|
|
434
|
+
<div class="sgroup"><div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Admin panel</span><span class="mt-0.5 block text-[13px] text-ink-300">Add games, post announcements and change the order.</span></span><button id="open-admin" type="button" class="btn btn-ghost shrink-0">Open</button></div></div>
|
|
435
|
+
</section>
|
|
436
|
+
|
|
437
|
+
<section id="spanel-cloak" role="tabpanel" aria-labelledby="stab-cloak" hidden>
|
|
438
|
+
<h3 class="shead">Tab disguise</h3>
|
|
439
|
+
<p class="mb-3 px-1 text-[13px] text-ink-300">Changes the tab's title and icon so it looks like something else.</p>
|
|
440
|
+
<div id="cloak-options" role="radiogroup" aria-label="Tab disguise" class="grid gap-2"></div>
|
|
441
|
+
<div id="custom-cloak" hidden class="mt-3 space-y-3">
|
|
442
|
+
<div><label for="cc-title" class="text-[13px] font-medium text-ink-200">Tab title</label><input id="cc-title" maxlength="80" autocomplete="off" class="field mt-1.5" /></div>
|
|
443
|
+
<div><label for="cc-icon" class="text-[13px] font-medium text-ink-200">Icon link <span class="font-normal text-ink-300">(optional)</span></label><input id="cc-icon" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://" class="field mt-1.5" /></div>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<h3 class="shead">Panic button</h3>
|
|
447
|
+
<div class="sgroup">
|
|
448
|
+
<label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Enable panic button</span><span class="mt-0.5 block text-[13px] text-ink-300">Instantly leaves this page and opens the site below.</span></span><input id="panic-on" type="checkbox" role="switch" class="switch" /></label>
|
|
449
|
+
<div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Shortcut</span><span class="mt-0.5 block text-[13px] text-ink-300"><span id="panic-key-hint">Works while this page has focus, not inside a game.</span></span></span><button id="panic-key" type="button" aria-describedby="panic-key-hint" class="btn btn-ghost shrink-0"></button></div>
|
|
450
|
+
<div class="py-3.5"><label for="panic-url" class="block text-[15px] font-medium">Go to</label><input id="panic-url" type="url" inputmode="url" autocomplete="off" spellcheck="false" aria-describedby="panic-url-err" class="field mt-1.5" /><p id="panic-url-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p></div>
|
|
451
|
+
<label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Show an on-screen button</span><span class="mt-0.5 block text-[13px] text-ink-300">A small button in the corner and in the game window, for phones or when a game has focus.</span></span><input id="panic-btn-on" type="checkbox" role="switch" class="switch" /></label>
|
|
308
452
|
</div>
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
453
|
+
|
|
454
|
+
<h3 class="shead">Privacy</h3>
|
|
455
|
+
<div class="sgroup">
|
|
456
|
+
<label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Mask text</span><span class="mt-0.5 block text-[13px] text-ink-300">Swaps letters for look-alike characters so on-screen keyword scanners can't read them. Screen readers may read masked text oddly.</span></span><input id="set-mask" type="checkbox" role="switch" class="switch" /></label>
|
|
457
|
+
<label class="srow"><span class="min-w-0"><span class="block text-[15px] font-medium">Ask before closing this tab</span><span class="mt-0.5 block text-[13px] text-ink-300">Shows your browser's “leave site?” prompt so you don't close it by accident.</span></span><input id="set-confirm" type="checkbox" role="switch" class="switch" /></label>
|
|
458
|
+
<div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Open in about:blank</span><span class="mt-0.5 block text-[13px] text-ink-300">Opens the site in a blank tab and sends this tab to your panic page.</span></span><button id="launch-blank" type="button" class="btn btn-ghost shrink-0">Open</button></div>
|
|
459
|
+
</div>
|
|
460
|
+
</section>
|
|
461
|
+
|
|
462
|
+
<section id="spanel-data" role="tabpanel" aria-labelledby="stab-data" hidden>
|
|
463
|
+
<h3 class="shead">Your settings</h3>
|
|
464
|
+
<div class="sgroup">
|
|
465
|
+
<div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Export settings</span><span class="mt-0.5 block text-[13px] text-ink-300">Save your settings to a file.</span></span><button id="export-settings" type="button" class="btn btn-ghost shrink-0">Export</button></div>
|
|
466
|
+
<div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Import settings</span><span class="mt-0.5 block text-[13px] text-ink-300">Load settings from a file you exported.</span></span><button id="import-settings-btn" type="button" class="btn btn-ghost shrink-0">Import</button><input id="import-settings" type="file" accept="application/json,.json" tabindex="-1" aria-label="Settings file" class="sr-only" /></div>
|
|
467
|
+
<div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Show announcements again</span><span class="mt-0.5 block text-[13px] text-ink-300">Forget which announcements you've already seen.</span></span><button id="reset-seen" type="button" class="btn btn-ghost shrink-0">Reset</button></div>
|
|
468
|
+
<div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Reset settings</span><span class="mt-0.5 block text-[13px] text-ink-300">Put every setting back to its default.</span></span><button id="reset-settings" type="button" class="btn btn-ghost shrink-0">Reset</button></div>
|
|
469
|
+
</div>
|
|
470
|
+
|
|
471
|
+
<h3 class="shead">Game library</h3>
|
|
472
|
+
<p id="lib-lock" class="mb-3 px-1 text-[13px] text-ink-300">Admin only. Unlock the admin panel to back up or restore your games.</p>
|
|
473
|
+
<div class="sgroup">
|
|
474
|
+
<div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Export games</span><span class="mt-0.5 block text-[13px] text-ink-300">Download every game as a file.</span></span><button id="export-games" type="button" class="btn btn-ghost shrink-0">Export</button></div>
|
|
475
|
+
<div class="srow plain"><span class="min-w-0"><span class="block text-[15px] font-medium">Import games</span><span class="mt-0.5 block text-[13px] text-ink-300">Add games from an export. Duplicates are skipped.</span></span><button id="import-games-btn" type="button" class="btn btn-ghost shrink-0">Choose file</button><input id="import-games" type="file" accept="application/json,.json" tabindex="-1" aria-label="Games file" class="sr-only" /></div>
|
|
312
476
|
</div>
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
477
|
+
<p id="lib-status" role="status" class="mt-3 px-1 text-[13px] text-ink-200"></p>
|
|
478
|
+
<div class="mt-3 flex gap-2"><button id="import-games-go" type="button" hidden class="btn btn-primary"></button><button id="unlock-admin" type="button" hidden class="btn btn-ghost">Unlock admin</button></div>
|
|
479
|
+
</section>
|
|
316
480
|
</div>
|
|
317
481
|
</div>
|
|
318
482
|
</div>
|
|
319
483
|
</div>
|
|
320
484
|
|
|
321
|
-
<div id="toast-root" class="fixed
|
|
485
|
+
<div id="toast-root" role="status" aria-live="polite" class="pointer-events-none fixed inset-x-0 bottom-4 z-[60] flex flex-col items-center gap-2 px-4 pb-[env(safe-area-inset-bottom)]"></div>
|
|
322
486
|
|
|
323
487
|
<script src="https://cdn.jsdelivr.net/npm/appwrite@26.2.0"></script>
|
|
324
488
|
<script type="module">
|
|
@@ -330,6 +494,8 @@ const CONFIG = {
|
|
|
330
494
|
databaseId: "6a9b8b23003de0ec5cf7",
|
|
331
495
|
gamesTableId: "6a9b8bb3000013a3fd80",
|
|
332
496
|
iconsBucketId: "6a9b8b36002565ac5987",
|
|
497
|
+
// Announcements need their own table in the same database. Paste its ID here (see Admin > Announcements for the columns).
|
|
498
|
+
announcementsTableId: "",
|
|
333
499
|
};
|
|
334
500
|
|
|
335
501
|
const ADMIN_PIN = "051511";
|
|
@@ -344,7 +510,51 @@ const storage = new Storage(client);
|
|
|
344
510
|
const isConfigured = () =>
|
|
345
511
|
!CONFIG.endpoint.includes("YOUR_") && !CONFIG.projectId.includes("YOUR_") && !CONFIG.databaseId.includes("YOUR_");
|
|
346
512
|
|
|
347
|
-
|
|
513
|
+
/* ------------------------------ settings model ------------------------------ */
|
|
514
|
+
const SETTINGS_KEY = "mlv2_settings";
|
|
515
|
+
const isHttp = (v) => { try { const p = new URL(v).protocol; return p === "http:" || p === "https:"; } catch { return false; } };
|
|
516
|
+
const ico = (svg) => "data:image/svg+xml," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">${svg}</svg>`);
|
|
517
|
+
const SIZES = {
|
|
518
|
+
compact: "grid-cols-4 sm:grid-cols-6 md:grid-cols-7 lg:grid-cols-8",
|
|
519
|
+
default: "grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6",
|
|
520
|
+
large: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5",
|
|
521
|
+
};
|
|
522
|
+
const CLOAKS = {
|
|
523
|
+
off: { label: "Off", sub: "Show the normal mlv2 tab", title: null, icon: null },
|
|
524
|
+
classroom: { label: "Google Classroom", sub: "Tab reads “Classes”", title: "Classes",
|
|
525
|
+
icon: ico('<rect width="32" height="32" rx="7" fill="#1e8e3e"/><rect x="6" y="9" width="20" height="14" rx="2" fill="#fff"/><circle cx="16" cy="16" r="3" fill="#1e8e3e"/><path d="M10 26h12" stroke="#fff" stroke-width="2" stroke-linecap="round"/>') },
|
|
526
|
+
ixl: { label: "IXL", sub: "Tab reads “IXL | Math, Language Arts, Science…”", title: "IXL | Math, Language Arts, Science, Social Studies, and Spanish",
|
|
527
|
+
icon: ico('<rect width="32" height="32" rx="7" fill="#fff"/><text x="16" y="21" font-family="Arial,sans-serif" font-size="12" font-weight="800" text-anchor="middle" fill="#0a74da">IXL</text>') },
|
|
528
|
+
github: { label: "GitHub", sub: "Tab reads “GitHub”", title: "GitHub",
|
|
529
|
+
icon: ico('<rect width="32" height="32" rx="16" fill="#0d1117"/><g transform="translate(4 4) scale(1.5)"><path fill="#fff" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></g>') },
|
|
530
|
+
blank: { label: "Blank", sub: "No visible title or icon", title: "\u00A0", icon: ico("") },
|
|
531
|
+
custom: { label: "Custom", sub: "Pick your own title and icon", title: null, icon: null },
|
|
532
|
+
};
|
|
533
|
+
const DEFAULTS = {
|
|
534
|
+
announceOnOpen: true, reduceMotion: false, tileSize: "default",
|
|
535
|
+
cloak: "off", customTitle: "", customIcon: "", maskText: false, confirmClose: false,
|
|
536
|
+
panic: { enabled: false, key: "`", url: "https://classroom.google.com", button: false },
|
|
537
|
+
};
|
|
538
|
+
function mergeSettings(base, src) {
|
|
539
|
+
const out = JSON.parse(JSON.stringify(base));
|
|
540
|
+
if (!src || typeof src !== "object") return out;
|
|
541
|
+
for (const k of Object.keys(base)) {
|
|
542
|
+
if (k === "panic") {
|
|
543
|
+
const p = src.panic && typeof src.panic === "object" ? src.panic : {};
|
|
544
|
+
for (const pk of Object.keys(base.panic)) if (typeof p[pk] === typeof base.panic[pk]) out.panic[pk] = p[pk];
|
|
545
|
+
} else if (typeof src[k] === typeof base[k]) out[k] = src[k];
|
|
546
|
+
}
|
|
547
|
+
if (!CLOAKS[out.cloak]) out.cloak = "off";
|
|
548
|
+
if (!SIZES[out.tileSize]) out.tileSize = "default";
|
|
549
|
+
if (!out.panic.key) out.panic.key = DEFAULTS.panic.key;
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
function loadSettings() { try { return mergeSettings(DEFAULTS, JSON.parse(localStorage.getItem(SETTINGS_KEY))); } catch { return mergeSettings(DEFAULTS, null); } }
|
|
553
|
+
function saveSettings() { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch { /* storage blocked */ } }
|
|
554
|
+
let settings = loadSettings();
|
|
555
|
+
const sysReduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
556
|
+
let reduceMotion = sysReduce || settings.reduceMotion;
|
|
557
|
+
|
|
348
558
|
function fx(targets, keyframes, options = {}) {
|
|
349
559
|
if (reduceMotion) options = { ...options, duration: 0, delay: 0 };
|
|
350
560
|
return animate(targets, keyframes, options);
|
|
@@ -361,7 +571,7 @@ let allGames = [];
|
|
|
361
571
|
let activeCategory = "All";
|
|
362
572
|
let searchTerm = "";
|
|
363
573
|
const BRAND = "#f01a36";
|
|
364
|
-
const PALETTE = ["#f01a36"
|
|
574
|
+
const PALETTE = ["#f01a36"];
|
|
365
575
|
let catColors = {};
|
|
366
576
|
let barShown = false;
|
|
367
577
|
|
|
@@ -395,7 +605,7 @@ function toast(message, tone = "default") {
|
|
|
395
605
|
const root = el("toast-root");
|
|
396
606
|
const node = document.createElement("div");
|
|
397
607
|
const toneClasses = tone === "error" ? "text-red-300" : "text-ink-50";
|
|
398
|
-
node.className = `glass glass-sheet relative rounded-full ${toneClasses} px-4 py-2.5 text-[14px] opacity-0`;
|
|
608
|
+
node.className = `glass glass-sheet pointer-events-auto relative rounded-full ${toneClasses} px-4 py-2.5 text-[14px] opacity-0`;
|
|
399
609
|
node.textContent = message;
|
|
400
610
|
root.appendChild(node);
|
|
401
611
|
pop(node, { y: 16, scale: 0.9, stiff: 420, damp: 24 });
|
|
@@ -455,6 +665,7 @@ let gridShown = false;
|
|
|
455
665
|
function renderGrid() {
|
|
456
666
|
const grid = el("grid");
|
|
457
667
|
const games = currentFiltered();
|
|
668
|
+
el("grid-status").textContent = allGames.length === 0 ? "No games yet" : games.length === 0 ? "No games match your search" : `${games.length} ${games.length === 1 ? "game" : "games"} shown`;
|
|
458
669
|
el("skeleton-grid").classList.add("hidden");
|
|
459
670
|
|
|
460
671
|
if (allGames.length === 0) {
|
|
@@ -525,6 +736,8 @@ async function loadGames() {
|
|
|
525
736
|
renderGrid();
|
|
526
737
|
} catch (err) {
|
|
527
738
|
console.error("Failed to load games:", err);
|
|
739
|
+
if (allGames.length) { toast("Couldn't refresh the game list", "error"); return; }
|
|
740
|
+
el("retry").classList.remove("hidden");
|
|
528
741
|
el("skeleton-grid").classList.add("hidden");
|
|
529
742
|
el("empty-title").textContent = "Couldn't load games";
|
|
530
743
|
el("empty-sub").textContent = err?.message
|
|
@@ -537,84 +750,117 @@ async function loadGames() {
|
|
|
537
750
|
loadGames();
|
|
538
751
|
|
|
539
752
|
/* --------------------------- search / filter --------------------------- */
|
|
753
|
+
const searchEl = el("search"), clearBtn = el("search-clear");
|
|
540
754
|
let searchDebounce;
|
|
541
|
-
|
|
755
|
+
function applySearch() { searchTerm = searchEl.value.trim().toLowerCase(); renderGrid(); }
|
|
756
|
+
searchEl.addEventListener("input", () => {
|
|
757
|
+
clearBtn.classList.toggle("hidden", !searchEl.value);
|
|
758
|
+
clearBtn.classList.toggle("grid", !!searchEl.value);
|
|
542
759
|
clearTimeout(searchDebounce);
|
|
543
|
-
searchDebounce = setTimeout(
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
760
|
+
searchDebounce = setTimeout(applySearch, 120);
|
|
761
|
+
});
|
|
762
|
+
clearBtn.addEventListener("click", () => {
|
|
763
|
+
searchEl.value = "";
|
|
764
|
+
clearBtn.classList.add("hidden");
|
|
765
|
+
clearBtn.classList.remove("grid");
|
|
766
|
+
applySearch();
|
|
767
|
+
searchEl.focus();
|
|
768
|
+
});
|
|
769
|
+
searchEl.addEventListener("keydown", (e) => { if (e.key === "Escape" && searchEl.value) clearBtn.click(); });
|
|
770
|
+
el("retry").addEventListener("click", () => {
|
|
771
|
+
el("retry").classList.add("hidden");
|
|
772
|
+
el("empty-state").classList.add("hidden");
|
|
773
|
+
el("empty-state").classList.remove("flex");
|
|
774
|
+
el("skeleton-grid").classList.remove("hidden");
|
|
775
|
+
loadGames();
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
/* ------------------------------- dialogs ------------------------------- */
|
|
779
|
+
const isHidden = (id) => el(id).classList.contains("hidden");
|
|
780
|
+
let opener = null;
|
|
781
|
+
function lockBg(on) { document.querySelectorAll("body > header, body > main, body > footer, body > #panic-btn").forEach((n) => n.toggleAttribute("inert", on)); }
|
|
782
|
+
function beginDialog() { opener = document.activeElement; lockBg(true); }
|
|
783
|
+
function endDialog() { lockBg(false); if (opener && document.contains(opener)) opener.focus(); opener = null; }
|
|
784
|
+
const focusSoon = (node) => requestAnimationFrame(() => node && node.focus());
|
|
785
|
+
|
|
786
|
+
document.addEventListener("keydown", (e) => {
|
|
787
|
+
if (e.key === "Escape") {
|
|
788
|
+
if (!isHidden("player-modal")) closePlayer();
|
|
789
|
+
else if (!isHidden("admin-login-modal")) closeAdminLogin();
|
|
790
|
+
else if (!isHidden("settings-modal")) closeSettings();
|
|
791
|
+
else if (!isHidden("ann-modal")) closeAnnouncements();
|
|
792
|
+
else if (!isHidden("admin-panel")) escapeAdmin();
|
|
793
|
+
} else if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey && allClosed()
|
|
794
|
+
&& !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || "")) {
|
|
795
|
+
e.preventDefault();
|
|
796
|
+
el("search").focus();
|
|
797
|
+
}
|
|
547
798
|
});
|
|
548
799
|
|
|
549
800
|
/* ------------------------------- player ------------------------------- */
|
|
550
801
|
function openPlayer(game) {
|
|
551
802
|
if (!game) return;
|
|
803
|
+
beginDialog();
|
|
552
804
|
el("player-title").textContent = game.name;
|
|
805
|
+
el("player-frame").title = game.name;
|
|
553
806
|
el("player-icon").src = iconSrcFor(game) || monogramSrc(game.name);
|
|
554
807
|
el("player-newtab").href = game.url;
|
|
555
|
-
el("player-frame").src = game.url;
|
|
556
808
|
el("player-loading").style.display = "grid";
|
|
809
|
+
el("player-frame").src = game.url;
|
|
557
810
|
el("player-modal").classList.remove("hidden");
|
|
558
811
|
document.documentElement.style.overflow = "hidden";
|
|
559
812
|
pop("#player-panel", { y: 20, scale: 0.94, stiff: 260, damp: 26 });
|
|
813
|
+
focusSoon(el("player-close"));
|
|
560
814
|
}
|
|
561
815
|
function closePlayer() {
|
|
562
816
|
fx("#player-panel", { opacity: [1, 0], scale: [1, 0.98] }, { duration: 0.18, ease: "easeIn" }).finished.then(() => {
|
|
563
817
|
el("player-modal").classList.add("hidden");
|
|
564
|
-
el("player-frame").src = "";
|
|
818
|
+
el("player-frame").src = "about:blank";
|
|
565
819
|
document.documentElement.style.overflow = "";
|
|
820
|
+
endDialog();
|
|
566
821
|
});
|
|
567
822
|
}
|
|
568
823
|
el("player-close").addEventListener("click", closePlayer);
|
|
569
824
|
document.querySelector("[data-player-close]").addEventListener("click", closePlayer);
|
|
570
|
-
|
|
571
|
-
if (e.key === "Escape") {
|
|
572
|
-
if (!el("player-modal").classList.contains("hidden")) closePlayer();
|
|
573
|
-
if (!el("admin-panel").classList.contains("hidden")) closeAdminPanel();
|
|
574
|
-
if (!el("admin-login-modal").classList.contains("hidden")) closeAdminLogin();
|
|
575
|
-
}
|
|
576
|
-
});
|
|
825
|
+
el("player-fs").addEventListener("click", () => { const f = el("player-frame"); (f.requestFullscreen || f.webkitRequestFullscreen)?.call(f); });
|
|
577
826
|
|
|
578
|
-
/*
|
|
827
|
+
/* ------------------------------ admin: PIN ------------------------------ */
|
|
828
|
+
function isUnlocked() { return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1"; }
|
|
579
829
|
function openAdminLogin() {
|
|
830
|
+
beginDialog();
|
|
580
831
|
el("admin-login-modal").classList.remove("hidden");
|
|
581
832
|
pop("#admin-login-form", { y: 16, scale: 0.92, stiff: 380, damp: 24 });
|
|
582
833
|
resetPinBoxes();
|
|
583
|
-
pinInputs[0]
|
|
834
|
+
focusSoon(pinInputs[0]);
|
|
584
835
|
}
|
|
585
836
|
function closeAdminLogin() {
|
|
586
837
|
el("admin-login-modal").classList.add("hidden");
|
|
587
838
|
el("admin-login-error").classList.add("hidden");
|
|
839
|
+
endDialog();
|
|
588
840
|
}
|
|
589
|
-
function openAdminPanel() {
|
|
590
|
-
el("admin-panel").classList.remove("hidden");
|
|
591
|
-
pop("#admin-panel-card", { y: 24, scale: 0.97, stiff: 300, damp: 28 });
|
|
592
|
-
renderAdminList();
|
|
593
|
-
}
|
|
594
|
-
function closeAdminPanel() { el("admin-panel").classList.add("hidden"); }
|
|
595
|
-
function isUnlocked() { return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1"; }
|
|
596
|
-
|
|
597
841
|
el("admin-trigger").addEventListener("click", () => { if (isUnlocked()) openAdminPanel(); else openAdminLogin(); });
|
|
598
|
-
document.
|
|
599
|
-
document.querySelector("[data-admin-panel-close]").addEventListener("click", closeAdminPanel);
|
|
842
|
+
document.querySelectorAll("[data-admin-login-close]").forEach((n) => n.addEventListener("click", closeAdminLogin));
|
|
600
843
|
|
|
601
844
|
const pinBoxesEl = el("pin-boxes");
|
|
602
845
|
pinBoxesEl.innerHTML = Array.from({ length: 6 })
|
|
603
|
-
.map((_, i) => `<input data-pin-idx="${i}" type="password" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit ${i + 1}"
|
|
846
|
+
.map((_, i) => `<input data-pin-idx="${i}" type="password" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit ${i + 1} of 6"
|
|
604
847
|
class="h-12 w-full rounded-[12px] bg-white/[0.07] text-center text-[20px] tabular-nums outline-none ring-1 ring-inset ring-white/10 transition-shadow focus:ring-2 focus:ring-red-500" />`)
|
|
605
848
|
.join("");
|
|
606
849
|
const pinInputs = Array.from(pinBoxesEl.querySelectorAll("[data-pin-idx]"));
|
|
607
|
-
|
|
608
850
|
function resetPinBoxes() { pinInputs.forEach((i) => (i.value = "")); el("admin-pin").value = ""; }
|
|
609
851
|
function syncPinValue() { el("admin-pin").value = pinInputs.map((i) => i.value).join(""); }
|
|
852
|
+
function submitIfFull() { if (el("admin-pin").value.length === 6) el("admin-login-form").requestSubmit(); }
|
|
610
853
|
pinInputs.forEach((input, idx) => {
|
|
611
854
|
input.addEventListener("input", () => {
|
|
612
855
|
input.value = input.value.replace(/[^0-9]/g, "").slice(0, 1);
|
|
613
856
|
syncPinValue();
|
|
614
857
|
if (input.value && idx < pinInputs.length - 1) pinInputs[idx + 1].focus();
|
|
858
|
+
submitIfFull();
|
|
615
859
|
});
|
|
616
860
|
input.addEventListener("keydown", (e) => {
|
|
617
861
|
if (e.key === "Backspace" && !input.value && idx > 0) pinInputs[idx - 1].focus();
|
|
862
|
+
if (e.key === "ArrowLeft" && idx > 0) pinInputs[idx - 1].focus();
|
|
863
|
+
if (e.key === "ArrowRight" && idx < pinInputs.length - 1) pinInputs[idx + 1].focus();
|
|
618
864
|
});
|
|
619
865
|
input.addEventListener("paste", (e) => {
|
|
620
866
|
e.preventDefault();
|
|
@@ -622,140 +868,1007 @@ pinInputs.forEach((input, idx) => {
|
|
|
622
868
|
digits.forEach((d, i) => pinInputs[i] && (pinInputs[i].value = d));
|
|
623
869
|
syncPinValue();
|
|
624
870
|
pinInputs[Math.min(digits.length, pinInputs.length - 1)]?.focus();
|
|
871
|
+
submitIfFull();
|
|
625
872
|
});
|
|
626
873
|
});
|
|
627
|
-
|
|
628
874
|
el("admin-login-form").addEventListener("submit", (e) => {
|
|
629
875
|
e.preventDefault();
|
|
630
876
|
const errBox = el("admin-login-error");
|
|
631
877
|
syncPinValue();
|
|
632
878
|
if (el("admin-pin").value === ADMIN_PIN) {
|
|
633
879
|
sessionStorage.setItem(ADMIN_SESSION_KEY, "1");
|
|
634
|
-
errBox.classList.add("hidden");
|
|
635
880
|
closeAdminLogin();
|
|
636
881
|
openAdminPanel();
|
|
637
882
|
} else {
|
|
638
|
-
errBox.textContent = "
|
|
639
|
-
if (!reduceMotion) animate("#pin-boxes", { x: [0, -10, 10, -8, 8, -4, 0] }, { duration: 0.45 });
|
|
883
|
+
errBox.textContent = "That PIN isn't right. Try again.";
|
|
640
884
|
errBox.classList.remove("hidden");
|
|
885
|
+
if (!reduceMotion) animate("#pin-boxes", { x: [0, -10, 10, -8, 8, -4, 0] }, { duration: 0.45 });
|
|
641
886
|
resetPinBoxes();
|
|
642
887
|
pinInputs[0]?.focus();
|
|
643
888
|
}
|
|
644
889
|
});
|
|
645
890
|
|
|
891
|
+
/* ---------------------------- admin: manage games ---------------------------- */
|
|
892
|
+
let adminRows = [], adminState = "loading", adminError = "";
|
|
893
|
+
let editingId = null, confirmingId = null, adminFilter = "", previewUrl = null;
|
|
894
|
+
|
|
895
|
+
const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0);
|
|
896
|
+
const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return u; } };
|
|
897
|
+
const sr = (t) => `<span class="sr-only">${escapeHtml(t)}</span>`;
|
|
898
|
+
const rowIcon = (g) => iconSrcFor(g) || monogramSrc(g.name);
|
|
899
|
+
const adminSay = (msg) => { const n = el("admin-live"); n.textContent = ""; setTimeout(() => (n.textContent = msg), 60); };
|
|
900
|
+
const dbArgs = { databaseId: CONFIG.databaseId, tableId: CONFIG.gamesTableId };
|
|
901
|
+
|
|
902
|
+
function openAdminPanel() {
|
|
903
|
+
beginDialog();
|
|
904
|
+
editingId = null; confirmingId = null; adminFilter = ""; el("admin-filter").value = "";
|
|
905
|
+
annEditing = null; annConfirming = null; annLoaded = false; dirtyWarned = false; dirty.games = dirty.ann = false;
|
|
906
|
+
selectAdminTab("atab-games");
|
|
907
|
+
el("admin-panel").classList.remove("hidden");
|
|
908
|
+
pop("#admin-panel-card", { y: 24, scale: 0.97, stiff: 300, damp: 28 });
|
|
909
|
+
focusSoon(el("admin-title"));
|
|
910
|
+
loadAdmin();
|
|
911
|
+
}
|
|
912
|
+
function closeAdminPanel() { el("admin-panel").classList.add("hidden"); endDialog(); }
|
|
913
|
+
el("admin-close").addEventListener("click", closeAdminPanel);
|
|
646
914
|
el("admin-logout").addEventListener("click", () => {
|
|
647
915
|
sessionStorage.removeItem(ADMIN_SESSION_KEY);
|
|
648
916
|
closeAdminPanel();
|
|
649
|
-
toast("
|
|
917
|
+
toast("Admin locked");
|
|
650
918
|
});
|
|
651
919
|
|
|
652
|
-
async function
|
|
653
|
-
|
|
654
|
-
|
|
920
|
+
async function loadAdmin() {
|
|
921
|
+
adminState = "loading";
|
|
922
|
+
renderAdmin();
|
|
655
923
|
try {
|
|
656
|
-
const res = await tablesDB.listRows({
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
924
|
+
const res = await tablesDB.listRows({ ...dbArgs, queries: [Query.limit(200), Query.orderAsc("order")] });
|
|
925
|
+
adminRows = res.rows.sort(byOrder);
|
|
926
|
+
adminState = "ready";
|
|
927
|
+
} catch (err) {
|
|
928
|
+
adminState = "error";
|
|
929
|
+
adminError = err?.message || "Couldn't load your games.";
|
|
930
|
+
}
|
|
931
|
+
renderAdmin();
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function formHtml(g) {
|
|
935
|
+
const isEdit = !!g;
|
|
936
|
+
const v = (x) => escapeHtml(x ?? "");
|
|
937
|
+
const cats = [...new Set(adminRows.map((r) => r.category).filter(Boolean))];
|
|
938
|
+
const hasIcon = isEdit && (g.iconFileId || g.iconUrl);
|
|
939
|
+
const base = escapeHtml(isEdit ? rowIcon(g) : monogramSrc("+"));
|
|
940
|
+
return `<form id="game-form" novalidate aria-labelledby="game-form-title" class="glass-lite mb-4 rounded-2xl p-4">
|
|
941
|
+
<h3 id="game-form-title" class="text-[16px] font-semibold">${isEdit ? `Edit ${v(g.name)}` : "Add a game"}</h3>
|
|
942
|
+
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
|
943
|
+
<div>
|
|
944
|
+
<label for="gf-name" class="text-[13px] font-medium text-ink-200">Name</label>
|
|
945
|
+
<input id="gf-name" required autocomplete="off" aria-describedby="gf-name-err" value="${v(g?.name)}" class="field mt-1.5" />
|
|
946
|
+
<p id="gf-name-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
947
|
+
</div>
|
|
948
|
+
<div>
|
|
949
|
+
<label for="gf-category" class="text-[13px] font-medium text-ink-200">Category <span class="font-normal text-ink-300">(optional)</span></label>
|
|
950
|
+
<input id="gf-category" list="category-options" autocomplete="off" value="${v(g?.category)}" class="field mt-1.5" />
|
|
951
|
+
<datalist id="category-options">${cats.map((c) => `<option value="${v(c)}"></option>`).join("")}</datalist>
|
|
952
|
+
</div>
|
|
953
|
+
<div class="sm:col-span-2">
|
|
954
|
+
<label for="gf-url" class="text-[13px] font-medium text-ink-200">Game link</label>
|
|
955
|
+
<input id="gf-url" type="url" inputmode="url" required autocomplete="off" spellcheck="false" placeholder="https://" aria-describedby="gf-url-hint gf-url-err" value="${v(g?.url)}" class="field mt-1.5" />
|
|
956
|
+
<p id="gf-url-hint" class="mt-1.5 text-[13px] text-ink-300">The page that opens when someone taps the game.</p>
|
|
957
|
+
<p id="gf-url-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
958
|
+
</div>
|
|
959
|
+
</div>
|
|
960
|
+
<fieldset class="mt-4 min-w-0">
|
|
961
|
+
<legend class="text-[13px] font-medium text-ink-200">Icon</legend>
|
|
962
|
+
<div class="mt-2 flex items-start gap-4">
|
|
963
|
+
<img id="gf-preview" src="${base}" data-base="${base}" alt="Icon preview" class="tile-img h-16 w-16 shrink-0 rounded-[22.37%] object-cover" onerror="this.onerror=null;this.src=this.dataset.base" />
|
|
964
|
+
<div class="grid min-w-0 flex-1 gap-3">
|
|
965
|
+
<div>
|
|
966
|
+
<label for="gf-file" class="text-[13px] text-ink-300">Upload an image</label>
|
|
967
|
+
<input id="gf-file" type="file" accept="image/*" aria-describedby="gf-file-err" class="mt-1.5 w-full text-[13px] text-ink-300 file:mr-3 file:rounded-full file:border-0 file:bg-white/10 file:px-3.5 file:py-1.5 file:text-[13px] file:font-medium file:text-ink-50" />
|
|
968
|
+
<p id="gf-file-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
969
|
+
</div>
|
|
970
|
+
<div>
|
|
971
|
+
<label for="gf-iconurl" class="text-[13px] text-ink-300">…or paste an image link</label>
|
|
972
|
+
<input id="gf-iconurl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://" aria-describedby="gf-iconurl-err" value="${v(g?.iconUrl)}" class="field mt-1.5" />
|
|
973
|
+
<p id="gf-iconurl-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
974
|
+
</div>
|
|
975
|
+
${hasIcon ? `<label class="flex items-center gap-2 text-[13px] text-ink-200"><input id="gf-remove" type="checkbox" class="h-4 w-4 accent-red-500" /> Remove the current icon (shows initials instead)</label>` : ""}
|
|
976
|
+
</div>
|
|
977
|
+
</div>
|
|
978
|
+
</fieldset>
|
|
979
|
+
<label class="mt-4 flex cursor-pointer items-center gap-3 text-[14px]"><input id="gf-enabled" type="checkbox" role="switch" class="switch" ${g?.enabled === false ? "" : "checked"} /> Show on the site</label>
|
|
980
|
+
<div class="mt-5 flex flex-wrap items-center gap-2">
|
|
981
|
+
<button type="submit" id="gf-submit" class="btn btn-primary">${isEdit ? "Save changes" : "Add game"}</button>
|
|
982
|
+
<button type="button" data-act="cancel-form" class="btn btn-ghost">Cancel</button>
|
|
983
|
+
</div>
|
|
984
|
+
</form>`;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function rowHtml(g, idx, canMove) {
|
|
988
|
+
if (editingId === g.$id) return `<li data-row="${g.$id}">${formHtml(g)}</li>`;
|
|
989
|
+
const n = escapeHtml(g.name);
|
|
990
|
+
const off = g.enabled === false;
|
|
991
|
+
const dis = editingId ? "disabled" : "";
|
|
992
|
+
const meta = [g.category, hostOf(g.url)].filter(Boolean).map(escapeHtml).join(" · ");
|
|
993
|
+
const arrow = (dir, d, label, disabled) => `<button type="button" data-act="${dir}" data-id="${g.$id}" aria-label="${label} ${n}" ${disabled || dis ? "disabled" : ""} class="glass-lite icon-btn"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg></button>`;
|
|
994
|
+
const actions = confirmingId === g.$id
|
|
995
|
+
? `<div role="group" aria-label="Confirm delete" class="mt-3 rounded-xl bg-red-500/10 p-3 ring-1 ring-red-500/30">
|
|
996
|
+
<p class="text-[14px]">Delete <strong>${n}</strong>? This can't be undone.</p>
|
|
997
|
+
<div class="mt-3 flex gap-2">
|
|
998
|
+
<button type="button" id="cancel-delete" data-act="cancel-delete" data-id="${g.$id}" class="btn btn-ghost">Cancel</button>
|
|
999
|
+
<button type="button" data-act="confirm-delete" data-id="${g.$id}" class="btn btn-danger">Delete</button>
|
|
673
1000
|
</div>
|
|
674
|
-
<input data-field="category" data-id="${g.$id}" value="${escapeHtml(g.category || "")}" placeholder="Category" aria-label="Category" class="field hidden !w-28 sm:block" />
|
|
675
|
-
<label class="hidden shrink-0 items-center gap-2 text-[13px] text-ink-300 sm:flex">
|
|
676
|
-
<input type="checkbox" data-field="enabled" data-id="${g.$id}" ${g.enabled === false ? "" : "checked"} class="h-4 w-4 accent-red-500" /> On
|
|
677
|
-
</label>
|
|
678
|
-
<button data-delete="${g.$id}" title="Delete" aria-label="Delete" class="glass-lite icon-btn hover:!text-red-400">
|
|
679
|
-
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2L4 6"/></svg>
|
|
680
|
-
</button>
|
|
681
1001
|
</div>`
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1002
|
+
: `<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
1003
|
+
${canMove ? arrow("up", "m6 15 6-6 6 6", "Move up:", idx === 0) + arrow("down", "m6 9 6 6 6-6", "Move down:", idx === adminRows.length - 1) : ""}
|
|
1004
|
+
<button type="button" data-act="edit" data-id="${g.$id}" ${dis} class="btn btn-ghost">Edit${sr(" " + g.name)}</button>
|
|
1005
|
+
<button type="button" data-act="delete" data-id="${g.$id}" ${dis} class="btn btn-ghost ml-auto hover:!text-red-300">Delete${sr(" " + g.name)}</button>
|
|
1006
|
+
</div>`;
|
|
1007
|
+
return `<li data-row="${g.$id}" class="glass-lite rounded-2xl p-3">
|
|
1008
|
+
<div class="flex items-center gap-3">
|
|
1009
|
+
<img src="${escapeHtml(rowIcon(g))}" alt="" class="tile-img h-12 w-12 shrink-0 rounded-[22.37%] object-cover ${off ? "opacity-50" : ""}" onerror="this.onerror=null;this.src='${monogramSrc(g.name)}'" />
|
|
1010
|
+
<div class="min-w-0 flex-1">
|
|
1011
|
+
<p class="truncate text-[15px] font-medium">${n}${off ? '<span class="tag">Hidden</span>' : ""}</p>
|
|
1012
|
+
<p class="truncate text-[13px] text-ink-300">${meta}</p>
|
|
1013
|
+
</div>
|
|
1014
|
+
<label class="flex shrink-0 cursor-pointer items-center gap-2 text-[13px] text-ink-200">
|
|
1015
|
+
<input type="checkbox" role="switch" class="switch" data-toggle="${g.$id}" ${off ? "" : "checked"} ${dis} />
|
|
1016
|
+
<span>Visible${sr(" on site: " + g.name)}</span>
|
|
1017
|
+
</label>
|
|
1018
|
+
</div>
|
|
1019
|
+
${actions}
|
|
1020
|
+
</li>`;
|
|
1021
|
+
}
|
|
701
1022
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
716
|
-
|
|
1023
|
+
function renderAdmin(focusSels = []) {
|
|
1024
|
+
dirty.games = false;
|
|
1025
|
+
el("admin-form-slot").innerHTML = editingId === "new" ? formHtml(null) : "";
|
|
1026
|
+
const list = el("admin-list"), sum = gamesSum;
|
|
1027
|
+
el("admin-add").disabled = !!editingId;
|
|
1028
|
+
el("admin-filter").disabled = !!editingId;
|
|
1029
|
+
if (adminState === "loading") { sum.textContent = ""; list.innerHTML = `<li class="py-8 text-center text-[14px] text-ink-300">Loading games…</li>`; return; }
|
|
1030
|
+
if (adminState === "error") {
|
|
1031
|
+
sum.textContent = "";
|
|
1032
|
+
list.innerHTML = `<li class="py-8 text-center"><p class="text-[14px] text-red-300">${escapeHtml(adminError)}</p><button type="button" data-act="retry" class="btn btn-ghost mt-3">Try again</button></li>`;
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const q = adminFilter;
|
|
1036
|
+
const rows = q ? adminRows.filter((g) => `${g.name} ${g.category || ""}`.toLowerCase().includes(q)) : adminRows;
|
|
1037
|
+
const visible = adminRows.filter((g) => g.enabled !== false).length;
|
|
1038
|
+
sum.textContent = q ? `Showing ${rows.length} of ${adminRows.length}` : `${adminRows.length} ${adminRows.length === 1 ? "game" : "games"} · ${visible} visible`;
|
|
1039
|
+
list.innerHTML = rows.length
|
|
1040
|
+
? rows.map((g) => rowHtml(g, adminRows.indexOf(g), !q)).join("")
|
|
1041
|
+
: `<li class="py-8 text-center text-[14px] text-ink-300">${adminRows.length ? `No games match “${escapeHtml(adminFilter)}”.` : "No games yet. Choose “Add game” to add your first one."}</li>`;
|
|
1042
|
+
if (editingId) { const n = el("gf-name"); n.focus(); n.scrollIntoView({ block: "nearest" }); return; }
|
|
1043
|
+
for (const sel of focusSels) {
|
|
1044
|
+
const t = el("admin-panel-card").querySelector(sel);
|
|
1045
|
+
if (t && !t.disabled) { t.focus(); break; }
|
|
717
1046
|
}
|
|
718
1047
|
}
|
|
719
1048
|
|
|
720
|
-
|
|
1049
|
+
function cancelForm() {
|
|
1050
|
+
const id = editingId;
|
|
1051
|
+
editingId = null;
|
|
1052
|
+
renderAdmin([id && id !== "new" ? `[data-act="edit"][data-id="${id}"]` : "#admin-add"]);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
el("admin-add").addEventListener("click", () => { editingId = "new"; confirmingId = null; renderAdmin(); });
|
|
1056
|
+
el("admin-filter").addEventListener("input", (e) => { adminFilter = e.target.value.trim().toLowerCase(); renderAdmin(); });
|
|
1057
|
+
|
|
1058
|
+
el("admin-body").addEventListener("click", (e) => {
|
|
1059
|
+
const b = e.target.closest("[data-act]");
|
|
1060
|
+
if (!b || b.disabled) return;
|
|
1061
|
+
const { act, id } = b.dataset;
|
|
1062
|
+
if (act === "retry") loadAdmin();
|
|
1063
|
+
else if (act === "edit") { editingId = id; confirmingId = null; renderAdmin(); }
|
|
1064
|
+
else if (act === "cancel-form") cancelForm();
|
|
1065
|
+
else if (act === "delete") { confirmingId = id; renderAdmin(["#cancel-delete"]); }
|
|
1066
|
+
else if (act === "cancel-delete") { confirmingId = null; renderAdmin([`[data-act="delete"][data-id="${id}"]`]); }
|
|
1067
|
+
else if (act === "confirm-delete") { b.disabled = true; b.textContent = "Deleting…"; deleteGame(id); }
|
|
1068
|
+
else if (act === "up") moveGame(id, -1);
|
|
1069
|
+
else if (act === "down") moveGame(id, 1);
|
|
1070
|
+
});
|
|
1071
|
+
el("admin-body").addEventListener("change", (e) => {
|
|
1072
|
+
const t = e.target;
|
|
1073
|
+
if (t.matches("[data-toggle]")) toggleGame(t.dataset.toggle, t.checked);
|
|
1074
|
+
else if (t.id === "gf-file" || t.id === "gf-remove") updatePreview();
|
|
1075
|
+
});
|
|
1076
|
+
el("admin-body").addEventListener("input", (e) => {
|
|
1077
|
+
const t = e.target;
|
|
1078
|
+
if (t.getAttribute?.("aria-invalid")) setErr(t.id, "");
|
|
1079
|
+
if (t.id === "gf-iconurl") updatePreview();
|
|
1080
|
+
});
|
|
1081
|
+
el("admin-body").addEventListener("submit", (e) => { if (e.target.id === "game-form") submitGameForm(e); });
|
|
1082
|
+
|
|
1083
|
+
function updatePreview() {
|
|
1084
|
+
const img = el("gf-preview"), file = el("gf-file").files[0], url = el("gf-iconurl").value.trim();
|
|
1085
|
+
if (previewUrl) { URL.revokeObjectURL(previewUrl); previewUrl = null; }
|
|
1086
|
+
if (el("gf-remove")?.checked) img.src = monogramSrc(el("gf-name").value || "?");
|
|
1087
|
+
else if (file && file.type.startsWith("image/")) img.src = previewUrl = URL.createObjectURL(file);
|
|
1088
|
+
else if (isHttp(url)) img.src = url;
|
|
1089
|
+
else img.src = img.dataset.base;
|
|
1090
|
+
}
|
|
1091
|
+
function setErr(id, msg) {
|
|
1092
|
+
const inp = el(id), p = el(id + "-err");
|
|
1093
|
+
p.textContent = msg || "";
|
|
1094
|
+
p.classList.toggle("hidden", !msg);
|
|
1095
|
+
if (msg) inp.setAttribute("aria-invalid", "true"); else inp.removeAttribute("aria-invalid");
|
|
1096
|
+
}
|
|
1097
|
+
function validateGameForm() {
|
|
1098
|
+
const name = el("gf-name").value.trim(), url = el("gf-url").value.trim(), iu = el("gf-iconurl").value.trim(), f = el("gf-file").files[0];
|
|
1099
|
+
const checks = [
|
|
1100
|
+
["gf-name", name ? "" : "Enter a name."],
|
|
1101
|
+
["gf-url", !url ? "Enter the game link." : isHttp(url) ? "" : "Enter a full link that starts with https://"],
|
|
1102
|
+
["gf-iconurl", !iu || isHttp(iu) ? "" : "Enter a full link that starts with https://"],
|
|
1103
|
+
["gf-file", !f || f.type.startsWith("image/") ? "" : "Choose an image file."],
|
|
1104
|
+
];
|
|
1105
|
+
let first = null;
|
|
1106
|
+
checks.forEach(([id, msg]) => { setErr(id, msg); if (msg && !first) first = id; });
|
|
1107
|
+
if (first) el(first).focus();
|
|
1108
|
+
return !first;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function flashRow(id) {
|
|
1112
|
+
const li = el("admin-list").querySelector(`[data-row="${id}"]`);
|
|
1113
|
+
if (!li) return;
|
|
1114
|
+
li.scrollIntoView({ block: "nearest" });
|
|
1115
|
+
if (!reduceMotion) animate(li, { backgroundColor: ["rgba(240,26,54,0.4)", "rgba(255,255,255,0.07)"] }, { duration: 1.2 });
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async function submitGameForm(e) {
|
|
721
1119
|
e.preventDefault();
|
|
722
|
-
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
1120
|
+
if (!validateGameForm()) return;
|
|
1121
|
+
const isEdit = editingId && editingId !== "new";
|
|
1122
|
+
const existing = isEdit ? adminRows.find((r) => r.$id === editingId) : null;
|
|
1123
|
+
const name = el("gf-name").value.trim(), url = el("gf-url").value.trim(), category = el("gf-category").value.trim();
|
|
1124
|
+
const iconField = el("gf-iconurl").value.trim(), file = el("gf-file").files[0], enabled = el("gf-enabled").checked, remove = !!el("gf-remove")?.checked;
|
|
1125
|
+
const btn = el("gf-submit"), label = btn.textContent;
|
|
1126
|
+
btn.disabled = true; btn.textContent = "Saving…";
|
|
1127
|
+
let uploadedId = null, oldFile = null;
|
|
726
1128
|
try {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1129
|
+
let iconFileId = existing?.iconFileId || "", iconUrl = existing?.iconUrl || "";
|
|
1130
|
+
if (remove) { oldFile = iconFileId; iconFileId = ""; iconUrl = ""; }
|
|
1131
|
+
else if (file) {
|
|
1132
|
+
uploadedId = (await storage.createFile({ bucketId: CONFIG.iconsBucketId, fileId: ID.unique(), file })).$id;
|
|
1133
|
+
oldFile = iconFileId; iconFileId = uploadedId; iconUrl = "";
|
|
1134
|
+
} else if (iconField && iconField !== iconUrl) { oldFile = iconFileId; iconFileId = ""; iconUrl = iconField; }
|
|
1135
|
+
else if (!iconField && iconUrl && !iconFileId) iconUrl = "";
|
|
1136
|
+
const data = { name, url, category, iconFileId, iconUrl, enabled };
|
|
1137
|
+
let row;
|
|
1138
|
+
if (isEdit) {
|
|
1139
|
+
row = await tablesDB.updateRow({ ...dbArgs, rowId: editingId, data });
|
|
1140
|
+
adminRows = adminRows.map((r) => (r.$id === row.$id ? row : r));
|
|
1141
|
+
} else {
|
|
1142
|
+
data.order = adminRows.reduce((m, r) => Math.max(m, r.order ?? -1), -1) + 1;
|
|
1143
|
+
row = await tablesDB.createRow({ ...dbArgs, rowId: ID.unique(), data });
|
|
1144
|
+
adminRows = [...adminRows, row].sort(byOrder);
|
|
737
1145
|
}
|
|
1146
|
+
if (oldFile) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: oldFile }).catch(() => {});
|
|
1147
|
+
editingId = null;
|
|
1148
|
+
renderAdmin([`[data-act="edit"][data-id="${row.$id}"]`]);
|
|
1149
|
+
flashRow(row.$id);
|
|
1150
|
+
toast(isEdit ? `Saved ${name}` : `Added ${name}`);
|
|
1151
|
+
loadGames();
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
if (uploadedId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: uploadedId }).catch(() => {});
|
|
1154
|
+
btn.disabled = false; btn.textContent = label;
|
|
1155
|
+
toast(err?.message || "Couldn't save. Check your connection and try again.", "error");
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
738
1158
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1159
|
+
async function toggleGame(id, on) {
|
|
1160
|
+
const row = adminRows.find((r) => r.$id === id);
|
|
1161
|
+
if (!row) return;
|
|
1162
|
+
const prev = row.enabled !== false;
|
|
1163
|
+
row.enabled = on;
|
|
1164
|
+
renderAdmin([`[data-toggle="${id}"]`]);
|
|
1165
|
+
try {
|
|
1166
|
+
await tablesDB.updateRow({ ...dbArgs, rowId: id, data: { enabled: on } });
|
|
1167
|
+
adminSay(`${row.name} is now ${on ? "visible" : "hidden"}`);
|
|
1168
|
+
loadGames();
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
row.enabled = prev;
|
|
1171
|
+
renderAdmin([`[data-toggle="${id}"]`]);
|
|
1172
|
+
toast(err?.message || "Couldn't update visibility", "error");
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function moveGame(id, dir) {
|
|
1177
|
+
const list = [...adminRows].sort(byOrder);
|
|
1178
|
+
const i = list.findIndex((r) => r.$id === id), j = i + dir;
|
|
1179
|
+
if (i < 0 || j < 0 || j >= list.length) return;
|
|
1180
|
+
const prev = list.map((r) => ({ ...r }));
|
|
1181
|
+
[list[i], list[j]] = [list[j], list[i]];
|
|
1182
|
+
const changes = [];
|
|
1183
|
+
list.forEach((r, k) => { if (r.order !== k) { r.order = k; changes.push(r); } });
|
|
1184
|
+
adminRows = list;
|
|
1185
|
+
const name = list[j].name;
|
|
1186
|
+
renderAdmin([`[data-act="${dir < 0 ? "up" : "down"}"][data-id="${id}"]`, `[data-act="${dir < 0 ? "down" : "up"}"][data-id="${id}"]`]);
|
|
1187
|
+
adminSay(`${name} moved to position ${j + 1} of ${list.length}`);
|
|
1188
|
+
try {
|
|
1189
|
+
await Promise.all(changes.map((r) => tablesDB.updateRow({ ...dbArgs, rowId: r.$id, data: { order: r.order } })));
|
|
1190
|
+
loadGames();
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
adminRows = prev;
|
|
1193
|
+
renderAdmin();
|
|
1194
|
+
toast(err?.message || "Couldn't change the order", "error");
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async function deleteGame(id) {
|
|
1199
|
+
const row = adminRows.find((r) => r.$id === id);
|
|
1200
|
+
if (!row) return;
|
|
1201
|
+
try {
|
|
1202
|
+
await tablesDB.deleteRow({ ...dbArgs, rowId: id });
|
|
1203
|
+
if (row.iconFileId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: row.iconFileId }).catch(() => {});
|
|
1204
|
+
adminRows = adminRows.filter((r) => r.$id !== id);
|
|
1205
|
+
confirmingId = null;
|
|
1206
|
+
renderAdmin(["#admin-add"]);
|
|
1207
|
+
toast(`Deleted ${row.name}`);
|
|
1208
|
+
loadGames();
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
renderAdmin(["#cancel-delete"]);
|
|
1211
|
+
toast(err?.message || "Couldn't delete. Try again.", "error");
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/* ============================ dialogs: shared helpers ============================ */
|
|
1216
|
+
const allClosed = () => ["player-modal", "admin-login-modal", "admin-panel", "settings-modal", "ann-modal"].every(isHidden);
|
|
1217
|
+
function openModal(id, card, focusId) {
|
|
1218
|
+
beginDialog();
|
|
1219
|
+
el(id).classList.remove("hidden");
|
|
1220
|
+
pop("#" + card, { y: 18, scale: 0.95, stiff: 320, damp: 26 });
|
|
1221
|
+
focusSoon(el(focusId));
|
|
1222
|
+
}
|
|
1223
|
+
function closeModal(id) { el(id).classList.add("hidden"); endDialog(); }
|
|
1224
|
+
|
|
1225
|
+
// Click (or tap) on the dimmed area outside a dialog closes it. Both the press and the release must land on the backdrop,
|
|
1226
|
+
// so dragging a text selection out of a dialog never closes it.
|
|
1227
|
+
let pressedEl = null;
|
|
1228
|
+
document.addEventListener("pointerdown", (e) => { pressedEl = e.target; }, true);
|
|
1229
|
+
const BACKDROPS = { login: () => closeAdminLogin(), admin: () => tryCloseAdmin(), settings: () => closeSettings(), ann: () => closeAnnouncements() };
|
|
1230
|
+
document.querySelectorAll("[data-backdrop]").forEach((b) =>
|
|
1231
|
+
b.addEventListener("click", (e) => { if (e.target === b && pressedEl === b) BACKDROPS[b.dataset.backdrop](); })
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
/* ============================ tabs (settings + admin) ============================ */
|
|
1235
|
+
function wireTabs(list, onSelect) {
|
|
1236
|
+
const tabs = [...list.querySelectorAll('[role="tab"]')];
|
|
1237
|
+
const activate = (t, focus) => {
|
|
1238
|
+
tabs.forEach((x) => {
|
|
1239
|
+
const on = x === t;
|
|
1240
|
+
x.setAttribute("aria-selected", String(on));
|
|
1241
|
+
x.tabIndex = on ? 0 : -1;
|
|
1242
|
+
document.getElementById(x.getAttribute("aria-controls")).hidden = !on;
|
|
744
1243
|
});
|
|
1244
|
+
if (focus) t.focus();
|
|
1245
|
+
onSelect?.(t);
|
|
1246
|
+
};
|
|
1247
|
+
tabs.forEach((t) => t.addEventListener("click", () => activate(t)));
|
|
1248
|
+
list.addEventListener("keydown", (e) => {
|
|
1249
|
+
const i = tabs.indexOf(document.activeElement);
|
|
1250
|
+
if (i < 0) return;
|
|
1251
|
+
let n = -1;
|
|
1252
|
+
if (e.key === "ArrowRight") n = (i + 1) % tabs.length;
|
|
1253
|
+
else if (e.key === "ArrowLeft") n = (i - 1 + tabs.length) % tabs.length;
|
|
1254
|
+
else if (e.key === "Home") n = 0;
|
|
1255
|
+
else if (e.key === "End") n = tabs.length - 1;
|
|
1256
|
+
if (n >= 0) { e.preventDefault(); activate(tabs[n], true); }
|
|
1257
|
+
});
|
|
1258
|
+
return (id) => activate(tabs.find((t) => t.id === id));
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/* ============================ admin: tabs, unsaved-changes guard ============================ */
|
|
1262
|
+
let adminTab = "games", annLoaded = false, annEditing = null, annConfirming = null, dirtyWarned = false;
|
|
1263
|
+
const dirty = { games: false, ann: false };
|
|
1264
|
+
const adminSummaries = { games: "", ann: "" };
|
|
1265
|
+
function setSummary(tab, text) { adminSummaries[tab] = text; if (tab === adminTab) el("admin-summary").textContent = text; }
|
|
1266
|
+
const gamesSum = { set textContent(v) { setSummary("games", v); } };
|
|
1267
|
+
const selectAdminTab = wireTabs(el("admin-tabs"), (t) => {
|
|
1268
|
+
adminTab = t.dataset.atab;
|
|
1269
|
+
el("admin-summary").textContent = adminSummaries[adminTab];
|
|
1270
|
+
if (adminTab === "ann" && !annLoaded) { annLoaded = true; loadAnnAdmin(); }
|
|
1271
|
+
});
|
|
1272
|
+
for (const evt of ["input", "change"]) {
|
|
1273
|
+
el("admin-panel-card").addEventListener(evt, (e) => { if (e.target.closest?.("form")) { dirty[adminTab] = true; dirtyWarned = false; } });
|
|
1274
|
+
}
|
|
1275
|
+
function tryCloseAdmin() {
|
|
1276
|
+
if (dirty[adminTab] && !dirtyWarned) {
|
|
1277
|
+
dirtyWarned = true;
|
|
1278
|
+
if (!reduceMotion) animate("#admin-panel-card", { x: [0, -8, 8, -5, 5, 0] }, { duration: 0.35 });
|
|
1279
|
+
toast("You have unsaved changes. Click outside again to discard them.");
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
dirtyWarned = false;
|
|
1283
|
+
closeAdminPanel();
|
|
1284
|
+
}
|
|
1285
|
+
function escapeAdmin() {
|
|
1286
|
+
if (adminTab === "ann") {
|
|
1287
|
+
if (annConfirming) { const id = annConfirming; annConfirming = null; renderAnnAdmin([`[data-aact="delete"][data-id="${id}"]`]); }
|
|
1288
|
+
else if (annEditing) cancelAnnForm();
|
|
1289
|
+
else closeAdminPanel();
|
|
1290
|
+
} else if (confirmingId) { const id = confirmingId; confirmingId = null; renderAdmin([`[data-act="delete"][data-id="${id}"]`]); }
|
|
1291
|
+
else if (editingId) cancelForm();
|
|
1292
|
+
else closeAdminPanel();
|
|
1293
|
+
}
|
|
745
1294
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1295
|
+
/* ============================ announcements: visitors ============================ */
|
|
1296
|
+
const SEEN_KEY = "mlv2_seen_announcements";
|
|
1297
|
+
const getSeen = () => { try { const v = JSON.parse(localStorage.getItem(SEEN_KEY)); return Array.isArray(v) ? v : []; } catch { return []; } };
|
|
1298
|
+
const markSeen = (ids) => { try { localStorage.setItem(SEEN_KEY, JSON.stringify([...new Set([...getSeen(), ...ids])].slice(-200))); } catch { /* storage blocked */ } };
|
|
1299
|
+
let announcements = [], annShown = [], annAutoShown = false;
|
|
1300
|
+
const fmtDate = (iso) => new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
1301
|
+
const annImage = (a) => (a.imageFileId ? String(storage.getFileView({ bucketId: CONFIG.iconsBucketId, fileId: a.imageFileId })) : a.imageUrl || "");
|
|
1302
|
+
|
|
1303
|
+
function annCard(a) {
|
|
1304
|
+
const img = annImage(a);
|
|
1305
|
+
return `<article class="rounded-[28px] bg-white/[0.06] p-2 ring-1 ring-inset ring-white/10">
|
|
1306
|
+
${img ? `<img src="${escapeHtml(img)}" alt="" loading="lazy" class="aspect-[16/9] w-full rounded-[20px] object-cover" onerror="this.remove()" />` : ""}
|
|
1307
|
+
<div class="px-3.5 pb-3 pt-3">
|
|
1308
|
+
<h3 class="text-[17px] font-bold leading-snug">${escapeHtml(a.title)} <span aria-hidden="true">•</span> <time datetime="${escapeHtml(a.$createdAt)}">${escapeHtml(fmtDate(a.$createdAt))}</time></h3>
|
|
1309
|
+
${a.content ? `<p class="mt-1 whitespace-pre-line break-words text-[15px] leading-relaxed text-ink-100">${escapeHtml(a.content)}</p>` : ""}
|
|
1310
|
+
</div>
|
|
1311
|
+
</article>`;
|
|
1312
|
+
}
|
|
1313
|
+
function refreshBell() {
|
|
1314
|
+
const seen = getSeen();
|
|
1315
|
+
const unseen = announcements.filter((a) => !seen.includes(a.$id)).length;
|
|
1316
|
+
el("bell-btn").hidden = announcements.length === 0;
|
|
1317
|
+
el("bell-dot").hidden = unseen === 0;
|
|
1318
|
+
el("bell-btn").setAttribute("aria-label", unseen ? `Announcements (${unseen} new)` : "Announcements");
|
|
1319
|
+
}
|
|
1320
|
+
function openAnnouncements(list) {
|
|
1321
|
+
if (!list.length) return;
|
|
1322
|
+
annShown = list;
|
|
1323
|
+
el("ann-list").innerHTML = list.map(annCard).join("");
|
|
1324
|
+
el("ann-list").scrollTop = 0;
|
|
1325
|
+
openModal("ann-modal", "ann-card", "ann-title");
|
|
1326
|
+
}
|
|
1327
|
+
function closeAnnouncements() {
|
|
1328
|
+
markSeen(annShown.map((a) => a.$id));
|
|
1329
|
+
annShown = [];
|
|
1330
|
+
closeModal("ann-modal");
|
|
1331
|
+
refreshBell();
|
|
1332
|
+
}
|
|
1333
|
+
async function loadAnnouncements() {
|
|
1334
|
+
if (!CONFIG.announcementsTableId) return;
|
|
1335
|
+
try {
|
|
1336
|
+
const res = await tablesDB.listRows({ databaseId: CONFIG.databaseId, tableId: CONFIG.announcementsTableId, queries: [Query.limit(20), Query.orderDesc("$createdAt")] });
|
|
1337
|
+
announcements = res.rows.filter((a) => a.enabled !== false && a.title);
|
|
1338
|
+
} catch (err) { console.warn("Announcements unavailable:", err); return; }
|
|
1339
|
+
refreshBell();
|
|
1340
|
+
const seen = getSeen();
|
|
1341
|
+
const unseen = announcements.filter((a) => !seen.includes(a.$id));
|
|
1342
|
+
if (settings.announceOnOpen && unseen.length && !annAutoShown) {
|
|
1343
|
+
annAutoShown = true;
|
|
1344
|
+
setTimeout(() => { if (allClosed()) openAnnouncements(unseen); }, 900);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
el("bell-btn").addEventListener("click", () => openAnnouncements(announcements));
|
|
1348
|
+
el("ann-close").addEventListener("click", closeAnnouncements);
|
|
1349
|
+
el("ann-done").addEventListener("click", closeAnnouncements);
|
|
1350
|
+
|
|
1351
|
+
/* ============================ announcements: admin ============================ */
|
|
1352
|
+
let annRows = [], annState = "idle", annError = "", annPreviewUrl = null;
|
|
1353
|
+
const annTable = () => ({ databaseId: CONFIG.databaseId, tableId: CONFIG.announcementsTableId });
|
|
1354
|
+
|
|
1355
|
+
async function loadAnnAdmin() {
|
|
1356
|
+
if (!CONFIG.announcementsTableId) { annState = "unconfigured"; renderAnnAdmin(); return; }
|
|
1357
|
+
annState = "loading";
|
|
1358
|
+
renderAnnAdmin();
|
|
1359
|
+
try {
|
|
1360
|
+
const res = await tablesDB.listRows({ ...annTable(), queries: [Query.limit(100), Query.orderDesc("$createdAt")] });
|
|
1361
|
+
annRows = res.rows;
|
|
1362
|
+
annState = "ready";
|
|
1363
|
+
} catch (err) { annState = "error"; annError = err?.message || "Couldn't load announcements."; }
|
|
1364
|
+
renderAnnAdmin();
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function annFormHtml(a) {
|
|
1368
|
+
const v = (x) => escapeHtml(x ?? "");
|
|
1369
|
+
const base = a ? escapeHtml(annImage(a)) : "";
|
|
1370
|
+
const hasImg = !!(a && (a.imageFileId || a.imageUrl));
|
|
1371
|
+
return `<form id="ann-form" novalidate aria-labelledby="ann-form-title" class="glass-lite mb-4 rounded-2xl p-4">
|
|
1372
|
+
<h3 id="ann-form-title" class="text-[16px] font-semibold">${a ? "Edit announcement" : "New announcement"}</h3>
|
|
1373
|
+
<div class="mt-4 space-y-4">
|
|
1374
|
+
<div>
|
|
1375
|
+
<label for="af-title" class="text-[13px] font-medium text-ink-200">Title</label>
|
|
1376
|
+
<input id="af-title" maxlength="120" autocomplete="off" aria-describedby="af-title-err" value="${v(a?.title)}" class="field mt-1.5" />
|
|
1377
|
+
<p id="af-title-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
1378
|
+
</div>
|
|
1379
|
+
<div>
|
|
1380
|
+
<label for="af-content" class="text-[13px] font-medium text-ink-200">Message <span class="font-normal text-ink-300">(optional)</span></label>
|
|
1381
|
+
<textarea id="af-content" rows="4" maxlength="2000" class="field mt-1.5 min-h-[104px] py-2.5 leading-relaxed" style="height:auto">${v(a?.content)}</textarea>
|
|
1382
|
+
</div>
|
|
1383
|
+
<fieldset class="min-w-0">
|
|
1384
|
+
<legend class="text-[13px] font-medium text-ink-200">Image <span class="font-normal text-ink-300">(optional)</span></legend>
|
|
1385
|
+
<img id="af-preview" src="${base}" data-base="${base}" alt="Image preview" ${base ? "" : "hidden"} class="mt-2 aspect-[16/9] w-full max-w-sm rounded-xl object-cover" onerror="this.hidden=true" />
|
|
1386
|
+
<div class="mt-3 grid gap-3">
|
|
1387
|
+
<div>
|
|
1388
|
+
<label for="af-file" class="text-[13px] text-ink-300">Upload an image</label>
|
|
1389
|
+
<input id="af-file" type="file" accept="image/*" aria-describedby="af-file-err" class="mt-1.5 w-full text-[13px] text-ink-300 file:mr-3 file:rounded-full file:border-0 file:bg-white/10 file:px-3.5 file:py-1.5 file:text-[13px] file:font-medium file:text-ink-50" />
|
|
1390
|
+
<p id="af-file-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
1391
|
+
</div>
|
|
1392
|
+
<div>
|
|
1393
|
+
<label for="af-imgurl" class="text-[13px] text-ink-300">…or paste an image link</label>
|
|
1394
|
+
<input id="af-imgurl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://" aria-describedby="af-imgurl-err" value="${v(a?.imageUrl)}" class="field mt-1.5" />
|
|
1395
|
+
<p id="af-imgurl-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
1396
|
+
</div>
|
|
1397
|
+
${hasImg ? `<label class="flex items-center gap-2 text-[13px] text-ink-200"><input id="af-remove" type="checkbox" class="h-4 w-4 accent-red-500" /> Remove the current image</label>` : ""}
|
|
1398
|
+
</div>
|
|
1399
|
+
</fieldset>
|
|
1400
|
+
<label class="flex cursor-pointer items-center gap-3 text-[14px]"><input id="af-enabled" type="checkbox" role="switch" class="switch" ${a?.enabled === false ? "" : "checked"} /> Published (visible to visitors)</label>
|
|
1401
|
+
</div>
|
|
1402
|
+
<div class="mt-5 flex flex-wrap items-center gap-2">
|
|
1403
|
+
<button type="submit" id="af-submit" class="btn btn-primary">${a ? "Save changes" : "Publish"}</button>
|
|
1404
|
+
<button type="button" data-aact="cancel-form" class="btn btn-ghost">Cancel</button>
|
|
1405
|
+
</div>
|
|
1406
|
+
</form>`;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function annRowHtml(a) {
|
|
1410
|
+
if (annEditing === a.$id) return `<li>${annFormHtml(a)}</li>`;
|
|
1411
|
+
const n = escapeHtml(a.title), off = a.enabled === false, dis = annEditing ? "disabled" : "";
|
|
1412
|
+
const snippet = escapeHtml((a.content || "").replace(/\s+/g, " ").slice(0, 70));
|
|
1413
|
+
const actions = annConfirming === a.$id
|
|
1414
|
+
? `<div role="group" aria-label="Confirm delete" class="mt-3 rounded-xl bg-red-500/10 p-3 ring-1 ring-red-500/30">
|
|
1415
|
+
<p class="text-[14px]">Delete <strong>${n}</strong>? This can't be undone.</p>
|
|
1416
|
+
<div class="mt-3 flex gap-2">
|
|
1417
|
+
<button type="button" id="ann-cancel-delete" data-aact="cancel-delete" data-id="${a.$id}" class="btn btn-ghost">Cancel</button>
|
|
1418
|
+
<button type="button" data-aact="confirm-delete" data-id="${a.$id}" class="btn btn-danger">Delete</button>
|
|
1419
|
+
</div>
|
|
1420
|
+
</div>`
|
|
1421
|
+
: `<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
1422
|
+
<button type="button" data-aact="edit" data-id="${a.$id}" ${dis} class="btn btn-ghost">Edit${sr(" " + a.title)}</button>
|
|
1423
|
+
<button type="button" data-aact="delete" data-id="${a.$id}" ${dis} class="btn btn-ghost ml-auto hover:!text-red-300">Delete${sr(" " + a.title)}</button>
|
|
1424
|
+
</div>`;
|
|
1425
|
+
return `<li class="glass-lite rounded-2xl p-3">
|
|
1426
|
+
<div class="flex items-center gap-3">
|
|
1427
|
+
<div class="min-w-0 flex-1">
|
|
1428
|
+
<p class="truncate text-[15px] font-medium">${n}${off ? '<span class="tag">Draft</span>' : ""}</p>
|
|
1429
|
+
<p class="truncate text-[13px] text-ink-300">${escapeHtml(fmtDate(a.$createdAt))}${snippet ? " · " + snippet : ""}</p>
|
|
1430
|
+
</div>
|
|
1431
|
+
<label class="flex shrink-0 cursor-pointer items-center gap-2 text-[13px] text-ink-200">
|
|
1432
|
+
<input type="checkbox" role="switch" class="switch" data-atoggle="${a.$id}" ${off ? "" : "checked"} ${dis} />
|
|
1433
|
+
<span>Published${sr(": " + a.title)}</span>
|
|
1434
|
+
</label>
|
|
1435
|
+
</div>
|
|
1436
|
+
${actions}
|
|
1437
|
+
</li>`;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function renderAnnAdmin(focusSels = []) {
|
|
1441
|
+
dirty.ann = false;
|
|
1442
|
+
const list = el("ann-admin-list");
|
|
1443
|
+
el("ann-form-slot").innerHTML = annEditing === "new" ? annFormHtml(null) : "";
|
|
1444
|
+
el("ann-add").disabled = !!annEditing || annState !== "ready";
|
|
1445
|
+
if (annState === "unconfigured") {
|
|
1446
|
+
setSummary("ann", "Not set up yet");
|
|
1447
|
+
list.innerHTML = `<li class="glass-lite rounded-2xl p-4 text-[14px] leading-relaxed text-ink-200">
|
|
1448
|
+
<p class="font-semibold text-ink-50">One-time setup</p>
|
|
1449
|
+
<p class="mt-2">Announcements need their own table. In Appwrite, create a table in the same database with these columns:</p>
|
|
1450
|
+
<ul class="mt-2 list-disc space-y-1 pl-5 text-ink-300">
|
|
1451
|
+
<li><code class="text-ink-50">title</code> string, size 120, required</li>
|
|
1452
|
+
<li><code class="text-ink-50">content</code> string, size 2000</li>
|
|
1453
|
+
<li><code class="text-ink-50">imageFileId</code> string, size 64</li>
|
|
1454
|
+
<li><code class="text-ink-50">imageUrl</code> string, size 2048</li>
|
|
1455
|
+
<li><code class="text-ink-50">enabled</code> boolean, default true</li>
|
|
1456
|
+
</ul>
|
|
1457
|
+
<p class="mt-2">Give it the same permissions as your games table, then paste its ID into <code class="text-ink-50">announcementsTableId</code> in the <code class="text-ink-50">CONFIG</code> block of this file.</p>
|
|
1458
|
+
</li>`;
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (annState === "loading") { setSummary("ann", ""); list.innerHTML = `<li class="py-8 text-center text-[14px] text-ink-300">Loading announcements…</li>`; return; }
|
|
1462
|
+
if (annState === "error") {
|
|
1463
|
+
setSummary("ann", "");
|
|
1464
|
+
list.innerHTML = `<li class="py-8 text-center"><p class="text-[14px] text-red-300">${escapeHtml(annError)}</p><button type="button" data-aact="retry" class="btn btn-ghost mt-3">Try again</button></li>`;
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
const pub = annRows.filter((a) => a.enabled !== false).length;
|
|
1468
|
+
setSummary("ann", `${annRows.length} ${annRows.length === 1 ? "announcement" : "announcements"} · ${pub} published`);
|
|
1469
|
+
list.innerHTML = annRows.length ? annRows.map(annRowHtml).join("") : `<li class="py-8 text-center text-[14px] text-ink-300">No announcements yet. Choose “New announcement” to post one.</li>`;
|
|
1470
|
+
if (annEditing) { const n = el("af-title"); n.focus(); n.scrollIntoView({ block: "nearest" }); return; }
|
|
1471
|
+
for (const sel of focusSels) {
|
|
1472
|
+
const t = el("admin-panel-card").querySelector(sel);
|
|
1473
|
+
if (t && !t.disabled) { t.focus(); break; }
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
function cancelAnnForm() {
|
|
1477
|
+
const id = annEditing;
|
|
1478
|
+
annEditing = null;
|
|
1479
|
+
renderAnnAdmin([id && id !== "new" ? `[data-aact="edit"][data-id="${id}"]` : "#ann-add"]);
|
|
1480
|
+
}
|
|
1481
|
+
function updateAnnPreview() {
|
|
1482
|
+
const img = el("af-preview"), file = el("af-file").files[0], url = el("af-imgurl").value.trim();
|
|
1483
|
+
if (annPreviewUrl) { URL.revokeObjectURL(annPreviewUrl); annPreviewUrl = null; }
|
|
1484
|
+
let src = img.dataset.base || "";
|
|
1485
|
+
if (el("af-remove")?.checked) src = "";
|
|
1486
|
+
else if (file && file.type.startsWith("image/")) src = annPreviewUrl = URL.createObjectURL(file);
|
|
1487
|
+
else if (isHttp(url)) src = url;
|
|
1488
|
+
img.hidden = !src;
|
|
1489
|
+
if (src) img.src = src;
|
|
1490
|
+
}
|
|
1491
|
+
function validateAnnForm() {
|
|
1492
|
+
const url = el("af-imgurl").value.trim(), f = el("af-file").files[0];
|
|
1493
|
+
const checks = [
|
|
1494
|
+
["af-title", el("af-title").value.trim() ? "" : "Enter a title."],
|
|
1495
|
+
["af-imgurl", !url || isHttp(url) ? "" : "Enter a full link that starts with https://"],
|
|
1496
|
+
["af-file", !f || f.type.startsWith("image/") ? "" : "Choose an image file."],
|
|
1497
|
+
];
|
|
1498
|
+
let first = null;
|
|
1499
|
+
checks.forEach(([id, msg]) => { setErr(id, msg); if (msg && !first) first = id; });
|
|
1500
|
+
if (first) el(first).focus();
|
|
1501
|
+
return !first;
|
|
1502
|
+
}
|
|
1503
|
+
async function submitAnn(e) {
|
|
1504
|
+
e.preventDefault();
|
|
1505
|
+
if (!validateAnnForm()) return;
|
|
1506
|
+
const isEdit = annEditing && annEditing !== "new";
|
|
1507
|
+
const existing = isEdit ? annRows.find((r) => r.$id === annEditing) : null;
|
|
1508
|
+
const title = el("af-title").value.trim(), content = el("af-content").value.trim(), urlField = el("af-imgurl").value.trim();
|
|
1509
|
+
const file = el("af-file").files[0], enabled = el("af-enabled").checked, remove = !!el("af-remove")?.checked;
|
|
1510
|
+
const btn = el("af-submit"), label = btn.textContent;
|
|
1511
|
+
btn.disabled = true; btn.textContent = "Saving…";
|
|
1512
|
+
let uploadedId = null, oldFile = null;
|
|
1513
|
+
try {
|
|
1514
|
+
let imageFileId = existing?.imageFileId || "", imageUrl = existing?.imageUrl || "";
|
|
1515
|
+
if (remove) { oldFile = imageFileId; imageFileId = ""; imageUrl = ""; }
|
|
1516
|
+
else if (file) {
|
|
1517
|
+
uploadedId = (await storage.createFile({ bucketId: CONFIG.iconsBucketId, fileId: ID.unique(), file })).$id;
|
|
1518
|
+
oldFile = imageFileId; imageFileId = uploadedId; imageUrl = "";
|
|
1519
|
+
} else if (urlField && urlField !== imageUrl) { oldFile = imageFileId; imageFileId = ""; imageUrl = urlField; }
|
|
1520
|
+
else if (!urlField && imageUrl && !imageFileId) imageUrl = "";
|
|
1521
|
+
const data = { title, content, imageFileId, imageUrl, enabled };
|
|
1522
|
+
let row;
|
|
1523
|
+
if (isEdit) {
|
|
1524
|
+
row = await tablesDB.updateRow({ ...annTable(), rowId: annEditing, data });
|
|
1525
|
+
annRows = annRows.map((r) => (r.$id === row.$id ? row : r));
|
|
1526
|
+
} else {
|
|
1527
|
+
row = await tablesDB.createRow({ ...annTable(), rowId: ID.unique(), data });
|
|
1528
|
+
annRows = [row, ...annRows];
|
|
1529
|
+
}
|
|
1530
|
+
if (oldFile) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: oldFile }).catch(() => {});
|
|
1531
|
+
annEditing = null;
|
|
1532
|
+
renderAnnAdmin([`[data-aact="edit"][data-id="${row.$id}"]`]);
|
|
1533
|
+
toast(isEdit ? `Saved “${title}”` : `Posted “${title}”`);
|
|
1534
|
+
loadAnnouncements();
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
if (uploadedId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: uploadedId }).catch(() => {});
|
|
1537
|
+
btn.disabled = false; btn.textContent = label;
|
|
1538
|
+
toast(err?.message || "Couldn't save. Check your connection and try again.", "error");
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
async function toggleAnn(id, on) {
|
|
1542
|
+
const row = annRows.find((r) => r.$id === id);
|
|
1543
|
+
if (!row) return;
|
|
1544
|
+
const prev = row.enabled !== false;
|
|
1545
|
+
row.enabled = on;
|
|
1546
|
+
renderAnnAdmin([`[data-atoggle="${id}"]`]);
|
|
1547
|
+
try {
|
|
1548
|
+
await tablesDB.updateRow({ ...annTable(), rowId: id, data: { enabled: on } });
|
|
1549
|
+
adminSay(`${row.title} is now ${on ? "published" : "a draft"}`);
|
|
1550
|
+
loadAnnouncements();
|
|
1551
|
+
} catch (err) {
|
|
1552
|
+
row.enabled = prev;
|
|
1553
|
+
renderAnnAdmin([`[data-atoggle="${id}"]`]);
|
|
1554
|
+
toast(err?.message || "Couldn't update", "error");
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
async function deleteAnn(id) {
|
|
1558
|
+
const row = annRows.find((r) => r.$id === id);
|
|
1559
|
+
if (!row) return;
|
|
1560
|
+
try {
|
|
1561
|
+
await tablesDB.deleteRow({ ...annTable(), rowId: id });
|
|
1562
|
+
if (row.imageFileId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: row.imageFileId }).catch(() => {});
|
|
1563
|
+
annRows = annRows.filter((r) => r.$id !== id);
|
|
1564
|
+
annConfirming = null;
|
|
1565
|
+
renderAnnAdmin(["#ann-add"]);
|
|
1566
|
+
toast(`Deleted “${row.title}”`);
|
|
1567
|
+
loadAnnouncements();
|
|
751
1568
|
} catch (err) {
|
|
752
|
-
|
|
753
|
-
toast(err?.message || "Couldn't
|
|
754
|
-
} finally {
|
|
755
|
-
submitBtn.disabled = false;
|
|
756
|
-
setTimeout(() => (statusEl.textContent = ""), 2500);
|
|
1569
|
+
renderAnnAdmin(["#ann-cancel-delete"]);
|
|
1570
|
+
toast(err?.message || "Couldn't delete. Try again.", "error");
|
|
757
1571
|
}
|
|
1572
|
+
}
|
|
1573
|
+
el("ann-add").addEventListener("click", () => { annEditing = "new"; annConfirming = null; renderAnnAdmin(); });
|
|
1574
|
+
el("ann-admin-body").addEventListener("click", (e) => {
|
|
1575
|
+
const b = e.target.closest("[data-aact]");
|
|
1576
|
+
if (!b || b.disabled) return;
|
|
1577
|
+
const { aact, id } = b.dataset;
|
|
1578
|
+
if (aact === "retry") loadAnnAdmin();
|
|
1579
|
+
else if (aact === "edit") { annEditing = id; annConfirming = null; renderAnnAdmin(); }
|
|
1580
|
+
else if (aact === "cancel-form") cancelAnnForm();
|
|
1581
|
+
else if (aact === "delete") { annConfirming = id; renderAnnAdmin(["#ann-cancel-delete"]); }
|
|
1582
|
+
else if (aact === "cancel-delete") { annConfirming = null; renderAnnAdmin([`[data-aact="delete"][data-id="${id}"]`]); }
|
|
1583
|
+
else if (aact === "confirm-delete") { b.disabled = true; b.textContent = "Deleting…"; deleteAnn(id); }
|
|
1584
|
+
});
|
|
1585
|
+
el("ann-admin-body").addEventListener("change", (e) => {
|
|
1586
|
+
const t = e.target;
|
|
1587
|
+
if (t.matches("[data-atoggle]")) toggleAnn(t.dataset.atoggle, t.checked);
|
|
1588
|
+
else if (t.id === "af-file" || t.id === "af-remove") updateAnnPreview();
|
|
758
1589
|
});
|
|
1590
|
+
el("ann-admin-body").addEventListener("input", (e) => {
|
|
1591
|
+
const t = e.target;
|
|
1592
|
+
if (t.getAttribute?.("aria-invalid")) setErr(t.id, "");
|
|
1593
|
+
if (t.id === "af-imgurl") updateAnnPreview();
|
|
1594
|
+
});
|
|
1595
|
+
el("ann-admin-body").addEventListener("submit", (e) => { if (e.target.id === "ann-form") submitAnn(e); });
|
|
1596
|
+
|
|
1597
|
+
/* ============================ settings: apply ============================ */
|
|
1598
|
+
const origTitle = document.title;
|
|
1599
|
+
const origIcon = document.querySelector('link[rel~="icon"]')?.href || "";
|
|
1600
|
+
function setFavicon(href) {
|
|
1601
|
+
document.head.querySelectorAll('link[rel~="icon"]').forEach((l) => l.remove());
|
|
1602
|
+
const l = document.createElement("link");
|
|
1603
|
+
l.rel = "icon"; l.href = href;
|
|
1604
|
+
document.head.appendChild(l);
|
|
1605
|
+
}
|
|
1606
|
+
function applyCloak() {
|
|
1607
|
+
const c = settings.cloak;
|
|
1608
|
+
let title = origTitle, icon = origIcon;
|
|
1609
|
+
if (c === "custom") { if (settings.customTitle) title = settings.customTitle; if (isHttp(settings.customIcon)) icon = settings.customIcon; }
|
|
1610
|
+
else if (c !== "off") { title = CLOAKS[c].title; icon = CLOAKS[c].icon; }
|
|
1611
|
+
document.title = title;
|
|
1612
|
+
setFavicon(icon);
|
|
1613
|
+
}
|
|
1614
|
+
function applyMotion() {
|
|
1615
|
+
reduceMotion = sysReduce || settings.reduceMotion;
|
|
1616
|
+
document.documentElement.classList.toggle("rm", reduceMotion);
|
|
1617
|
+
}
|
|
1618
|
+
function applyTileSize() {
|
|
1619
|
+
for (const g of [el("grid"), el("skeleton-grid")]) {
|
|
1620
|
+
Object.values(SIZES).forEach((c) => g.classList.remove(...c.split(" ")));
|
|
1621
|
+
g.classList.add(...SIZES[settings.tileSize].split(" "));
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// "Mask text": swap letters for look-alike characters in everything on the page (not in the admin/settings dialogs).
|
|
1626
|
+
const MASK = { a: "\u0430", c: "\u0441", e: "\u0435", i: "\u0456", j: "\u0458", o: "\u043E", p: "\u0440", s: "\u0455", x: "\u0445", y: "\u0443",
|
|
1627
|
+
A: "\u0410", B: "\u0412", C: "\u0421", E: "\u0415", H: "\u041D", I: "\u0406", J: "\u0408", K: "\u041A", M: "\u041C", O: "\u041E", P: "\u0420", S: "\u0405", T: "\u0422", X: "\u0425", Y: "\u04AE" };
|
|
1628
|
+
const maskStr = (t) => t.replace(/[acejiopsxyABCEHIJKMOPSTXY]/g, (c) => MASK[c]);
|
|
1629
|
+
const maskOrig = new WeakMap();
|
|
1630
|
+
function maskNode(n) {
|
|
1631
|
+
if (n.nodeType !== 3 || !n.nodeValue.trim() || n.parentElement?.closest("script,style,textarea,[data-nomask]")) return;
|
|
1632
|
+
if (!maskOrig.has(n)) maskOrig.set(n, n.nodeValue);
|
|
1633
|
+
n.nodeValue = maskStr(maskOrig.get(n));
|
|
1634
|
+
}
|
|
1635
|
+
function walkText(root, fn) {
|
|
1636
|
+
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
1637
|
+
let n;
|
|
1638
|
+
while ((n = w.nextNode())) fn(n);
|
|
1639
|
+
}
|
|
1640
|
+
let maskObs = null;
|
|
1641
|
+
function setMask(on) {
|
|
1642
|
+
maskObs?.disconnect();
|
|
1643
|
+
maskObs = null;
|
|
1644
|
+
if (on) {
|
|
1645
|
+
walkText(document.body, maskNode);
|
|
1646
|
+
maskObs = new MutationObserver((muts) => muts.forEach((m) => m.addedNodes.forEach((n) => (n.nodeType === 3 ? maskNode(n) : n.nodeType === 1 && walkText(n, maskNode)))));
|
|
1647
|
+
maskObs.observe(document.body, { childList: true, subtree: true });
|
|
1648
|
+
} else walkText(document.body, (n) => { if (maskOrig.has(n)) { n.nodeValue = maskOrig.get(n); maskOrig.delete(n); } });
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/* ============================ panic button + safety ============================ */
|
|
1652
|
+
let panicking = false, capturingKey = false;
|
|
1653
|
+
function comboOf(e) {
|
|
1654
|
+
const k = e.key;
|
|
1655
|
+
if (["Control", "Shift", "Alt", "Meta"].includes(k)) return "";
|
|
1656
|
+
const parts = [];
|
|
1657
|
+
if (e.ctrlKey) parts.push("Ctrl");
|
|
1658
|
+
if (e.altKey) parts.push("Alt");
|
|
1659
|
+
if (e.metaKey) parts.push("Meta");
|
|
1660
|
+
if (e.shiftKey && k.length > 1) parts.push("Shift");
|
|
1661
|
+
parts.push(k.length === 1 ? k.toUpperCase() : k);
|
|
1662
|
+
return parts.join("+");
|
|
1663
|
+
}
|
|
1664
|
+
const showCombo = (c) => c.replace(/(^|\+) $/, "$1Space");
|
|
1665
|
+
function panic() {
|
|
1666
|
+
panicking = true;
|
|
1667
|
+
document.documentElement.style.visibility = "hidden";
|
|
1668
|
+
location.replace(isHttp(settings.panic.url) ? settings.panic.url : DEFAULTS.panic.url);
|
|
1669
|
+
setTimeout(() => { document.documentElement.style.visibility = ""; panicking = false; }, 3000); // restore if the navigation was blocked
|
|
1670
|
+
}
|
|
1671
|
+
function updatePanicUI() {
|
|
1672
|
+
const on = settings.panic.enabled && settings.panic.button;
|
|
1673
|
+
el("panic-btn").hidden = !on;
|
|
1674
|
+
el("player-panic").hidden = !on;
|
|
1675
|
+
el("panic-key").textContent = showCombo(settings.panic.key);
|
|
1676
|
+
}
|
|
1677
|
+
document.addEventListener("keydown", (e) => {
|
|
1678
|
+
if (capturingKey) {
|
|
1679
|
+
const c = comboOf(e);
|
|
1680
|
+
if (!c) return;
|
|
1681
|
+
e.preventDefault(); e.stopPropagation();
|
|
1682
|
+
capturingKey = false;
|
|
1683
|
+
if (e.key !== "Escape") { settings.panic.key = c; saveSettings(); }
|
|
1684
|
+
updatePanicUI();
|
|
1685
|
+
el("panic-key").focus();
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
const p = settings.panic;
|
|
1689
|
+
if (!p.enabled || e.repeat || comboOf(e) !== p.key) return;
|
|
1690
|
+
const typing = e.target.matches?.("input, textarea, select, [contenteditable]");
|
|
1691
|
+
if (typing && !(e.ctrlKey || e.altKey || e.metaKey) && e.key.length === 1) return;
|
|
1692
|
+
e.preventDefault();
|
|
1693
|
+
panic();
|
|
1694
|
+
}, true);
|
|
1695
|
+
el("panic-btn").addEventListener("click", panic);
|
|
1696
|
+
el("player-panic").addEventListener("click", panic);
|
|
1697
|
+
addEventListener("beforeunload", (e) => { if (settings.confirmClose && !panicking) { e.preventDefault(); e.returnValue = ""; } });
|
|
1698
|
+
|
|
1699
|
+
function launchBlank() {
|
|
1700
|
+
const w = window.open("about:blank", "_blank");
|
|
1701
|
+
if (!w) { toast("Your browser blocked the new tab. Allow pop-ups for this site and try again.", "error"); return; }
|
|
1702
|
+
const d = w.document;
|
|
1703
|
+
d.title = document.title;
|
|
1704
|
+
d.body.style.cssText = "margin:0;background:#000";
|
|
1705
|
+
const f = d.createElement("iframe");
|
|
1706
|
+
f.src = location.href;
|
|
1707
|
+
f.style.cssText = "position:fixed;inset:0;width:100%;height:100%;border:0";
|
|
1708
|
+
f.allow = "autoplay; fullscreen; gamepad";
|
|
1709
|
+
d.body.appendChild(f);
|
|
1710
|
+
panic();
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/* ============================ settings: dialog ============================ */
|
|
1714
|
+
const selectSettingsTab = wireTabs(el("settings-tabs"));
|
|
1715
|
+
function renderCloakOptions() {
|
|
1716
|
+
el("cloak-options").innerHTML = Object.entries(CLOAKS).map(([k, c]) =>
|
|
1717
|
+
`<label class="opt"><input type="radio" name="cloak" value="${k}" class="sr-only" /><img src="${escapeHtml(c.icon || origIcon)}" alt="" class="h-8 w-8 shrink-0 rounded-lg bg-white/10" />
|
|
1718
|
+
<span class="min-w-0"><span class="block text-[15px] font-medium">${escapeHtml(c.label)}</span><span class="block truncate text-[13px] text-ink-300">${escapeHtml(c.sub)}</span></span></label>`).join("");
|
|
1719
|
+
}
|
|
1720
|
+
function syncSettingsUI() {
|
|
1721
|
+
el("set-announce").checked = settings.announceOnOpen;
|
|
1722
|
+
el("set-reduce").checked = settings.reduceMotion;
|
|
1723
|
+
document.querySelector(`input[name="tile"][value="${settings.tileSize}"]`).checked = true;
|
|
1724
|
+
document.querySelector(`input[name="cloak"][value="${settings.cloak}"]`).checked = true;
|
|
1725
|
+
el("custom-cloak").hidden = settings.cloak !== "custom";
|
|
1726
|
+
el("cc-title").value = settings.customTitle;
|
|
1727
|
+
el("cc-icon").value = settings.customIcon;
|
|
1728
|
+
el("panic-on").checked = settings.panic.enabled;
|
|
1729
|
+
el("panic-url").value = settings.panic.url;
|
|
1730
|
+
el("panic-btn-on").checked = settings.panic.button;
|
|
1731
|
+
el("set-mask").checked = settings.maskText;
|
|
1732
|
+
el("set-confirm").checked = settings.confirmClose;
|
|
1733
|
+
updatePanicUI();
|
|
1734
|
+
}
|
|
1735
|
+
function applyAllSettings() { applyMotion(); applyTileSize(); applyCloak(); setMask(settings.maskText); updatePanicUI(); }
|
|
1736
|
+
function refreshLib() {
|
|
1737
|
+
const ok = isUnlocked();
|
|
1738
|
+
el("export-games").disabled = !ok;
|
|
1739
|
+
el("import-games-btn").disabled = !ok;
|
|
1740
|
+
el("lib-lock").hidden = ok;
|
|
1741
|
+
el("unlock-admin").hidden = ok;
|
|
1742
|
+
}
|
|
1743
|
+
function openSettings() { syncSettingsUI(); refreshLib(); openModal("settings-modal", "settings-card", "settings-title"); }
|
|
1744
|
+
function closeSettings() { closeModal("settings-modal"); }
|
|
1745
|
+
el("settings-btn").addEventListener("click", openSettings);
|
|
1746
|
+
el("settings-close").addEventListener("click", closeSettings);
|
|
1747
|
+
el("open-admin").addEventListener("click", () => { closeSettings(); if (isUnlocked()) openAdminPanel(); else openAdminLogin(); });
|
|
1748
|
+
el("unlock-admin").addEventListener("click", () => { closeSettings(); openAdminLogin(); });
|
|
1749
|
+
|
|
1750
|
+
const on = (id, evt, fn) => el(id).addEventListener(evt, fn);
|
|
1751
|
+
on("set-announce", "change", (e) => { settings.announceOnOpen = e.target.checked; saveSettings(); });
|
|
1752
|
+
on("set-reduce", "change", (e) => { settings.reduceMotion = e.target.checked; saveSettings(); applyMotion(); });
|
|
1753
|
+
document.querySelectorAll('input[name="tile"]').forEach((r) => r.addEventListener("change", () => { settings.tileSize = r.value; saveSettings(); applyTileSize(); }));
|
|
1754
|
+
on("cloak-options", "change", (e) => {
|
|
1755
|
+
settings.cloak = e.target.value; saveSettings(); applyCloak();
|
|
1756
|
+
el("custom-cloak").hidden = settings.cloak !== "custom";
|
|
1757
|
+
if (settings.cloak === "custom") focusSoon(el("cc-title"));
|
|
1758
|
+
});
|
|
1759
|
+
on("cc-title", "input", (e) => { settings.customTitle = e.target.value; saveSettings(); applyCloak(); });
|
|
1760
|
+
on("cc-icon", "input", (e) => { settings.customIcon = e.target.value.trim(); saveSettings(); applyCloak(); });
|
|
1761
|
+
on("panic-on", "change", (e) => { settings.panic.enabled = e.target.checked; saveSettings(); updatePanicUI(); });
|
|
1762
|
+
on("panic-btn-on", "change", (e) => { settings.panic.button = e.target.checked; saveSettings(); updatePanicUI(); });
|
|
1763
|
+
on("panic-key", "click", () => { capturingKey = true; el("panic-key").textContent = "Press a key…"; });
|
|
1764
|
+
on("panic-key", "blur", () => { if (capturingKey) { capturingKey = false; updatePanicUI(); } });
|
|
1765
|
+
on("panic-url", "input", (e) => {
|
|
1766
|
+
const v = e.target.value.trim(), err = el("panic-url-err");
|
|
1767
|
+
const bad = v && !isHttp(v);
|
|
1768
|
+
err.textContent = bad ? "Enter a full link that starts with https://" : "";
|
|
1769
|
+
err.classList.toggle("hidden", !bad);
|
|
1770
|
+
e.target.toggleAttribute("aria-invalid", !!bad);
|
|
1771
|
+
if (!bad) { settings.panic.url = v || DEFAULTS.panic.url; saveSettings(); }
|
|
1772
|
+
});
|
|
1773
|
+
on("set-mask", "change", (e) => { settings.maskText = e.target.checked; saveSettings(); setMask(settings.maskText); });
|
|
1774
|
+
on("set-confirm", "change", (e) => { settings.confirmClose = e.target.checked; saveSettings(); });
|
|
1775
|
+
on("launch-blank", "click", launchBlank);
|
|
1776
|
+
|
|
1777
|
+
/* ============================ settings: data ============================ */
|
|
1778
|
+
function download(name, obj) {
|
|
1779
|
+
const a = document.createElement("a");
|
|
1780
|
+
a.href = URL.createObjectURL(new Blob([JSON.stringify(obj, null, 2)], { type: "application/json" }));
|
|
1781
|
+
a.download = name;
|
|
1782
|
+
document.body.appendChild(a);
|
|
1783
|
+
a.click();
|
|
1784
|
+
a.remove();
|
|
1785
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
|
|
1786
|
+
}
|
|
1787
|
+
const libStatus = (t) => { el("lib-status").textContent = t; };
|
|
1788
|
+
let resetTimer = null, pendingImport = null;
|
|
1789
|
+
on("export-settings", "click", () => { download("mlv2-settings.json", { app: "mlv2", type: "settings", version: 1, settings }); toast("Settings exported"); });
|
|
1790
|
+
on("import-settings-btn", "click", () => el("import-settings").click());
|
|
1791
|
+
on("import-settings", "change", async (e) => {
|
|
1792
|
+
const f = e.target.files[0];
|
|
1793
|
+
e.target.value = "";
|
|
1794
|
+
if (!f) return;
|
|
1795
|
+
try {
|
|
1796
|
+
const data = JSON.parse(await f.text());
|
|
1797
|
+
if (data?.type !== "settings" || typeof data.settings !== "object") throw new Error("bad");
|
|
1798
|
+
settings = mergeSettings(DEFAULTS, data.settings);
|
|
1799
|
+
saveSettings(); applyAllSettings(); syncSettingsUI();
|
|
1800
|
+
toast("Settings imported");
|
|
1801
|
+
} catch { toast("That doesn't look like a settings file.", "error"); }
|
|
1802
|
+
});
|
|
1803
|
+
on("reset-seen", "click", () => { try { localStorage.removeItem(SEEN_KEY); } catch { /* storage blocked */ } refreshBell(); toast("You'll see announcements again"); });
|
|
1804
|
+
on("reset-settings", "click", (e) => {
|
|
1805
|
+
const b = e.currentTarget;
|
|
1806
|
+
if (b.dataset.armed) {
|
|
1807
|
+
clearTimeout(resetTimer); delete b.dataset.armed; b.textContent = "Reset";
|
|
1808
|
+
settings = mergeSettings(DEFAULTS, null);
|
|
1809
|
+
saveSettings(); applyAllSettings(); syncSettingsUI();
|
|
1810
|
+
toast("Settings reset");
|
|
1811
|
+
} else {
|
|
1812
|
+
b.dataset.armed = "1"; b.textContent = "Click again to confirm";
|
|
1813
|
+
resetTimer = setTimeout(() => { delete b.dataset.armed; b.textContent = "Reset"; }, 4000);
|
|
1814
|
+
}
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
async function fetchAllGames() {
|
|
1818
|
+
const res = await tablesDB.listRows({ ...dbArgs, queries: [Query.limit(200), Query.orderAsc("order")] });
|
|
1819
|
+
return res.rows.sort(byOrder);
|
|
1820
|
+
}
|
|
1821
|
+
on("export-games", "click", async () => {
|
|
1822
|
+
if (!isUnlocked()) return refreshLib();
|
|
1823
|
+
libStatus("Preparing export…");
|
|
1824
|
+
try {
|
|
1825
|
+
const rows = await fetchAllGames();
|
|
1826
|
+
const games = rows.map((g) => ({ name: g.name, url: g.url, category: g.category || "", iconUrl: g.iconFileId ? String(iconSrcFor(g) || "") : g.iconUrl || "", enabled: g.enabled !== false }));
|
|
1827
|
+
download("mlv2-games.json", { app: "mlv2", type: "games", version: 1, exportedAt: new Date().toISOString(), games });
|
|
1828
|
+
libStatus(`Exported ${games.length} ${games.length === 1 ? "game" : "games"}.`);
|
|
1829
|
+
} catch (err) { libStatus(err?.message || "Couldn't export your games."); }
|
|
1830
|
+
});
|
|
1831
|
+
on("import-games-btn", "click", () => el("import-games").click());
|
|
1832
|
+
on("import-games", "change", async (e) => {
|
|
1833
|
+
const f = e.target.files[0], go = el("import-games-go");
|
|
1834
|
+
e.target.value = "";
|
|
1835
|
+
if (!f || !isUnlocked()) return;
|
|
1836
|
+
go.hidden = true; pendingImport = null;
|
|
1837
|
+
try {
|
|
1838
|
+
const data = JSON.parse(await f.text());
|
|
1839
|
+
if (data?.type !== "games" || !Array.isArray(data.games)) throw new Error("That file isn't an mlv2 games export.");
|
|
1840
|
+
const existing = await fetchAllGames();
|
|
1841
|
+
const have = new Set(existing.map((g) => g.url));
|
|
1842
|
+
const valid = data.games.filter((g) => g && typeof g.name === "string" && g.name.trim() && isHttp(g.url));
|
|
1843
|
+
const fresh = valid.filter((g, i, a) => !have.has(g.url) && a.findIndex((x) => x.url === g.url) === i);
|
|
1844
|
+
pendingImport = { fresh, base: existing.reduce((m, r) => Math.max(m, r.order ?? -1), -1) + 1 };
|
|
1845
|
+
libStatus(`Found ${valid.length} valid games. ${fresh.length} ${fresh.length === 1 ? "is" : "are"} new; the rest are already in your library.`);
|
|
1846
|
+
if (fresh.length) { go.textContent = `Import ${fresh.length} new ${fresh.length === 1 ? "game" : "games"}`; go.hidden = false; }
|
|
1847
|
+
} catch (err) { libStatus(err?.message || "Couldn't read that file."); }
|
|
1848
|
+
});
|
|
1849
|
+
on("import-games-go", "click", async (e) => {
|
|
1850
|
+
const go = e.currentTarget;
|
|
1851
|
+
if (!pendingImport || !isUnlocked()) return;
|
|
1852
|
+
const { fresh, base } = pendingImport;
|
|
1853
|
+
go.disabled = true;
|
|
1854
|
+
let done = 0, failed = 0;
|
|
1855
|
+
for (const [i, g] of fresh.entries()) {
|
|
1856
|
+
libStatus(`Importing ${i + 1} of ${fresh.length}…`);
|
|
1857
|
+
try {
|
|
1858
|
+
await tablesDB.createRow({ ...dbArgs, rowId: ID.unique(), data: { name: g.name.trim(), url: g.url, category: String(g.category || "").trim(), iconFileId: "", iconUrl: isHttp(g.iconUrl) ? g.iconUrl : "", enabled: g.enabled !== false, order: base + i } });
|
|
1859
|
+
done++;
|
|
1860
|
+
} catch { failed++; }
|
|
1861
|
+
}
|
|
1862
|
+
go.disabled = false; go.hidden = true; pendingImport = null;
|
|
1863
|
+
libStatus(`Imported ${done} ${done === 1 ? "game" : "games"}${failed ? `, ${failed} failed` : ""}.`);
|
|
1864
|
+
toast(failed ? `Imported ${done}, ${failed} failed` : `Imported ${done} ${done === 1 ? "game" : "games"}`, failed ? "error" : "default");
|
|
1865
|
+
loadGames();
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
/* ============================ boot ============================ */
|
|
1869
|
+
renderCloakOptions();
|
|
1870
|
+
applyAllSettings();
|
|
1871
|
+
loadAnnouncements();
|
|
759
1872
|
|
|
760
1873
|
/* ---------------------------- tile hover FX ---------------------------- */
|
|
761
1874
|
const gridEl = el("grid");
|
|
@@ -781,7 +1894,7 @@ gridEl.addEventListener("pointerout", (e) => {
|
|
|
781
1894
|
});
|
|
782
1895
|
|
|
783
1896
|
el("player-frame").addEventListener("load", () => {
|
|
784
|
-
if (el("player-frame").getAttribute("src")) el("player-loading").style.display = "none";
|
|
1897
|
+
if (el("player-frame").getAttribute("src") !== "about:blank") el("player-loading").style.display = "none";
|
|
785
1898
|
});
|
|
786
1899
|
|
|
787
1900
|
/* ------------------------------ hero motion ------------------------------ */
|