mlv2boii 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +684 -340
- package/package.json +1 -1
package/index.html
CHANGED
|
@@ -7,107 +7,144 @@
|
|
|
7
7
|
<meta name="description" content="mlv2: a small, fast library of games." />
|
|
8
8
|
<meta name="color-scheme" content="dark" />
|
|
9
9
|
<meta name="theme-color" content="#000000" />
|
|
10
|
-
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23000'/%3E%3Ccircle cx='16' cy='16' r='
|
|
10
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23000'/%3E%3Ccircle cx='16' cy='16' r='7' fill='%23f01a36'/%3E%3Ccircle cx='14' cy='13' r='2.2' fill='%23fff' opacity='.7'/%3E%3C/svg%3E" />
|
|
11
11
|
|
|
12
|
-
<!-- Fonts: SF Pro on Apple devices (system stack), Inter everywhere else -->
|
|
13
12
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
13
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
14
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
16
15
|
|
|
17
|
-
<!-- Tailwind CSS v4 (Play CDN, mirrored on jsDelivr) -->
|
|
18
16
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
19
17
|
<style type="text/tailwindcss">
|
|
20
18
|
/*
|
|
21
|
-
Design language
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
Type: one family. 17/19/21 for reading, 13 for labels, 44-72 for the title.
|
|
25
|
-
Shape: 10px controls, 12px groups, 28px sheets (concentric: 12 + 16 padding), pills for chips,
|
|
26
|
-
22.37% on game icons (the iOS app icon ratio).
|
|
27
|
-
Motion: 150-300ms ease-out on interaction, one entrance, all of it off under reduced-motion.
|
|
19
|
+
Design language: void black + ambient colour glow, glass on every floating layer,
|
|
20
|
+
glossy "inflated" candy elements (orbs, badges, chips, buttons, hero shapes).
|
|
21
|
+
Brand red-pink is the lead; orange / lime / blue / purple are category colours.
|
|
28
22
|
*/
|
|
29
23
|
@theme {
|
|
30
24
|
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", ui-sans-serif, system-ui, sans-serif;
|
|
31
25
|
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
|
|
32
|
-
|
|
33
|
-
--color-ink-
|
|
34
|
-
--color-ink-
|
|
35
|
-
--color-
|
|
36
|
-
--color-ink-800: #161618;
|
|
37
|
-
--color-ink-700: #232326;
|
|
38
|
-
--color-ink-600: #2c2c30;
|
|
39
|
-
--color-ink-500: #48484c;
|
|
40
|
-
--color-ink-400: #76767b;
|
|
41
|
-
--color-ink-300: #a1a1a6;
|
|
42
|
-
--color-ink-200: #c7c7cc;
|
|
43
|
-
--color-ink-100: #e5e5ea;
|
|
44
|
-
--color-ink-50: #f5f5f7;
|
|
45
|
-
|
|
46
|
-
--color-red-700: #8f0e20;
|
|
47
|
-
--color-red-600: #c11228;
|
|
48
|
-
--color-red-500: #e8142f;
|
|
49
|
-
--color-red-400: #ff4d61;
|
|
50
|
-
--color-red-300: #ff8b96;
|
|
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: #8e8e93;
|
|
28
|
+
--color-ink-300: #a1a1a6; --color-ink-200: #c7c7cc; --color-ink-100: #e5e5ea; --color-ink-50: #f5f5f7;
|
|
29
|
+
--color-red-700: #8f0e20; --color-red-600: #c11228; --color-red-500: #f01a36; --color-red-400: #ff4d63; --color-red-300: #ff8b96;
|
|
51
30
|
}
|
|
52
|
-
|
|
53
31
|
@layer components {
|
|
54
|
-
.btn { @apply inline-flex h-11 items-center justify-center rounded-
|
|
55
|
-
.btn
|
|
56
|
-
.
|
|
57
|
-
.
|
|
58
|
-
.field { @apply h-11 w-full rounded-[10px] bg-white/[0.07] px-3 text-[16px] text-ink-50 ring-1 ring-inset ring-white/10 transition-shadow duration-200 placeholder:text-ink-400 focus:ring-2 focus:ring-red-500 sm:h-9 sm:text-[14px]; }
|
|
59
|
-
.chip { @apply h-10 shrink-0 whitespace-nowrap rounded-full px-4 text-[14px] font-medium transition-colors duration-200 sm:h-9; }
|
|
32
|
+
.btn { @apply inline-flex h-11 items-center justify-center rounded-full px-5 text-[15px] font-semibold transition-[background-color,filter,transform] duration-200 active:scale-[0.97] disabled:opacity-50 sm:h-9 sm:text-[14px]; }
|
|
33
|
+
.icon-btn { @apply grid h-11 w-11 shrink-0 place-items-center rounded-full text-ink-200 transition-colors duration-200 hover:text-white sm:h-9 sm:w-9; }
|
|
34
|
+
.field { @apply h-11 w-full rounded-[12px] bg-white/[0.07] px-3 text-[16px] text-ink-50 ring-1 ring-inset ring-white/10 transition-shadow duration-200 placeholder:text-ink-400 focus:ring-2 focus:ring-red-500 sm:h-9 sm:text-[14px]; }
|
|
35
|
+
.chip { @apply inline-flex h-10 shrink-0 items-center gap-2 whitespace-nowrap rounded-full pl-4 pr-1.5 text-[14px] font-medium transition-colors duration-200 sm:h-9; }
|
|
60
36
|
}
|
|
61
37
|
</style>
|
|
62
38
|
|
|
63
39
|
<style>
|
|
64
|
-
html
|
|
40
|
+
html { background: #000; }
|
|
65
41
|
body { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
|
66
|
-
::selection { background: rgba(
|
|
42
|
+
::selection { background: rgba(240,26,54,0.4); color: #fff; }
|
|
67
43
|
input { caret-color: var(--color-red-400); }
|
|
68
|
-
|
|
69
|
-
/* keyboard focus: one red ring everywhere */
|
|
70
44
|
:focus-visible { outline: 2px solid var(--color-red-400); outline-offset: 2px; }
|
|
71
|
-
|
|
45
|
+
.field:focus-visible, [data-pin-idx]:focus-visible { outline: none; }
|
|
46
|
+
[tabindex="-1"]:focus { outline: none; }
|
|
72
47
|
.tile:focus-visible { outline: none; }
|
|
73
48
|
.tile:focus-visible .tile-icon { outline: 2px solid var(--color-red-400); outline-offset: 3px; }
|
|
74
|
-
|
|
75
|
-
/* safe areas (viewport-fit=cover) */
|
|
76
49
|
.page-x { padding-left: max(1.25rem, env(safe-area-inset-left)); padding-right: max(1.25rem, env(safe-area-inset-right)); }
|
|
77
|
-
|
|
78
|
-
/* game icons: 10% edge so dark art never melts into the canvas */
|
|
79
50
|
.tile-img { outline: 1px solid rgba(255,255,255,0.1); outline-offset: -1px; }
|
|
80
51
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.glass-sheet {
|
|
91
|
-
|
|
92
|
-
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
|
93
|
-
backdrop-filter: blur(40px) saturate(180%);
|
|
94
|
-
}
|
|
95
|
-
.glass::before {
|
|
96
|
-
content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; pointer-events: none;
|
|
52
|
+
/* ---------- ambient colour: gives the glass something to refract ---------- */
|
|
53
|
+
.ambient { position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
|
54
|
+
background:
|
|
55
|
+
radial-gradient(680px 420px at 85% 2%, rgba(240,26,54,0.11), transparent 70%),
|
|
56
|
+
radial-gradient(720px 520px at 8% 108%, rgba(240,26,54,0.06), transparent 70%); }
|
|
57
|
+
|
|
58
|
+
/* ---------- liquid glass ---------- */
|
|
59
|
+
.glass { background: rgba(255,255,255,0.06); -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.22), inset 0 -1px 0 rgba(255,255,255,0.04), 0 10px 32px rgba(0,0,0,0.45); }
|
|
61
|
+
.glass-sheet { background: rgba(28,28,31,0.55); -webkit-backdrop-filter: blur(40px) saturate(180%); backdrop-filter: blur(40px) saturate(180%); }
|
|
62
|
+
.glass::before { content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; pointer-events: none;
|
|
97
63
|
background: linear-gradient(135deg, rgba(255,255,255,0.4), rgba(255,255,255,0.05) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.22));
|
|
98
|
-
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
99
|
-
-webkit-mask-composite: xor; mask-composite: exclude;
|
|
100
|
-
}
|
|
101
|
-
/* small controls: same edge light, no blur (nothing behind them to blur) */
|
|
64
|
+
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; }
|
|
102
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); }
|
|
103
66
|
.glass-lite:hover { background: rgba(255,255,255,0.12); }
|
|
104
|
-
|
|
105
|
-
@
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
67
|
+
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .glass { background: rgba(22,22,24,0.94); } .glass-sheet { background: rgba(18,18,20,0.97); } }
|
|
68
|
+
@media (prefers-reduced-transparency: reduce) { .glass, .glass-sheet { -webkit-backdrop-filter: none; backdrop-filter: none; background: #161618; } }
|
|
69
|
+
|
|
70
|
+
/* ---------- bubbly / inflated ---------- */
|
|
71
|
+
.orb { width: 14px; height: 14px; border-radius: 999px; flex-shrink: 0;
|
|
72
|
+
background: radial-gradient(circle at 32% 28%, #fff 0 12%, #ff7a8a 28%, #f01a36 62%, #a30c22);
|
|
73
|
+
box-shadow: 0 0 14px rgba(240,26,54,0.8), inset 0 -2px 3px rgba(0,0,0,0.25); }
|
|
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;
|
|
75
|
+
background: linear-gradient(180deg, #ff6b7d, #f01a36 60%, #c80f27);
|
|
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); }
|
|
77
|
+
.glow-line { display: inline-block; padding: 0 0 0.1em;
|
|
78
|
+
background: linear-gradient(180deg, #ffb8c0 0%, #ff4d63 48%, #f01a36 100%); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
|
79
|
+
.btn-primary { color: #fff; background: linear-gradient(180deg, #ff6b7d, #f01a36 55%, #c80f27);
|
|
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); }
|
|
81
|
+
.btn-primary:hover { filter: brightness(1.08); }
|
|
82
|
+
.btn-ghost { color: var(--color-ink-50); background: rgba(255,255,255,0.08); box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), inset 0 0 0 1px rgba(255,255,255,0.08); }
|
|
83
|
+
.btn-ghost:hover { background: rgba(255,255,255,0.14); }
|
|
84
|
+
.chip-on { background: linear-gradient(180deg, #ff6b7d, #f01a36 60%, #c80f27);
|
|
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); }
|
|
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); }
|
|
88
|
+
.chip-on .chip-count { background: #fff; color: #b50e28; box-shadow: none; }
|
|
89
|
+
|
|
90
|
+
/* game tiles: glass card, glossy icon, glow in the category colour */
|
|
91
|
+
.tile { background: rgba(255,255,255,0.05); border-radius: 28px; padding: 10px 10px 12px;
|
|
92
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.16), inset 0 0 0 1px rgba(255,255,255,0.07);
|
|
93
|
+
transition: background-color .3s ease-out, box-shadow .3s ease-out; }
|
|
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
|
+
.tile-icon::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
|
|
97
|
+
background: linear-gradient(160deg, rgba(255,255,255,0.3), rgba(255,255,255,0) 42%);
|
|
98
|
+
box-shadow: inset 0 2px 0 rgba(255,255,255,0.35), inset 0 -8px 14px rgba(0,0,0,0.25); }
|
|
99
|
+
|
|
100
|
+
/* hero shapes */
|
|
101
|
+
.float { display: block; width: 100%; height: auto; aspect-ratio: 1; filter: drop-shadow(0 14px 22px var(--g)); transform: rotate(var(--r, 0deg));
|
|
102
|
+
animation: float 6s ease-in-out infinite; animation-delay: var(--d, 0s); }
|
|
103
|
+
@keyframes float { 0%, 100% { transform: translateY(0) rotate(var(--r, 0deg)); } 50% { transform: translateY(-12px) rotate(calc(var(--r, 0deg) + 4deg)); } }
|
|
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
|
+
/* ---------- motion ---------- */
|
|
121
|
+
.w { display: inline-block; opacity: 0; }
|
|
122
|
+
.glowwrap { display: inline-block; filter: drop-shadow(0 0 22px rgba(240,26,54,0.55)); }
|
|
123
|
+
.shape { position: absolute; opacity: 0; }
|
|
124
|
+
.par { will-change: transform; }
|
|
125
|
+
.ambient { animation: breathe 10s ease-in-out infinite; }
|
|
126
|
+
@keyframes breathe { 0%, 100% { opacity: 0.75; } 50% { opacity: 1; } }
|
|
127
|
+
.badge { position: relative; overflow: hidden; }
|
|
128
|
+
.badge::after { content: ""; position: absolute; inset: 0; pointer-events: none;
|
|
129
|
+
background: linear-gradient(105deg, transparent 35%, rgba(255,255,255,0.6) 50%, transparent 65%);
|
|
130
|
+
transform: translateX(-120%); animation: sheen 4.5s ease-in-out 1.6s infinite; }
|
|
131
|
+
@keyframes sheen { 0%, 55% { transform: translateX(-120%); } 100% { transform: translateX(120%); } }
|
|
132
|
+
.shimmer { background: linear-gradient(100deg, #161618 30%, #242428 50%, #161618 70%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
|
133
|
+
@keyframes shimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } }
|
|
134
|
+
#search-wrap { width: 100%; max-width: 16rem; transition: max-width .4s cubic-bezier(.22,1,.36,1); }
|
|
135
|
+
#search-wrap:focus-within { max-width: 22rem; }
|
|
136
|
+
.chip { transition: translate .2s ease-out, background-color .2s, color .2s; }
|
|
137
|
+
.chip:hover { translate: 0 -2px; }
|
|
138
|
+
.chip:active { scale: .94; }
|
|
139
|
+
.tile { position: relative; }
|
|
140
|
+
.tile::before { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; opacity: 0; transition: opacity .3s;
|
|
141
|
+
background: radial-gradient(170px circle at var(--mx, 50%) var(--my, 50%), rgba(255,255,255,0.15), transparent 70%); }
|
|
142
|
+
.tile:hover::before { opacity: 1; }
|
|
143
|
+
.tile-icon { transform: perspective(700px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); transition-timing-function: cubic-bezier(.34,1.56,.64,1); }
|
|
144
|
+
.tile.moving .tile-icon { transition-duration: .12s; transition-timing-function: ease-out; }
|
|
145
|
+
.pulse-orb { animation: pulse-orb 1.2s ease-in-out infinite; }
|
|
146
|
+
@keyframes pulse-orb { 0%, 100% { transform: scale(.8); opacity: .6; } 50% { transform: scale(1.25); opacity: 1; } }
|
|
147
|
+
@media (prefers-reduced-motion: reduce) { .ambient, .badge::after, .shimmer, .pulse-orb { animation: none; } .tile-icon { transform: none; } }
|
|
111
148
|
|
|
112
149
|
.no-scrollbar { scrollbar-width: none; }
|
|
113
150
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
|
@@ -115,30 +152,38 @@
|
|
|
115
152
|
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
116
153
|
*::-webkit-scrollbar-thumb { background: #2c2c30; border-radius: 999px; }
|
|
117
154
|
|
|
118
|
-
|
|
119
|
-
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
|
155
|
+
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
|
120
156
|
.rise { animation: rise 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; animation-delay: var(--d, 0s); }
|
|
121
|
-
@media (prefers-reduced-motion: reduce) { .rise { animation: none; } }
|
|
122
|
-
|
|
157
|
+
@media (prefers-reduced-motion: reduce) { .rise, .float { animation: none; } }
|
|
123
158
|
</style>
|
|
124
159
|
</head>
|
|
125
160
|
|
|
126
161
|
<body class="min-h-screen bg-ink-950 font-sans text-ink-50 antialiased">
|
|
127
162
|
|
|
163
|
+
<a href="#games-heading" class="skip">Skip to games</a>
|
|
164
|
+
|
|
165
|
+
<div class="ambient" aria-hidden="true"></div>
|
|
166
|
+
|
|
167
|
+
<!-- shared gradients for the hero shapes -->
|
|
168
|
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
|
|
169
|
+
<radialGradient id="gP" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffc0c7"/><stop offset=".38" stop-color="#ff3347"/><stop offset="1" stop-color="#b30d25"/></radialGradient>
|
|
170
|
+
<radialGradient id="gO" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffb8c0"/><stop offset=".38" stop-color="#e8142f"/><stop offset="1" stop-color="#7d0a1c"/></radialGradient>
|
|
171
|
+
<radialGradient id="gL" cx=".35" cy=".25" r=".9"><stop offset="0" stop-color="#ffd0d5"/><stop offset=".38" stop-color="#ff5c70"/><stop offset="1" stop-color="#c11228"/></radialGradient>
|
|
172
|
+
</defs></svg>
|
|
173
|
+
|
|
128
174
|
<!-- ================= HEADER ================= -->
|
|
129
175
|
<header class="pointer-events-none sticky top-0 z-40 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
|
130
176
|
<div class="page-x mx-auto max-w-5xl">
|
|
131
177
|
<div class="glass pointer-events-auto relative flex h-14 items-center gap-4 rounded-full pl-5 pr-2">
|
|
132
|
-
<a href="#" class="flex shrink-0 items-center gap-2" aria-label="mlv2 home">
|
|
133
|
-
<span class="
|
|
178
|
+
<a href="#" class="flex shrink-0 items-center gap-2.5" aria-label="mlv2 home">
|
|
179
|
+
<span class="orb"></span>
|
|
134
180
|
<span class="text-[17px] font-semibold tracking-[-0.02em]">mlv2</span>
|
|
135
181
|
</a>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<span id="count-badge" class="hidden shrink-0 text-[13px] tabular-nums text-ink-400 sm:block"><span id="count-num">0</span> games</span>
|
|
139
|
-
<div class="relative w-full max-w-[16rem]">
|
|
182
|
+
<div role="search" class="ml-auto flex min-w-0 flex-1 items-center justify-end">
|
|
183
|
+
<div id="search-wrap" class="relative">
|
|
140
184
|
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
141
|
-
<input id="search" type="
|
|
185
|
+
<input id="search" type="search" placeholder="Search games" aria-label="Search games" autocomplete="off" enterkeyhint="search" class="field rounded-full pl-9 pr-9" />
|
|
186
|
+
<button id="search-clear" type="button" aria-label="Clear search" class="absolute right-1.5 top-1/2 hidden h-7 w-7 -translate-y-1/2 place-items-center rounded-full text-ink-300 hover:text-white"><svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
|
142
187
|
</div>
|
|
143
188
|
</div>
|
|
144
189
|
</div>
|
|
@@ -146,48 +191,74 @@
|
|
|
146
191
|
</header>
|
|
147
192
|
|
|
148
193
|
<!-- ================= MAIN ================= -->
|
|
149
|
-
<main class="page-x mx-auto max-w-5xl pb-24">
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<h1 class="
|
|
154
|
-
<p class="rise mt-4 text-[19px] text-ink-300 sm:text-[21px]" style="--d: 0.
|
|
194
|
+
<main class="page-x relative z-10 mx-auto max-w-5xl pb-24">
|
|
195
|
+
|
|
196
|
+
<section class="relative pb-10 pt-12 sm:pb-14 sm:pt-20">
|
|
197
|
+
<div class="rise mb-5"><span id="count-badge" class="badge"><svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg><span><span id="count-num">0</span> games</span></span></div>
|
|
198
|
+
<h1 class="text-balance text-[2.75rem] font-semibold leading-[1.04] tracking-[-0.04em] text-ink-50 sm:text-[4.5rem]"><span class="w">school</span> <span class="w">sucks,</span><br/><span class="glowwrap"><span class="w glow-line">games</span> <span class="w glow-line">r</span> <span class="w glow-line">better</span></span></h1>
|
|
199
|
+
<p class="rise mt-4 text-[19px] text-ink-300 sm:text-[21px]" style="--d: 0.6s">we're so back</p>
|
|
200
|
+
|
|
201
|
+
<!-- floating bubbly shapes (large screens) -->
|
|
202
|
+
<div id="shapes" class="pointer-events-none absolute right-2 top-4 hidden h-[360px] w-[400px] lg:block" aria-hidden="true">
|
|
203
|
+
<div class="shape" style="left:30px;top:90px;width:200px"><div class="par" data-depth="1"><svg viewBox="0 0 120 120" class="float" style="--r:-8deg;--g:rgba(240,26,54,.4);--d:-1s">
|
|
204
|
+
<rect x="8" y="30" width="104" height="62" rx="31" fill="url(#gP)"/>
|
|
205
|
+
<path d="M34 50v24M22 62h24" stroke="#fff" stroke-width="9" stroke-linecap="round" opacity=".92"/>
|
|
206
|
+
<circle cx="82" cy="54" r="6" fill="#fff" opacity=".92"/><circle cx="96" cy="68" r="6" fill="#fff" opacity=".92"/>
|
|
207
|
+
<ellipse cx="44" cy="40" rx="28" ry="7" fill="#fff" opacity=".45"/>
|
|
208
|
+
</svg></div></div>
|
|
209
|
+
<div class="shape" style="left:255px;top:10px;width:120px"><div class="par" data-depth="1.6"><svg viewBox="0 0 120 120" class="float" style="--r:10deg;--g:rgba(240,26,54,.35);--d:-3s">
|
|
210
|
+
<polygon points="60,16 71.8,43.8 101.9,46.4 79,66.2 85.9,95.6 60,80 34.1,95.6 41,66.2 18.2,46.4 48.2,43.8" fill="url(#gO)" stroke="url(#gO)" stroke-width="14" stroke-linejoin="round"/>
|
|
211
|
+
<ellipse cx="48" cy="40" rx="14" ry="5" fill="#fff" opacity=".5" transform="rotate(-25 48 40)"/>
|
|
212
|
+
</svg></div></div>
|
|
213
|
+
<div class="shape" style="left:255px;top:175px;width:105px"><div class="par" data-depth="0.8"><svg viewBox="0 0 120 120" class="float" style="--r:-6deg;--g:rgba(240,26,54,.35);--d:-2s">
|
|
214
|
+
<path d="M44 34 L90 60 L44 86 Z" fill="url(#gL)" stroke="url(#gL)" stroke-width="16" stroke-linejoin="round"/>
|
|
215
|
+
<ellipse cx="50" cy="48" rx="8" ry="4" fill="#fff" opacity=".55" transform="rotate(35 50 48)"/>
|
|
216
|
+
</svg></div></div>
|
|
217
|
+
<div class="shape" style="left:55px;top:268px;width:84px"><div class="par" data-depth="1.3"><svg viewBox="0 0 120 120" class="float" style="--r:8deg;--g:rgba(240,26,54,.3);--d:-4s">
|
|
218
|
+
<path d="M60 26v68M26 60h68" fill="none" stroke="url(#gP)" stroke-width="30" stroke-linecap="round"/>
|
|
219
|
+
<ellipse cx="52" cy="30" rx="6" ry="3" fill="#fff" opacity=".55"/>
|
|
220
|
+
</svg></div></div>
|
|
221
|
+
<div class="shape" style="left:165px;top:14px;width:52px"><div class="par" data-depth="2"><svg viewBox="0 0 120 120" class="float" style="--r:0deg;--g:rgba(240,26,54,.35);--d:-5s">
|
|
222
|
+
<circle cx="60" cy="60" r="42" fill="url(#gP)"/><ellipse cx="46" cy="42" rx="16" ry="8" fill="#fff" opacity=".55" transform="rotate(-30 46 42)"/>
|
|
223
|
+
</svg></div></div>
|
|
224
|
+
<div class="shape" style="left:175px;top:300px;width:40px"><div class="par" data-depth="1.8"><svg viewBox="0 0 120 120" class="float" style="--r:0deg;--g:rgba(240,26,54,.3);--d:-2.5s">
|
|
225
|
+
<circle cx="60" cy="60" r="42" fill="url(#gL)"/><ellipse cx="46" cy="42" rx="16" ry="8" fill="#fff" opacity=".55" transform="rotate(-30 46 42)"/>
|
|
226
|
+
</svg></div></div>
|
|
227
|
+
</div>
|
|
155
228
|
</section>
|
|
156
229
|
|
|
157
|
-
|
|
158
|
-
<div id="category-bar" role="group" aria-label="Categories" class="no-scrollbar -mx-5 mb-8 flex items-center gap-2 overflow-x-auto px-5 sm:mx-0 sm:mb-10 sm:px-0"></div>
|
|
230
|
+
<div id="category-bar" role="group" aria-label="Categories" class="no-scrollbar -mx-5 -mt-2 mb-6 flex items-center gap-2 overflow-x-auto px-5 py-3 sm:mx-0 sm:mb-8 sm:px-0"></div>
|
|
159
231
|
|
|
160
|
-
|
|
161
|
-
<div id="setup-banner" class="mb-8 hidden rounded-xl bg-ink-900 p-4 ring-1 ring-white/10">
|
|
232
|
+
<div id="setup-banner" class="glass-lite mb-8 hidden rounded-2xl p-4">
|
|
162
233
|
<p class="text-[15px] font-semibold">Not connected yet</p>
|
|
163
234
|
<p class="mt-1 text-[14px] leading-relaxed text-ink-300">Open this file and fill in the <code class="font-mono text-ink-50">CONFIG</code> object near the bottom with your Appwrite endpoint, project, database, table and bucket IDs.</p>
|
|
164
235
|
</div>
|
|
165
236
|
|
|
166
|
-
|
|
167
|
-
<
|
|
237
|
+
<h2 id="games-heading" tabindex="-1" class="sr-only">Games</h2>
|
|
238
|
+
<p id="grid-status" role="status" class="sr-only"></p>
|
|
239
|
+
<div id="skeleton-grid" class="grid grid-cols-3 gap-3 sm:grid-cols-4 sm:gap-4 md:grid-cols-5 lg:grid-cols-6"></div>
|
|
168
240
|
|
|
169
|
-
|
|
170
|
-
<div id="empty-state" class="hidden flex-col items-center justify-center py-24 text-center">
|
|
241
|
+
<div id="empty-state" aria-live="polite" class="hidden flex-col items-center justify-center py-24 text-center">
|
|
171
242
|
<p id="empty-title" class="text-[19px] font-semibold">No games yet</p>
|
|
172
|
-
<p id="empty-sub" class="mt-2 max-w-xs text-pretty text-[15px] text-ink-
|
|
243
|
+
<p id="empty-sub" class="mt-2 max-w-xs text-pretty text-[15px] text-ink-300">Games you add in Appwrite with a valid link will show up here automatically.</p>
|
|
244
|
+
<button id="retry" type="button" class="btn btn-ghost mt-5 hidden">Try again</button>
|
|
173
245
|
</div>
|
|
174
246
|
|
|
175
|
-
|
|
176
|
-
<div id="grid" class="hidden grid grid-cols-3 gap-x-4 gap-y-7 sm:grid-cols-4 sm:gap-x-5 sm:gap-y-8 md:grid-cols-5 lg:grid-cols-6"></div>
|
|
247
|
+
<div id="grid" class="hidden grid grid-cols-3 gap-3 sm:grid-cols-4 sm:gap-4 md:grid-cols-5 lg:grid-cols-6"></div>
|
|
177
248
|
</main>
|
|
178
249
|
|
|
179
250
|
<!-- ================= FOOTER ================= -->
|
|
180
|
-
<footer class="border-t border-white/[0.08]">
|
|
251
|
+
<footer class="relative z-10 border-t border-white/[0.08]">
|
|
181
252
|
<div class="page-x mx-auto flex max-w-5xl items-center justify-between py-4 text-[13px] text-ink-400">
|
|
182
253
|
<p>mlv2 © <span id="year"></span></p>
|
|
183
|
-
<button id="admin-trigger" class="-
|
|
254
|
+
<button id="admin-trigger" aria-haspopup="dialog" class="glass-lite rounded-full px-4 py-2 transition-colors duration-200 hover:text-ink-50">Admin</button>
|
|
184
255
|
</div>
|
|
185
256
|
</footer>
|
|
186
257
|
|
|
187
258
|
<!-- ================= PLAYER MODAL ================= -->
|
|
188
259
|
<div id="player-modal" class="fixed inset-0 z-50 hidden">
|
|
189
260
|
<div class="absolute inset-0 bg-black" data-player-close></div>
|
|
190
|
-
<div id="player-panel" class="relative flex h-full w-full flex-col opacity-0">
|
|
261
|
+
<div id="player-panel" role="dialog" aria-modal="true" aria-labelledby="player-title" class="relative flex h-full w-full flex-col opacity-0">
|
|
191
262
|
<div class="border-b border-white/[0.08] bg-black pt-[env(safe-area-inset-top)]">
|
|
192
263
|
<div class="flex h-14 items-center justify-between gap-3 px-3 sm:px-4">
|
|
193
264
|
<div class="flex min-w-0 items-center gap-3">
|
|
@@ -195,6 +266,9 @@
|
|
|
195
266
|
<p id="player-title" class="truncate text-[15px] font-semibold"></p>
|
|
196
267
|
</div>
|
|
197
268
|
<div class="flex shrink-0 items-center gap-2">
|
|
269
|
+
<button id="player-fs" type="button" title="Fullscreen" aria-label="Fullscreen" class="glass-lite icon-btn">
|
|
270
|
+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
|
|
271
|
+
</button>
|
|
198
272
|
<a id="player-newtab" target="_blank" rel="noopener" title="Open in new tab" aria-label="Open in new tab" class="glass-lite icon-btn">
|
|
199
273
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
|
|
200
274
|
</a>
|
|
@@ -205,7 +279,8 @@
|
|
|
205
279
|
</div>
|
|
206
280
|
</div>
|
|
207
281
|
<div class="relative flex-1 bg-black">
|
|
208
|
-
<
|
|
282
|
+
<div id="player-loading" class="absolute inset-0 grid place-items-center"><span class="orb pulse-orb" style="width:22px;height:22px"></span></div>
|
|
283
|
+
<iframe id="player-frame" class="relative h-full w-full" allow="autoplay; fullscreen; gamepad; pointer-lock; cross-origin-isolated" allowfullscreen loading="eager"></iframe>
|
|
209
284
|
</div>
|
|
210
285
|
</div>
|
|
211
286
|
</div>
|
|
@@ -214,17 +289,12 @@
|
|
|
214
289
|
<div id="admin-login-modal" class="fixed inset-0 z-50 hidden">
|
|
215
290
|
<div class="absolute inset-0 bg-black/50 backdrop-blur-md" data-admin-login-close></div>
|
|
216
291
|
<div class="absolute inset-0 flex items-center justify-center p-4">
|
|
217
|
-
<form id="admin-login-form" class="glass glass-sheet relative w-full max-w-[340px] rounded-[28px] p-6 opacity-0" data-fx>
|
|
218
|
-
<
|
|
292
|
+
<form id="admin-login-form" role="dialog" aria-modal="true" aria-labelledby="pin-title" class="glass glass-sheet relative w-full max-w-[340px] rounded-[28px] p-6 opacity-0" data-fx>
|
|
293
|
+
<h2 id="pin-title" class="text-[19px] font-semibold">Enter PIN</h2>
|
|
219
294
|
<p class="mt-1.5 text-pretty text-[14px] text-ink-400">This unlocks the admin screen on this device. Write access is controlled by your Appwrite permissions.</p>
|
|
220
|
-
|
|
221
|
-
<div id="pin-boxes" class="mt-5 grid grid-cols-6 gap-2">
|
|
222
|
-
<!-- 6 single-digit inputs, filled in by JS -->
|
|
223
|
-
</div>
|
|
295
|
+
<div id="pin-boxes" role="group" aria-labelledby="pin-title" class="mt-5 grid grid-cols-6 gap-2"></div>
|
|
224
296
|
<input id="admin-pin" type="hidden" />
|
|
225
|
-
|
|
226
297
|
<p id="admin-login-error" class="mt-3 hidden text-[14px] text-red-400" role="alert"></p>
|
|
227
|
-
|
|
228
298
|
<button type="submit" id="admin-login-submit" class="btn btn-primary mt-5 w-full">Unlock</button>
|
|
229
299
|
<button type="button" data-admin-login-close class="btn btn-ghost mt-2 w-full">Cancel</button>
|
|
230
300
|
</form>
|
|
@@ -233,66 +303,38 @@
|
|
|
233
303
|
|
|
234
304
|
<!-- ================= ADMIN: PANEL ================= -->
|
|
235
305
|
<div id="admin-panel" class="fixed inset-0 z-50 hidden">
|
|
236
|
-
<div class="absolute inset-0 bg-black/50 backdrop-blur-md"
|
|
306
|
+
<div id="admin-backdrop" class="absolute inset-0 bg-black/50 backdrop-blur-md"></div>
|
|
237
307
|
<div class="absolute inset-0 flex items-start justify-center p-0 sm:items-center sm:p-4">
|
|
238
|
-
<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]"
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
<p class="text-[
|
|
243
|
-
<p class="mt-1 text-[13px] text-ink-400">Unlocked with PIN</p>
|
|
308
|
+
<div id="admin-panel-card" role="dialog" aria-modal="true" aria-labelledby="admin-title" class="glass glass-sheet relative flex h-full w-full flex-col opacity-0 sm:h-[85vh] sm:max-w-2xl sm:rounded-[32px]">
|
|
309
|
+
<div class="flex shrink-0 items-center justify-between gap-3 border-b border-white/[0.08] px-5 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
|
310
|
+
<div class="min-w-0">
|
|
311
|
+
<h2 id="admin-title" tabindex="-1" class="text-[17px] font-semibold leading-tight">Manage games</h2>
|
|
312
|
+
<p id="admin-summary" class="mt-0.5 text-[13px] text-ink-300"></p>
|
|
244
313
|
</div>
|
|
245
|
-
<div class="flex items-center gap-2">
|
|
246
|
-
<button id="admin-logout" class="btn btn-ghost">Lock</button>
|
|
247
|
-
<button
|
|
314
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
315
|
+
<button id="admin-logout" type="button" class="btn btn-ghost">Lock</button>
|
|
316
|
+
<button id="admin-close" type="button" aria-label="Close" class="glass-lite icon-btn">
|
|
248
317
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
|
249
318
|
</button>
|
|
250
319
|
</div>
|
|
251
320
|
</div>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
</div>
|
|
263
|
-
<div>
|
|
264
|
-
<label for="new-category" class="text-[13px] text-ink-300">Category (optional)</label>
|
|
265
|
-
<input id="new-category" class="field mt-1.5" />
|
|
266
|
-
</div>
|
|
267
|
-
<div class="sm:col-span-2">
|
|
268
|
-
<label for="new-url" class="text-[13px] text-ink-300">Game URL</label>
|
|
269
|
-
<input id="new-url" required type="url" placeholder="https://" class="field mt-1.5" />
|
|
270
|
-
</div>
|
|
271
|
-
<div>
|
|
272
|
-
<label for="new-icon-file" class="text-[13px] text-ink-300">Icon upload</label>
|
|
273
|
-
<input id="new-icon-file" type="file" accept="image/*" class="mt-2 w-full text-[13px] text-ink-400 file:mr-3 file:rounded-full file:border-0 file:bg-white/10 file:px-3.5 file:py-1.5 file:text-[13px] file:font-medium file:text-ink-50" />
|
|
274
|
-
</div>
|
|
275
|
-
<div>
|
|
276
|
-
<label for="new-icon-url" class="text-[13px] text-ink-300">Or external icon URL</label>
|
|
277
|
-
<input id="new-icon-url" type="url" placeholder="https://" class="field mt-1.5" />
|
|
278
|
-
</div>
|
|
279
|
-
</div>
|
|
280
|
-
<div class="mt-5 flex items-center gap-3">
|
|
281
|
-
<button type="submit" id="add-game-submit" class="btn btn-primary">Add game</button>
|
|
282
|
-
<span id="add-game-status" class="text-[13px] text-ink-400"></span>
|
|
283
|
-
</div>
|
|
284
|
-
</form>
|
|
285
|
-
|
|
286
|
-
<!-- existing games -->
|
|
287
|
-
<p class="mb-3 mt-8 text-[15px] font-semibold">Games</p>
|
|
288
|
-
<div id="admin-list" class="space-y-2"></div>
|
|
321
|
+
<div class="flex shrink-0 gap-2 px-5 pt-4">
|
|
322
|
+
<div class="min-w-0 flex-1">
|
|
323
|
+
<label for="admin-filter" class="sr-only">Filter games</label>
|
|
324
|
+
<input id="admin-filter" type="search" placeholder="Filter games" autocomplete="off" class="field" />
|
|
325
|
+
</div>
|
|
326
|
+
<button id="admin-add" type="button" class="btn btn-primary shrink-0">Add game</button>
|
|
327
|
+
</div>
|
|
328
|
+
<div id="admin-body" class="flex-1 overflow-y-auto px-5 py-4">
|
|
329
|
+
<div id="admin-form-slot"></div>
|
|
330
|
+
<ul id="admin-list" class="space-y-2" aria-label="Games"></ul>
|
|
289
331
|
</div>
|
|
332
|
+
<p id="admin-live" role="status" class="sr-only"></p>
|
|
290
333
|
</div>
|
|
291
334
|
</div>
|
|
292
335
|
</div>
|
|
293
336
|
|
|
294
|
-
|
|
295
|
-
<div id="toast-root" class="fixed bottom-4 right-4 z-[60] flex flex-col items-end gap-2 pb-[env(safe-area-inset-bottom)]"></div>
|
|
337
|
+
<div id="toast-root" role="status" aria-live="polite" class="pointer-events-none fixed inset-x-0 bottom-4 z-[60] flex flex-col items-center gap-2 px-4 pb-[env(safe-area-inset-bottom)]"></div>
|
|
296
338
|
|
|
297
339
|
<script src="https://cdn.jsdelivr.net/npm/appwrite@26.2.0"></script>
|
|
298
340
|
<script type="module">
|
|
@@ -302,7 +344,7 @@ const CONFIG = {
|
|
|
302
344
|
endpoint: "https://nyc.cloud.appwrite.io/v1",
|
|
303
345
|
projectId: "6a9b8a1b001b9c61537f",
|
|
304
346
|
databaseId: "6a9b8b23003de0ec5cf7",
|
|
305
|
-
gamesTableId: "6a9b8bb3000013a3fd80",
|
|
347
|
+
gamesTableId: "6a9b8bb3000013a3fd80",
|
|
306
348
|
iconsBucketId: "6a9b8b36002565ac5987",
|
|
307
349
|
};
|
|
308
350
|
|
|
@@ -316,9 +358,7 @@ const tablesDB = new TablesDB(client);
|
|
|
316
358
|
const storage = new Storage(client);
|
|
317
359
|
|
|
318
360
|
const isConfigured = () =>
|
|
319
|
-
!CONFIG.endpoint.includes("YOUR_") &&
|
|
320
|
-
!CONFIG.projectId.includes("YOUR_") &&
|
|
321
|
-
!CONFIG.databaseId.includes("YOUR_");
|
|
361
|
+
!CONFIG.endpoint.includes("YOUR_") && !CONFIG.projectId.includes("YOUR_") && !CONFIG.databaseId.includes("YOUR_");
|
|
322
362
|
|
|
323
363
|
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
324
364
|
function fx(targets, keyframes, options = {}) {
|
|
@@ -326,10 +366,20 @@ function fx(targets, keyframes, options = {}) {
|
|
|
326
366
|
return animate(targets, keyframes, options);
|
|
327
367
|
}
|
|
328
368
|
|
|
369
|
+
function pop(targets, { y = 12, scale = 0.94, delay = 0, stiff = 320, damp = 24 } = {}) {
|
|
370
|
+
if (reduceMotion) return animate(targets, { opacity: 1 }, { duration: 0 });
|
|
371
|
+
animate(targets, { opacity: [0, 1] }, { duration: 0.35, delay, ease: "easeOut" });
|
|
372
|
+
return animate(targets, { y: [y, 0], scale: [scale, 1] }, { type: "spring", stiffness: stiff, damping: damp, delay });
|
|
373
|
+
}
|
|
374
|
+
|
|
329
375
|
/* ------------------------------- state ------------------------------- */
|
|
330
376
|
let allGames = [];
|
|
331
377
|
let activeCategory = "All";
|
|
332
378
|
let searchTerm = "";
|
|
379
|
+
const BRAND = "#f01a36";
|
|
380
|
+
const PALETTE = ["#f01a36"];
|
|
381
|
+
let catColors = {};
|
|
382
|
+
let barShown = false;
|
|
333
383
|
|
|
334
384
|
const el = (id) => document.getElementById(id);
|
|
335
385
|
document.getElementById("year").textContent = new Date().getFullYear();
|
|
@@ -348,7 +398,7 @@ function monogramSrc(name) {
|
|
|
348
398
|
const initials = (name || "?").trim().slice(0, 2).toUpperCase();
|
|
349
399
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
350
400
|
<rect width="100" height="100" fill="#161618"/>
|
|
351
|
-
<text x="50" y="58" font-family="-apple-system, Inter, sans-serif" font-size="34" font-weight="600" fill="#
|
|
401
|
+
<text x="50" y="58" font-family="-apple-system, Inter, sans-serif" font-size="34" font-weight="600" fill="#f01a36" text-anchor="middle">${initials}</text>
|
|
352
402
|
</svg>`;
|
|
353
403
|
return "data:image/svg+xml;utf8," + encodeURIComponent(svg);
|
|
354
404
|
}
|
|
@@ -361,10 +411,10 @@ function toast(message, tone = "default") {
|
|
|
361
411
|
const root = el("toast-root");
|
|
362
412
|
const node = document.createElement("div");
|
|
363
413
|
const toneClasses = tone === "error" ? "text-red-300" : "text-ink-50";
|
|
364
|
-
node.className = `glass glass-sheet relative rounded-full ${toneClasses} px-4 py-2.5 text-[14px] opacity-0`;
|
|
414
|
+
node.className = `glass glass-sheet pointer-events-auto relative rounded-full ${toneClasses} px-4 py-2.5 text-[14px] opacity-0`;
|
|
365
415
|
node.textContent = message;
|
|
366
416
|
root.appendChild(node);
|
|
367
|
-
|
|
417
|
+
pop(node, { y: 16, scale: 0.9, stiff: 420, damp: 24 });
|
|
368
418
|
setTimeout(() => {
|
|
369
419
|
fx(node, { opacity: [1, 0], y: [0, 6] }, { duration: 0.2, ease: "easeIn" }).finished.then(() => node.remove());
|
|
370
420
|
}, 2800);
|
|
@@ -373,7 +423,7 @@ function toast(message, tone = "default") {
|
|
|
373
423
|
/* -------------------------- render: skeleton -------------------------- */
|
|
374
424
|
function renderSkeleton() {
|
|
375
425
|
el("skeleton-grid").innerHTML = Array.from({ length: 12 })
|
|
376
|
-
.map(() => `<div class="
|
|
426
|
+
.map(() => `<div class="glass-lite rounded-[28px] p-2.5"><div class="shimmer aspect-square rounded-[22.37%]"></div><div class="shimmer mx-auto mt-3 h-3 w-2/3 rounded-full"></div></div>`)
|
|
377
427
|
.join("");
|
|
378
428
|
}
|
|
379
429
|
renderSkeleton();
|
|
@@ -387,30 +437,41 @@ function currentFiltered() {
|
|
|
387
437
|
});
|
|
388
438
|
}
|
|
389
439
|
|
|
390
|
-
function renderCategoryBar() {
|
|
391
|
-
const
|
|
440
|
+
function renderCategoryBar(mode) {
|
|
441
|
+
const names = [...new Set(allGames.map((g) => g.category || "Uncategorized"))];
|
|
442
|
+
catColors = {};
|
|
443
|
+
names.forEach((n, i) => (catColors[n] = PALETTE[i % PALETTE.length]));
|
|
444
|
+
const countOf = (n) => allGames.filter((g) => (g.category || "Uncategorized") === n).length;
|
|
392
445
|
const bar = el("category-bar");
|
|
393
|
-
bar.innerHTML =
|
|
446
|
+
bar.innerHTML = ["All", ...names]
|
|
394
447
|
.map((c) => {
|
|
395
448
|
const active = c === activeCategory;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
449
|
+
const color = c === "All" ? BRAND : catColors[c];
|
|
450
|
+
const n = c === "All" ? allGames.length : countOf(c);
|
|
451
|
+
return `<button data-cat="${escapeHtml(c)}" aria-pressed="${active}" style="--c:${color}" class="chip ${mode === "intro" ? "opacity-0 " : ""}${
|
|
452
|
+
active ? "chip-on text-white" : "glass-lite text-ink-200 hover:text-ink-50"
|
|
453
|
+
}">${escapeHtml(c)}<span class="chip-count">${n}</span></button>`;
|
|
399
454
|
})
|
|
400
455
|
.join("");
|
|
401
456
|
bar.querySelectorAll("[data-cat]").forEach((btn) =>
|
|
402
457
|
btn.addEventListener("click", () => {
|
|
403
458
|
activeCategory = btn.dataset.cat;
|
|
404
|
-
renderCategoryBar();
|
|
459
|
+
renderCategoryBar("click");
|
|
405
460
|
renderGrid();
|
|
406
461
|
})
|
|
407
462
|
);
|
|
463
|
+
if (mode === "intro") pop("#category-bar .chip", { y: 10, scale: 0.9, delay: stagger(0.05, { startDelay: 0.3 }), stiff: 380, damp: 22 });
|
|
464
|
+
if (mode === "click" && !reduceMotion) {
|
|
465
|
+
const on = bar.querySelector('[aria-pressed="true"]');
|
|
466
|
+
if (on) animate(on, { scale: [0.88, 1] }, { type: "spring", stiffness: 520, damping: 16 });
|
|
467
|
+
}
|
|
408
468
|
}
|
|
409
469
|
|
|
410
470
|
let gridShown = false;
|
|
411
471
|
function renderGrid() {
|
|
412
472
|
const grid = el("grid");
|
|
413
473
|
const games = currentFiltered();
|
|
474
|
+
el("grid-status").textContent = allGames.length === 0 ? "No games yet" : games.length === 0 ? "No games match your search" : `${games.length} ${games.length === 1 ? "game" : "games"} shown`;
|
|
414
475
|
el("skeleton-grid").classList.add("hidden");
|
|
415
476
|
|
|
416
477
|
if (allGames.length === 0) {
|
|
@@ -435,11 +496,12 @@ function renderGrid() {
|
|
|
435
496
|
grid.innerHTML = games
|
|
436
497
|
.map((g) => {
|
|
437
498
|
const src = iconSrcFor(g) || monogramSrc(g.name);
|
|
438
|
-
|
|
439
|
-
|
|
499
|
+
const color = catColors[g.category || "Uncategorized"] || BRAND;
|
|
500
|
+
return `<button data-fx data-id="${g.$id}" style="--c:${color}" class="tile group block w-full text-left opacity-0">
|
|
501
|
+
<span class="tile-icon block aspect-square overflow-hidden rounded-[22.37%] bg-ink-800 transition-transform duration-300 ease-out group-hover:scale-[1.05] group-active:scale-[0.97]">
|
|
440
502
|
<img src="${src}" alt="" loading="lazy" class="tile-img h-full w-full object-cover" onerror="this.onerror=null;this.src='${monogramSrc(g.name)}'" />
|
|
441
503
|
</span>
|
|
442
|
-
<span class="mt-
|
|
504
|
+
<span class="mt-3 block truncate text-center text-[13px] font-medium leading-tight text-ink-50">${escapeHtml(g.name)}</span>
|
|
443
505
|
${g.category ? `<span class="mt-0.5 block truncate text-center text-[12px] text-ink-400">${escapeHtml(g.category)}</span>` : ""}
|
|
444
506
|
</button>`;
|
|
445
507
|
})
|
|
@@ -449,14 +511,11 @@ function renderGrid() {
|
|
|
449
511
|
card.addEventListener("click", () => openPlayer(games.find((g) => g.$id === card.dataset.id)))
|
|
450
512
|
);
|
|
451
513
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
{ duration: gridShown ? 0.18 : 0.5, delay: gridShown ? 0 : stagger(0.03), ease: "easeOut" });
|
|
514
|
+
if (!gridShown) pop("#grid [data-fx]", { y: 22, scale: 0.88, delay: stagger(0.04, { startDelay: 0.2 }), stiff: 260, damp: 20 });
|
|
515
|
+
else pop("#grid [data-fx]", { y: 8, scale: 0.94, delay: stagger(0.015), stiff: 420, damp: 28 });
|
|
455
516
|
gridShown = true;
|
|
456
517
|
}
|
|
457
518
|
|
|
458
|
-
el("count-badge") && (el("count-num").textContent = "0");
|
|
459
|
-
|
|
460
519
|
/* ------------------------------ fetching ------------------------------ */
|
|
461
520
|
async function loadGames() {
|
|
462
521
|
if (!isConfigured()) {
|
|
@@ -475,11 +534,16 @@ async function loadGames() {
|
|
|
475
534
|
queries: [Query.limit(200), Query.orderAsc("order")],
|
|
476
535
|
});
|
|
477
536
|
allGames = res.rows.filter((g) => g.url && g.enabled !== false);
|
|
478
|
-
el("count-num").textContent
|
|
479
|
-
|
|
537
|
+
const from = Number(el("count-num").textContent) || 0;
|
|
538
|
+
if (reduceMotion || from === allGames.length) el("count-num").textContent = allGames.length;
|
|
539
|
+
else animate(from, allGames.length, { duration: 1, delay: 0.3, ease: "easeOut", onUpdate: (v) => (el("count-num").textContent = Math.round(v)) });
|
|
540
|
+
renderCategoryBar(barShown ? undefined : "intro");
|
|
541
|
+
barShown = true;
|
|
480
542
|
renderGrid();
|
|
481
543
|
} catch (err) {
|
|
482
544
|
console.error("Failed to load games:", err);
|
|
545
|
+
if (allGames.length) { toast("Couldn't refresh the game list", "error"); return; }
|
|
546
|
+
el("retry").classList.remove("hidden");
|
|
483
547
|
el("skeleton-grid").classList.add("hidden");
|
|
484
548
|
el("empty-title").textContent = "Couldn't load games";
|
|
485
549
|
el("empty-sub").textContent = err?.message
|
|
@@ -492,100 +556,119 @@ async function loadGames() {
|
|
|
492
556
|
loadGames();
|
|
493
557
|
|
|
494
558
|
/* --------------------------- search / filter --------------------------- */
|
|
559
|
+
const searchEl = el("search"), clearBtn = el("search-clear");
|
|
495
560
|
let searchDebounce;
|
|
496
|
-
|
|
561
|
+
function applySearch() { searchTerm = searchEl.value.trim().toLowerCase(); renderGrid(); }
|
|
562
|
+
searchEl.addEventListener("input", () => {
|
|
563
|
+
clearBtn.classList.toggle("hidden", !searchEl.value);
|
|
564
|
+
clearBtn.classList.toggle("grid", !!searchEl.value);
|
|
497
565
|
clearTimeout(searchDebounce);
|
|
498
|
-
searchDebounce = setTimeout(
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
566
|
+
searchDebounce = setTimeout(applySearch, 120);
|
|
567
|
+
});
|
|
568
|
+
clearBtn.addEventListener("click", () => {
|
|
569
|
+
searchEl.value = "";
|
|
570
|
+
clearBtn.classList.add("hidden");
|
|
571
|
+
clearBtn.classList.remove("grid");
|
|
572
|
+
applySearch();
|
|
573
|
+
searchEl.focus();
|
|
574
|
+
});
|
|
575
|
+
searchEl.addEventListener("keydown", (e) => { if (e.key === "Escape" && searchEl.value) clearBtn.click(); });
|
|
576
|
+
el("retry").addEventListener("click", () => {
|
|
577
|
+
el("retry").classList.add("hidden");
|
|
578
|
+
el("empty-state").classList.add("hidden");
|
|
579
|
+
el("empty-state").classList.remove("flex");
|
|
580
|
+
el("skeleton-grid").classList.remove("hidden");
|
|
581
|
+
loadGames();
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
/* ------------------------------- dialogs ------------------------------- */
|
|
585
|
+
const isHidden = (id) => el(id).classList.contains("hidden");
|
|
586
|
+
let opener = null;
|
|
587
|
+
function lockBg(on) { document.querySelectorAll("body > header, body > main, body > footer").forEach((n) => n.toggleAttribute("inert", on)); }
|
|
588
|
+
function beginDialog() { opener = document.activeElement; lockBg(true); }
|
|
589
|
+
function endDialog() { lockBg(false); if (opener && document.contains(opener)) opener.focus(); opener = null; }
|
|
590
|
+
const focusSoon = (node) => requestAnimationFrame(() => node && node.focus());
|
|
591
|
+
|
|
592
|
+
document.addEventListener("keydown", (e) => {
|
|
593
|
+
if (e.key === "Escape") {
|
|
594
|
+
if (!isHidden("player-modal")) closePlayer();
|
|
595
|
+
else if (!isHidden("admin-login-modal")) closeAdminLogin();
|
|
596
|
+
else if (!isHidden("admin-panel")) {
|
|
597
|
+
if (confirmingId) { const id = confirmingId; confirmingId = null; renderAdmin([`[data-act="delete"][data-id="${id}"]`]); }
|
|
598
|
+
else if (editingId) cancelForm();
|
|
599
|
+
else closeAdminPanel();
|
|
600
|
+
}
|
|
601
|
+
} else if (e.key === "/" && !e.ctrlKey && !e.metaKey && !e.altKey && isHidden("player-modal") && isHidden("admin-login-modal") && isHidden("admin-panel")
|
|
602
|
+
&& !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || "")) {
|
|
603
|
+
e.preventDefault();
|
|
604
|
+
el("search").focus();
|
|
605
|
+
}
|
|
502
606
|
});
|
|
503
607
|
|
|
504
608
|
/* ------------------------------- player ------------------------------- */
|
|
505
609
|
function openPlayer(game) {
|
|
506
610
|
if (!game) return;
|
|
611
|
+
beginDialog();
|
|
507
612
|
el("player-title").textContent = game.name;
|
|
613
|
+
el("player-frame").title = game.name;
|
|
508
614
|
el("player-icon").src = iconSrcFor(game) || monogramSrc(game.name);
|
|
509
615
|
el("player-newtab").href = game.url;
|
|
616
|
+
el("player-loading").style.display = "grid";
|
|
510
617
|
el("player-frame").src = game.url;
|
|
511
|
-
|
|
512
|
-
modal.classList.remove("hidden");
|
|
618
|
+
el("player-modal").classList.remove("hidden");
|
|
513
619
|
document.documentElement.style.overflow = "hidden";
|
|
514
|
-
|
|
620
|
+
pop("#player-panel", { y: 20, scale: 0.94, stiff: 260, damp: 26 });
|
|
621
|
+
focusSoon(el("player-close"));
|
|
515
622
|
}
|
|
516
623
|
function closePlayer() {
|
|
517
624
|
fx("#player-panel", { opacity: [1, 0], scale: [1, 0.98] }, { duration: 0.18, ease: "easeIn" }).finished.then(() => {
|
|
518
625
|
el("player-modal").classList.add("hidden");
|
|
519
|
-
el("player-frame").src = "";
|
|
626
|
+
el("player-frame").src = "about:blank";
|
|
520
627
|
document.documentElement.style.overflow = "";
|
|
628
|
+
endDialog();
|
|
521
629
|
});
|
|
522
630
|
}
|
|
523
631
|
el("player-close").addEventListener("click", closePlayer);
|
|
524
632
|
document.querySelector("[data-player-close]").addEventListener("click", closePlayer);
|
|
525
|
-
|
|
526
|
-
if (e.key === "Escape") {
|
|
527
|
-
if (!el("player-modal").classList.contains("hidden")) closePlayer();
|
|
528
|
-
if (!el("admin-panel").classList.contains("hidden")) closeAdminPanel();
|
|
529
|
-
if (!el("admin-login-modal").classList.contains("hidden")) closeAdminLogin();
|
|
530
|
-
}
|
|
531
|
-
});
|
|
633
|
+
el("player-fs").addEventListener("click", () => { const f = el("player-frame"); (f.requestFullscreen || f.webkitRequestFullscreen)?.call(f); });
|
|
532
634
|
|
|
533
|
-
/*
|
|
635
|
+
/* ------------------------------ admin: PIN ------------------------------ */
|
|
636
|
+
function isUnlocked() { return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1"; }
|
|
534
637
|
function openAdminLogin() {
|
|
638
|
+
beginDialog();
|
|
535
639
|
el("admin-login-modal").classList.remove("hidden");
|
|
536
|
-
|
|
640
|
+
pop("#admin-login-form", { y: 16, scale: 0.92, stiff: 380, damp: 24 });
|
|
537
641
|
resetPinBoxes();
|
|
538
|
-
pinInputs[0]
|
|
642
|
+
focusSoon(pinInputs[0]);
|
|
539
643
|
}
|
|
540
644
|
function closeAdminLogin() {
|
|
541
645
|
el("admin-login-modal").classList.add("hidden");
|
|
542
646
|
el("admin-login-error").classList.add("hidden");
|
|
647
|
+
endDialog();
|
|
543
648
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
fx("#admin-panel-card", { opacity: [0, 1], y: [12, 0] }, { duration: 0.28, ease: "easeOut" });
|
|
547
|
-
renderAdminList();
|
|
548
|
-
}
|
|
549
|
-
function closeAdminPanel() {
|
|
550
|
-
el("admin-panel").classList.add("hidden");
|
|
551
|
-
}
|
|
552
|
-
function isUnlocked() {
|
|
553
|
-
return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1";
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
el("admin-trigger").addEventListener("click", () => {
|
|
557
|
-
if (isUnlocked()) openAdminPanel();
|
|
558
|
-
else openAdminLogin();
|
|
559
|
-
});
|
|
560
|
-
document.querySelector("[data-admin-login-close]").addEventListener("click", closeAdminLogin);
|
|
561
|
-
document.querySelector("[data-admin-panel-close]").addEventListener("click", closeAdminPanel);
|
|
649
|
+
el("admin-trigger").addEventListener("click", () => { if (isUnlocked()) openAdminPanel(); else openAdminLogin(); });
|
|
650
|
+
document.querySelectorAll("[data-admin-login-close]").forEach((n) => n.addEventListener("click", closeAdminLogin));
|
|
562
651
|
|
|
563
|
-
/* ------- 6-digit PIN pad: build boxes, wire auto-advance + paste ------- */
|
|
564
652
|
const pinBoxesEl = el("pin-boxes");
|
|
565
653
|
pinBoxesEl.innerHTML = Array.from({ length: 6 })
|
|
566
|
-
.map(
|
|
567
|
-
|
|
568
|
-
`<input data-pin-idx="${i}" type="password" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit ${i + 1}"
|
|
569
|
-
class="h-12 w-full rounded-[10px] 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" />`
|
|
570
|
-
)
|
|
654
|
+
.map((_, i) => `<input data-pin-idx="${i}" type="password" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Digit ${i + 1} of 6"
|
|
655
|
+
class="h-12 w-full rounded-[12px] bg-white/[0.07] text-center text-[20px] tabular-nums outline-none ring-1 ring-inset ring-white/10 transition-shadow focus:ring-2 focus:ring-red-500" />`)
|
|
571
656
|
.join("");
|
|
572
657
|
const pinInputs = Array.from(pinBoxesEl.querySelectorAll("[data-pin-idx]"));
|
|
573
|
-
|
|
574
|
-
function
|
|
575
|
-
|
|
576
|
-
el("admin-pin").value = "";
|
|
577
|
-
}
|
|
578
|
-
function syncPinValue() {
|
|
579
|
-
el("admin-pin").value = pinInputs.map((i) => i.value).join("");
|
|
580
|
-
}
|
|
658
|
+
function resetPinBoxes() { pinInputs.forEach((i) => (i.value = "")); el("admin-pin").value = ""; }
|
|
659
|
+
function syncPinValue() { el("admin-pin").value = pinInputs.map((i) => i.value).join(""); }
|
|
660
|
+
function submitIfFull() { if (el("admin-pin").value.length === 6) el("admin-login-form").requestSubmit(); }
|
|
581
661
|
pinInputs.forEach((input, idx) => {
|
|
582
662
|
input.addEventListener("input", () => {
|
|
583
663
|
input.value = input.value.replace(/[^0-9]/g, "").slice(0, 1);
|
|
584
664
|
syncPinValue();
|
|
585
665
|
if (input.value && idx < pinInputs.length - 1) pinInputs[idx + 1].focus();
|
|
666
|
+
submitIfFull();
|
|
586
667
|
});
|
|
587
668
|
input.addEventListener("keydown", (e) => {
|
|
588
669
|
if (e.key === "Backspace" && !input.value && idx > 0) pinInputs[idx - 1].focus();
|
|
670
|
+
if (e.key === "ArrowLeft" && idx > 0) pinInputs[idx - 1].focus();
|
|
671
|
+
if (e.key === "ArrowRight" && idx < pinInputs.length - 1) pinInputs[idx + 1].focus();
|
|
589
672
|
});
|
|
590
673
|
input.addEventListener("paste", (e) => {
|
|
591
674
|
e.preventDefault();
|
|
@@ -593,146 +676,407 @@ pinInputs.forEach((input, idx) => {
|
|
|
593
676
|
digits.forEach((d, i) => pinInputs[i] && (pinInputs[i].value = d));
|
|
594
677
|
syncPinValue();
|
|
595
678
|
pinInputs[Math.min(digits.length, pinInputs.length - 1)]?.focus();
|
|
679
|
+
submitIfFull();
|
|
596
680
|
});
|
|
597
681
|
});
|
|
598
|
-
|
|
599
682
|
el("admin-login-form").addEventListener("submit", (e) => {
|
|
600
683
|
e.preventDefault();
|
|
601
684
|
const errBox = el("admin-login-error");
|
|
602
685
|
syncPinValue();
|
|
603
686
|
if (el("admin-pin").value === ADMIN_PIN) {
|
|
604
687
|
sessionStorage.setItem(ADMIN_SESSION_KEY, "1");
|
|
605
|
-
errBox.classList.add("hidden");
|
|
606
688
|
closeAdminLogin();
|
|
607
689
|
openAdminPanel();
|
|
608
690
|
} else {
|
|
609
|
-
errBox.textContent = "
|
|
691
|
+
errBox.textContent = "That PIN isn't right. Try again.";
|
|
610
692
|
errBox.classList.remove("hidden");
|
|
693
|
+
if (!reduceMotion) animate("#pin-boxes", { x: [0, -10, 10, -8, 8, -4, 0] }, { duration: 0.45 });
|
|
611
694
|
resetPinBoxes();
|
|
612
695
|
pinInputs[0]?.focus();
|
|
613
696
|
}
|
|
614
697
|
});
|
|
615
698
|
|
|
699
|
+
/* ---------------------------- admin: manage games ---------------------------- */
|
|
700
|
+
let adminRows = [], adminState = "loading", adminError = "";
|
|
701
|
+
let editingId = null, confirmingId = null, adminFilter = "", previewUrl = null;
|
|
702
|
+
|
|
703
|
+
const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0);
|
|
704
|
+
const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return u; } };
|
|
705
|
+
const isHttp = (v) => { try { const p = new URL(v).protocol; return p === "http:" || p === "https:"; } catch { return false; } };
|
|
706
|
+
const sr = (t) => `<span class="sr-only">${escapeHtml(t)}</span>`;
|
|
707
|
+
const rowIcon = (g) => iconSrcFor(g) || monogramSrc(g.name);
|
|
708
|
+
const adminSay = (msg) => { const n = el("admin-live"); n.textContent = ""; setTimeout(() => (n.textContent = msg), 60); };
|
|
709
|
+
const dbArgs = { databaseId: CONFIG.databaseId, tableId: CONFIG.gamesTableId };
|
|
710
|
+
|
|
711
|
+
function openAdminPanel() {
|
|
712
|
+
beginDialog();
|
|
713
|
+
editingId = null; confirmingId = null; adminFilter = ""; el("admin-filter").value = "";
|
|
714
|
+
el("admin-panel").classList.remove("hidden");
|
|
715
|
+
pop("#admin-panel-card", { y: 24, scale: 0.97, stiff: 300, damp: 28 });
|
|
716
|
+
focusSoon(el("admin-title"));
|
|
717
|
+
loadAdmin();
|
|
718
|
+
}
|
|
719
|
+
function closeAdminPanel() { el("admin-panel").classList.add("hidden"); endDialog(); }
|
|
720
|
+
el("admin-close").addEventListener("click", closeAdminPanel);
|
|
721
|
+
el("admin-backdrop").addEventListener("click", () => { if (!editingId) closeAdminPanel(); });
|
|
616
722
|
el("admin-logout").addEventListener("click", () => {
|
|
617
723
|
sessionStorage.removeItem(ADMIN_SESSION_KEY);
|
|
618
724
|
closeAdminPanel();
|
|
619
|
-
toast("
|
|
725
|
+
toast("Admin locked");
|
|
620
726
|
});
|
|
621
727
|
|
|
622
|
-
async function
|
|
623
|
-
|
|
624
|
-
|
|
728
|
+
async function loadAdmin() {
|
|
729
|
+
adminState = "loading";
|
|
730
|
+
renderAdmin();
|
|
625
731
|
try {
|
|
626
|
-
const res = await tablesDB.listRows({
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
732
|
+
const res = await tablesDB.listRows({ ...dbArgs, queries: [Query.limit(200), Query.orderAsc("order")] });
|
|
733
|
+
adminRows = res.rows.sort(byOrder);
|
|
734
|
+
adminState = "ready";
|
|
735
|
+
} catch (err) {
|
|
736
|
+
adminState = "error";
|
|
737
|
+
adminError = err?.message || "Couldn't load your games.";
|
|
738
|
+
}
|
|
739
|
+
renderAdmin();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function formHtml(g) {
|
|
743
|
+
const isEdit = !!g;
|
|
744
|
+
const v = (x) => escapeHtml(x ?? "");
|
|
745
|
+
const cats = [...new Set(adminRows.map((r) => r.category).filter(Boolean))];
|
|
746
|
+
const hasIcon = isEdit && (g.iconFileId || g.iconUrl);
|
|
747
|
+
const base = escapeHtml(isEdit ? rowIcon(g) : monogramSrc("+"));
|
|
748
|
+
return `<form id="game-form" novalidate aria-labelledby="game-form-title" class="glass-lite mb-4 rounded-2xl p-4">
|
|
749
|
+
<h3 id="game-form-title" class="text-[16px] font-semibold">${isEdit ? `Edit ${v(g.name)}` : "Add a game"}</h3>
|
|
750
|
+
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
|
751
|
+
<div>
|
|
752
|
+
<label for="gf-name" class="text-[13px] font-medium text-ink-200">Name</label>
|
|
753
|
+
<input id="gf-name" required autocomplete="off" aria-describedby="gf-name-err" value="${v(g?.name)}" class="field mt-1.5" />
|
|
754
|
+
<p id="gf-name-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
755
|
+
</div>
|
|
756
|
+
<div>
|
|
757
|
+
<label for="gf-category" class="text-[13px] font-medium text-ink-200">Category <span class="font-normal text-ink-300">(optional)</span></label>
|
|
758
|
+
<input id="gf-category" list="category-options" autocomplete="off" value="${v(g?.category)}" class="field mt-1.5" />
|
|
759
|
+
<datalist id="category-options">${cats.map((c) => `<option value="${v(c)}"></option>`).join("")}</datalist>
|
|
760
|
+
</div>
|
|
761
|
+
<div class="sm:col-span-2">
|
|
762
|
+
<label for="gf-url" class="text-[13px] font-medium text-ink-200">Game link</label>
|
|
763
|
+
<input id="gf-url" type="url" inputmode="url" required autocomplete="off" spellcheck="false" placeholder="https://" aria-describedby="gf-url-hint gf-url-err" value="${v(g?.url)}" class="field mt-1.5" />
|
|
764
|
+
<p id="gf-url-hint" class="mt-1.5 text-[13px] text-ink-300">The page that opens when someone taps the game.</p>
|
|
765
|
+
<p id="gf-url-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
766
|
+
</div>
|
|
767
|
+
</div>
|
|
768
|
+
<fieldset class="mt-4 min-w-0">
|
|
769
|
+
<legend class="text-[13px] font-medium text-ink-200">Icon</legend>
|
|
770
|
+
<div class="mt-2 flex items-start gap-4">
|
|
771
|
+
<img id="gf-preview" src="${base}" data-base="${base}" alt="Icon preview" class="tile-img h-16 w-16 shrink-0 rounded-[22.37%] object-cover" onerror="this.onerror=null;this.src=this.dataset.base" />
|
|
772
|
+
<div class="grid min-w-0 flex-1 gap-3">
|
|
773
|
+
<div>
|
|
774
|
+
<label for="gf-file" class="text-[13px] text-ink-300">Upload an image</label>
|
|
775
|
+
<input id="gf-file" type="file" accept="image/*" aria-describedby="gf-file-err" class="mt-1.5 w-full text-[13px] text-ink-300 file:mr-3 file:rounded-full file:border-0 file:bg-white/10 file:px-3.5 file:py-1.5 file:text-[13px] file:font-medium file:text-ink-50" />
|
|
776
|
+
<p id="gf-file-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
777
|
+
</div>
|
|
778
|
+
<div>
|
|
779
|
+
<label for="gf-iconurl" class="text-[13px] text-ink-300">…or paste an image link</label>
|
|
780
|
+
<input id="gf-iconurl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://" aria-describedby="gf-iconurl-err" value="${v(g?.iconUrl)}" class="field mt-1.5" />
|
|
781
|
+
<p id="gf-iconurl-err" role="alert" class="mt-1.5 hidden text-[13px] text-red-300"></p>
|
|
782
|
+
</div>
|
|
783
|
+
${hasIcon ? `<label class="flex items-center gap-2 text-[13px] text-ink-200"><input id="gf-remove" type="checkbox" class="h-4 w-4 accent-red-500" /> Remove the current icon (shows initials instead)</label>` : ""}
|
|
784
|
+
</div>
|
|
785
|
+
</div>
|
|
786
|
+
</fieldset>
|
|
787
|
+
<label class="mt-4 flex cursor-pointer items-center gap-3 text-[14px]"><input id="gf-enabled" type="checkbox" role="switch" class="switch" ${g?.enabled === false ? "" : "checked"} /> Show on the site</label>
|
|
788
|
+
<div class="mt-5 flex flex-wrap items-center gap-2">
|
|
789
|
+
<button type="submit" id="gf-submit" class="btn btn-primary">${isEdit ? "Save changes" : "Add game"}</button>
|
|
790
|
+
<button type="button" data-act="cancel-form" class="btn btn-ghost">Cancel</button>
|
|
791
|
+
</div>
|
|
792
|
+
</form>`;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function rowHtml(g, idx, canMove) {
|
|
796
|
+
if (editingId === g.$id) return `<li data-row="${g.$id}">${formHtml(g)}</li>`;
|
|
797
|
+
const n = escapeHtml(g.name);
|
|
798
|
+
const off = g.enabled === false;
|
|
799
|
+
const dis = editingId ? "disabled" : "";
|
|
800
|
+
const meta = [g.category, hostOf(g.url)].filter(Boolean).map(escapeHtml).join(" · ");
|
|
801
|
+
const arrow = (dir, d, label, disabled) => `<button type="button" data-act="${dir}" data-id="${g.$id}" aria-label="${label} ${n}" ${disabled || dis ? "disabled" : ""} class="glass-lite icon-btn"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg></button>`;
|
|
802
|
+
const actions = confirmingId === g.$id
|
|
803
|
+
? `<div role="group" aria-label="Confirm delete" class="mt-3 rounded-xl bg-red-500/10 p-3 ring-1 ring-red-500/30">
|
|
804
|
+
<p class="text-[14px]">Delete <strong>${n}</strong>? This can't be undone.</p>
|
|
805
|
+
<div class="mt-3 flex gap-2">
|
|
806
|
+
<button type="button" id="cancel-delete" data-act="cancel-delete" data-id="${g.$id}" class="btn btn-ghost">Cancel</button>
|
|
807
|
+
<button type="button" data-act="confirm-delete" data-id="${g.$id}" class="btn btn-danger">Delete</button>
|
|
643
808
|
</div>
|
|
644
|
-
<input data-field="category" data-id="${g.$id}" value="${escapeHtml(g.category || "")}" placeholder="Category" aria-label="Category" class="field hidden !w-28 sm:block" />
|
|
645
|
-
<label class="hidden shrink-0 items-center gap-2 text-[13px] text-ink-300 sm:flex">
|
|
646
|
-
<input type="checkbox" data-field="enabled" data-id="${g.$id}" ${g.enabled === false ? "" : "checked"} class="h-4 w-4 accent-red-500" /> On
|
|
647
|
-
</label>
|
|
648
|
-
<button data-delete="${g.$id}" title="Delete" aria-label="Delete" class="glass-lite icon-btn hover:!text-red-400">
|
|
649
|
-
<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>
|
|
650
|
-
</button>
|
|
651
809
|
</div>`
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
}
|
|
810
|
+
: `<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
811
|
+
${canMove ? arrow("up", "m6 15 6-6 6 6", "Move up:", idx === 0) + arrow("down", "m6 9 6 6 6-6", "Move down:", idx === adminRows.length - 1) : ""}
|
|
812
|
+
<button type="button" data-act="edit" data-id="${g.$id}" ${dis} class="btn btn-ghost">Edit${sr(" " + g.name)}</button>
|
|
813
|
+
<button type="button" data-act="delete" data-id="${g.$id}" ${dis} class="btn btn-ghost ml-auto hover:!text-red-300">Delete${sr(" " + g.name)}</button>
|
|
814
|
+
</div>`;
|
|
815
|
+
return `<li data-row="${g.$id}" class="glass-lite rounded-2xl p-3">
|
|
816
|
+
<div class="flex items-center gap-3">
|
|
817
|
+
<img src="${escapeHtml(rowIcon(g))}" alt="" class="tile-img h-12 w-12 shrink-0 rounded-[22.37%] object-cover ${off ? "opacity-50" : ""}" onerror="this.onerror=null;this.src='${monogramSrc(g.name)}'" />
|
|
818
|
+
<div class="min-w-0 flex-1">
|
|
819
|
+
<p class="truncate text-[15px] font-medium">${n}${off ? '<span class="tag">Hidden</span>' : ""}</p>
|
|
820
|
+
<p class="truncate text-[13px] text-ink-300">${meta}</p>
|
|
821
|
+
</div>
|
|
822
|
+
<label class="flex shrink-0 cursor-pointer items-center gap-2 text-[13px] text-ink-200">
|
|
823
|
+
<input type="checkbox" role="switch" class="switch" data-toggle="${g.$id}" ${off ? "" : "checked"} ${dis} />
|
|
824
|
+
<span>Visible${sr(" on site: " + g.name)}</span>
|
|
825
|
+
</label>
|
|
826
|
+
</div>
|
|
827
|
+
${actions}
|
|
828
|
+
</li>`;
|
|
829
|
+
}
|
|
670
830
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
831
|
+
function renderAdmin(focusSels = []) {
|
|
832
|
+
el("admin-form-slot").innerHTML = editingId === "new" ? formHtml(null) : "";
|
|
833
|
+
const list = el("admin-list"), sum = el("admin-summary");
|
|
834
|
+
el("admin-add").disabled = !!editingId;
|
|
835
|
+
el("admin-filter").disabled = !!editingId;
|
|
836
|
+
if (adminState === "loading") { sum.textContent = ""; list.innerHTML = `<li class="py-8 text-center text-[14px] text-ink-300">Loading games…</li>`; return; }
|
|
837
|
+
if (adminState === "error") {
|
|
838
|
+
sum.textContent = "";
|
|
839
|
+
list.innerHTML = `<li class="py-8 text-center"><p class="text-[14px] text-red-300">${escapeHtml(adminError)}</p><button type="button" data-act="retry" class="btn btn-ghost mt-3">Try again</button></li>`;
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const q = adminFilter;
|
|
843
|
+
const rows = q ? adminRows.filter((g) => `${g.name} ${g.category || ""}`.toLowerCase().includes(q)) : adminRows;
|
|
844
|
+
const visible = adminRows.filter((g) => g.enabled !== false).length;
|
|
845
|
+
sum.textContent = q ? `Showing ${rows.length} of ${adminRows.length}` : `${adminRows.length} ${adminRows.length === 1 ? "game" : "games"} · ${visible} visible`;
|
|
846
|
+
list.innerHTML = rows.length
|
|
847
|
+
? rows.map((g) => rowHtml(g, adminRows.indexOf(g), !q)).join("")
|
|
848
|
+
: `<li class="py-8 text-center text-[14px] text-ink-300">${adminRows.length ? `No games match “${escapeHtml(adminFilter)}”.` : "No games yet. Choose “Add game” to add your first one."}</li>`;
|
|
849
|
+
if (editingId) { const n = el("gf-name"); n.focus(); n.scrollIntoView({ block: "nearest" }); return; }
|
|
850
|
+
for (const sel of focusSels) {
|
|
851
|
+
const t = el("admin-panel-card").querySelector(sel);
|
|
852
|
+
if (t && !t.disabled) { t.focus(); break; }
|
|
686
853
|
}
|
|
687
854
|
}
|
|
688
855
|
|
|
689
|
-
|
|
856
|
+
function cancelForm() {
|
|
857
|
+
const id = editingId;
|
|
858
|
+
editingId = null;
|
|
859
|
+
renderAdmin([id && id !== "new" ? `[data-act="edit"][data-id="${id}"]` : "#admin-add"]);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
el("admin-add").addEventListener("click", () => { editingId = "new"; confirmingId = null; renderAdmin(); });
|
|
863
|
+
el("admin-filter").addEventListener("input", (e) => { adminFilter = e.target.value.trim().toLowerCase(); renderAdmin(); });
|
|
864
|
+
|
|
865
|
+
el("admin-body").addEventListener("click", (e) => {
|
|
866
|
+
const b = e.target.closest("[data-act]");
|
|
867
|
+
if (!b || b.disabled) return;
|
|
868
|
+
const { act, id } = b.dataset;
|
|
869
|
+
if (act === "retry") loadAdmin();
|
|
870
|
+
else if (act === "edit") { editingId = id; confirmingId = null; renderAdmin(); }
|
|
871
|
+
else if (act === "cancel-form") cancelForm();
|
|
872
|
+
else if (act === "delete") { confirmingId = id; renderAdmin(["#cancel-delete"]); }
|
|
873
|
+
else if (act === "cancel-delete") { confirmingId = null; renderAdmin([`[data-act="delete"][data-id="${id}"]`]); }
|
|
874
|
+
else if (act === "confirm-delete") { b.disabled = true; b.textContent = "Deleting…"; deleteGame(id); }
|
|
875
|
+
else if (act === "up") moveGame(id, -1);
|
|
876
|
+
else if (act === "down") moveGame(id, 1);
|
|
877
|
+
});
|
|
878
|
+
el("admin-body").addEventListener("change", (e) => {
|
|
879
|
+
const t = e.target;
|
|
880
|
+
if (t.matches("[data-toggle]")) toggleGame(t.dataset.toggle, t.checked);
|
|
881
|
+
else if (t.id === "gf-file" || t.id === "gf-remove") updatePreview();
|
|
882
|
+
});
|
|
883
|
+
el("admin-body").addEventListener("input", (e) => {
|
|
884
|
+
const t = e.target;
|
|
885
|
+
if (t.getAttribute?.("aria-invalid")) setErr(t.id, "");
|
|
886
|
+
if (t.id === "gf-iconurl") updatePreview();
|
|
887
|
+
});
|
|
888
|
+
el("admin-body").addEventListener("submit", (e) => { if (e.target.id === "game-form") submitGameForm(e); });
|
|
889
|
+
|
|
890
|
+
function updatePreview() {
|
|
891
|
+
const img = el("gf-preview"), file = el("gf-file").files[0], url = el("gf-iconurl").value.trim();
|
|
892
|
+
if (previewUrl) { URL.revokeObjectURL(previewUrl); previewUrl = null; }
|
|
893
|
+
if (el("gf-remove")?.checked) img.src = monogramSrc(el("gf-name").value || "?");
|
|
894
|
+
else if (file && file.type.startsWith("image/")) img.src = previewUrl = URL.createObjectURL(file);
|
|
895
|
+
else if (isHttp(url)) img.src = url;
|
|
896
|
+
else img.src = img.dataset.base;
|
|
897
|
+
}
|
|
898
|
+
function setErr(id, msg) {
|
|
899
|
+
const inp = el(id), p = el(id + "-err");
|
|
900
|
+
p.textContent = msg || "";
|
|
901
|
+
p.classList.toggle("hidden", !msg);
|
|
902
|
+
if (msg) inp.setAttribute("aria-invalid", "true"); else inp.removeAttribute("aria-invalid");
|
|
903
|
+
}
|
|
904
|
+
function validateGameForm() {
|
|
905
|
+
const name = el("gf-name").value.trim(), url = el("gf-url").value.trim(), iu = el("gf-iconurl").value.trim(), f = el("gf-file").files[0];
|
|
906
|
+
const checks = [
|
|
907
|
+
["gf-name", name ? "" : "Enter a name."],
|
|
908
|
+
["gf-url", !url ? "Enter the game link." : isHttp(url) ? "" : "Enter a full link that starts with https://"],
|
|
909
|
+
["gf-iconurl", !iu || isHttp(iu) ? "" : "Enter a full link that starts with https://"],
|
|
910
|
+
["gf-file", !f || f.type.startsWith("image/") ? "" : "Choose an image file."],
|
|
911
|
+
];
|
|
912
|
+
let first = null;
|
|
913
|
+
checks.forEach(([id, msg]) => { setErr(id, msg); if (msg && !first) first = id; });
|
|
914
|
+
if (first) el(first).focus();
|
|
915
|
+
return !first;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function flashRow(id) {
|
|
919
|
+
const li = el("admin-list").querySelector(`[data-row="${id}"]`);
|
|
920
|
+
if (!li) return;
|
|
921
|
+
li.scrollIntoView({ block: "nearest" });
|
|
922
|
+
if (!reduceMotion) animate(li, { backgroundColor: ["rgba(240,26,54,0.4)", "rgba(255,255,255,0.07)"] }, { duration: 1.2 });
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function submitGameForm(e) {
|
|
690
926
|
e.preventDefault();
|
|
691
|
-
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
927
|
+
if (!validateGameForm()) return;
|
|
928
|
+
const isEdit = editingId && editingId !== "new";
|
|
929
|
+
const existing = isEdit ? adminRows.find((r) => r.$id === editingId) : null;
|
|
930
|
+
const name = el("gf-name").value.trim(), url = el("gf-url").value.trim(), category = el("gf-category").value.trim();
|
|
931
|
+
const iconField = el("gf-iconurl").value.trim(), file = el("gf-file").files[0], enabled = el("gf-enabled").checked, remove = !!el("gf-remove")?.checked;
|
|
932
|
+
const btn = el("gf-submit"), label = btn.textContent;
|
|
933
|
+
btn.disabled = true; btn.textContent = "Saving…";
|
|
934
|
+
let uploadedId = null, oldFile = null;
|
|
695
935
|
try {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
936
|
+
let iconFileId = existing?.iconFileId || "", iconUrl = existing?.iconUrl || "";
|
|
937
|
+
if (remove) { oldFile = iconFileId; iconFileId = ""; iconUrl = ""; }
|
|
938
|
+
else if (file) {
|
|
939
|
+
uploadedId = (await storage.createFile({ bucketId: CONFIG.iconsBucketId, fileId: ID.unique(), file })).$id;
|
|
940
|
+
oldFile = iconFileId; iconFileId = uploadedId; iconUrl = "";
|
|
941
|
+
} else if (iconField && iconField !== iconUrl) { oldFile = iconFileId; iconFileId = ""; iconUrl = iconField; }
|
|
942
|
+
else if (!iconField && iconUrl && !iconFileId) iconUrl = "";
|
|
943
|
+
const data = { name, url, category, iconFileId, iconUrl, enabled };
|
|
944
|
+
let row;
|
|
945
|
+
if (isEdit) {
|
|
946
|
+
row = await tablesDB.updateRow({ ...dbArgs, rowId: editingId, data });
|
|
947
|
+
adminRows = adminRows.map((r) => (r.$id === row.$id ? row : r));
|
|
948
|
+
} else {
|
|
949
|
+
data.order = adminRows.reduce((m, r) => Math.max(m, r.order ?? -1), -1) + 1;
|
|
950
|
+
row = await tablesDB.createRow({ ...dbArgs, rowId: ID.unique(), data });
|
|
951
|
+
adminRows = [...adminRows, row].sort(byOrder);
|
|
706
952
|
}
|
|
953
|
+
if (oldFile) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: oldFile }).catch(() => {});
|
|
954
|
+
editingId = null;
|
|
955
|
+
renderAdmin([`[data-act="edit"][data-id="${row.$id}"]`]);
|
|
956
|
+
flashRow(row.$id);
|
|
957
|
+
toast(isEdit ? `Saved ${name}` : `Added ${name}`);
|
|
958
|
+
loadGames();
|
|
959
|
+
} catch (err) {
|
|
960
|
+
if (uploadedId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: uploadedId }).catch(() => {});
|
|
961
|
+
btn.disabled = false; btn.textContent = label;
|
|
962
|
+
toast(err?.message || "Couldn't save. Check your connection and try again.", "error");
|
|
963
|
+
}
|
|
964
|
+
}
|
|
707
965
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
966
|
+
async function toggleGame(id, on) {
|
|
967
|
+
const row = adminRows.find((r) => r.$id === id);
|
|
968
|
+
if (!row) return;
|
|
969
|
+
const prev = row.enabled !== false;
|
|
970
|
+
row.enabled = on;
|
|
971
|
+
renderAdmin([`[data-toggle="${id}"]`]);
|
|
972
|
+
try {
|
|
973
|
+
await tablesDB.updateRow({ ...dbArgs, rowId: id, data: { enabled: on } });
|
|
974
|
+
adminSay(`${row.name} is now ${on ? "visible" : "hidden"}`);
|
|
975
|
+
loadGames();
|
|
976
|
+
} catch (err) {
|
|
977
|
+
row.enabled = prev;
|
|
978
|
+
renderAdmin([`[data-toggle="${id}"]`]);
|
|
979
|
+
toast(err?.message || "Couldn't update visibility", "error");
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function moveGame(id, dir) {
|
|
984
|
+
const list = [...adminRows].sort(byOrder);
|
|
985
|
+
const i = list.findIndex((r) => r.$id === id), j = i + dir;
|
|
986
|
+
if (i < 0 || j < 0 || j >= list.length) return;
|
|
987
|
+
const prev = list.map((r) => ({ ...r }));
|
|
988
|
+
[list[i], list[j]] = [list[j], list[i]];
|
|
989
|
+
const changes = [];
|
|
990
|
+
list.forEach((r, k) => { if (r.order !== k) { r.order = k; changes.push(r); } });
|
|
991
|
+
adminRows = list;
|
|
992
|
+
const name = list[j].name;
|
|
993
|
+
renderAdmin([`[data-act="${dir < 0 ? "up" : "down"}"][data-id="${id}"]`, `[data-act="${dir < 0 ? "down" : "up"}"][data-id="${id}"]`]);
|
|
994
|
+
adminSay(`${name} moved to position ${j + 1} of ${list.length}`);
|
|
995
|
+
try {
|
|
996
|
+
await Promise.all(changes.map((r) => tablesDB.updateRow({ ...dbArgs, rowId: r.$id, data: { order: r.order } })));
|
|
997
|
+
loadGames();
|
|
998
|
+
} catch (err) {
|
|
999
|
+
adminRows = prev;
|
|
1000
|
+
renderAdmin();
|
|
1001
|
+
toast(err?.message || "Couldn't change the order", "error");
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
722
1004
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1005
|
+
async function deleteGame(id) {
|
|
1006
|
+
const row = adminRows.find((r) => r.$id === id);
|
|
1007
|
+
if (!row) return;
|
|
1008
|
+
try {
|
|
1009
|
+
await tablesDB.deleteRow({ ...dbArgs, rowId: id });
|
|
1010
|
+
if (row.iconFileId) storage.deleteFile({ bucketId: CONFIG.iconsBucketId, fileId: row.iconFileId }).catch(() => {});
|
|
1011
|
+
adminRows = adminRows.filter((r) => r.$id !== id);
|
|
1012
|
+
confirmingId = null;
|
|
1013
|
+
renderAdmin(["#admin-add"]);
|
|
1014
|
+
toast(`Deleted ${row.name}`);
|
|
1015
|
+
loadGames();
|
|
728
1016
|
} catch (err) {
|
|
729
|
-
|
|
730
|
-
toast(err?.message || "Couldn't
|
|
731
|
-
} finally {
|
|
732
|
-
submitBtn.disabled = false;
|
|
733
|
-
setTimeout(() => (statusEl.textContent = ""), 2500);
|
|
1017
|
+
renderAdmin(["#cancel-delete"]);
|
|
1018
|
+
toast(err?.message || "Couldn't delete. Try again.", "error");
|
|
734
1019
|
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/* ---------------------------- tile hover FX ---------------------------- */
|
|
1023
|
+
const gridEl = el("grid");
|
|
1024
|
+
gridEl.addEventListener("pointermove", (e) => {
|
|
1025
|
+
if (e.pointerType !== "mouse" || reduceMotion) return;
|
|
1026
|
+
const t = e.target.closest(".tile");
|
|
1027
|
+
if (!t) return;
|
|
1028
|
+
const r = t.getBoundingClientRect();
|
|
1029
|
+
const x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height;
|
|
1030
|
+
t.classList.add("moving");
|
|
1031
|
+
t.style.setProperty("--mx", x * 100 + "%");
|
|
1032
|
+
t.style.setProperty("--my", y * 100 + "%");
|
|
1033
|
+
const icon = t.firstElementChild;
|
|
1034
|
+
icon.style.setProperty("--ry", ((x - 0.5) * 16).toFixed(2) + "deg");
|
|
1035
|
+
icon.style.setProperty("--rx", ((0.5 - y) * 16).toFixed(2) + "deg");
|
|
1036
|
+
});
|
|
1037
|
+
gridEl.addEventListener("pointerout", (e) => {
|
|
1038
|
+
const t = e.target.closest(".tile");
|
|
1039
|
+
if (!t || t.contains(e.relatedTarget)) return;
|
|
1040
|
+
t.classList.remove("moving");
|
|
1041
|
+
t.firstElementChild.style.removeProperty("--rx");
|
|
1042
|
+
t.firstElementChild.style.removeProperty("--ry");
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
el("player-frame").addEventListener("load", () => {
|
|
1046
|
+
if (el("player-frame").getAttribute("src") !== "about:blank") el("player-loading").style.display = "none";
|
|
735
1047
|
});
|
|
1048
|
+
|
|
1049
|
+
/* ------------------------------ hero motion ------------------------------ */
|
|
1050
|
+
(function hero() {
|
|
1051
|
+
const show = () => document.querySelectorAll(".w, .shape").forEach((n) => (n.style.opacity = 1));
|
|
1052
|
+
if (reduceMotion) return show();
|
|
1053
|
+
animate(".w", { opacity: [0, 1], y: [32, 0], filter: ["blur(14px)", "blur(0px)"] },
|
|
1054
|
+
{ duration: 0.85, delay: stagger(0.08, { startDelay: 0.1 }), ease: [0.22, 1, 0.36, 1] })
|
|
1055
|
+
.finished.then(() => document.querySelectorAll(".w").forEach((w) => (w.style.filter = "none")));
|
|
1056
|
+
animate(".shape", { opacity: [0, 1] }, { duration: 0.4, delay: stagger(0.1, { startDelay: 0.4 }) });
|
|
1057
|
+
animate(".shape", { scale: [0.2, 1], rotate: [-40, 0] }, { type: "spring", stiffness: 170, damping: 13, delay: stagger(0.1, { startDelay: 0.4 }) });
|
|
1058
|
+
|
|
1059
|
+
// gentle cursor + scroll parallax on the shapes
|
|
1060
|
+
const pars = [...document.querySelectorAll(".par")];
|
|
1061
|
+
const wrap = el("shapes");
|
|
1062
|
+
let tx = 0, ty = 0, cx = 0, cy = 0;
|
|
1063
|
+
addEventListener("pointermove", (e) => { tx = e.clientX / innerWidth - 0.5; ty = e.clientY / innerHeight - 0.5; }, { passive: true });
|
|
1064
|
+
(function loop() {
|
|
1065
|
+
cx += (tx - cx) * 0.07;
|
|
1066
|
+
cy += (ty - cy) * 0.07;
|
|
1067
|
+
if (wrap.offsetParent) {
|
|
1068
|
+
pars.forEach((p) => {
|
|
1069
|
+
const d = Number(p.dataset.depth);
|
|
1070
|
+
p.style.transform = `translate3d(${(-cx * 36 * d).toFixed(2)}px, ${(-cy * 36 * d - scrollY * 0.12 * d).toFixed(2)}px, 0)`;
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
requestAnimationFrame(loop);
|
|
1074
|
+
})();
|
|
1075
|
+
})();
|
|
1076
|
+
</script>
|
|
1077
|
+
<script>
|
|
1078
|
+
// safety net: never leave the title or shapes invisible if the module script is blocked
|
|
1079
|
+
setTimeout(function () { document.querySelectorAll(".w, .shape").forEach(function (n) { if (getComputedStyle(n).opacity < 1) n.style.opacity = 1; }); }, 3500);
|
|
736
1080
|
</script>
|
|
737
1081
|
</body>
|
|
738
1082
|
</html>
|