Files
furumi-ng/furumi-node-player/client/src/components/header/Header.tsx
T
Ultradesu 6b1aa6b5d5
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m23s
Publish Node Player Image / build-and-push-image (push) Successful in 37s
Publish Web Player Image / build-and-push-image (push) Successful in 1m49s
feat: add recent plays history modal
- 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>
2026-04-08 17:08:54 +01:00

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">&#9776;</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}
/>
)}
</>
)
}