feat: add user support with play event tracking

Backend (Rust API):
- Add users and play_events tables (migration 0005)
- Extract full user identity from JWT (sub, username, email, name)
  and pass AuthUser via request extensions to all handlers
- Auto-upsert user in background on every authenticated request
- POST /api/tracks/:slug/play endpoint to record play events
- Allow POST method in CORS

Frontend (Node player):
- Call recordPlay() when a track starts playing
- Add user profile avatar with dropdown menu (name, email, sign out)
- Pass user info from App through FurumiPlayer to Header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ultradesu
2026-04-08 16:51:53 +01:00
parent 5bc2b55ffd
commit 4fdd56dae4
10 changed files with 283 additions and 25 deletions
@@ -1,3 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { SearchDropdown } from '../SearchDropdown'
import styles from './header.module.css'
@@ -8,16 +9,61 @@ type SearchResultItem = {
detail?: string
}
type UserInfo = {
sub: string
name?: string
email?: string
}
type HeaderProps = {
searchOpen: boolean
searchResults: SearchResultItem[]
onSearchSelect: (type: string, slug: string) => void
user: UserInfo
}
function UserMenu({ user }: { user: UserInfo }) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
const initials = (user.name ?? user.sub)
.split(' ')
.map((w) => w[0])
.slice(0, 2)
.join('')
.toUpperCase()
return (
<div className={styles.userMenu} ref={ref}>
<button className={styles.userAvatar} onClick={() => setOpen(!open)} title={user.name ?? user.sub}>
{initials}
</button>
{open && (
<div className={styles.userDropdown}>
<div className={styles.userInfo}>
<div className={styles.userName}>{user.name ?? user.sub}</div>
{user.email && <div className={styles.userEmail}>{user.email}</div>}
</div>
<a href="/auth/logout" className={styles.userLogout}>Sign out</a>
</div>
)}
</div>
)
}
export function Header({
searchOpen,
searchResults,
onSearchSelect,
user,
}: HeaderProps) {
return (
<header className={styles.header}>
@@ -40,6 +86,7 @@ export function Header({
onSelect={onSearchSelect}
/>
</div>
<UserMenu user={user} />
</div>
</header>
)
@@ -32,4 +32,80 @@
margin-left: 0.25rem;
font-weight: 500;
text-decoration: none;
}
/* User menu */
.userMenu {
position: relative;
}
.userAvatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
color: #fff;
border: none;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.userAvatar:hover {
background: var(--accent-dim);
}
.userDropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 200px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
z-index: 100;
overflow: hidden;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.userInfo {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.userName {
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
}
.userEmail {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 2px;
}
.userLogout {
display: block;
padding: 10px 16px;
color: var(--danger);
text-decoration: none;
font-size: 0.85rem;
font-weight: 500;
transition: background 0.15s;
}
.userLogout:hover {
background: var(--bg-hover);
}