diff --git a/Cargo.lock b/Cargo.lock index 35865bf..b75769e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", diff --git a/src/player/mod.rs b/src/player/mod.rs index 4195465..15905bd 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -9,7 +9,7 @@ use cot::http::header::{ use cot::json::Json; use cot::request::extractors::{Path, UrlQuery}; use cot::response::IntoResponse; -use cot::router::method::{get, post}; +use cot::router::method::{delete, get, post}; use cot::router::{Route, Router}; use cot::session::Session; use cot::{App, Body, Template}; @@ -1877,6 +1877,36 @@ async fn user_upload_review_save_handler( Json(review).into_response() } +async fn user_upload_review_delete_handler( + session: Session, + db: Database, + pool: &sqlx::PgPool, + path: Path, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let review_id = path.0.id; + let uploaded_by_pattern = format!(r#""uploaded_by_user_id"\s*:\s*{}([^0-9]|$)"#, user.id); + let result = sqlx::query( + r#"DELETE FROM furumusic__pending_review + WHERE id = $1 + AND context_json IS NOT NULL + AND context_json ~ $2 + AND status IN ('pending', 'failed')"#, + ) + .bind(review_id) + .bind(uploaded_by_pattern) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + if result.rows_affected() == 0 { + return Ok(json_error(StatusCode::NOT_FOUND, "upload review not found")); + } + let page = load_user_uploads_page(pool, user.id, 500).await?; + Json(page).into_response() +} + async fn user_upload_review_approve_handler( session: Session, db: Database, @@ -5778,6 +5808,30 @@ impl App for PlayerApp { }), "player_upload_review_save", ), + Route::with_handler_and_name( + "/uploads/reviews/{id}", + delete({ + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + move |session: Session, db: Database, path: Path| { + 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; + user_upload_review_delete_handler(session, db, pg_pool, path).await + } + } + }), + "player_upload_review_delete", + ), Route::with_handler_and_name( "/uploads/reviews/{id}/approve", post({ diff --git a/templates/player/modals.html b/templates/player/modals.html index 4f8b059..ccecb10 100644 --- a/templates/player/modals.html +++ b/templates/player/modals.html @@ -472,7 +472,7 @@
- +
diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 8f3e7b6..c47540e 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -411,6 +411,7 @@ document.addEventListener('alpine:init', () => { duration: 0, volume: 0.7, _prevVolume: 0.7, + _volumeCurve: 2.4, shuffle: false, repeatMode: 'off', // off, all, one progress: 0, @@ -662,10 +663,21 @@ document.addEventListener('alpine:init', () => { audio.volume = this.volume; }, + volumeSliderPercent() { + if (this.volume <= 0) return 0; + return Math.pow(this.volume, 1 / this._volumeCurve) * 100; + }, + + _volumeFromSliderPosition(position) { + const pct = Math.max(0, Math.min(1, Number(position || 0))); + if (pct <= 0.002) return 0; + return Math.pow(pct, this._volumeCurve); + }, + _setVolumeFromClientX(clientX, bar) { const rect = bar.getBoundingClientRect(); const pct = rect.width > 0 ? (clientX - rect.left) / rect.width : 0; - this.setVolume(pct); + this.setVolume(this._volumeFromSliderPosition(pct)); }, setVolumeFromClick(event) { @@ -2706,20 +2718,19 @@ document.addEventListener('alpine:init', () => { this.uploadReviewDraft = null; }, - async saveUploadReview() { - if (!this.uploadReviewEditId || !this.uploadReviewDraft) return; + async deleteUploadReview() { + if (!this.uploadReviewEditId) return; const id = this.uploadReviewEditId; this.uploadReviewSavingId = id; try { const res = await fetch(`/api/player/uploads/reviews/${id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(this.uploadReviewPayload()), + method: 'DELETE', }); const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Failed to save review'); - this.uploadPending = this.uploadPending.map(item => item.id === id ? data : item); - this._setMessage('Pending metadata saved'); + if (!res.ok) throw new Error(data.error || 'Failed to delete review'); + this.applyUploadPage(data); + this.cancelUploadReviewEdit(); + this._setMessage('Review deleted'); } catch (err) { this._setMessage(err.message || String(err), true); } finally { diff --git a/templates/player/shell.html b/templates/player/shell.html index 35ec093..44c4416 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -1050,7 +1050,7 @@
-
+
diff --git a/templates/player/styles.html b/templates/player/styles.html index b7d83df..a2b1808 100644 --- a/templates/player/styles.html +++ b/templates/player/styles.html @@ -1287,7 +1287,7 @@ button.user-stat:hover { .volume-btn svg { width: 18px; height: 18px; } .volume-slider { - width: 80px; + width: 104px; height: 4px; background: var(--bg-active); border-radius: 2px; @@ -2920,13 +2920,13 @@ button.user-stat:hover { z-index: 140; display: flex; align-items: center; - justify-content: flex-end; + justify-content: center; padding: 40px; background: rgba(0,0,0,0.32); } .upload-editor-drawer { - width: min(460px, calc(100vw - 48px)); + width: min(680px, calc(100vw - 48px)); max-height: calc(100vh - 80px); overflow-y: auto; border: 1px solid var(--border-color); @@ -3662,7 +3662,7 @@ button.user-stat:hover { } .volume-slider { - width: 74px; + width: 88px; height: 6px; border-radius: 999px; } @@ -4142,7 +4142,7 @@ button.user-stat:hover { } .volume-slider { - width: 58px; + width: 72px; } .player-btn {