Compare commits
1 Commits
ea6a6410f3
...
abe321a317
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abe321a317 |
@@ -14,5 +14,5 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window"] }
|
||||
web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window", "DragEvent", "DataTransfer"] }
|
||||
js-sys = "0.3"
|
||||
|
||||
@@ -44,6 +44,20 @@ async fn post_empty<T: DeserializeOwned>(url: &str) -> Result<T, ApiError> {
|
||||
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn put_json<T: DeserializeOwned>(url: &str, body: &str) -> Result<T, ApiError> {
|
||||
let resp = Request::put(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.map_err(|e| ApiError(e.to_string()))?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError(e.to_string()))?;
|
||||
if !resp.ok() {
|
||||
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn delete(url: &str) -> Result<(), ApiError> {
|
||||
let resp = Request::delete(url)
|
||||
.send()
|
||||
@@ -304,6 +318,36 @@ pub fn export_m3u_url(id: i32) -> String {
|
||||
format!("{BASE}/playlists/{id}/m3u")
|
||||
}
|
||||
|
||||
pub async fn add_track_to_playlist(
|
||||
playlist_id: i32,
|
||||
track_id: i32,
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
let body = serde_json::json!({"track_id": track_id}).to_string();
|
||||
post_json(&format!("{BASE}/playlists/{playlist_id}/tracks"), &body).await
|
||||
}
|
||||
|
||||
pub async fn remove_track_from_playlist(
|
||||
playlist_id: i32,
|
||||
track_id: i32,
|
||||
) -> Result<(), ApiError> {
|
||||
delete(&format!(
|
||||
"{BASE}/playlists/{playlist_id}/tracks/{track_id}"
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn reorder_playlist_tracks(
|
||||
playlist_id: i32,
|
||||
track_ids: &[i32],
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
let body = serde_json::json!({"track_ids": track_ids}).to_string();
|
||||
put_json(&format!("{BASE}/playlists/{playlist_id}/tracks"), &body).await
|
||||
}
|
||||
|
||||
pub async fn search_tracks(query: &str) -> Result<Vec<Track>, ApiError> {
|
||||
get_json(&format!("{BASE}/tracks?q={query}&limit=50")).await
|
||||
}
|
||||
|
||||
// --- YouTube Auth ---
|
||||
|
||||
pub async fn get_ytauth_status() -> Result<YtAuthStatus, ApiError> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -323,6 +323,18 @@ table.tasks-table td { overflow: hidden; text-overflow: ellipsis; }
|
||||
.auth-card h1 { font-size: 1.8rem; color: var(--accent); margin-bottom: 0.25rem; }
|
||||
.auth-card p { margin-bottom: 1.5rem; }
|
||||
|
||||
/* Tab bar */
|
||||
.tab-bar { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 2px solid var(--border); }
|
||||
.tab-btn { padding: 0.5rem 1rem; background: none; border: none; color: var(--text-secondary); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; font-size: 0.9rem; }
|
||||
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tab-btn:hover { color: var(--text-primary); }
|
||||
|
||||
/* Drag and drop */
|
||||
.drag-handle { cursor: grab; user-select: none; color: var(--text-muted); padding-right: 0.5rem; font-size: 1.2rem; }
|
||||
tr.drag-over { background: rgba(59, 130, 246, 0.1); }
|
||||
tr[draggable="true"] { cursor: grab; }
|
||||
tr[draggable="true"]:active { cursor: grabbing; }
|
||||
|
||||
/* Sidebar user section */
|
||||
.sidebar-user {
|
||||
margin-top: auto;
|
||||
|
||||
@@ -22,7 +22,16 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.route(web::put().to(update_playlist))
|
||||
.route(web::delete().to(delete_playlist)),
|
||||
)
|
||||
.service(web::resource("/playlists/{id}/m3u").route(web::get().to(export_m3u)));
|
||||
.service(web::resource("/playlists/{id}/m3u").route(web::get().to(export_m3u)))
|
||||
.service(
|
||||
web::resource("/playlists/{id}/tracks")
|
||||
.route(web::post().to(add_track))
|
||||
.route(web::put().to(reorder_tracks)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/playlists/{id}/tracks/{track_id}")
|
||||
.route(web::delete().to(remove_track)),
|
||||
);
|
||||
}
|
||||
|
||||
/// POST /api/playlists/generate — generate a playlist without saving.
|
||||
@@ -194,6 +203,56 @@ async fn delete_playlist(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddTrackRequest {
|
||||
track_id: i32,
|
||||
}
|
||||
|
||||
/// POST /api/playlists/{id}/tracks — add a track to playlist.
|
||||
async fn add_track(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<i32>,
|
||||
body: web::Json<AddTrackRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
auth::require_auth(&session)?;
|
||||
let id = path.into_inner();
|
||||
let req = body.into_inner();
|
||||
queries::playlists::add_track(state.db.conn(), id, req.track_id).await?;
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReorderTracksRequest {
|
||||
track_ids: Vec<i32>,
|
||||
}
|
||||
|
||||
/// PUT /api/playlists/{id}/tracks — reorder tracks in playlist.
|
||||
async fn reorder_tracks(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<i32>,
|
||||
body: web::Json<ReorderTracksRequest>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
auth::require_auth(&session)?;
|
||||
let id = path.into_inner();
|
||||
let req = body.into_inner();
|
||||
queries::playlists::reorder_tracks(state.db.conn(), id, req.track_ids).await?;
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// DELETE /api/playlists/{id}/tracks/{track_id} — remove a track from playlist.
|
||||
async fn remove_track(
|
||||
state: web::Data<AppState>,
|
||||
session: Session,
|
||||
path: web::Path<(i32, i32)>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
auth::require_auth(&session)?;
|
||||
let (id, track_id) = path.into_inner();
|
||||
queries::playlists::remove_track(state.db.conn(), id, track_id).await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
/// GET /api/playlists/{id}/m3u — export as M3U file.
|
||||
async fn export_m3u(
|
||||
state: web::Data<AppState>,
|
||||
|
||||
Reference in New Issue
Block a user