From 6b1aa6b5d50ffa8580f750b51fc20496a8b0d205 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 17:08:54 +0100 Subject: [PATCH] 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) --- Cargo.lock | 1 + .../client/src/FurumiPlayer.tsx | 1 + .../client/src/components/header/Header.tsx | 62 +++++---- .../src/components/header/RecentPlays.tsx | 64 +++++++++ .../src/components/header/header.module.css | 123 ++++++++++++++++++ furumi-node-player/client/src/furumiApi.ts | 13 ++ furumi-web-player/Cargo.toml | 1 + furumi-web-player/src/db.rs | 32 +++++ furumi-web-player/src/web/api.rs | 10 ++ furumi-web-player/src/web/mod.rs | 3 +- 10 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 furumi-node-player/client/src/components/header/RecentPlays.tsx diff --git a/Cargo.lock b/Cargo.lock index 0e209f0..98a7483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1176,6 +1176,7 @@ dependencies = [ "anyhow", "axum", "base64 0.22.1", + "chrono", "clap", "hmac", "jsonwebtoken 9.3.1", diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index c2572e5..4217803 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -520,6 +520,7 @@ export function FurumiPlayer({ user }: { user: UserProfile }) { searchOpen={searchOpen} searchResults={searchResults} onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)} + onPlayTrack={(slug) => searchSelectRef.current('track', slug)} user={user} /> diff --git a/furumi-node-player/client/src/components/header/Header.tsx b/furumi-node-player/client/src/components/header/Header.tsx index c227db3..9a1aada 100644 --- a/furumi-node-player/client/src/components/header/Header.tsx +++ b/furumi-node-player/client/src/components/header/Header.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { SearchDropdown } from '../SearchDropdown' +import { RecentPlays } from './RecentPlays' import styles from './header.module.css' type SearchResultItem = { @@ -19,10 +20,11 @@ type HeaderProps = { searchOpen: boolean searchResults: SearchResultItem[] onSearchSelect: (type: string, slug: string) => void + onPlayTrack: (slug: string) => void user: UserInfo } -function UserMenu({ user }: { user: UserInfo }) { +function UserMenu({ user, onShowRecent }: { user: UserInfo; onShowRecent: () => void }) { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -52,6 +54,9 @@ function UserMenu({ user }: { user: UserInfo }) {
{user.name ?? user.sub}
{user.email &&
{user.email}
} + Sign out )} @@ -63,31 +68,42 @@ export function Header({ searchOpen, searchResults, onSearchSelect, + onPlayTrack, user, }: HeaderProps) { + const [showRecent, setShowRecent] = useState(false) + return ( -
-
- - - - - - - Furumi - v -
-
-
- - + <> +
+
+ + + + + + + Furumi + v
- -
-
+
+
+ + +
+ setShowRecent(true)} /> +
+ + {showRecent && ( + setShowRecent(false)} + onPlay={onPlayTrack} + /> + )} + ) } diff --git a/furumi-node-player/client/src/components/header/RecentPlays.tsx b/furumi-node-player/client/src/components/header/RecentPlays.tsx new file mode 100644 index 0000000..fcc645f --- /dev/null +++ b/furumi-node-player/client/src/components/header/RecentPlays.tsx @@ -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(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 ( +
+
e.stopPropagation()}> +
+

Recent plays

+ +
+
+ {loading &&

Loading...

} + {!loading && (!plays || plays.length === 0) && ( +

No play history yet

+ )} + {plays?.map((p, i) => ( +
{ onPlay(p.track_slug); onClose() }} + > +
+
{p.track_title}
+
{p.artist_name}
+
+
{timeAgo(p.played_at)}
+
+ ))} +
+
+
+ ) +} diff --git a/furumi-node-player/client/src/components/header/header.module.css b/furumi-node-player/client/src/components/header/header.module.css index 02af660..0375b19 100644 --- a/furumi-node-player/client/src/components/header/header.module.css +++ b/furumi-node-player/client/src/components/header/header.module.css @@ -108,4 +108,127 @@ .userLogout: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; } \ No newline at end of file diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index eb9aa4e..4ed3312 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -80,6 +80,19 @@ export async function getTrackInfo(trackSlug: string): Promise { + const res = await furumiApi.get('/me/recent').catch(() => null) + return res?.data ?? null +} + export async function recordPlay(trackSlug: string): Promise { await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null) } diff --git a/furumi-web-player/Cargo.toml b/furumi-web-player/Cargo.toml index 6638f38..17270c2 100644 --- a/furumi-web-player/Cargo.toml +++ b/furumi-web-player/Cargo.toml @@ -9,6 +9,7 @@ axum = { version = "0.7", features = ["tokio", "macros"] } clap = { version = "4.5", features = ["derive", "env"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] } tokio = { version = "1.50", features = ["full"] } tower = { version = "0.4", features = ["util"] } diff --git a/furumi-web-player/src/db.rs b/furumi-web-player/src/db.rs index 0fad353..be139ac 100644 --- a/furumi-web-player/src/db.rs +++ b/furumi-web-player/src/db.rs @@ -126,6 +126,38 @@ pub async fn record_play_event( 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, + pub played_at: chrono::DateTime, +} + +pub async fn recent_plays( + pool: &PgPool, + user_id: &str, + limit: i32, +) -> Result, 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 --- pub async fn list_artists(pool: &PgPool) -> Result, sqlx::Error> { diff --git a/furumi-web-player/src/web/api.rs b/furumi-web-player/src/web/api.rs index 7357702..b19249a 100644 --- a/furumi-web-player/src/web/api.rs +++ b/furumi-web-player/src/web/api.rs @@ -295,6 +295,16 @@ pub async fn search(State(state): State, Query(q): Query) -> imp // --- Play tracking --- +pub async fn recent_plays( + State(state): State, + Extension(user): Extension, +) -> 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( State(state): State, Path(slug): Path, diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index d35a8a1..c25cb71 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -30,7 +30,8 @@ pub fn build_router(state: Arc) -> Router { .route("/tracks/:slug/cover", get(api::track_cover)) .route("/stream/:slug", get(api::stream_track)) .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() .nest("/api", library);