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>
This commit is contained in:
Generated
+1
@@ -1176,6 +1176,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"hmac",
|
"hmac",
|
||||||
"jsonwebtoken 9.3.1",
|
"jsonwebtoken 9.3.1",
|
||||||
|
|||||||
@@ -520,6 +520,7 @@ export function FurumiPlayer({ user }: { user: UserProfile }) {
|
|||||||
searchOpen={searchOpen}
|
searchOpen={searchOpen}
|
||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
||||||
|
onPlayTrack={(slug) => searchSelectRef.current('track', slug)}
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { SearchDropdown } from '../SearchDropdown'
|
import { SearchDropdown } from '../SearchDropdown'
|
||||||
|
import { RecentPlays } from './RecentPlays'
|
||||||
import styles from './header.module.css'
|
import styles from './header.module.css'
|
||||||
|
|
||||||
type SearchResultItem = {
|
type SearchResultItem = {
|
||||||
@@ -19,10 +20,11 @@ type HeaderProps = {
|
|||||||
searchOpen: boolean
|
searchOpen: boolean
|
||||||
searchResults: SearchResultItem[]
|
searchResults: SearchResultItem[]
|
||||||
onSearchSelect: (type: string, slug: string) => void
|
onSearchSelect: (type: string, slug: string) => void
|
||||||
|
onPlayTrack: (slug: string) => void
|
||||||
user: UserInfo
|
user: UserInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserMenu({ user }: { user: UserInfo }) {
|
function UserMenu({ user, onShowRecent }: { user: UserInfo; onShowRecent: () => void }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -52,6 +54,9 @@ function UserMenu({ user }: { user: UserInfo }) {
|
|||||||
<div className={styles.userName}>{user.name ?? user.sub}</div>
|
<div className={styles.userName}>{user.name ?? user.sub}</div>
|
||||||
{user.email && <div className={styles.userEmail}>{user.email}</div>}
|
{user.email && <div className={styles.userEmail}>{user.email}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
<button className={styles.userAction} onClick={() => { setOpen(false); onShowRecent() }}>
|
||||||
|
Recent plays
|
||||||
|
</button>
|
||||||
<a href="/auth/logout" className={styles.userLogout}>Sign out</a>
|
<a href="/auth/logout" className={styles.userLogout}>Sign out</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -63,31 +68,42 @@ export function Header({
|
|||||||
searchOpen,
|
searchOpen,
|
||||||
searchResults,
|
searchResults,
|
||||||
onSearchSelect,
|
onSearchSelect,
|
||||||
|
onPlayTrack,
|
||||||
user,
|
user,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
|
const [showRecent, setShowRecent] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<>
|
||||||
<div className={styles.headerLogo}>
|
<header className={styles.header}>
|
||||||
<button className="btn-menu">☰</button>
|
<div className={styles.headerLogo}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<button className="btn-menu">☰</button>
|
||||||
<circle cx="9" cy="18" r="3" />
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="18" cy="15" r="3" />
|
<circle cx="9" cy="18" r="3" />
|
||||||
<path d="M12 18V6l9-3v3" />
|
<circle cx="18" cy="15" r="3" />
|
||||||
</svg>
|
<path d="M12 18V6l9-3v3" />
|
||||||
Furumi
|
</svg>
|
||||||
<span className={styles.headerVersion}>v</span>
|
Furumi
|
||||||
</div>
|
<span className={styles.headerVersion}>v</span>
|
||||||
<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>
|
</div>
|
||||||
<UserMenu user={user} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
</div>
|
<div className="search-wrap">
|
||||||
</header>
|
<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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getRecentPlays, type RecentPlay } from '../../furumiApi'
|
||||||
|
import styles from './header.module.css'
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs}h ago`
|
||||||
|
const days = Math.floor(hrs / 24)
|
||||||
|
return `${days}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentPlays({ onClose, onPlay }: { onClose: () => void; onPlay: (slug: string) => void }) {
|
||||||
|
const [plays, setPlays] = useState<RecentPlay[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getRecentPlays().then((data) => {
|
||||||
|
setPlays(data)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.recentOverlay} onClick={onClose}>
|
||||||
|
<div className={styles.recentPanel} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className={styles.recentHeader}>
|
||||||
|
<h2>Recent plays</h2>
|
||||||
|
<button className={styles.recentClose} onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.recentList}>
|
||||||
|
{loading && <p className={styles.recentEmpty}>Loading...</p>}
|
||||||
|
{!loading && (!plays || plays.length === 0) && (
|
||||||
|
<p className={styles.recentEmpty}>No play history yet</p>
|
||||||
|
)}
|
||||||
|
{plays?.map((p, i) => (
|
||||||
|
<div
|
||||||
|
key={`${p.track_slug}-${i}`}
|
||||||
|
className={styles.recentItem}
|
||||||
|
onClick={() => { onPlay(p.track_slug); onClose() }}
|
||||||
|
>
|
||||||
|
<div className={styles.recentTrack}>
|
||||||
|
<div className={styles.recentTitle}>{p.track_title}</div>
|
||||||
|
<div className={styles.recentArtist}>{p.artist_name}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.recentTime}>{timeAgo(p.played_at)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -108,4 +108,127 @@
|
|||||||
|
|
||||||
.userLogout:hover {
|
.userLogout:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAction {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAction:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recent plays overlay */
|
||||||
|
|
||||||
|
.recentOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 200;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
animation: fadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentPanel {
|
||||||
|
width: min(480px, 90vw);
|
||||||
|
max-height: 70vh;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentHeader h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentClose:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentList {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentItem:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentTrack {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentTitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentArtist {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentTime {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentEmpty {
|
||||||
|
padding: 32px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,19 @@ export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | nul
|
|||||||
return res?.data ?? null
|
return res?.data ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RecentPlay = {
|
||||||
|
track_slug: string
|
||||||
|
track_title: string
|
||||||
|
artist_name: string
|
||||||
|
album_slug: string | null
|
||||||
|
played_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentPlays(): Promise<RecentPlay[] | null> {
|
||||||
|
const res = await furumiApi.get<RecentPlay[]>('/me/recent').catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export async function recordPlay(trackSlug: string): Promise<void> {
|
export async function recordPlay(trackSlug: string): Promise<void> {
|
||||||
await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null)
|
await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ axum = { version = "0.7", features = ["tokio", "macros"] }
|
|||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
||||||
tokio = { version = "1.50", features = ["full"] }
|
tokio = { version = "1.50", features = ["full"] }
|
||||||
tower = { version = "0.4", features = ["util"] }
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
|||||||
@@ -126,6 +126,38 @@ pub async fn record_play_event(
|
|||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct RecentPlay {
|
||||||
|
pub track_slug: String,
|
||||||
|
pub track_title: String,
|
||||||
|
pub artist_name: String,
|
||||||
|
pub album_slug: Option<String>,
|
||||||
|
pub played_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn recent_plays(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: &str,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Vec<RecentPlay>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, RecentPlay>(
|
||||||
|
r#"SELECT t.slug AS track_slug, t.title AS track_title,
|
||||||
|
ar.name AS artist_name, al.slug AS album_slug,
|
||||||
|
pe.played_at
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON pe.track_id = t.id
|
||||||
|
JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
ORDER BY pe.played_at DESC
|
||||||
|
LIMIT $2"#
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
|
|
||||||
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
|
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
|
||||||
|
|||||||
@@ -295,6 +295,16 @@ pub async fn search(State(state): State<S>, Query(q): Query<SearchQuery>) -> imp
|
|||||||
|
|
||||||
// --- Play tracking ---
|
// --- Play tracking ---
|
||||||
|
|
||||||
|
pub async fn recent_plays(
|
||||||
|
State(state): State<S>,
|
||||||
|
Extension(user): Extension<AuthUser>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match db::recent_plays(&state.pool, &user.id, 50).await {
|
||||||
|
Ok(plays) => (StatusCode::OK, Json(serde_json::to_value(plays).unwrap())).into_response(),
|
||||||
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn record_play(
|
pub async fn record_play(
|
||||||
State(state): State<S>,
|
State(state): State<S>,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/tracks/:slug/cover", get(api::track_cover))
|
.route("/tracks/:slug/cover", get(api::track_cover))
|
||||||
.route("/stream/:slug", get(api::stream_track))
|
.route("/stream/:slug", get(api::stream_track))
|
||||||
.route("/search", get(api::search))
|
.route("/search", get(api::search))
|
||||||
.route("/tracks/:slug/play", post(api::record_play));
|
.route("/tracks/:slug/play", post(api::record_play))
|
||||||
|
.route("/me/recent", get(api::recent_plays));
|
||||||
|
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
.nest("/api", library);
|
.nest("/api", library);
|
||||||
|
|||||||
Reference in New Issue
Block a user