mlv2boii 1.0.7 → 1.0.9
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 +290 -236
- package/package.json +1 -1
package/index.html
CHANGED
|
@@ -7,164 +7,229 @@
|
|
|
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: #76767b;
|
|
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
|
input:focus-visible { outline: none; }
|
|
72
46
|
.tile:focus-visible { outline: none; }
|
|
73
47
|
.tile:focus-visible .tile-icon { outline: 2px solid var(--color-red-400); outline-offset: 3px; }
|
|
74
|
-
|
|
75
|
-
/* safe areas (viewport-fit=cover) */
|
|
76
48
|
.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
49
|
.tile-img { outline: 1px solid rgba(255,255,255,0.1); outline-offset: -1px; }
|
|
80
50
|
|
|
51
|
+
/* ---------- ambient colour: gives the glass something to refract ---------- */
|
|
52
|
+
.ambient { position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
|
53
|
+
background:
|
|
54
|
+
radial-gradient(680px 420px at 85% 2%, rgba(240,26,54,0.11), transparent 70%),
|
|
55
|
+
radial-gradient(720px 520px at 8% 108%, rgba(240,26,54,0.06), transparent 70%); }
|
|
56
|
+
|
|
57
|
+
/* ---------- liquid glass ---------- */
|
|
58
|
+
.glass { background: rgba(255,255,255,0.06); -webkit-backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
|
|
59
|
+
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); }
|
|
60
|
+
.glass-sheet { background: rgba(28,28,31,0.55); -webkit-backdrop-filter: blur(40px) saturate(180%); backdrop-filter: blur(40px) saturate(180%); }
|
|
61
|
+
.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.4), rgba(255,255,255,0.05) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.22));
|
|
63
|
+
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; }
|
|
64
|
+
.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
|
+
.glass-lite:hover { background: rgba(255,255,255,0.12); }
|
|
66
|
+
@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); } }
|
|
67
|
+
@media (prefers-reduced-transparency: reduce) { .glass, .glass-sheet { -webkit-backdrop-filter: none; backdrop-filter: none; background: #161618; } }
|
|
68
|
+
|
|
69
|
+
/* ---------- bubbly / inflated ---------- */
|
|
70
|
+
.orb { width: 14px; height: 14px; border-radius: 999px; flex-shrink: 0;
|
|
71
|
+
background: radial-gradient(circle at 32% 28%, #fff 0 12%, #ff7a8a 28%, #f01a36 62%, #a30c22);
|
|
72
|
+
box-shadow: 0 0 14px rgba(240,26,54,0.8), inset 0 -2px 3px rgba(0,0,0,0.25); }
|
|
73
|
+
.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
|
+
background: linear-gradient(180deg, #ff6b7d, #f01a36 60%, #c80f27);
|
|
75
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.55), inset 0 -2px 0 rgba(0,0,0,0.16), 0 6px 22px -2px rgba(240,26,54,0.7); }
|
|
76
|
+
.glow-line { display: inline-block; padding: 0 0.04em 0.1em;
|
|
77
|
+
background: linear-gradient(180deg, #ffb8c0 0%, #ff4d63 48%, #f01a36 100%); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
|
78
|
+
.btn-primary { color: #fff; background: linear-gradient(180deg, #ff6b7d, #f01a36 55%, #c80f27);
|
|
79
|
+
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); }
|
|
80
|
+
.btn-primary:hover { filter: brightness(1.08); }
|
|
81
|
+
.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); }
|
|
82
|
+
.btn-ghost:hover { background: rgba(255,255,255,0.14); }
|
|
83
|
+
.chip-on { background: linear-gradient(180deg, #ff6b7d, #f01a36 60%, #c80f27);
|
|
84
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.5), inset 0 -2px 0 rgba(0,0,0,0.16), 0 6px 20px -4px rgba(240,26,54,0.65); }
|
|
85
|
+
.chip-count { display: grid; place-items: center; min-width: 26px; height: 26px; padding: 0 8px; border-radius: 999px; font-size: 12px; font-weight: 700; color: #111; background: var(--c);
|
|
86
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.6), 0 0 14px color-mix(in srgb, var(--c) 55%, transparent); }
|
|
87
|
+
.chip-on .chip-count { background: #fff; color: #b50e28; box-shadow: none; }
|
|
88
|
+
|
|
89
|
+
/* game tiles: glass card, glossy icon, glow in the category colour */
|
|
90
|
+
.tile { background: rgba(255,255,255,0.05); border-radius: 28px; padding: 10px 10px 12px;
|
|
91
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.16), inset 0 0 0 1px rgba(255,255,255,0.07);
|
|
92
|
+
transition: background-color .3s ease-out, box-shadow .3s ease-out; }
|
|
93
|
+
.tile:hover { background: color-mix(in srgb, var(--c) 13%, rgba(255,255,255,0.05));
|
|
94
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.28), inset 0 0 0 1px color-mix(in srgb, var(--c) 55%, transparent), 0 16px 40px -10px color-mix(in srgb, var(--c) 60%, transparent); }
|
|
95
|
+
.tile-icon { position: relative; box-shadow: 0 10px 24px -8px color-mix(in srgb, var(--c) 28%, transparent); }
|
|
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
|
+
/* ---------- motion ---------- */
|
|
106
|
+
.w { display: inline-block; opacity: 0; }
|
|
107
|
+
.glowwrap { display: inline-block; filter: drop-shadow(0 0 22px rgba(240,26,54,0.55)); }
|
|
108
|
+
.shape { position: absolute; opacity: 0; }
|
|
109
|
+
.par { will-change: transform; }
|
|
110
|
+
.ambient { animation: breathe 10s ease-in-out infinite; }
|
|
111
|
+
@keyframes breathe { 0%, 100% { opacity: 0.75; } 50% { opacity: 1; } }
|
|
112
|
+
.badge { position: relative; overflow: hidden; }
|
|
113
|
+
.badge::after { content: ""; position: absolute; inset: 0; pointer-events: none;
|
|
114
|
+
background: linear-gradient(105deg, transparent 35%, rgba(255,255,255,0.6) 50%, transparent 65%);
|
|
115
|
+
transform: translateX(-120%); animation: sheen 4.5s ease-in-out 1.6s infinite; }
|
|
116
|
+
@keyframes sheen { 0%, 55% { transform: translateX(-120%); } 100% { transform: translateX(120%); } }
|
|
117
|
+
.shimmer { background: linear-gradient(100deg, #161618 30%, #242428 50%, #161618 70%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
|
|
118
|
+
@keyframes shimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } }
|
|
119
|
+
#search-wrap { width: 100%; max-width: 16rem; transition: max-width .4s cubic-bezier(.22,1,.36,1); }
|
|
120
|
+
#search-wrap:focus-within { max-width: 22rem; }
|
|
121
|
+
.chip { transition: translate .2s ease-out, background-color .2s, color .2s; }
|
|
122
|
+
.chip:hover { translate: 0 -2px; }
|
|
123
|
+
.chip:active { scale: .94; }
|
|
124
|
+
.tile { position: relative; }
|
|
125
|
+
.tile::before { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; opacity: 0; transition: opacity .3s;
|
|
126
|
+
background: radial-gradient(170px circle at var(--mx, 50%) var(--my, 50%), rgba(255,255,255,0.15), transparent 70%); }
|
|
127
|
+
.tile:hover::before { opacity: 1; }
|
|
128
|
+
.tile-icon { transform: perspective(700px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); transition-timing-function: cubic-bezier(.34,1.56,.64,1); }
|
|
129
|
+
.tile.moving .tile-icon { transition-duration: .12s; transition-timing-function: ease-out; }
|
|
130
|
+
.pulse-orb { animation: pulse-orb 1.2s ease-in-out infinite; }
|
|
131
|
+
@keyframes pulse-orb { 0%, 100% { transform: scale(.8); opacity: .6; } 50% { transform: scale(1.25); opacity: 1; } }
|
|
132
|
+
@media (prefers-reduced-motion: reduce) { .ambient, .badge::after, .shimmer, .pulse-orb { animation: none; } .tile-icon { transform: none; } }
|
|
133
|
+
|
|
81
134
|
.no-scrollbar { scrollbar-width: none; }
|
|
82
135
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
|
83
136
|
* { scrollbar-width: thin; scrollbar-color: #2c2c30 transparent; }
|
|
84
137
|
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
85
138
|
*::-webkit-scrollbar-thumb { background: #2c2c30; border-radius: 999px; }
|
|
86
139
|
|
|
87
|
-
|
|
88
|
-
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
|
140
|
+
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
|
89
141
|
.rise { animation: rise 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; animation-delay: var(--d, 0s); }
|
|
90
|
-
@media (prefers-reduced-motion: reduce) { .rise { animation: none; } }
|
|
91
|
-
|
|
92
|
-
/* Blank Overlay Lock Screen */
|
|
93
|
-
#blank-overlay {
|
|
94
|
-
position: fixed;
|
|
95
|
-
inset: 0;
|
|
96
|
-
background-color: #000000;
|
|
97
|
-
z-index: 99999;
|
|
98
|
-
display: flex;
|
|
99
|
-
transition: opacity 0.4s ease, visibility 0.4s ease;
|
|
100
|
-
}
|
|
101
|
-
#blank-overlay.unlocked {
|
|
102
|
-
opacity: 0;
|
|
103
|
-
visibility: hidden;
|
|
104
|
-
pointer-events: none;
|
|
105
|
-
}
|
|
142
|
+
@media (prefers-reduced-motion: reduce) { .rise, .float { animation: none; } }
|
|
106
143
|
</style>
|
|
107
144
|
</head>
|
|
108
145
|
|
|
109
146
|
<body class="min-h-screen bg-ink-950 font-sans text-ink-50 antialiased">
|
|
110
147
|
|
|
111
|
-
|
|
112
|
-
|
|
148
|
+
<div class="ambient" aria-hidden="true"></div>
|
|
149
|
+
|
|
150
|
+
<!-- shared gradients for the hero shapes -->
|
|
151
|
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
|
|
152
|
+
<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
|
+
<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
|
+
<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
|
+
</defs></svg>
|
|
113
158
|
|
|
114
159
|
<!-- ================= HEADER ================= -->
|
|
115
|
-
<header class="sticky top-0 z-40
|
|
116
|
-
<div class="page-x mx-auto
|
|
117
|
-
|
|
118
|
-
|
|
160
|
+
<header class="pointer-events-none sticky top-0 z-40 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
|
161
|
+
<div class="page-x mx-auto max-w-5xl">
|
|
162
|
+
<div class="glass pointer-events-auto relative flex h-14 items-center gap-4 rounded-full pl-5 pr-2">
|
|
163
|
+
<a href="#" class="flex shrink-0 items-center gap-2.5" aria-label="mlv2 home">
|
|
164
|
+
<span class="orb"></span>
|
|
119
165
|
<span class="text-[17px] font-semibold tracking-[-0.02em]">mlv2</span>
|
|
120
166
|
</a>
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
<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>
|
|
124
|
-
<div class="relative w-full max-w-[16rem]">
|
|
167
|
+
<div class="ml-auto flex min-w-0 flex-1 items-center justify-end">
|
|
168
|
+
<div id="search-wrap" class="relative">
|
|
125
169
|
<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>
|
|
126
|
-
<input id="search" type="text" placeholder="Search games" aria-label="Search games" autocomplete="off" enterkeyhint="search" class="field pl-9" />
|
|
170
|
+
<input id="search" type="text" placeholder="Search games" aria-label="Search games" autocomplete="off" enterkeyhint="search" class="field rounded-full pl-9" />
|
|
127
171
|
</div>
|
|
128
172
|
</div>
|
|
129
173
|
</div>
|
|
174
|
+
</div>
|
|
130
175
|
</header>
|
|
131
176
|
|
|
132
177
|
<!-- ================= MAIN ================= -->
|
|
133
|
-
<main class="page-x mx-auto max-w-5xl pb-24">
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<h1 class="
|
|
138
|
-
<p class="rise mt-4 text-[19px] text-ink-300 sm:text-[21px]" style="--d: 0.
|
|
178
|
+
<main class="page-x relative z-10 mx-auto max-w-5xl pb-24">
|
|
179
|
+
|
|
180
|
+
<section class="relative pb-10 pt-12 sm:pb-14 sm:pt-20">
|
|
181
|
+
<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>
|
|
182
|
+
<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>
|
|
183
|
+
<p class="rise mt-4 text-[19px] text-ink-300 sm:text-[21px]" style="--d: 0.6s">we're so back</p>
|
|
184
|
+
|
|
185
|
+
<!-- floating bubbly shapes (large screens) -->
|
|
186
|
+
<div id="shapes" class="pointer-events-none absolute right-2 top-4 hidden h-[360px] w-[400px] lg:block" aria-hidden="true">
|
|
187
|
+
<div class="shape" style="left:20px;top:120px;width:190px"><div class="par" data-depth="1"><svg viewBox="0 0 120 120" class="float" style="--r:-8deg;--g:rgba(240,26,54,.4);--d:-1s">
|
|
188
|
+
<rect x="8" y="30" width="104" height="62" rx="31" fill="url(#gP)"/>
|
|
189
|
+
<path d="M34 50v24M22 62h24" stroke="#fff" stroke-width="9" stroke-linecap="round" opacity=".92"/>
|
|
190
|
+
<circle cx="82" cy="54" r="6" fill="#fff" opacity=".92"/><circle cx="96" cy="68" r="6" fill="#fff" opacity=".92"/>
|
|
191
|
+
<ellipse cx="44" cy="40" rx="28" ry="7" fill="#fff" opacity=".45"/>
|
|
192
|
+
</svg></div></div>
|
|
193
|
+
<div class="shape" style="left:250px;top:0;width:120px"><div class="par" data-depth="1.6"><svg viewBox="0 0 120 120" class="float" style="--r:10deg;--g:rgba(240,26,54,.35);--d:-3s">
|
|
194
|
+
<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
|
+
<ellipse cx="48" cy="40" rx="14" ry="5" fill="#fff" opacity=".5" transform="rotate(-25 48 40)"/>
|
|
196
|
+
</svg></div></div>
|
|
197
|
+
<div class="shape" style="left:272px;top:170px;width:120px"><div class="par" data-depth="0.7"><svg viewBox="0 0 120 120" class="float" style="--r:-6deg;--g:rgba(255,255,255,.14);--d:-2s">
|
|
198
|
+
<path d="M44 34 L90 60 L44 86 Z" fill="url(#gB)" stroke="url(#gB)" stroke-width="16" stroke-linejoin="round"/>
|
|
199
|
+
<ellipse cx="50" cy="48" rx="8" ry="4" fill="#fff" opacity=".5" transform="rotate(35 50 48)"/>
|
|
200
|
+
</svg></div></div>
|
|
201
|
+
<div class="shape" style="left:60px;top:270px;width:86px"><div class="par" data-depth="1.3"><svg viewBox="0 0 120 120" class="float" style="--r:8deg;--g:rgba(240,26,54,.3);--d:-4s">
|
|
202
|
+
<path d="M60 26v68M26 60h68" fill="none" stroke="url(#gL)" stroke-width="30" stroke-linecap="round"/>
|
|
203
|
+
<ellipse cx="52" cy="30" rx="6" ry="3" fill="#fff" opacity=".55"/>
|
|
204
|
+
</svg></div></div>
|
|
205
|
+
<div class="shape" style="left:150px;top:20px;width:64px"><div class="par" data-depth="2"><svg viewBox="0 0 120 120" class="float" style="--r:0deg;--g:rgba(255,255,255,.12);--d:-5s">
|
|
206
|
+
<circle cx="60" cy="60" r="40" fill="url(#gV)"/><ellipse cx="46" cy="42" rx="16" ry="8" fill="#fff" opacity=".5" transform="rotate(-30 46 42)"/>
|
|
207
|
+
</svg></div></div>
|
|
208
|
+
</div>
|
|
139
209
|
</section>
|
|
140
210
|
|
|
141
|
-
|
|
142
|
-
<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>
|
|
211
|
+
<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>
|
|
143
212
|
|
|
144
|
-
|
|
145
|
-
<div id="setup-banner" class="mb-8 hidden rounded-xl bg-ink-900 p-4 ring-1 ring-white/10">
|
|
213
|
+
<div id="setup-banner" class="glass-lite mb-8 hidden rounded-2xl p-4">
|
|
146
214
|
<p class="text-[15px] font-semibold">Not connected yet</p>
|
|
147
215
|
<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>
|
|
148
216
|
</div>
|
|
149
217
|
|
|
150
|
-
|
|
151
|
-
<div id="skeleton-grid" class="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>
|
|
218
|
+
<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>
|
|
152
219
|
|
|
153
|
-
<!-- empty state -->
|
|
154
220
|
<div id="empty-state" class="hidden flex-col items-center justify-center py-24 text-center">
|
|
155
221
|
<p id="empty-title" class="text-[19px] font-semibold">No games yet</p>
|
|
156
222
|
<p id="empty-sub" class="mt-2 max-w-xs text-pretty text-[15px] text-ink-400">Games you add in Appwrite with a valid link will show up here automatically.</p>
|
|
157
223
|
</div>
|
|
158
224
|
|
|
159
|
-
|
|
160
|
-
<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>
|
|
225
|
+
<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>
|
|
161
226
|
</main>
|
|
162
227
|
|
|
163
228
|
<!-- ================= FOOTER ================= -->
|
|
164
|
-
<footer class="border-t border-white/[0.08]">
|
|
229
|
+
<footer class="relative z-10 border-t border-white/[0.08]">
|
|
165
230
|
<div class="page-x mx-auto flex max-w-5xl items-center justify-between py-4 text-[13px] text-ink-400">
|
|
166
231
|
<p>mlv2 © <span id="year"></span></p>
|
|
167
|
-
<button id="admin-trigger" class="-
|
|
232
|
+
<button id="admin-trigger" class="glass-lite rounded-full px-4 py-2 transition-colors duration-200 hover:text-ink-50">Admin</button>
|
|
168
233
|
</div>
|
|
169
234
|
</footer>
|
|
170
235
|
|
|
@@ -179,36 +244,32 @@
|
|
|
179
244
|
<p id="player-title" class="truncate text-[15px] font-semibold"></p>
|
|
180
245
|
</div>
|
|
181
246
|
<div class="flex shrink-0 items-center gap-2">
|
|
182
|
-
<a id="player-newtab" target="_blank" rel="noopener" title="Open in new tab" aria-label="Open in new tab" class="icon-btn">
|
|
247
|
+
<a id="player-newtab" target="_blank" rel="noopener" title="Open in new tab" aria-label="Open in new tab" class="glass-lite icon-btn">
|
|
183
248
|
<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>
|
|
184
249
|
</a>
|
|
185
|
-
<button id="player-close" title="Close" aria-label="Close" class="icon-btn">
|
|
250
|
+
<button id="player-close" title="Close" aria-label="Close" class="glass-lite icon-btn">
|
|
186
251
|
<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>
|
|
187
252
|
</button>
|
|
188
253
|
</div>
|
|
189
254
|
</div>
|
|
190
255
|
</div>
|
|
191
256
|
<div class="relative flex-1 bg-black">
|
|
192
|
-
<
|
|
257
|
+
<div id="player-loading" class="absolute inset-0 grid place-items-center"><span class="orb pulse-orb" style="width:22px;height:22px"></span></div>
|
|
258
|
+
<iframe id="player-frame" class="relative h-full w-full" allow="autoplay; fullscreen; gamepad; pointer-lock; cross-origin-isolated" allowfullscreen loading="eager"></iframe>
|
|
193
259
|
</div>
|
|
194
260
|
</div>
|
|
195
261
|
</div>
|
|
196
262
|
|
|
197
263
|
<!-- ================= ADMIN: PIN GATE ================= -->
|
|
198
264
|
<div id="admin-login-modal" class="fixed inset-0 z-50 hidden">
|
|
199
|
-
<div class="absolute inset-0 bg-black/
|
|
265
|
+
<div class="absolute inset-0 bg-black/50 backdrop-blur-md" data-admin-login-close></div>
|
|
200
266
|
<div class="absolute inset-0 flex items-center justify-center p-4">
|
|
201
|
-
<form id="admin-login-form" class="w-full max-w-[340px] rounded-[28px]
|
|
267
|
+
<form id="admin-login-form" class="glass glass-sheet relative w-full max-w-[340px] rounded-[28px] p-6 opacity-0" data-fx>
|
|
202
268
|
<p class="text-[19px] font-semibold">Enter PIN</p>
|
|
203
269
|
<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>
|
|
204
|
-
|
|
205
|
-
<div id="pin-boxes" class="mt-5 grid grid-cols-6 gap-2">
|
|
206
|
-
<!-- 6 single-digit inputs, filled in by JS -->
|
|
207
|
-
</div>
|
|
270
|
+
<div id="pin-boxes" class="mt-5 grid grid-cols-6 gap-2"></div>
|
|
208
271
|
<input id="admin-pin" type="hidden" />
|
|
209
|
-
|
|
210
272
|
<p id="admin-login-error" class="mt-3 hidden text-[14px] text-red-400" role="alert"></p>
|
|
211
|
-
|
|
212
273
|
<button type="submit" id="admin-login-submit" class="btn btn-primary mt-5 w-full">Unlock</button>
|
|
213
274
|
<button type="button" data-admin-login-close class="btn btn-ghost mt-2 w-full">Cancel</button>
|
|
214
275
|
</form>
|
|
@@ -217,10 +278,9 @@
|
|
|
217
278
|
|
|
218
279
|
<!-- ================= ADMIN: PANEL ================= -->
|
|
219
280
|
<div id="admin-panel" class="fixed inset-0 z-50 hidden">
|
|
220
|
-
<div class="absolute inset-0 bg-black/
|
|
281
|
+
<div class="absolute inset-0 bg-black/50 backdrop-blur-md" data-admin-panel-close></div>
|
|
221
282
|
<div class="absolute inset-0 flex items-start justify-center p-0 sm:items-center sm:p-4">
|
|
222
|
-
<div id="admin-panel-card" class="relative flex h-full w-full flex-col
|
|
223
|
-
|
|
283
|
+
<div id="admin-panel-card" class="glass glass-sheet relative flex h-full w-full flex-col opacity-0 sm:h-[85vh] sm:max-w-2xl sm:rounded-[32px]" data-fx>
|
|
224
284
|
<div class="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.08] px-5 pt-[env(safe-area-inset-top)] box-content">
|
|
225
285
|
<div>
|
|
226
286
|
<p class="text-[17px] font-semibold leading-none">Admin</p>
|
|
@@ -228,46 +288,29 @@
|
|
|
228
288
|
</div>
|
|
229
289
|
<div class="flex items-center gap-2">
|
|
230
290
|
<button id="admin-logout" class="btn btn-ghost">Lock</button>
|
|
231
|
-
<button data-admin-panel-close aria-label="Close" class="icon-btn">
|
|
291
|
+
<button data-admin-panel-close aria-label="Close" class="glass-lite icon-btn">
|
|
232
292
|
<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>
|
|
233
293
|
</button>
|
|
234
294
|
</div>
|
|
235
295
|
</div>
|
|
236
|
-
|
|
237
296
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
|
238
|
-
|
|
239
|
-
<!-- add game form -->
|
|
240
|
-
<form id="add-game-form" class="rounded-xl p-4 ring-1 ring-white/10">
|
|
297
|
+
<form id="add-game-form" class="glass-lite rounded-2xl p-4">
|
|
241
298
|
<p class="text-[15px] font-semibold">Add game</p>
|
|
242
299
|
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
243
|
-
<div>
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
</div>
|
|
247
|
-
<div>
|
|
248
|
-
<label for="new-category" class="text-[13px] text-ink-300">Category (optional)</label>
|
|
249
|
-
<input id="new-category" class="field mt-1.5" />
|
|
250
|
-
</div>
|
|
251
|
-
<div class="sm:col-span-2">
|
|
252
|
-
<label for="new-url" class="text-[13px] text-ink-300">Game URL</label>
|
|
253
|
-
<input id="new-url" required type="url" placeholder="https://" class="field mt-1.5" />
|
|
254
|
-
</div>
|
|
300
|
+
<div><label for="new-name" class="text-[13px] text-ink-300">Name</label><input id="new-name" required class="field mt-1.5" /></div>
|
|
301
|
+
<div><label for="new-category" class="text-[13px] text-ink-300">Category (optional)</label><input id="new-category" class="field mt-1.5" /></div>
|
|
302
|
+
<div class="sm:col-span-2"><label for="new-url" class="text-[13px] text-ink-300">Game URL</label><input id="new-url" required type="url" placeholder="https://" class="field mt-1.5" /></div>
|
|
255
303
|
<div>
|
|
256
304
|
<label for="new-icon-file" class="text-[13px] text-ink-300">Icon upload</label>
|
|
257
305
|
<input id="new-icon-file" type="file" accept="image/*" class="mt-2 w-full text-[13px] text-ink-400 file:mr-3 file:rounded-full file:border-0 file:bg-white/10 file:px-3.5 file:py-1.5 file:text-[13px] file:font-medium file:text-ink-50" />
|
|
258
306
|
</div>
|
|
259
|
-
<div>
|
|
260
|
-
<label for="new-icon-url" class="text-[13px] text-ink-300">Or external icon URL</label>
|
|
261
|
-
<input id="new-icon-url" type="url" placeholder="https://" class="field mt-1.5" />
|
|
262
|
-
</div>
|
|
307
|
+
<div><label for="new-icon-url" class="text-[13px] text-ink-300">Or external icon URL</label><input id="new-icon-url" type="url" placeholder="https://" class="field mt-1.5" /></div>
|
|
263
308
|
</div>
|
|
264
309
|
<div class="mt-5 flex items-center gap-3">
|
|
265
310
|
<button type="submit" id="add-game-submit" class="btn btn-primary">Add game</button>
|
|
266
311
|
<span id="add-game-status" class="text-[13px] text-ink-400"></span>
|
|
267
312
|
</div>
|
|
268
313
|
</form>
|
|
269
|
-
|
|
270
|
-
<!-- existing games -->
|
|
271
314
|
<p class="mb-3 mt-8 text-[15px] font-semibold">Games</p>
|
|
272
315
|
<div id="admin-list" class="space-y-2"></div>
|
|
273
316
|
</div>
|
|
@@ -275,53 +318,8 @@
|
|
|
275
318
|
</div>
|
|
276
319
|
</div>
|
|
277
320
|
|
|
278
|
-
<!-- ================= TOASTS ================= -->
|
|
279
321
|
<div id="toast-root" class="fixed bottom-4 right-4 z-[60] flex flex-col items-end gap-2 pb-[env(safe-area-inset-bottom)]"></div>
|
|
280
322
|
|
|
281
|
-
<!-- Konami Code Script with 1-Hour Expiration -->
|
|
282
|
-
<script>
|
|
283
|
-
(function() {
|
|
284
|
-
const STORAGE_KEY = 'mlv2_unlocked_expiry';
|
|
285
|
-
const ONE_HOUR_MS = 60 * 60 * 1000; // 1 hour in milliseconds
|
|
286
|
-
const overlay = document.getElementById('blank-overlay');
|
|
287
|
-
|
|
288
|
-
// Check on page load if unlock session is still valid
|
|
289
|
-
const savedExpiry = localStorage.getItem(STORAGE_KEY);
|
|
290
|
-
if (savedExpiry && Date.now() < parseInt(savedExpiry, 10)) {
|
|
291
|
-
overlay.classList.add('unlocked');
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const konamiCode = [
|
|
295
|
-
'ArrowUp', 'ArrowUp',
|
|
296
|
-
'ArrowDown', 'ArrowDown',
|
|
297
|
-
'ArrowLeft', 'ArrowRight',
|
|
298
|
-
'ArrowLeft', 'ArrowRight',
|
|
299
|
-
'b', 'a', 'Enter'
|
|
300
|
-
];
|
|
301
|
-
let konamiIndex = 0;
|
|
302
|
-
|
|
303
|
-
document.addEventListener('keydown', (e) => {
|
|
304
|
-
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
|
305
|
-
const targetKey = konamiCode[konamiIndex].length === 1
|
|
306
|
-
? konamiCode[konamiIndex].toLowerCase()
|
|
307
|
-
: konamiCode[konamiIndex];
|
|
308
|
-
|
|
309
|
-
if (key === targetKey) {
|
|
310
|
-
konamiIndex++;
|
|
311
|
-
if (konamiIndex === konamiCode.length) {
|
|
312
|
-
// Unlock website and save expiration time for 1 hour
|
|
313
|
-
overlay.classList.add('unlocked');
|
|
314
|
-
const expiryTime = Date.now() + ONE_HOUR_MS;
|
|
315
|
-
localStorage.setItem(STORAGE_KEY, expiryTime.toString());
|
|
316
|
-
konamiIndex = 0;
|
|
317
|
-
}
|
|
318
|
-
} else {
|
|
319
|
-
konamiIndex = 0;
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
})();
|
|
323
|
-
</script>
|
|
324
|
-
|
|
325
323
|
<script src="https://cdn.jsdelivr.net/npm/appwrite@26.2.0"></script>
|
|
326
324
|
<script type="module">
|
|
327
325
|
import { animate, stagger, inView } from "https://cdn.jsdelivr.net/npm/motion@12/+esm";
|
|
@@ -330,7 +328,7 @@ const CONFIG = {
|
|
|
330
328
|
endpoint: "https://nyc.cloud.appwrite.io/v1",
|
|
331
329
|
projectId: "6a9b8a1b001b9c61537f",
|
|
332
330
|
databaseId: "6a9b8b23003de0ec5cf7",
|
|
333
|
-
gamesTableId: "6a9b8bb3000013a3fd80",
|
|
331
|
+
gamesTableId: "6a9b8bb3000013a3fd80",
|
|
334
332
|
iconsBucketId: "6a9b8b36002565ac5987",
|
|
335
333
|
};
|
|
336
334
|
|
|
@@ -344,9 +342,7 @@ const tablesDB = new TablesDB(client);
|
|
|
344
342
|
const storage = new Storage(client);
|
|
345
343
|
|
|
346
344
|
const isConfigured = () =>
|
|
347
|
-
!CONFIG.endpoint.includes("YOUR_") &&
|
|
348
|
-
!CONFIG.projectId.includes("YOUR_") &&
|
|
349
|
-
!CONFIG.databaseId.includes("YOUR_");
|
|
345
|
+
!CONFIG.endpoint.includes("YOUR_") && !CONFIG.projectId.includes("YOUR_") && !CONFIG.databaseId.includes("YOUR_");
|
|
350
346
|
|
|
351
347
|
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
352
348
|
function fx(targets, keyframes, options = {}) {
|
|
@@ -354,10 +350,20 @@ function fx(targets, keyframes, options = {}) {
|
|
|
354
350
|
return animate(targets, keyframes, options);
|
|
355
351
|
}
|
|
356
352
|
|
|
353
|
+
function pop(targets, { y = 12, scale = 0.94, delay = 0, stiff = 320, damp = 24 } = {}) {
|
|
354
|
+
if (reduceMotion) return animate(targets, { opacity: 1 }, { duration: 0 });
|
|
355
|
+
animate(targets, { opacity: [0, 1] }, { duration: 0.35, delay, ease: "easeOut" });
|
|
356
|
+
return animate(targets, { y: [y, 0], scale: [scale, 1] }, { type: "spring", stiffness: stiff, damping: damp, delay });
|
|
357
|
+
}
|
|
358
|
+
|
|
357
359
|
/* ------------------------------- state ------------------------------- */
|
|
358
360
|
let allGames = [];
|
|
359
361
|
let activeCategory = "All";
|
|
360
362
|
let searchTerm = "";
|
|
363
|
+
const BRAND = "#f01a36";
|
|
364
|
+
const PALETTE = ["#f01a36", "#ff6b7d", "#ff98a5"];
|
|
365
|
+
let catColors = {};
|
|
366
|
+
let barShown = false;
|
|
361
367
|
|
|
362
368
|
const el = (id) => document.getElementById(id);
|
|
363
369
|
document.getElementById("year").textContent = new Date().getFullYear();
|
|
@@ -376,7 +382,7 @@ function monogramSrc(name) {
|
|
|
376
382
|
const initials = (name || "?").trim().slice(0, 2).toUpperCase();
|
|
377
383
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
378
384
|
<rect width="100" height="100" fill="#161618"/>
|
|
379
|
-
<text x="50" y="58" font-family="-apple-system, Inter, sans-serif" font-size="34" font-weight="600" fill="#
|
|
385
|
+
<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>
|
|
380
386
|
</svg>`;
|
|
381
387
|
return "data:image/svg+xml;utf8," + encodeURIComponent(svg);
|
|
382
388
|
}
|
|
@@ -389,10 +395,10 @@ function toast(message, tone = "default") {
|
|
|
389
395
|
const root = el("toast-root");
|
|
390
396
|
const node = document.createElement("div");
|
|
391
397
|
const toneClasses = tone === "error" ? "text-red-300" : "text-ink-50";
|
|
392
|
-
node.className = `rounded-full ${toneClasses}
|
|
398
|
+
node.className = `glass glass-sheet relative rounded-full ${toneClasses} px-4 py-2.5 text-[14px] opacity-0`;
|
|
393
399
|
node.textContent = message;
|
|
394
400
|
root.appendChild(node);
|
|
395
|
-
|
|
401
|
+
pop(node, { y: 16, scale: 0.9, stiff: 420, damp: 24 });
|
|
396
402
|
setTimeout(() => {
|
|
397
403
|
fx(node, { opacity: [1, 0], y: [0, 6] }, { duration: 0.2, ease: "easeIn" }).finished.then(() => node.remove());
|
|
398
404
|
}, 2800);
|
|
@@ -401,7 +407,7 @@ function toast(message, tone = "default") {
|
|
|
401
407
|
/* -------------------------- render: skeleton -------------------------- */
|
|
402
408
|
function renderSkeleton() {
|
|
403
409
|
el("skeleton-grid").innerHTML = Array.from({ length: 12 })
|
|
404
|
-
.map(() => `<div class="
|
|
410
|
+
.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>`)
|
|
405
411
|
.join("");
|
|
406
412
|
}
|
|
407
413
|
renderSkeleton();
|
|
@@ -415,24 +421,34 @@ function currentFiltered() {
|
|
|
415
421
|
});
|
|
416
422
|
}
|
|
417
423
|
|
|
418
|
-
function renderCategoryBar() {
|
|
419
|
-
const
|
|
424
|
+
function renderCategoryBar(mode) {
|
|
425
|
+
const names = [...new Set(allGames.map((g) => g.category || "Uncategorized"))];
|
|
426
|
+
catColors = {};
|
|
427
|
+
names.forEach((n, i) => (catColors[n] = PALETTE[i % PALETTE.length]));
|
|
428
|
+
const countOf = (n) => allGames.filter((g) => (g.category || "Uncategorized") === n).length;
|
|
420
429
|
const bar = el("category-bar");
|
|
421
|
-
bar.innerHTML =
|
|
430
|
+
bar.innerHTML = ["All", ...names]
|
|
422
431
|
.map((c) => {
|
|
423
432
|
const active = c === activeCategory;
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
433
|
+
const color = c === "All" ? BRAND : catColors[c];
|
|
434
|
+
const n = c === "All" ? allGames.length : countOf(c);
|
|
435
|
+
return `<button data-cat="${escapeHtml(c)}" aria-pressed="${active}" style="--c:${color}" class="chip ${mode === "intro" ? "opacity-0 " : ""}${
|
|
436
|
+
active ? "chip-on text-white" : "glass-lite text-ink-200 hover:text-ink-50"
|
|
437
|
+
}">${escapeHtml(c)}<span class="chip-count">${n}</span></button>`;
|
|
427
438
|
})
|
|
428
439
|
.join("");
|
|
429
440
|
bar.querySelectorAll("[data-cat]").forEach((btn) =>
|
|
430
441
|
btn.addEventListener("click", () => {
|
|
431
442
|
activeCategory = btn.dataset.cat;
|
|
432
|
-
renderCategoryBar();
|
|
443
|
+
renderCategoryBar("click");
|
|
433
444
|
renderGrid();
|
|
434
445
|
})
|
|
435
446
|
);
|
|
447
|
+
if (mode === "intro") pop("#category-bar .chip", { y: 10, scale: 0.9, delay: stagger(0.05, { startDelay: 0.3 }), stiff: 380, damp: 22 });
|
|
448
|
+
if (mode === "click" && !reduceMotion) {
|
|
449
|
+
const on = bar.querySelector('[aria-pressed="true"]');
|
|
450
|
+
if (on) animate(on, { scale: [0.88, 1] }, { type: "spring", stiffness: 520, damping: 16 });
|
|
451
|
+
}
|
|
436
452
|
}
|
|
437
453
|
|
|
438
454
|
let gridShown = false;
|
|
@@ -463,11 +479,12 @@ function renderGrid() {
|
|
|
463
479
|
grid.innerHTML = games
|
|
464
480
|
.map((g) => {
|
|
465
481
|
const src = iconSrcFor(g) || monogramSrc(g.name);
|
|
466
|
-
|
|
467
|
-
|
|
482
|
+
const color = catColors[g.category || "Uncategorized"] || BRAND;
|
|
483
|
+
return `<button data-fx data-id="${g.$id}" style="--c:${color}" class="tile group block w-full text-left opacity-0">
|
|
484
|
+
<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]">
|
|
468
485
|
<img src="${src}" alt="" loading="lazy" class="tile-img h-full w-full object-cover" onerror="this.onerror=null;this.src='${monogramSrc(g.name)}'" />
|
|
469
486
|
</span>
|
|
470
|
-
<span class="mt-
|
|
487
|
+
<span class="mt-3 block truncate text-center text-[13px] font-medium leading-tight text-ink-50">${escapeHtml(g.name)}</span>
|
|
471
488
|
${g.category ? `<span class="mt-0.5 block truncate text-center text-[12px] text-ink-400">${escapeHtml(g.category)}</span>` : ""}
|
|
472
489
|
</button>`;
|
|
473
490
|
})
|
|
@@ -477,14 +494,11 @@ function renderGrid() {
|
|
|
477
494
|
card.addEventListener("click", () => openPlayer(games.find((g) => g.$id === card.dataset.id)))
|
|
478
495
|
);
|
|
479
496
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
{ duration: gridShown ? 0.18 : 0.5, delay: gridShown ? 0 : stagger(0.03), ease: "easeOut" });
|
|
497
|
+
if (!gridShown) pop("#grid [data-fx]", { y: 22, scale: 0.88, delay: stagger(0.04, { startDelay: 0.2 }), stiff: 260, damp: 20 });
|
|
498
|
+
else pop("#grid [data-fx]", { y: 8, scale: 0.94, delay: stagger(0.015), stiff: 420, damp: 28 });
|
|
483
499
|
gridShown = true;
|
|
484
500
|
}
|
|
485
501
|
|
|
486
|
-
el("count-badge") && (el("count-num").textContent = "0");
|
|
487
|
-
|
|
488
502
|
/* ------------------------------ fetching ------------------------------ */
|
|
489
503
|
async function loadGames() {
|
|
490
504
|
if (!isConfigured()) {
|
|
@@ -503,8 +517,11 @@ async function loadGames() {
|
|
|
503
517
|
queries: [Query.limit(200), Query.orderAsc("order")],
|
|
504
518
|
});
|
|
505
519
|
allGames = res.rows.filter((g) => g.url && g.enabled !== false);
|
|
506
|
-
el("count-num").textContent
|
|
507
|
-
|
|
520
|
+
const from = Number(el("count-num").textContent) || 0;
|
|
521
|
+
if (reduceMotion || from === allGames.length) el("count-num").textContent = allGames.length;
|
|
522
|
+
else animate(from, allGames.length, { duration: 1, delay: 0.3, ease: "easeOut", onUpdate: (v) => (el("count-num").textContent = Math.round(v)) });
|
|
523
|
+
renderCategoryBar(barShown ? undefined : "intro");
|
|
524
|
+
barShown = true;
|
|
508
525
|
renderGrid();
|
|
509
526
|
} catch (err) {
|
|
510
527
|
console.error("Failed to load games:", err);
|
|
@@ -536,10 +553,10 @@ function openPlayer(game) {
|
|
|
536
553
|
el("player-icon").src = iconSrcFor(game) || monogramSrc(game.name);
|
|
537
554
|
el("player-newtab").href = game.url;
|
|
538
555
|
el("player-frame").src = game.url;
|
|
539
|
-
|
|
540
|
-
modal.classList.remove("hidden");
|
|
556
|
+
el("player-loading").style.display = "grid";
|
|
557
|
+
el("player-modal").classList.remove("hidden");
|
|
541
558
|
document.documentElement.style.overflow = "hidden";
|
|
542
|
-
|
|
559
|
+
pop("#player-panel", { y: 20, scale: 0.94, stiff: 260, damp: 26 });
|
|
543
560
|
}
|
|
544
561
|
function closePlayer() {
|
|
545
562
|
fx("#player-panel", { opacity: [1, 0], scale: [1, 0.98] }, { duration: 0.18, ease: "easeIn" }).finished.then(() => {
|
|
@@ -561,7 +578,7 @@ document.addEventListener("keydown", (e) => {
|
|
|
561
578
|
/* -------------------------------- admin -------------------------------- */
|
|
562
579
|
function openAdminLogin() {
|
|
563
580
|
el("admin-login-modal").classList.remove("hidden");
|
|
564
|
-
|
|
581
|
+
pop("#admin-login-form", { y: 16, scale: 0.92, stiff: 380, damp: 24 });
|
|
565
582
|
resetPinBoxes();
|
|
566
583
|
pinInputs[0]?.focus();
|
|
567
584
|
}
|
|
@@ -571,41 +588,25 @@ function closeAdminLogin() {
|
|
|
571
588
|
}
|
|
572
589
|
function openAdminPanel() {
|
|
573
590
|
el("admin-panel").classList.remove("hidden");
|
|
574
|
-
|
|
591
|
+
pop("#admin-panel-card", { y: 24, scale: 0.97, stiff: 300, damp: 28 });
|
|
575
592
|
renderAdminList();
|
|
576
593
|
}
|
|
577
|
-
function closeAdminPanel() {
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
function isUnlocked() {
|
|
581
|
-
return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1";
|
|
582
|
-
}
|
|
594
|
+
function closeAdminPanel() { el("admin-panel").classList.add("hidden"); }
|
|
595
|
+
function isUnlocked() { return sessionStorage.getItem(ADMIN_SESSION_KEY) === "1"; }
|
|
583
596
|
|
|
584
|
-
el("admin-trigger").addEventListener("click", () => {
|
|
585
|
-
if (isUnlocked()) openAdminPanel();
|
|
586
|
-
else openAdminLogin();
|
|
587
|
-
});
|
|
597
|
+
el("admin-trigger").addEventListener("click", () => { if (isUnlocked()) openAdminPanel(); else openAdminLogin(); });
|
|
588
598
|
document.querySelector("[data-admin-login-close]").addEventListener("click", closeAdminLogin);
|
|
589
599
|
document.querySelector("[data-admin-panel-close]").addEventListener("click", closeAdminPanel);
|
|
590
600
|
|
|
591
|
-
/* ------- 6-digit PIN pad: build boxes, wire auto-advance + paste ------- */
|
|
592
601
|
const pinBoxesEl = el("pin-boxes");
|
|
593
602
|
pinBoxesEl.innerHTML = Array.from({ length: 6 })
|
|
594
|
-
.map(
|
|
595
|
-
|
|
596
|
-
`<input data-pin-idx="${i}" type="password" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit ${i + 1}"
|
|
597
|
-
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" />`
|
|
598
|
-
)
|
|
603
|
+
.map((_, i) => `<input data-pin-idx="${i}" type="password" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit ${i + 1}"
|
|
604
|
+
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" />`)
|
|
599
605
|
.join("");
|
|
600
606
|
const pinInputs = Array.from(pinBoxesEl.querySelectorAll("[data-pin-idx]"));
|
|
601
607
|
|
|
602
|
-
function resetPinBoxes() {
|
|
603
|
-
|
|
604
|
-
el("admin-pin").value = "";
|
|
605
|
-
}
|
|
606
|
-
function syncPinValue() {
|
|
607
|
-
el("admin-pin").value = pinInputs.map((i) => i.value).join("");
|
|
608
|
-
}
|
|
608
|
+
function resetPinBoxes() { pinInputs.forEach((i) => (i.value = "")); el("admin-pin").value = ""; }
|
|
609
|
+
function syncPinValue() { el("admin-pin").value = pinInputs.map((i) => i.value).join(""); }
|
|
609
610
|
pinInputs.forEach((input, idx) => {
|
|
610
611
|
input.addEventListener("input", () => {
|
|
611
612
|
input.value = input.value.replace(/[^0-9]/g, "").slice(0, 1);
|
|
@@ -635,6 +636,7 @@ el("admin-login-form").addEventListener("submit", (e) => {
|
|
|
635
636
|
openAdminPanel();
|
|
636
637
|
} else {
|
|
637
638
|
errBox.textContent = "Wrong PIN.";
|
|
639
|
+
if (!reduceMotion) animate("#pin-boxes", { x: [0, -10, 10, -8, 8, -4, 0] }, { duration: 0.45 });
|
|
638
640
|
errBox.classList.remove("hidden");
|
|
639
641
|
resetPinBoxes();
|
|
640
642
|
pinInputs[0]?.focus();
|
|
@@ -663,7 +665,7 @@ async function renderAdminList() {
|
|
|
663
665
|
list.innerHTML = res.rows
|
|
664
666
|
.map(
|
|
665
667
|
(g) => `
|
|
666
|
-
<div class="flex items-center gap-3 rounded-
|
|
668
|
+
<div class="glass-lite flex items-center gap-3 rounded-2xl p-3" data-row="${g.$id}">
|
|
667
669
|
<img src="${iconSrcFor(g) || monogramSrc(g.name)}" alt="" class="tile-img h-10 w-10 shrink-0 rounded-[22.37%] object-cover" onerror="this.onerror=null;this.src='${monogramSrc(g.name)}'" />
|
|
668
670
|
<div class="min-w-0 flex-1">
|
|
669
671
|
<input data-field="name" data-id="${g.$id}" value="${escapeHtml(g.name)}" aria-label="Name" class="w-full border-b border-transparent bg-transparent text-[15px] font-medium outline-none focus:border-red-500" />
|
|
@@ -673,12 +675,13 @@ async function renderAdminList() {
|
|
|
673
675
|
<label class="hidden shrink-0 items-center gap-2 text-[13px] text-ink-300 sm:flex">
|
|
674
676
|
<input type="checkbox" data-field="enabled" data-id="${g.$id}" ${g.enabled === false ? "" : "checked"} class="h-4 w-4 accent-red-500" /> On
|
|
675
677
|
</label>
|
|
676
|
-
<button data-delete="${g.$id}" title="Delete" aria-label="Delete" class="icon-btn hover:!text-red-400">
|
|
678
|
+
<button data-delete="${g.$id}" title="Delete" aria-label="Delete" class="glass-lite icon-btn hover:!text-red-400">
|
|
677
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>
|
|
678
680
|
</button>
|
|
679
681
|
</div>`
|
|
680
682
|
)
|
|
681
683
|
.join("");
|
|
684
|
+
if (!reduceMotion) animate("#admin-list > div", { opacity: [0, 1], y: [8, 0] }, { duration: 0.3, delay: stagger(0.03), ease: "easeOut" });
|
|
682
685
|
|
|
683
686
|
list.querySelectorAll("input[data-field]").forEach((input) => {
|
|
684
687
|
const commit = async () => {
|
|
@@ -737,15 +740,7 @@ el("add-game-form").addEventListener("submit", async (e) => {
|
|
|
737
740
|
databaseId: CONFIG.databaseId,
|
|
738
741
|
tableId: CONFIG.gamesTableId,
|
|
739
742
|
rowId: ID.unique(),
|
|
740
|
-
data: {
|
|
741
|
-
name,
|
|
742
|
-
url,
|
|
743
|
-
category,
|
|
744
|
-
iconFileId,
|
|
745
|
-
iconUrl: iconFileId ? "" : iconUrlField,
|
|
746
|
-
order: allGames.length,
|
|
747
|
-
enabled: true,
|
|
748
|
-
},
|
|
743
|
+
data: { name, url, category, iconFileId, iconUrl: iconFileId ? "" : iconUrlField, order: allGames.length, enabled: true },
|
|
749
744
|
});
|
|
750
745
|
|
|
751
746
|
statusEl.textContent = "Added.";
|
|
@@ -761,6 +756,65 @@ el("add-game-form").addEventListener("submit", async (e) => {
|
|
|
761
756
|
setTimeout(() => (statusEl.textContent = ""), 2500);
|
|
762
757
|
}
|
|
763
758
|
});
|
|
759
|
+
|
|
760
|
+
/* ---------------------------- tile hover FX ---------------------------- */
|
|
761
|
+
const gridEl = el("grid");
|
|
762
|
+
gridEl.addEventListener("pointermove", (e) => {
|
|
763
|
+
if (e.pointerType !== "mouse" || reduceMotion) return;
|
|
764
|
+
const t = e.target.closest(".tile");
|
|
765
|
+
if (!t) return;
|
|
766
|
+
const r = t.getBoundingClientRect();
|
|
767
|
+
const x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height;
|
|
768
|
+
t.classList.add("moving");
|
|
769
|
+
t.style.setProperty("--mx", x * 100 + "%");
|
|
770
|
+
t.style.setProperty("--my", y * 100 + "%");
|
|
771
|
+
const icon = t.firstElementChild;
|
|
772
|
+
icon.style.setProperty("--ry", ((x - 0.5) * 16).toFixed(2) + "deg");
|
|
773
|
+
icon.style.setProperty("--rx", ((0.5 - y) * 16).toFixed(2) + "deg");
|
|
774
|
+
});
|
|
775
|
+
gridEl.addEventListener("pointerout", (e) => {
|
|
776
|
+
const t = e.target.closest(".tile");
|
|
777
|
+
if (!t || t.contains(e.relatedTarget)) return;
|
|
778
|
+
t.classList.remove("moving");
|
|
779
|
+
t.firstElementChild.style.removeProperty("--rx");
|
|
780
|
+
t.firstElementChild.style.removeProperty("--ry");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
el("player-frame").addEventListener("load", () => {
|
|
784
|
+
if (el("player-frame").getAttribute("src")) el("player-loading").style.display = "none";
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
/* ------------------------------ hero motion ------------------------------ */
|
|
788
|
+
(function hero() {
|
|
789
|
+
const show = () => document.querySelectorAll(".w, .shape").forEach((n) => (n.style.opacity = 1));
|
|
790
|
+
if (reduceMotion) return show();
|
|
791
|
+
animate(".w", { opacity: [0, 1], y: [32, 0], filter: ["blur(14px)", "blur(0px)"] },
|
|
792
|
+
{ duration: 0.85, delay: stagger(0.08, { startDelay: 0.1 }), ease: [0.22, 1, 0.36, 1] })
|
|
793
|
+
.finished.then(() => document.querySelectorAll(".w").forEach((w) => (w.style.filter = "none")));
|
|
794
|
+
animate(".shape", { opacity: [0, 1] }, { duration: 0.4, delay: stagger(0.1, { startDelay: 0.4 }) });
|
|
795
|
+
animate(".shape", { scale: [0.2, 1], rotate: [-40, 0] }, { type: "spring", stiffness: 170, damping: 13, delay: stagger(0.1, { startDelay: 0.4 }) });
|
|
796
|
+
|
|
797
|
+
// gentle cursor + scroll parallax on the shapes
|
|
798
|
+
const pars = [...document.querySelectorAll(".par")];
|
|
799
|
+
const wrap = el("shapes");
|
|
800
|
+
let tx = 0, ty = 0, cx = 0, cy = 0;
|
|
801
|
+
addEventListener("pointermove", (e) => { tx = e.clientX / innerWidth - 0.5; ty = e.clientY / innerHeight - 0.5; }, { passive: true });
|
|
802
|
+
(function loop() {
|
|
803
|
+
cx += (tx - cx) * 0.07;
|
|
804
|
+
cy += (ty - cy) * 0.07;
|
|
805
|
+
if (wrap.offsetParent) {
|
|
806
|
+
pars.forEach((p) => {
|
|
807
|
+
const d = Number(p.dataset.depth);
|
|
808
|
+
p.style.transform = `translate3d(${(-cx * 36 * d).toFixed(2)}px, ${(-cy * 36 * d - scrollY * 0.12 * d).toFixed(2)}px, 0)`;
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
requestAnimationFrame(loop);
|
|
812
|
+
})();
|
|
813
|
+
})();
|
|
814
|
+
</script>
|
|
815
|
+
<script>
|
|
816
|
+
// safety net: never leave the title or shapes invisible if the module script is blocked
|
|
817
|
+
setTimeout(function () { document.querySelectorAll(".w, .shape").forEach(function (n) { if (getComputedStyle(n).opacity < 1) n.style.opacity = 1; }); }, 3500);
|
|
764
818
|
</script>
|
|
765
819
|
</body>
|
|
766
820
|
</html>
|