refactor: Phase E — types de réponses API standardisés + SVGs inline → Icon

E1 - API responses:
- Crée responses.rs avec OkResponse, DeletedResponse, UpdatedResponse,
  RevokedResponse, UnlinkedResponse, StatusResponse (6 tests de sérialisation)
- Remplace ~15 json!() inline par des types structurés dans books, libraries,
  tokens, users, handlers, anilist, metadata, download_detection, torrent_import
- Signatures de retour des handlers typées (plus de serde_json::Value)

E2 - SVGs → Icon component:
- Ajoute icon "lock" au composant Icon
- Remplace ~30 SVGs inline par <Icon> dans 9 composants
  (FolderPicker, FolderBrowser, LiveSearchForm, JobRow, LibraryActions,
  ReadingStatusModal, EditBookForm, EditSeriesForm, UserSwitcher)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 17:02:39 +02:00
parent 2670969d7e
commit e34d7a671a
21 changed files with 197 additions and 110 deletions

View File

@@ -433,7 +433,7 @@ pub async fn link_series(
pub async fn unlink_series(
State(state): State<AppState>,
Path((library_id, series_name)): Path<(Uuid, String)>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::UnlinkedResponse>, ApiError> {
let result = sqlx::query(
"DELETE FROM anilist_series_links WHERE library_id = $1 AND series_name = $2",
)
@@ -446,7 +446,7 @@ pub async fn unlink_series(
return Err(ApiError::not_found("AniList link not found"));
}
Ok(Json(serde_json::json!({"unlinked": true})))
Ok(Json(crate::responses::UnlinkedResponse::new()))
}
/// Toggle AniList sync for a library

View File

@@ -669,7 +669,7 @@ pub async fn get_thumbnail(
pub async fn delete_book(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
// Fetch the book and its file path
let row = sqlx::query(
"SELECT b.library_id, b.thumbnail_path, bf.abs_path \
@@ -727,5 +727,5 @@ pub async fn delete_book(
id, scan_job_id, library_id
);
Ok(Json(serde_json::json!({ "ok": true })))
Ok(Json(crate::responses::OkResponse::new()))
}

View File

@@ -441,7 +441,7 @@ pub async fn delete_available_download(
State(state): State<AppState>,
Path(id): Path<Uuid>,
axum::extract::Query(query): axum::extract::Query<DeleteAvailableQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
if let Some(release_idx) = query.release {
// Remove a single release from the JSON array
let row = sqlx::query("SELECT available_releases FROM available_downloads WHERE id = $1")
@@ -486,7 +486,7 @@ pub async fn delete_available_download(
}
}
Ok(Json(serde_json::json!({ "ok": true })))
Ok(Json(crate::responses::OkResponse::new()))
}
#[derive(Deserialize)]

View File

@@ -17,9 +17,9 @@ pub async fn docs_redirect() -> impl axum::response::IntoResponse {
axum::response::Redirect::to("/swagger-ui/")
}
pub async fn ready(State(state): State<AppState>) -> Result<Json<serde_json::Value>, ApiError> {
pub async fn ready(State(state): State<AppState>) -> Result<Json<crate::responses::StatusResponse>, ApiError> {
sqlx::query("SELECT 1").execute(&state.pool).await?;
Ok(Json(serde_json::json!({"status": "ready"})))
Ok(Json(crate::responses::StatusResponse::new("ready")))
}
pub async fn metrics(State(state): State<AppState>) -> String {

View File

@@ -188,7 +188,7 @@ pub async fn create_library(
pub async fn delete_library(
State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
let result = sqlx::query("DELETE FROM libraries WHERE id = $1")
.bind(id)
.execute(&state.pool)
@@ -198,7 +198,7 @@ pub async fn delete_library(
return Err(ApiError::not_found("library not found"));
}
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
Ok(Json(crate::responses::DeletedResponse::new(id)))
}
fn canonicalize_library_root(root_path: &str) -> Result<PathBuf, ApiError> {

View File

@@ -19,6 +19,7 @@ mod pages;
mod prowlarr;
mod qbittorrent;
mod reading_progress;
mod responses;
mod torrent_import;
mod reading_status_match;
mod reading_status_push;

View File

@@ -413,7 +413,7 @@ pub async fn approve_metadata(
pub async fn reject_metadata(
State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::StatusResponse>, ApiError> {
let result = sqlx::query(
"UPDATE external_metadata_links SET status = 'rejected', updated_at = NOW() WHERE id = $1",
)
@@ -425,7 +425,7 @@ pub async fn reject_metadata(
return Err(ApiError::not_found("link not found"));
}
Ok(Json(serde_json::json!({"status": "rejected"})))
Ok(Json(crate::responses::StatusResponse::new("rejected")))
}
// ---------------------------------------------------------------------------
@@ -571,7 +571,7 @@ pub async fn get_missing_books(
pub async fn delete_metadata_link(
State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
let result = sqlx::query("DELETE FROM external_metadata_links WHERE id = $1")
.bind(id)
.execute(&state.pool)
@@ -581,7 +581,7 @@ pub async fn delete_metadata_link(
return Err(ApiError::not_found("link not found"));
}
Ok(Json(serde_json::json!({"deleted": true, "id": id.to_string()})))
Ok(Json(crate::responses::DeletedResponse::new(id)))
}
// ---------------------------------------------------------------------------

133
apps/api/src/responses.rs Normal file
View File

@@ -0,0 +1,133 @@
use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;
/// Simple acknowledgment response.
#[derive(Debug, Serialize, ToSchema)]
pub struct OkResponse {
pub ok: bool,
}
impl OkResponse {
pub fn new() -> Self {
Self { ok: true }
}
}
/// Response for resource deletion operations.
#[derive(Debug, Serialize, ToSchema)]
pub struct DeletedResponse {
pub deleted: bool,
pub id: Uuid,
}
impl DeletedResponse {
pub fn new(id: Uuid) -> Self {
Self { deleted: true, id }
}
}
/// Response for resource update operations.
#[derive(Debug, Serialize, ToSchema)]
pub struct UpdatedResponse {
pub updated: bool,
pub id: Uuid,
}
impl UpdatedResponse {
pub fn new(id: Uuid) -> Self {
Self { updated: true, id }
}
}
/// Response for token revocation.
#[derive(Debug, Serialize, ToSchema)]
pub struct RevokedResponse {
pub revoked: bool,
pub id: Uuid,
}
impl RevokedResponse {
pub fn new(id: Uuid) -> Self {
Self { revoked: true, id }
}
}
/// Response for unlinking operations (e.g., AniList).
#[derive(Debug, Serialize, ToSchema)]
pub struct UnlinkedResponse {
pub unlinked: bool,
}
impl UnlinkedResponse {
pub fn new() -> Self {
Self { unlinked: true }
}
}
/// Simple status response.
#[derive(Debug, Serialize, ToSchema)]
pub struct StatusResponse {
pub status: String,
}
impl StatusResponse {
pub fn new(status: &str) -> Self {
Self {
status: status.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ok_response_serializes() {
let r = OkResponse::new();
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json, serde_json::json!({"ok": true}));
}
#[test]
fn deleted_response_serializes() {
let id = Uuid::nil();
let r = DeletedResponse::new(id);
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["deleted"], true);
assert_eq!(json["id"], id.to_string());
}
#[test]
fn updated_response_serializes() {
let id = Uuid::nil();
let r = UpdatedResponse::new(id);
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["updated"], true);
assert_eq!(json["id"], id.to_string());
}
#[test]
fn revoked_response_serializes() {
let id = Uuid::nil();
let r = RevokedResponse::new(id);
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["revoked"], true);
assert_eq!(json["id"], id.to_string());
}
#[test]
fn unlinked_response_serializes() {
let r = UnlinkedResponse::new();
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json, serde_json::json!({"unlinked": true}));
}
#[test]
fn status_response_serializes() {
let r = StatusResponse::new("ready");
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json, serde_json::json!({"status": "ready"}));
}
}

View File

@@ -176,7 +176,7 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
pub async fn revoke_token(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::RevokedResponse>, ApiError> {
let result = sqlx::query("UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL")
.bind(id)
.execute(&state.pool)
@@ -186,7 +186,7 @@ pub async fn revoke_token(
return Err(ApiError::not_found("token not found"));
}
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
Ok(Json(crate::responses::RevokedResponse::new(id)))
}
#[derive(Deserialize, ToSchema)]
@@ -216,7 +216,7 @@ pub async fn update_token(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTokenRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::UpdatedResponse>, ApiError> {
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
.bind(input.user_id)
.bind(id)
@@ -227,7 +227,7 @@ pub async fn update_token(
return Err(ApiError::not_found("token not found"));
}
Ok(Json(serde_json::json!({"updated": true, "id": id})))
Ok(Json(crate::responses::UpdatedResponse::new(id)))
}
/// Permanently delete a revoked API token
@@ -249,7 +249,7 @@ pub async fn update_token(
pub async fn delete_token(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
let result = sqlx::query("DELETE FROM api_tokens WHERE id = $1 AND revoked_at IS NOT NULL")
.bind(id)
.execute(&state.pool)
@@ -259,5 +259,5 @@ pub async fn delete_token(
return Err(ApiError::not_found("token not found or not revoked"));
}
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
Ok(Json(crate::responses::DeletedResponse::new(id)))
}

View File

@@ -55,14 +55,14 @@ struct ImportedFile {
pub async fn notify_torrent_done(
State(state): State<AppState>,
Json(body): Json<TorrentNotifyRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
if body.hash.is_empty() {
return Err(ApiError::bad_request("hash is required"));
}
if !is_torrent_import_enabled(&state.pool).await {
info!("Torrent import disabled, ignoring notification for hash {}", body.hash);
return Ok(Json(serde_json::json!({ "ok": true })));
return Ok(Json(crate::responses::OkResponse::new()));
}
let row = sqlx::query(
@@ -74,7 +74,7 @@ pub async fn notify_torrent_done(
let Some(row) = row else {
info!("Torrent notification for unknown hash {}, ignoring", body.hash);
return Ok(Json(serde_json::json!({ "ok": true })));
return Ok(Json(crate::responses::OkResponse::new()));
};
let torrent_id: Uuid = row.get("id");
@@ -96,7 +96,7 @@ pub async fn notify_torrent_done(
}
});
Ok(Json(serde_json::json!({ "ok": true })))
Ok(Json(crate::responses::OkResponse::new()))
}
/// List recent torrent downloads (admin).
@@ -145,7 +145,7 @@ pub async fn list_torrent_downloads(
pub async fn delete_torrent_download(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
let row = sqlx::query("SELECT qb_hash, status FROM torrent_downloads WHERE id = $1")
.bind(id)
.fetch_optional(&state.pool)
@@ -187,7 +187,7 @@ pub async fn delete_torrent_download(
.await?;
info!("Deleted torrent download {id}");
Ok(Json(serde_json::json!({ "ok": true })))
Ok(Json(crate::responses::OkResponse::new()))
}
// ─── Background poller ────────────────────────────────────────────────────────

View File

@@ -136,7 +136,7 @@ pub async fn update_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(input): Json<CreateUserRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::UpdatedResponse>, ApiError> {
if input.username.trim().is_empty() {
return Err(ApiError::bad_request("username is required"));
}
@@ -159,7 +159,7 @@ pub async fn update_user(
return Err(ApiError::not_found("user not found"));
}
Ok(Json(serde_json::json!({"updated": true, "id": id})))
Ok(Json(crate::responses::UpdatedResponse::new(id)))
}
/// Delete a reader user (cascades on tokens and reading progress)
@@ -181,7 +181,7 @@ pub async fn update_user(
pub async fn delete_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
let result = sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(&state.pool)
@@ -191,5 +191,5 @@ pub async fn delete_user(
return Err(ApiError::not_found("user not found"));
}
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
Ok(Json(crate::responses::DeletedResponse::new(id)))
}