diff --git a/apps/api/src/anilist.rs b/apps/api/src/anilist.rs index bbe2782..746b2cf 100644 --- a/apps/api/src/anilist.rs +++ b/apps/api/src/anilist.rs @@ -433,7 +433,7 @@ pub async fn link_series( pub async fn unlink_series( State(state): State, Path((library_id, series_name)): Path<(Uuid, String)>, -) -> Result, ApiError> { +) -> Result, 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 diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index 7912d9b..94ca299 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -669,7 +669,7 @@ pub async fn get_thumbnail( pub async fn delete_book( State(state): State, Path(id): Path, -) -> Result, ApiError> { +) -> Result, 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())) } diff --git a/apps/api/src/download_detection.rs b/apps/api/src/download_detection.rs index 44a59b6..98e988f 100644 --- a/apps/api/src/download_detection.rs +++ b/apps/api/src/download_detection.rs @@ -441,7 +441,7 @@ pub async fn delete_available_download( State(state): State, Path(id): Path, axum::extract::Query(query): axum::extract::Query, -) -> Result, ApiError> { +) -> Result, 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)] diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index 3795eca..efbdd3f 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -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) -> Result, ApiError> { +pub async fn ready(State(state): State) -> Result, 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) -> String { diff --git a/apps/api/src/libraries.rs b/apps/api/src/libraries.rs index 0f7a24d..bc05ea2 100644 --- a/apps/api/src/libraries.rs +++ b/apps/api/src/libraries.rs @@ -188,7 +188,7 @@ pub async fn create_library( pub async fn delete_library( State(state): State, AxumPath(id): AxumPath, -) -> Result, ApiError> { +) -> Result, 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 { diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index d22decb..e94ad6e 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -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; diff --git a/apps/api/src/metadata.rs b/apps/api/src/metadata.rs index 1a533ea..b0b828f 100644 --- a/apps/api/src/metadata.rs +++ b/apps/api/src/metadata.rs @@ -413,7 +413,7 @@ pub async fn approve_metadata( pub async fn reject_metadata( State(state): State, AxumPath(id): AxumPath, -) -> Result, ApiError> { +) -> Result, 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, AxumPath(id): AxumPath, -) -> Result, ApiError> { +) -> Result, 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))) } // --------------------------------------------------------------------------- diff --git a/apps/api/src/responses.rs b/apps/api/src/responses.rs new file mode 100644 index 0000000..758c30f --- /dev/null +++ b/apps/api/src/responses.rs @@ -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"})); + } +} diff --git a/apps/api/src/tokens.rs b/apps/api/src/tokens.rs index 000498a..76c77e7 100644 --- a/apps/api/src/tokens.rs +++ b/apps/api/src/tokens.rs @@ -176,7 +176,7 @@ pub async fn list_tokens(State(state): State) -> Result, Path(id): Path, -) -> Result, ApiError> { +) -> Result, 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, Path(id): Path, Json(input): Json, -) -> Result, ApiError> { +) -> Result, 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, Path(id): Path, -) -> Result, ApiError> { +) -> Result, 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))) } diff --git a/apps/api/src/torrent_import.rs b/apps/api/src/torrent_import.rs index f10dec4..2ac17ab 100644 --- a/apps/api/src/torrent_import.rs +++ b/apps/api/src/torrent_import.rs @@ -55,14 +55,14 @@ struct ImportedFile { pub async fn notify_torrent_done( State(state): State, Json(body): Json, -) -> Result, ApiError> { +) -> Result, 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, Path(id): Path, -) -> Result, ApiError> { +) -> Result, 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 ──────────────────────────────────────────────────────── diff --git a/apps/api/src/users.rs b/apps/api/src/users.rs index ab55df5..ca4d035 100644 --- a/apps/api/src/users.rs +++ b/apps/api/src/users.rs @@ -136,7 +136,7 @@ pub async fn update_user( State(state): State, Path(id): Path, Json(input): Json, -) -> Result, ApiError> { +) -> Result, 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, Path(id): Path, -) -> Result, ApiError> { +) -> Result, 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))) } diff --git a/apps/backoffice/app/components/EditBookForm.tsx b/apps/backoffice/app/components/EditBookForm.tsx index 595f020..f1a53fd 100644 --- a/apps/backoffice/app/components/EditBookForm.tsx +++ b/apps/backoffice/app/components/EditBookForm.tsx @@ -5,6 +5,7 @@ import { Modal } from "./ui/Modal"; import { useRouter } from "next/navigation"; import { BookDto } from "@/lib/api"; import { FormField, FormLabel, FormInput } from "./ui/Form"; +import { Icon } from "./ui"; import { useTranslation } from "../../lib/i18n/context"; function LockButton({ @@ -30,9 +31,7 @@ function LockButton({ title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")} > {locked ? ( - - - + ) : ( @@ -306,9 +305,7 @@ export function EditBookForm({ book }: EditBookFormProps) { {/* Lock legend */} {Object.values(lockedFields).some(Boolean) && (

- - - + {t("editBook.lockedFieldsNote")}

)} diff --git a/apps/backoffice/app/components/EditSeriesForm.tsx b/apps/backoffice/app/components/EditSeriesForm.tsx index 0371268..56a9988 100644 --- a/apps/backoffice/app/components/EditSeriesForm.tsx +++ b/apps/backoffice/app/components/EditSeriesForm.tsx @@ -4,6 +4,7 @@ import { useState, useTransition, useEffect, useCallback } from "react"; import { Modal } from "./ui/Modal"; import { useRouter } from "next/navigation"; import { FormField, FormLabel, FormInput } from "./ui/Form"; +import { Icon } from "./ui"; import { useTranslation } from "../../lib/i18n/context"; function LockButton({ @@ -29,9 +30,7 @@ function LockButton({ title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")} > {locked ? ( - - - + ) : ( @@ -444,9 +443,7 @@ export function EditSeriesForm({ {/* Lock legend */} {Object.values(lockedFields).some(Boolean) && (

- - - + {t("editBook.lockedFieldsNote")}

)} diff --git a/apps/backoffice/app/components/FolderBrowser.tsx b/apps/backoffice/app/components/FolderBrowser.tsx index 002c93c..a0b6acd 100644 --- a/apps/backoffice/app/components/FolderBrowser.tsx +++ b/apps/backoffice/app/components/FolderBrowser.tsx @@ -3,6 +3,7 @@ import { useState, useCallback } from "react"; import { FolderItem } from "../../lib/api"; import { useTranslation } from "../../lib/i18n/context"; +import { Icon } from "./ui"; interface TreeNode extends FolderItem { children?: TreeNode[]; @@ -116,14 +117,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder ) : ( - - - + )} ) : ( @@ -153,9 +147,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder {/* Selected indicator */} {isSelected && ( - - - + )} diff --git a/apps/backoffice/app/components/FolderPicker.tsx b/apps/backoffice/app/components/FolderPicker.tsx index 429cbe5..b077761 100644 --- a/apps/backoffice/app/components/FolderPicker.tsx +++ b/apps/backoffice/app/components/FolderPicker.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { createPortal } from "react-dom"; import { FolderBrowser } from "./FolderBrowser"; import { FolderItem } from "../../lib/api"; -import { Button } from "./ui"; +import { Button, Icon } from "./ui"; import { useTranslation } from "../../lib/i18n/context"; interface FolderPickerProps { @@ -45,9 +45,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP onClick={() => onSelect("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-destructive transition-colors" > - - - + )} @@ -57,9 +55,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP onClick={() => setIsOpen(true)} className="flex items-center gap-2" > - - - + {t("common.browse")} @@ -79,9 +75,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP {/* Header */}
- - - + {t("folder.selectFolderTitle")}
diff --git a/apps/backoffice/app/components/JobRow.tsx b/apps/backoffice/app/components/JobRow.tsx index 0748183..b7918a8 100644 --- a/apps/backoffice/app/components/JobRow.tsx +++ b/apps/backoffice/app/components/JobRow.tsx @@ -279,9 +279,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, onReplay, form size="xs" onClick={() => onCancel(job.id)} > - - - + {t("common.cancel")} )} diff --git a/apps/backoffice/app/components/LibraryActions.tsx b/apps/backoffice/app/components/LibraryActions.tsx index dedc642..2b4a10d 100644 --- a/apps/backoffice/app/components/LibraryActions.tsx +++ b/apps/backoffice/app/components/LibraryActions.tsx @@ -2,7 +2,7 @@ import { useState, useTransition } from "react"; import { createPortal } from "react-dom"; -import { Button } from "../components/ui"; +import { Button, Icon } from "../components/ui"; import { ProviderIcon } from "../components/ProviderIcon"; import { useTranslation } from "../../lib/i18n/context"; @@ -134,9 +134,7 @@ export function LibraryActions({ onClick={() => setIsOpen(false)} className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg" > - - - + @@ -147,9 +145,7 @@ export function LibraryActions({ {/* Section: Indexation */}

- - - + {t("libraryActions.sectionIndexation")}

@@ -322,9 +318,7 @@ export function LibraryActions({ {/* Section: Prowlarr */}

- - - + {t("libraryActions.sectionProwlarr")}

diff --git a/apps/backoffice/app/components/LiveSearchForm.tsx b/apps/backoffice/app/components/LiveSearchForm.tsx index 609a51e..ba7f716 100644 --- a/apps/backoffice/app/components/LiveSearchForm.tsx +++ b/apps/backoffice/app/components/LiveSearchForm.tsx @@ -3,6 +3,7 @@ import { useRef, useCallback, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslation } from "../../lib/i18n/context"; +import { Icon } from "./ui"; // SVG path data for filter icons, keyed by field name const FILTER_ICONS: Record = { @@ -137,14 +138,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc {/* Search input with icon */} {textFields.map((field) => (
- - - + - - - + {t("common.clear")} )} diff --git a/apps/backoffice/app/components/ReadingStatusModal.tsx b/apps/backoffice/app/components/ReadingStatusModal.tsx index d5c0b8c..a5c0eeb 100644 --- a/apps/backoffice/app/components/ReadingStatusModal.tsx +++ b/apps/backoffice/app/components/ReadingStatusModal.tsx @@ -3,7 +3,7 @@ import { useState, useCallback } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; -import { Button } from "./ui"; +import { Button, Icon } from "./ui"; import { useTranslation } from "../../lib/i18n/context"; import type { AnilistMediaResultDto, AnilistSeriesLinkDto } from "../../lib/api"; @@ -116,9 +116,7 @@ export function ReadingStatusModal({ onClick={handleOpen} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors" > - - - + {t("readingStatus.button")} @@ -130,15 +128,11 @@ export function ReadingStatusModal({ {/* Header */}
- - - + {providerLabel} — {seriesName}
diff --git a/apps/backoffice/app/components/UserSwitcher.tsx b/apps/backoffice/app/components/UserSwitcher.tsx index b77df17..17b411b 100644 --- a/apps/backoffice/app/components/UserSwitcher.tsx +++ b/apps/backoffice/app/components/UserSwitcher.tsx @@ -2,6 +2,7 @@ import { useState, useTransition, useRef, useEffect } from "react"; import type { UserDto } from "@/lib/api"; +import { Icon } from "./ui"; export function UserSwitcher({ users, @@ -63,9 +64,7 @@ export function UserSwitcher({ {activeUser ? activeUser.username : "Admin"} - - - + {open && ( @@ -84,9 +83,7 @@ export function UserSwitcher({ Admin {!isImpersonating && ( - - - + )} @@ -108,9 +105,7 @@ export function UserSwitcher({ {user.username} {activeUserId === user.id && ( - - - + )} ))} diff --git a/apps/backoffice/app/components/ui/Icon.tsx b/apps/backoffice/app/components/ui/Icon.tsx index b6a803e..9b99fa7 100644 --- a/apps/backoffice/app/components/ui/Icon.tsx +++ b/apps/backoffice/app/components/ui/Icon.tsx @@ -38,7 +38,8 @@ type IconName = | "bell" | "link" | "eye" - | "download"; + | "download" + | "lock"; type IconSize = "sm" | "md" | "lg" | "xl"; @@ -96,6 +97,7 @@ const icons: Record = { link: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1", eye: "M15 12a3 3 0 11-6 0 3 3 0 016 0zm-3-9C7.477 3 3.268 6.11 1.5 12c1.768 5.89 5.977 9 10.5 9s8.732-3.11 10.5-9C20.732 6.11 16.523 3 12 3z", download: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4", + lock: "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z", }; const colorClasses: Partial> = {