From 709f319bc524506117feb0d256f96b5f0d74890e Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Mon, 25 May 2026 15:57:10 +0300 Subject: [PATCH] Fixed UI --- Cargo.toml | 2 +- src/player/mod.rs | 126 +++++++++++++++++++- templates/player.html | 269 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 387 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1ef0f13..5c1d7ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.4" +version = "0.1.5" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/player/mod.rs b/src/player/mod.rs index a39223b..06e5186 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -155,6 +155,25 @@ struct UserProfile { stats: UserStats, } +#[derive(Debug, Serialize, JsonSchema)] +struct PlayHistoryItem { + id: i64, + track_id: i64, + track_title: String, + release_title: Option, + played_at: String, + duration_listened: Option, + completed: bool, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct PlayHistoryPage { + items: Vec, + total: i64, + page: i32, + per_page: i32, +} + #[derive(Debug, Deserialize)] struct HistoryEntry { track_id: i64, @@ -162,6 +181,12 @@ struct HistoryEntry { completed: bool, } +#[derive(Debug, Deserialize)] +struct HistoryQuery { + page: Option, + limit: Option, +} + #[derive(Debug, Deserialize)] struct TracksByIdsRequest { ids: Vec, @@ -362,6 +387,17 @@ struct SearchTrackRow { release_cover_file_id: Option, } +#[derive(sqlx::FromRow)] +struct PlayHistoryRow { + id: i64, + track_id: i64, + track_title: String, + release_title: Option, + played_at: String, + duration_listened: Option, + completed: bool, +} + #[derive(sqlx::FromRow)] struct ReleaseInfoRow { id: i64, @@ -471,7 +507,7 @@ async fn artists_handler( FROM furumusic__artist a JOIN furumusic__release_artist ra ON ra.artist_id = a.id JOIN furumusic__release r ON r.id = ra.release_id - WHERE a.is_hidden = false AND r.is_hidden = false"#, + WHERE a.is_hidden = false AND r.is_hidden = false AND ra.position = 0"#, ) .fetch_one(pool) .await @@ -489,6 +525,7 @@ async fn artists_handler( FROM furumusic__release_artist ra JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false LEFT JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false + WHERE ra.position = 0 GROUP BY ra.artist_id ) s ON s.artist_id = a.id WHERE a.is_hidden = false @@ -1238,6 +1275,69 @@ async fn put_state_handler( // POST /api/player/history // --------------------------------------------------------------------------- +async fn history_list_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + query: cot::request::extractors::UrlQuery, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + + let page = query.0.page.unwrap_or(1).max(1); + let per_page = query.0.limit.unwrap_or(20).clamp(1, 100); + let offset = (page - 1) as i64 * per_page as i64; + + let total: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1") + .bind(user.id) + .fetch_one(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + let rows = sqlx::query_as::<_, PlayHistoryRow>( + r#"SELECT ph.id, + ph.track_id, + t.title::text AS track_title, + r.title::text AS release_title, + ph.played_at::text AS played_at, + ph.duration_listened, + ph.completed + FROM furumusic__play_history ph + JOIN furumusic__track t ON t.id = ph.track_id + LEFT JOIN furumusic__release r ON r.id = t.release_id + WHERE ph.user_id = $1 + ORDER BY ph.played_at DESC, ph.id DESC + LIMIT $2 OFFSET $3"#, + ) + .bind(user.id) + .bind(per_page as i64) + .bind(offset) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + Json(PlayHistoryPage { + items: rows + .into_iter() + .map(|row| PlayHistoryItem { + id: row.id, + track_id: row.track_id, + track_title: row.track_title, + release_title: row.release_title, + played_at: row.played_at, + duration_listened: row.duration_listened, + completed: row.completed, + }) + .collect(), + total, + page, + per_page, + }) + .into_response() +} + async fn history_handler( session: Session, db: Database, @@ -2625,7 +2725,29 @@ impl App for PlayerApp { // -- Play history -- Route::with_handler_and_name( "/history", - cot::router::method::post({ + get({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, + db: Database, + query: cot::request::extractors::UrlQuery| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + history_list_handler(session, db, pg_pool, query).await + } + } + }) + .post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); move |session: Session, db: Database, json: Json| { diff --git a/templates/player.html b/templates/player.html index 143a802..8c7f5f0 100644 --- a/templates/player.html +++ b/templates/player.html @@ -142,6 +142,17 @@ body { background: var(--bg-primary); } +button.user-stat { + border: 0; + color: inherit; + cursor: pointer; + text-align: left; +} + +button.user-stat:hover { + background: var(--bg-hover); +} + .user-stat-value { display: block; font-size: 13px; @@ -980,6 +991,29 @@ body { background: var(--bg-hover); } +.mobile-account-popover { + position: absolute; + right: 16px; + top: 66px; + z-index: 80; + width: min(286px, calc(100vw - 32px)); + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-elevated); + box-shadow: 0 16px 36px rgba(0,0,0,0.42); +} + +.mobile-account-popover .user-widget-main { + grid-template-columns: 36px minmax(0, 1fr); +} + +.mobile-account-logout { + width: 100%; + margin-top: 12px; + justify-content: center; +} + .torrent-import-btn { display: flex; align-items: center; @@ -1284,6 +1318,60 @@ body { overflow: hidden; } +.history-modal { + width: min(620px, calc(100vw - 32px)); + max-width: 620px; +} + +.history-list { + margin-top: 12px; + overflow-y: auto; + max-height: min(54vh, 460px); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.history-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); +} + +.history-row:last-child { border-bottom: 0; } + +.history-title { + min-width: 0; + color: var(--text-primary); + font-size: 13px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-release, +.history-date, +.history-duration { + color: var(--text-subdued); + font-size: 12px; +} + +.history-date, +.history-duration { + text-align: right; + white-space: nowrap; +} + +.history-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 12px; +} + .torrent-modal-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); @@ -1774,6 +1862,11 @@ body { display: none; } + .mobile-account-popover { + right: 8px; + top: 64px; + } + .card-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; @@ -2017,17 +2110,17 @@ body {
-
+
+
likes
- - min + + listened
@@ -2082,7 +2175,7 @@ body {
-
+
@@ -2719,6 +2841,50 @@ body {
+ + +
@@ -2743,6 +2909,7 @@ document.addEventListener('alpine:init', () => { // ----------------------------------------------------------------------- Alpine.store('user', { profile: null, + menuOpen: false, init() { this.load(); @@ -2767,11 +2934,99 @@ document.addEventListener('alpine:init', () => { return new Intl.NumberFormat().format(value || 0); }, + duration(minutes) { + let value = Number(minutes || 0); + const units = [ + ['y', 525600], + ['mo', 43800], + ['d', 1440], + ['h', 60], + ['m', 1], + ]; + const parts = []; + for (const [label, size] of units) { + if (value >= size) { + const count = Math.floor(value / size); + value -= count * size; + parts.push(count + label); + } + if (parts.length >= 2) break; + } + return parts.length ? parts.join(' ') : '0m'; + }, + logout() { window.location.href = '/logout'; }, }); + // ----------------------------------------------------------------------- + // Play history store + // ----------------------------------------------------------------------- + Alpine.store('history', { + modal: false, + items: [], + page: 1, + perPage: 20, + total: 0, + loading: false, + message: '', + error: false, + + open() { + this.modal = true; + this.load(1); + }, + + close() { + this.modal = false; + }, + + totalPages() { + return Math.max(1, Math.ceil(this.total / this.perPage)); + }, + + async load(page) { + page = Math.max(1, page || 1); + this.loading = true; + this.error = false; + this.message = 'Loading history...'; + try { + const res = await fetch(`/api/player/history?page=${page}&limit=${this.perPage}`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to load history'); + this.items = data.items || []; + this.page = data.page || page; + this.perPage = data.per_page || this.perPage; + this.total = data.total || 0; + this.message = this.total ? (this.total + ' total plays') : ''; + } catch (err) { + this.error = true; + this.message = err.message || String(err); + } finally { + this.loading = false; + } + }, + + date(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); + }, + + duration(seconds) { + if (!seconds) return '0:00'; + return formatTime(Number(seconds)); + }, + }); + // ----------------------------------------------------------------------- // Player store // -----------------------------------------------------------------------