Rebuild front end in TypeScript with a pager, crossfades and reactive visuals

The front end moves to web/ (Vite, TypeScript, no framework) and is built
into web/dist, which the server embeds. hls.js is bundled from npm and
loaded on demand instead of vendored.

The feed is a custom pager instead of CSS scroll snapping: drag and flick
with a spring that keeps the finger's speed, a rubber band at the ends,
one page per wheel or trackpad gesture, and only three cards in the DOM.

Playback uses two decks. The next track is buffered shortly after the
current one starts and tracks crossfade through a Web Audio graph; on iOS,
where that would break lock-screen playback, they switch with a cut. An
analyser drives a bass pulse of the artwork and its glow, and each card
takes its accent colour from the artist image. Styles start from design
tokens and respect reduced motion.

Docker gets a Node build stage; legal pages use their own small stylesheet.
This commit is contained in:
nvrl committed 2026-11-01 12:57:17 +01:00
1 parent 27225332fb
commit b4470850bf
33 files changed
+3115 -899

No files matched your search

+527
View File
@@ -0,0 +1,527 @@
/* Design tokens ---------------------------------------------------------- */
:root {
color-scheme: dark;
--bg: #000;
--fg: #f2f2f2;
--muted: #9a9a9a;
--faint: #666;
--accent: #ff4d6d;
--panel: rgba(18, 18, 20, 0.94);
--line: #ffffff1f;
--glass: rgba(255, 255, 255, 0.1);
--glass-strong: rgba(255, 255, 255, 0.2);
--radius-s: 8px;
--radius-m: 14px;
--radius-l: 18px;
--shadow: 0 16px 40px #000a;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: var(--font);
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
--fast: 0.15s;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--fg);
font: 15px/1.4 var(--font);
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
a { color: inherit; }
button, input {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 999px;
padding: 0.35em 0.9em;
}
[hidden] { display: none !important; }
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
/* Header and panels ------------------------------------------------------ */
#bar {
position: fixed;
inset: 0 0 auto 0;
z-index: 10;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
padding-top: max(0.6rem, env(safe-area-inset-top));
background: linear-gradient(#000c, #0000);
}
.logo { font-weight: 600; margin-right: auto; }
.logo b { color: var(--accent); }
#stats { color: var(--muted); font-size: 0.8rem; }
#help-btn { width: 2.2em; padding: 0.35em 0; }
#account-btn { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#filters-btn.active::after {
content: "";
display: inline-block;
width: 0.45em;
height: 0.45em;
margin-left: 0.4em;
border-radius: 50%;
background: var(--accent);
vertical-align: middle;
}
#bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
.panel {
position: fixed;
z-index: 11;
top: calc(3rem + env(safe-area-inset-top));
right: 1rem;
background: var(--panel);
border: 1px solid #333;
border-radius: 12px;
padding: 0.8rem 1rem;
box-shadow: 0 16px 40px #000a;
}
#filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
#filters input[type=number] {
width: 4.5em;
background: #222;
border: 1px solid #444;
border-radius: 6px;
padding: 0.2em 0.4em;
}
#likes { width: min(26rem, calc(100vw - 2rem)); max-height: 70vh; overflow: auto; }
#likes h2 { margin: 0 0 0.5rem; font-size: 1rem; }
#likes ol { margin: 0; padding: 0; list-style: none; }
#likes li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0;
border-bottom: 1px solid #ffffff10;
}
#likes li:last-child { border-bottom: none; }
#likes li .meta { min-width: 0; flex: 1; }
#likes li .meta a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
#likes li .meta a:hover { text-decoration: underline; }
#likes li .meta span { color: var(--muted); font-size: 0.8rem; }
#likes li .play-now {
flex: none;
width: 2rem;
height: 2rem;
padding: 0;
display: grid;
place-items: center;
}
#likes li .play-now svg { width: 0.9rem; height: 0.9rem; }
#account { width: min(20rem, calc(100vw - 2rem)); display: flex; flex-direction: column; align-items: flex-start; gap: 0.7rem; }
#account h2 { margin: 0; font-size: 1rem; }
#account a { font-size: 0.85rem; color: var(--muted); }
#sync { width: 100%; display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem;
padding-bottom: 0.7rem; border-bottom: 1px solid #ffffff14; }
#sync h3 { margin: 0; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
#sync-status { margin: 0; font-size: 0.85rem; color: var(--muted); }
#sync-status.on::before { content: "● "; color: #3ddc84; }
#delete-form { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
#delete-form label { color: var(--muted); font-size: 0.85rem; }
#delete-form input { padding: 0.4em 0.6em; border-radius: 8px; border: 1px solid #444; background: #111; }
.link.danger { color: var(--accent); }
button.danger:not(.link) { background: var(--accent); border: none; color: #fff; font-weight: 600; }
.legal-links a { color: inherit; }
/* Feed ------------------------------------------------------------------- */
/* The pager (pager.ts) places cards with transforms and sets --away (0 in place,
1 a full page away); the visualizer sets --energy (0..1, bass level) on the
playing card; --card-accent comes from the artist image. */
#feed {
position: fixed;
inset: 0;
overflow: hidden;
touch-action: none;
overscroll-behavior: none;
}
.card {
--card-accent: var(--accent);
--energy: 0;
--away: 0;
position: absolute;
inset: 0;
will-change: transform;
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
cursor: pointer;
}
.bg {
position: absolute;
inset: -40px;
background: #111 center / cover no-repeat;
filter: blur(40px) brightness(0.45) saturate(1.3);
}
.glow {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(55% 40% at 50% 40%, color-mix(in oklab, var(--card-accent) 55%, transparent), transparent 70%);
opacity: calc(0.18 + var(--energy) * 0.55);
}
.content {
position: relative;
/* Cards shrink and dim a little as they leave, for depth while swiping. */
transform: scale(calc(1 - var(--away) * 0.08));
opacity: calc(1 - var(--away) * 0.55);
width: min(34rem, calc(100vw - 5.5rem));
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.5rem;
}
.art {
position: relative;
width: min(16rem, 42vh, 70vw);
aspect-ratio: 1;
border-radius: 14px;
background: #222 center / cover no-repeat;
box-shadow:
0 20px 60px #000a,
0 0 calc(10px + var(--energy) * 70px) color-mix(in oklab, var(--card-accent) calc(15% + var(--energy) * 45%), transparent);
display: grid;
place-items: center;
margin-bottom: 0.6rem;
/* Pulse on the beat through `scale`; `transform` is left to the pause animation. */
scale: calc(1 + var(--energy) * 0.045);
transition: transform 0.25s var(--ease-out), filter 0.25s ease;
}
.initial { font-size: 4rem; font-weight: 700; color: #fff6; }
.card.paused .art { transform: scale(0.96); filter: brightness(0.7); }
.paused-badge {
position: absolute;
inset: 0;
margin: auto;
width: 4.5rem;
height: 4.5rem;
border-radius: 50%;
background: #000a;
display: grid;
place-items: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.paused-badge svg { width: 2rem; height: 2rem; margin-left: 0.2rem; }
.card.paused .paused-badge { opacity: 1; }
.spinner {
position: absolute;
inset: 0;
margin: auto;
width: 3rem;
height: 3rem;
border-radius: 50%;
border: 3px solid #ffffff33;
border-top-color: #fff;
opacity: 0;
pointer-events: none;
animation: spin 0.8s linear infinite;
}
.card.buffering .spinner { opacity: 1; }
.card.buffering .paused-badge { opacity: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.title {
font: 800 clamp(1.3rem, 4.2vw, 1.9rem)/1.1 var(--font-display);
letter-spacing: -0.01em;
margin: 0;
overflow-wrap: anywhere;
text-wrap: balance;
}
.byline { display: flex; align-items: center; gap: 0.6rem; }
.artist { color: var(--muted); text-decoration: none; }
.artist:hover { text-decoration: underline; }
.follow {
font-size: 0.75rem;
padding: 0.15em 0.75em;
border-radius: 999px;
border: 1px solid #ffffff40;
background: transparent;
}
.follow.on { background: #ffffff1f; border-color: transparent; color: var(--muted); }
.chips { display: flex; gap: 0.4rem; flex-wrap: wrap; justify-content: center; }
.chips span {
font-size: 0.75rem;
padding: 0.1em 0.6em;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.chips .free { background: #1f7a4d; }
.chips span:first-child { background: color-mix(in oklab, var(--card-accent) 35%, transparent); }
.chips .preview { background: #6a4b16; }
.desc {
margin: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 0.85rem;
}
.wave-wrap { position: relative; width: 100%; margin-top: 0.6rem; }
.wave { display: block; width: 100%; height: 64px; cursor: pointer; touch-action: none; }
.wave-tip {
position: absolute;
bottom: calc(100% + 4px);
transform: translateX(-50%);
padding: 0.1em 0.45em;
border-radius: 6px;
background: #000c;
font-size: 0.75rem;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
}
.wave-wrap.hover .wave-tip { opacity: 1; }
.time { width: 100%; display: flex; justify-content: space-between; color: var(--muted); font-size: 0.75rem; }
.counts { color: var(--muted); font-size: 0.8rem; }
.actions {
position: absolute;
right: 0.8rem;
bottom: 18vh;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.actions > * {
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
border: none;
text-decoration: none;
padding: 0;
transition: background 0.15s, transform 0.15s;
}
.actions > *:hover { background: rgba(255, 255, 255, 0.2); }
.actions > *:active { transform: scale(0.9); }
.actions svg { width: 1.3rem; height: 1.3rem; }
.actions .like.on { color: var(--card-accent); background: color-mix(in oklab, var(--card-accent) 22%, transparent); }
.actions .like.pop svg { animation: pop 0.35s ease; }
@keyframes pop { 50% { transform: scale(1.4); } }
/* Feedback overlays ------------------------------------------------------ */
.flash {
position: absolute;
z-index: 2;
top: 50%;
left: 50%;
width: 5.5rem;
height: 5.5rem;
margin: -2.75rem 0 0 -2.75rem;
border-radius: 50%;
background: #000a;
display: grid;
place-items: center;
pointer-events: none;
animation: flash 0.6s ease forwards;
}
.flash svg { width: 2.4rem; height: 2.4rem; }
@keyframes flash {
from { opacity: 1; transform: scale(0.7); }
to { opacity: 0; transform: scale(1.3); }
}
.burst {
position: absolute;
z-index: 3;
width: 6rem;
height: 6rem;
margin: -3rem 0 0 -3rem;
color: var(--card-accent);
pointer-events: none;
filter: drop-shadow(0 6px 20px color-mix(in oklab, var(--card-accent) 55%, transparent));
animation: burst 0.8s cubic-bezier(.2, .9, .3, 1.3) forwards;
}
@keyframes burst {
0% { opacity: 0; transform: scale(0.3) rotate(var(--tilt)); }
25% { opacity: 1; transform: scale(1.15) rotate(var(--tilt)); }
60% { opacity: 1; transform: scale(1) rotate(var(--tilt)); }
100% { opacity: 0; transform: translateY(-3rem) scale(0.9) rotate(var(--tilt)); }
}
#toast {
position: fixed;
z-index: 40;
left: 50%;
bottom: max(2rem, env(safe-area-inset-bottom));
transform: translate(-50%, 1rem);
padding: 0.6em 1.1em;
border-radius: 999px;
background: #222e;
border: 1px solid #ffffff22;
font-size: 0.9rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
white-space: nowrap;
}
#toast.show { opacity: 1; transform: translate(-50%, 0); }
#feed-status {
position: fixed;
z-index: 5;
left: 50%;
bottom: max(6rem, calc(env(safe-area-inset-bottom) + 5rem));
transform: translateX(-50%);
width: min(26rem, calc(100vw - 2rem));
margin: 0;
color: var(--muted);
text-align: center;
pointer-events: none;
}
#feed:empty + #feed-status { bottom: 50%; }
#start {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #000b;
cursor: pointer;
}
#start button { font-size: 1.3rem; padding: 0.7em 1.6em; background: var(--accent); border: none; color: #fff; }
#help {
position: fixed;
inset: 0;
z-index: 25;
display: grid;
place-items: center;
padding: 1rem;
background: #000b;
animation: fade 0.2s ease;
}
@keyframes fade { from { opacity: 0; } }
.help-box {
width: min(22rem, 100%);
background: var(--panel);
border: 1px solid #333;
border-radius: 16px;
padding: 1.2rem 1.4rem;
}
.help-box h2 { margin: 0 0 0.8rem; font-size: 1.1rem; }
.help-box dl { display: grid; grid-template-columns: auto 1fr; gap: 0.45rem 1rem; margin: 0; }
.help-box dt { font-family: ui-monospace, monospace; font-size: 0.85rem; color: var(--accent); }
.help-box dd { margin: 0; }
.help-box p { color: var(--muted); font-size: 0.85rem; margin: 1rem 0; }
.help-box button { width: 100%; background: var(--accent); border: none; color: #fff; padding: 0.6em; font-weight: 600; }
@media (hover: hover) and (pointer: fine) { .touch-only { display: none !important; } }
@media not ((hover: hover) and (pointer: fine)) { .pointer-only { display: none !important; } }
/* Sign-in ---------------------------------------------------------------- */
#login {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 1rem;
background:
radial-gradient(60rem 40rem at 20% 10%, #ff4d6d33, transparent 60%),
radial-gradient(50rem 40rem at 90% 90%, #4d6dff2e, transparent 60%),
#050507;
}
.login-box {
width: min(24rem, 100%);
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.login-box h1 { margin: 0; font-size: 2.4rem; }
.login-box h1 b { color: var(--accent); }
.tagline { margin: 0 0 0.8rem; color: var(--muted); }
.login-box form { display: flex; flex-direction: column; gap: 0.6rem; }
.login-box label { color: var(--muted); font-size: 0.9rem; }
.login-box input {
padding: 0.7em 0.9em;
border-radius: 10px;
border: 1px solid #333;
background: #111;
}
#code { font-family: ui-monospace, monospace; font-size: 1.4rem; letter-spacing: 0.5em; text-align: center; }
.login-box button[type=submit] { background: var(--accent); border: none; color: #fff; padding: 0.7em; font-weight: 600; }
.login-box button[disabled] { opacity: 0.6; cursor: wait; }
.login-links { display: flex; justify-content: space-between; }
.login-box label.check { display: flex; gap: 0.6rem; align-items: flex-start; color: var(--fg); cursor: pointer; }
.check input { margin-top: 0.2rem; accent-color: var(--accent); }
.check small { display: block; margin-top: 0.2rem; color: #777; font-size: 0.75rem; line-height: 1.4; }
#login-cancel { align-self: center; }
.link { background: none; border: none; padding: 0; color: var(--muted); text-decoration: underline; font-size: 0.85rem; }
#login-error { margin: 0; min-height: 1.2em; color: var(--accent); font-size: 0.9rem; }
.fineprint { margin: 0; color: #666; font-size: 0.75rem; }
/* Phones ----------------------------------------------------------------- */
@media (max-width: 640px) {
#stats { display: none; }
#bar { gap: 0.4rem; padding-left: 0.7rem; padding-right: 0.7rem; }
#bar button { padding: 0.3em 0.7em; font-size: 0.85rem; }
#help-btn { display: none; }
#account-btn { max-width: 6.5rem; }
.panel { left: 0.7rem; right: 0.7rem; }
#likes { width: auto; }
.content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; }
.actions {
right: auto;
left: 50%;
bottom: max(1.2rem, env(safe-area-inset-bottom));
transform: translateX(-50%);
flex-direction: row;
gap: 0.9rem;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}