6b1aa6b5d5
- GET /api/me/recent endpoint returning last 50 play events with track and artist info - RecentPlays modal component with time-ago display - "Recent plays" button in user dropdown menu - Clicking a track in history starts playback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
110 lines
3.1 KiB
TypeScript
110 lines
3.1 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
|
import { SearchDropdown } from '../SearchDropdown'
|
|
import { RecentPlays } from './RecentPlays'
|
|
import styles from './header.module.css'
|
|
|
|
type SearchResultItem = {
|
|
result_type: string
|
|
slug: string
|
|
name: string
|
|
detail?: string
|
|
}
|
|
|
|
type UserInfo = {
|
|
sub: string
|
|
name?: string
|
|
email?: string
|
|
}
|
|
|
|
type HeaderProps = {
|
|
searchOpen: boolean
|
|
searchResults: SearchResultItem[]
|
|
onSearchSelect: (type: string, slug: string) => void
|
|
onPlayTrack: (slug: string) => void
|
|
user: UserInfo
|
|
}
|
|
|
|
function UserMenu({ user, onShowRecent }: { user: UserInfo; onShowRecent: () => void }) {
|
|
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>
|
|
<button className={styles.userAction} onClick={() => { setOpen(false); onShowRecent() }}>
|
|
Recent plays
|
|
</button>
|
|
<a href="/auth/logout" className={styles.userLogout}>Sign out</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Header({
|
|
searchOpen,
|
|
searchResults,
|
|
onSearchSelect,
|
|
onPlayTrack,
|
|
user,
|
|
}: HeaderProps) {
|
|
const [showRecent, setShowRecent] = useState(false)
|
|
|
|
return (
|
|
<>
|
|
<header className={styles.header}>
|
|
<div className={styles.headerLogo}>
|
|
<button className="btn-menu">☰</button>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="9" cy="18" r="3" />
|
|
<circle cx="18" cy="15" r="3" />
|
|
<path d="M12 18V6l9-3v3" />
|
|
</svg>
|
|
Furumi
|
|
<span className={styles.headerVersion}>v</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
|
<div className="search-wrap">
|
|
<input id="searchInput" placeholder="Search..." />
|
|
<SearchDropdown
|
|
isOpen={searchOpen}
|
|
results={searchResults}
|
|
onSelect={onSearchSelect}
|
|
/>
|
|
</div>
|
|
<UserMenu user={user} onShowRecent={() => setShowRecent(true)} />
|
|
</div>
|
|
</header>
|
|
{showRecent && (
|
|
<RecentPlays
|
|
onClose={() => setShowRecent(false)}
|
|
onPlay={onPlayTrack}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|