mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-04 00:46:59 +01:00
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:
1 parent
27225332fb
commit
b4470850bf
33 files changed
+3115
-899
No files matched your search
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user