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 -1
View File
@@ -57,7 +57,7 @@ function App() {
// Authenticated — render player immediately
if (!loading && user) {
return <FurumiPlayer />
return <FurumiPlayer user={user} />
}
// Loading — show spinner (no login form flash)
+10 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import './furumi-player.css'
import { searchTracks, preloadStream, fetchCoverBlob } from './furumiApi'
import { searchTracks, preloadStream, fetchCoverBlob, recordPlay } from './furumiApi'
import { store, useAppDispatch, useAppSelector } from './store'
import { fetchArtists } from './store/slices/artistsSlice'
import { fetchArtistAlbums } from './store/slices/albumsSlice'
@@ -29,7 +29,13 @@ import { MainPanel, type Crumb } from './components/MainPanel'
import { PlayerBar } from './components/PlayerBar'
import type { Track } from './types'
export function FurumiPlayer() {
export type UserProfile = {
sub: string
name?: string
email?: string
}
export function FurumiPlayer({ user }: { user: UserProfile }) {
const dispatch = useAppDispatch()
const artistsLoading = useAppSelector((s) => s.artists.loading)
const artistsError = useAppSelector((s) => s.artists.error)
@@ -293,6 +299,7 @@ export function FurumiPlayer() {
dispatch(playAtIndex(i))
const track = store.getState().queue.items[i]
void playback.loadStreamForTrack(track.slug)
void recordPlay(track.slug)
if (window.history && window.history.replaceState) {
const url = new URL(window.location.href)
url.searchParams.set('t', track.slug)
@@ -513,6 +520,7 @@ export function FurumiPlayer() {
searchOpen={searchOpen}
searchResults={searchResults}
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
user={user}
/>
<MainPanel
@@ -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);
}
@@ -80,6 +80,10 @@ export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | nul
return res?.data ?? null
}
export async function recordPlay(trackSlug: string): Promise<void> {
await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null)
}
export async function preloadStream(trackSlug: string) {
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
}