mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 02:48:31 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fa620a70 | ||
|
|
3e57ba0345 | ||
|
|
b85496fe58 | ||
|
|
4027c31a61 |
@@ -38,6 +38,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
top-tier clean pass in one key (`keysCleared`), any depth rung, or
|
top-tier clean pass in one key (`keysCleared`), any depth rung, or
|
||||||
mastery — rather than only the maxed-speed depth flips. Genres without a
|
mastery — rather than only the maxed-speed depth flips. Genres without a
|
||||||
curated drill stay songs-only.
|
curated drill stay songs-only.
|
||||||
|
- **Career passport visuals pack** — earned covers and badge stamps become
|
||||||
|
trading cards (pointer-tracked tilt + light glint, hover-capable devices
|
||||||
|
only); the ghost stamp visibly "carves in" as qualifying songs land (a
|
||||||
|
conic ink fill, no numbers added); the Gold rung preview is a small foil
|
||||||
|
chip with a shimmer sweep, still honestly labeled coming. All theatrics
|
||||||
|
disabled under `prefers-reduced-motion`.
|
||||||
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
||||||
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
||||||
passport walls: genre badges computed on read from `song_stats` × the library's
|
passport walls: genre badges computed on read from `song_stats` × the library's
|
||||||
|
|||||||
+13
-4
@@ -2130,16 +2130,25 @@ class MetadataDB:
|
|||||||
return self._stats_row(filename, int(arrangement))
|
return self._stats_row(filename, int(arrangement))
|
||||||
|
|
||||||
def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict:
|
def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict:
|
||||||
"""Accrue wall-clock play time only (no plays/score/position change) —
|
"""Accrue wall-clock play time (no plays/score/position change) —
|
||||||
the recorder's seconds-only flush for unscored plays that ran to the
|
the recorder's seconds-only flush for unscored plays that ran to the
|
||||||
song's natural end (no resume position to touch there: `song:ended`
|
song's natural end (no resume position to touch there: `song:ended`
|
||||||
must not overwrite Continue with the end-of-song offset)."""
|
must not overwrite Continue with the end-of-song offset). Stamps
|
||||||
|
last_played_at like touch_position does: the song WAS played, so
|
||||||
|
/api/stats/recent and Continue ordering must see it. Accepted skew:
|
||||||
|
the recorder retries FAILED flushes later, which stamps recency at
|
||||||
|
retry time — rare (offline corner), self-healing on the next play,
|
||||||
|
and preferable to the alternative (keep-existing would leave repeat
|
||||||
|
plays looking stale, the common case)."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""INSERT INTO song_stats (filename, arrangement, seconds_total, updated_at)
|
"""INSERT INTO song_stats (filename, arrangement, seconds_total,
|
||||||
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'))
|
last_played_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
|
||||||
|
strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||||
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||||
|
last_played_at = excluded.last_played_at,
|
||||||
updated_at = excluded.updated_at""",
|
updated_at = excluded.updated_at""",
|
||||||
(filename, int(arrangement), float(seconds)),
|
(filename, int(arrangement), float(seconds)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
|
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
|
||||||
.pp-cover:hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
|
.pp-cover:not(.pp-tilt):hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
|
||||||
.pp-cover-title {
|
.pp-cover-title {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -475,3 +475,87 @@
|
|||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Visuals pack: trading-card tilt, emerging ink, gold foil ──────────── */
|
||||||
|
|
||||||
|
/* Trading-card tilt (earned artifacts; JS feeds --pp-tilt-* on hover-capable
|
||||||
|
pointers only). */
|
||||||
|
.pp-tilt {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.pp-cover.pp-tilt {
|
||||||
|
transform: perspective(700px)
|
||||||
|
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
|
||||||
|
rotate(var(--pp-cover-rot, 0deg));
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.pp-cover.pp-tilt:hover { box-shadow: 0 12px 26px rgba(0, 0, 0, 0.6); }
|
||||||
|
.pp-stamp-page.pp-tilt {
|
||||||
|
overflow: visible;
|
||||||
|
transform: perspective(600px)
|
||||||
|
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
|
||||||
|
rotate(var(--pp-rot));
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.pp-tilt::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(105deg,
|
||||||
|
transparent calc(var(--pp-glint-x, 50%) - 14%),
|
||||||
|
rgba(255, 255, 255, 0.16) var(--pp-glint-x, 50%),
|
||||||
|
transparent calc(var(--pp-glint-x, 50%) + 14%));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pp-tilt:hover::after { opacity: 1; }
|
||||||
|
|
||||||
|
/* Emerging-stamp ink: the ghost fills as qualifying songs land. */
|
||||||
|
.pp-stamp-ghost::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 7%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: conic-gradient(rgba(154, 91, 22, 0.16) var(--pp-fill, 0%), transparent 0);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gold foil preview — honest "coming", never earnable-looking. */
|
||||||
|
.pp-gold-foil {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
padding: 0.28rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px dashed #c8b273;
|
||||||
|
color: #a8946d;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.32em;
|
||||||
|
}
|
||||||
|
.pp-gold-foil::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(100deg, transparent 40%, rgba(255, 223, 128, 0.35) 50%, transparent 60%);
|
||||||
|
transform: translateX(-120%);
|
||||||
|
animation: pp-foil 3.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pp-foil {
|
||||||
|
0%, 55% { transform: translateX(-120%); }
|
||||||
|
100% { transform: translateX(120%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pp-gold-foil::after { animation: none; }
|
||||||
|
.pp-cover.pp-tilt, .pp-stamp-page.pp-tilt { transition: none; }
|
||||||
|
/* The hover glint is motion theatrics too — not just the JS tilt. */
|
||||||
|
.pp-tilt::after { display: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -479,12 +479,17 @@
|
|||||||
|
|
||||||
function ppCoverHTML(inst, p) {
|
function ppCoverHTML(inst, p) {
|
||||||
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
||||||
const stamp = p.badge === 'earned'
|
const earned = p.badge === 'earned';
|
||||||
|
const stamp = earned
|
||||||
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
||||||
: '';
|
: '';
|
||||||
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
||||||
const hours = fmtHours(p.seconds_total);
|
const hours = fmtHours(p.seconds_total);
|
||||||
return `<button class="pp-cover pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="transform:rotate(${rot}deg)">
|
// Earned covers are trading cards: rotation moves into a CSS var so
|
||||||
|
// the pointer-tracked tilt transform can compose with it.
|
||||||
|
const style = earned
|
||||||
|
? `--pp-cover-rot:${rot}deg` : `transform:rotate(${rot}deg)`;
|
||||||
|
return `<button class="pp-cover${earned ? ' pp-tilt' : ''} pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="${style}">
|
||||||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||||||
${stamp}
|
${stamp}
|
||||||
@@ -560,6 +565,15 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emerging-stamp ink: how much of the ghost stamp has "carved in".
|
||||||
|
// Song progress toward the bar only — the invite line stays the words.
|
||||||
|
function ppFillFraction(p) {
|
||||||
|
if (!p || p.badge !== 'in_progress') return 0;
|
||||||
|
const need = Number((p.requirement || {}).songs) || 0;
|
||||||
|
if (need <= 0) return 0;
|
||||||
|
return Math.max(0, Math.min(1, (p.qualifying_count || 0) / need));
|
||||||
|
}
|
||||||
|
|
||||||
function ppBookHTML(inst, p, pendingSlam) {
|
function ppBookHTML(inst, p, pendingSlam) {
|
||||||
const req = p.requirement || {};
|
const req = p.requirement || {};
|
||||||
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
|
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
|
||||||
@@ -582,13 +596,15 @@
|
|||||||
if (p.badge === 'shown_not_judged') {
|
if (p.badge === 'shown_not_judged') {
|
||||||
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
||||||
} else if (p.badge === 'earned') {
|
} else if (p.badge === 'earned') {
|
||||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-stamp-tier">BRONZE</span>
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
||||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
||||||
} else {
|
} else {
|
||||||
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
const fill = (ppFillFraction(p) * 100).toFixed(0);
|
||||||
|
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
|
||||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||||
<span class="pp-stamp-tier">BRONZE</span>
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -658,6 +674,7 @@
|
|||||||
if (!stamp) return;
|
if (!stamp) return;
|
||||||
stamp.classList.remove('pp-stamp-hidden');
|
stamp.classList.remove('pp-stamp-hidden');
|
||||||
stamp.classList.add('pp-slam');
|
stamp.classList.add('pp-slam');
|
||||||
|
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
|
||||||
if (book) book.classList.add('pp-shake');
|
if (book) book.classList.add('pp-shake');
|
||||||
sfx('stamp');
|
sfx('stamp');
|
||||||
markBadgeSeen(inst, gkey);
|
markBadgeSeen(inst, gkey);
|
||||||
@@ -708,6 +725,52 @@
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Trading-card tilt (earned artifacts only) ─────────────────────────
|
||||||
|
let _tiltRaf = 0;
|
||||||
|
let _tiltEl = null;
|
||||||
|
|
||||||
|
function tiltAllowed() {
|
||||||
|
try {
|
||||||
|
return window.matchMedia('(hover: hover)').matches &&
|
||||||
|
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
} catch (_) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTilt(el) {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.removeProperty('--pp-tilt-x');
|
||||||
|
el.style.removeProperty('--pp-tilt-y');
|
||||||
|
el.style.removeProperty('--pp-glint-x');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTiltMove(e) {
|
||||||
|
if (!tiltAllowed()) return;
|
||||||
|
const card = e.target && e.target.closest ? e.target.closest('.pp-tilt') : null;
|
||||||
|
if (_tiltEl && _tiltEl !== card) { resetTilt(_tiltEl); _tiltEl = null; }
|
||||||
|
if (!card) return;
|
||||||
|
_tiltEl = card;
|
||||||
|
if (_tiltRaf) return;
|
||||||
|
const x = e.clientX;
|
||||||
|
const y = e.clientY;
|
||||||
|
_tiltRaf = requestAnimationFrame(() => {
|
||||||
|
_tiltRaf = 0;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
if (!r.width || !r.height) return;
|
||||||
|
const px = (x - r.left) / r.width;
|
||||||
|
const py = (y - r.top) / r.height;
|
||||||
|
card.style.setProperty('--pp-tilt-x', `${((0.5 - py) * 10).toFixed(2)}deg`);
|
||||||
|
card.style.setProperty('--pp-tilt-y', `${((px - 0.5) * 12).toFixed(2)}deg`);
|
||||||
|
card.style.setProperty('--pp-glint-x', `${(px * 100).toFixed(1)}%`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTiltLeave() {
|
||||||
|
// Cancel any queued frame: it closes over the departed card and would
|
||||||
|
// re-apply tilt vars after the pointer has left.
|
||||||
|
if (_tiltRaf) { cancelAnimationFrame(_tiltRaf); _tiltRaf = 0; }
|
||||||
|
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
|
||||||
|
}
|
||||||
|
|
||||||
function openGenre(inst, genre) {
|
function openGenre(inst, genre) {
|
||||||
fetch(`${API}/passports/open`, {
|
fetch(`${API}/passports/open`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -798,7 +861,11 @@
|
|||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
const screen = document.getElementById('plugin-career');
|
const screen = document.getElementById('plugin-career');
|
||||||
if (screen) screen.addEventListener('click', onClick);
|
if (screen) {
|
||||||
|
screen.addEventListener('click', onClick);
|
||||||
|
screen.addEventListener('pointermove', onTiltMove);
|
||||||
|
screen.addEventListener('pointerleave', onTiltLeave);
|
||||||
|
}
|
||||||
const sm = window.feedBack;
|
const sm = window.feedBack;
|
||||||
if (sm && typeof sm.on === 'function') {
|
if (sm && typeof sm.on === 'function') {
|
||||||
// New song stats can add stars → thresholds may cross mid-session.
|
// New song stats can add stars → thresholds may cross mid-session.
|
||||||
@@ -819,7 +886,7 @@
|
|||||||
// the badge-diff logic; nothing here touches the DOM.
|
// the badge-diff logic; nothing here touches the DOM.
|
||||||
window.__careerPassportTest = {
|
window.__careerPassportTest = {
|
||||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||||
fmtHours,
|
fmtHours, ppFillFraction,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -138,3 +138,15 @@ test('fmtHours: silent under a minute, minutes under an hour, tenths after', ()
|
|||||||
assert.equal(fmtHours(null), '');
|
assert.equal(fmtHours(null), '');
|
||||||
assert.equal(fmtHours('junk'), '');
|
assert.equal(fmtHours('junk'), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ppFillFraction: song progress toward the bar, in-progress only', () => {
|
||||||
|
const { ppFillFraction } = load().__careerPassportTest;
|
||||||
|
const p = (badge, q, songs) => ({ badge, qualifying_count: q, requirement: { songs } });
|
||||||
|
assert.equal(ppFillFraction(p('in_progress', 3, 5)), 0.6);
|
||||||
|
assert.equal(ppFillFraction(p('in_progress', 0, 5)), 0);
|
||||||
|
assert.equal(ppFillFraction(p('in_progress', 7, 5)), 1); // clamped
|
||||||
|
assert.equal(ppFillFraction(p('earned', 5, 5)), 0); // no fill once earned
|
||||||
|
assert.equal(ppFillFraction(p('shown_not_judged', 3, 5)), 0);
|
||||||
|
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
|
||||||
|
assert.equal(ppFillFraction(null), 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -101,6 +101,10 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
// A 4xx/5xx JSON error body must read as FAILURE — callers
|
||||||
|
// re-queue accrued seconds on null, and a parsed error object
|
||||||
|
// would silently drop them.
|
||||||
|
if (!r.ok) return null;
|
||||||
try { return await r.json(); } catch (e) { return null; }
|
try { return await r.json(); } catch (e) { return null; }
|
||||||
} catch (e) { return null; /* offline / endpoint absent — non-fatal */ }
|
} catch (e) { return null; /* offline / endpoint absent — non-fatal */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -480,6 +480,11 @@ def test_seconds_only_post_accrues_without_touching_position(client):
|
|||||||
# overwrite Continue with the end-of-song offset).
|
# overwrite Continue with the end-of-song offset).
|
||||||
assert row["plays"] == 0
|
assert row["plays"] == 0
|
||||||
assert row["last_position"] == pytest.approx(42.0)
|
assert row["last_position"] == pytest.approx(42.0)
|
||||||
|
# Recency must come from the seconds-only POST itself — prove it on a
|
||||||
|
# FRESH row (the position touch above already stamps last_played_at,
|
||||||
|
# which would make an assertion here vacuous).
|
||||||
|
r2 = client.post("/api/stats", json={"filename": "fresh.archive", "seconds": 30})
|
||||||
|
assert r2.json()["stats"]["last_played_at"]
|
||||||
# Still counts as playing today for the streak.
|
# Still counts as playing today for the streak.
|
||||||
assert r.json()["progress"]["current_streak"] == 1
|
assert r.json()["progress"]["current_streak"] == 1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user