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( pub async fn unlink_series(
State(state): State<AppState>, State(state): State<AppState>,
Path((library_id, series_name)): Path<(Uuid, String)>, Path((library_id, series_name)): Path<(Uuid, String)>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<crate::responses::UnlinkedResponse>, ApiError> {
let result = sqlx::query( let result = sqlx::query(
"DELETE FROM anilist_series_links WHERE library_id = $1 AND series_name = $2", "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")); 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 /// Toggle AniList sync for a library

View File

@@ -669,7 +669,7 @@ pub async fn get_thumbnail(
pub async fn delete_book( pub async fn delete_book(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<crate::responses::OkResponse>, ApiError> {
// Fetch the book and its file path // Fetch the book and its file path
let row = sqlx::query( let row = sqlx::query(
"SELECT b.library_id, b.thumbnail_path, bf.abs_path \ "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 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>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
axum::extract::Query(query): axum::extract::Query<DeleteAvailableQuery>, 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 { if let Some(release_idx) = query.release {
// Remove a single release from the JSON array // Remove a single release from the JSON array
let row = sqlx::query("SELECT available_releases FROM available_downloads WHERE id = $1") 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)] #[derive(Deserialize)]

View File

@@ -17,9 +17,9 @@ pub async fn docs_redirect() -> impl axum::response::IntoResponse {
axum::response::Redirect::to("/swagger-ui/") 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?; 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 { 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( pub async fn delete_library(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>, 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") let result = sqlx::query("DELETE FROM libraries WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
@@ -198,7 +198,7 @@ pub async fn delete_library(
return Err(ApiError::not_found("library not found")); 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> { fn canonicalize_library_root(root_path: &str) -> Result<PathBuf, ApiError> {

View File

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

View File

@@ -413,7 +413,7 @@ pub async fn approve_metadata(
pub async fn reject_metadata( pub async fn reject_metadata(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>, AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<crate::responses::StatusResponse>, ApiError> {
let result = sqlx::query( let result = sqlx::query(
"UPDATE external_metadata_links SET status = 'rejected', updated_at = NOW() WHERE id = $1", "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")); 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( pub async fn delete_metadata_link(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>, 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") let result = sqlx::query("DELETE FROM external_metadata_links WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
@@ -581,7 +581,7 @@ pub async fn delete_metadata_link(
return Err(ApiError::not_found("link not found")); 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( pub async fn revoke_token(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, 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") let result = sqlx::query("UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
@@ -186,7 +186,7 @@ pub async fn revoke_token(
return Err(ApiError::not_found("token not found")); 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)] #[derive(Deserialize, ToSchema)]
@@ -216,7 +216,7 @@ pub async fn update_token(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(input): Json<UpdateTokenRequest>, 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") let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
.bind(input.user_id) .bind(input.user_id)
.bind(id) .bind(id)
@@ -227,7 +227,7 @@ pub async fn update_token(
return Err(ApiError::not_found("token not found")); 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 /// Permanently delete a revoked API token
@@ -249,7 +249,7 @@ pub async fn update_token(
pub async fn delete_token( pub async fn delete_token(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, 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") let result = sqlx::query("DELETE FROM api_tokens WHERE id = $1 AND revoked_at IS NOT NULL")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
@@ -259,5 +259,5 @@ pub async fn delete_token(
return Err(ApiError::not_found("token not found or not revoked")); 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( pub async fn notify_torrent_done(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<TorrentNotifyRequest>, Json(body): Json<TorrentNotifyRequest>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<crate::responses::OkResponse>, ApiError> {
if body.hash.is_empty() { if body.hash.is_empty() {
return Err(ApiError::bad_request("hash is required")); return Err(ApiError::bad_request("hash is required"));
} }
if !is_torrent_import_enabled(&state.pool).await { if !is_torrent_import_enabled(&state.pool).await {
info!("Torrent import disabled, ignoring notification for hash {}", body.hash); 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( let row = sqlx::query(
@@ -74,7 +74,7 @@ pub async fn notify_torrent_done(
let Some(row) = row else { let Some(row) = row else {
info!("Torrent notification for unknown hash {}, ignoring", body.hash); 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"); 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). /// List recent torrent downloads (admin).
@@ -145,7 +145,7 @@ pub async fn list_torrent_downloads(
pub async fn delete_torrent_download( pub async fn delete_torrent_download(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, 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") let row = sqlx::query("SELECT qb_hash, status FROM torrent_downloads WHERE id = $1")
.bind(id) .bind(id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
@@ -187,7 +187,7 @@ pub async fn delete_torrent_download(
.await?; .await?;
info!("Deleted torrent download {id}"); info!("Deleted torrent download {id}");
Ok(Json(serde_json::json!({ "ok": true }))) Ok(Json(crate::responses::OkResponse::new()))
} }
// ─── Background poller ──────────────────────────────────────────────────────── // ─── Background poller ────────────────────────────────────────────────────────

View File

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

View File

@@ -5,6 +5,7 @@ import { Modal } from "./ui/Modal";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { BookDto } from "@/lib/api"; import { BookDto } from "@/lib/api";
import { FormField, FormLabel, FormInput } from "./ui/Form"; import { FormField, FormLabel, FormInput } from "./ui/Form";
import { Icon } from "./ui";
import { useTranslation } from "../../lib/i18n/context"; import { useTranslation } from "../../lib/i18n/context";
function LockButton({ function LockButton({
@@ -30,9 +31,7 @@ function LockButton({
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")} title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
> >
{locked ? ( {locked ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="lock" size="sm" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
) : ( ) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
@@ -306,9 +305,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
{/* Lock legend */} {/* Lock legend */}
{Object.values(lockedFields).some(Boolean) && ( {Object.values(lockedFields).some(Boolean) && (
<p className="text-xs text-amber-500 flex items-center gap-1.5"> <p className="text-xs text-amber-500 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="lock" size="sm" className="!w-3.5 !h-3.5 shrink-0" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
{t("editBook.lockedFieldsNote")} {t("editBook.lockedFieldsNote")}
</p> </p>
)} )}

View File

@@ -4,6 +4,7 @@ import { useState, useTransition, useEffect, useCallback } from "react";
import { Modal } from "./ui/Modal"; import { Modal } from "./ui/Modal";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { FormField, FormLabel, FormInput } from "./ui/Form"; import { FormField, FormLabel, FormInput } from "./ui/Form";
import { Icon } from "./ui";
import { useTranslation } from "../../lib/i18n/context"; import { useTranslation } from "../../lib/i18n/context";
function LockButton({ function LockButton({
@@ -29,9 +30,7 @@ function LockButton({
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")} title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
> >
{locked ? ( {locked ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="lock" size="sm" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
) : ( ) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
@@ -444,9 +443,7 @@ export function EditSeriesForm({
{/* Lock legend */} {/* Lock legend */}
{Object.values(lockedFields).some(Boolean) && ( {Object.values(lockedFields).some(Boolean) && (
<p className="text-xs text-amber-500 flex items-center gap-1.5"> <p className="text-xs text-amber-500 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="lock" size="sm" className="!w-3.5 !h-3.5 shrink-0" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
{t("editBook.lockedFieldsNote")} {t("editBook.lockedFieldsNote")}
</p> </p>
)} )}

View File

@@ -3,6 +3,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { FolderItem } from "../../lib/api"; import { FolderItem } from "../../lib/api";
import { useTranslation } from "../../lib/i18n/context"; import { useTranslation } from "../../lib/i18n/context";
import { Icon } from "./ui";
interface TreeNode extends FolderItem { interface TreeNode extends FolderItem {
children?: TreeNode[]; children?: TreeNode[];
@@ -116,14 +117,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
) : ( ) : (
<svg <Icon name="chevronRight" size="sm" className={`!w-3 !h-3 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} />
className={`w-3 h-3 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)} )}
</button> </button>
) : ( ) : (
@@ -153,9 +147,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
{/* Selected indicator */} {/* Selected indicator */}
{isSelected && ( {isSelected && (
<svg className="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="check" size="sm" className="text-primary flex-shrink-0" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)} )}
</div> </div>

View File

@@ -4,7 +4,7 @@ import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { FolderBrowser } from "./FolderBrowser"; import { FolderBrowser } from "./FolderBrowser";
import { FolderItem } from "../../lib/api"; import { FolderItem } from "../../lib/api";
import { Button } from "./ui"; import { Button, Icon } from "./ui";
import { useTranslation } from "../../lib/i18n/context"; import { useTranslation } from "../../lib/i18n/context";
interface FolderPickerProps { interface FolderPickerProps {
@@ -45,9 +45,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
onClick={() => onSelect("")} onClick={() => onSelect("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-destructive transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-destructive transition-colors"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="x" size="sm" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
)} )}
</div> </div>
@@ -57,9 +55,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="folder" size="sm" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{t("common.browse")} {t("common.browse")}
</Button> </Button>
</div> </div>
@@ -79,9 +75,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-muted/30"> <div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-muted/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="folder" size="md" className="text-primary" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="font-medium">{t("folder.selectFolderTitle")}</span> <span className="font-medium">{t("folder.selectFolderTitle")}</span>
</div> </div>
<button <button
@@ -89,9 +83,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded" className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="x" size="md" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>

View File

@@ -279,9 +279,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, onReplay, form
size="xs" size="xs"
onClick={() => onCancel(job.id)} onClick={() => onCancel(job.id)}
> >
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="x" size="sm" className="!w-3.5 !h-3.5 mr-1.5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
)} )}

View File

@@ -2,7 +2,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Button } from "../components/ui"; import { Button, Icon } from "../components/ui";
import { ProviderIcon } from "../components/ProviderIcon"; import { ProviderIcon } from "../components/ProviderIcon";
import { useTranslation } from "../../lib/i18n/context"; import { useTranslation } from "../../lib/i18n/context";
@@ -134,9 +134,7 @@ export function LibraryActions({
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg" className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="x" size="md" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
@@ -147,9 +145,7 @@ export function LibraryActions({
{/* Section: Indexation */} {/* Section: Indexation */}
<div className="space-y-5"> <div className="space-y-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide"> <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="folder" size="sm" className="text-primary" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{t("libraryActions.sectionIndexation")} {t("libraryActions.sectionIndexation")}
</h3> </h3>
@@ -322,9 +318,7 @@ export function LibraryActions({
{/* Section: Prowlarr */} {/* Section: Prowlarr */}
<div className="space-y-5"> <div className="space-y-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide"> <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="download" size="sm" className="text-primary" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{t("libraryActions.sectionProwlarr")} {t("libraryActions.sectionProwlarr")}
</h3> </h3>
<div> <div>

View File

@@ -3,6 +3,7 @@
import { useRef, useCallback, useEffect } from "react"; import { useRef, useCallback, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "../../lib/i18n/context"; import { useTranslation } from "../../lib/i18n/context";
import { Icon } from "./ui";
// SVG path data for filter icons, keyed by field name // SVG path data for filter icons, keyed by field name
const FILTER_ICONS: Record<string, string> = { const FILTER_ICONS: Record<string, string> = {
@@ -137,14 +138,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
{/* Search input with icon */} {/* Search input with icon */}
{textFields.map((field) => ( {textFields.map((field) => (
<div key={field.name} className="relative"> <div key={field.name} className="relative">
<svg <Icon name="search" size="md" className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input <input
name={field.name} name={field.name}
type="text" type="text"
@@ -205,9 +199,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
transition-colors duration-200 transition-colors duration-200
" "
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="x" size="sm" className="!w-3.5 !h-3.5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
{t("common.clear")} {t("common.clear")}
</button> </button>
)} )}

View File

@@ -3,7 +3,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "./ui"; import { Button, Icon } from "./ui";
import { useTranslation } from "../../lib/i18n/context"; import { useTranslation } from "../../lib/i18n/context";
import type { AnilistMediaResultDto, AnilistSeriesLinkDto } from "../../lib/api"; import type { AnilistMediaResultDto, AnilistSeriesLinkDto } from "../../lib/api";
@@ -116,9 +116,7 @@ export function ReadingStatusModal({
onClick={handleOpen} 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" 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"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="link" size="sm" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
{t("readingStatus.button")} {t("readingStatus.button")}
</button> </button>
@@ -130,15 +128,11 @@ export function ReadingStatusModal({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30"> <div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<svg className="w-5 h-5 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="link" size="md" className="text-cyan-500" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
<span className="font-semibold text-lg">{providerLabel} {seriesName}</span> <span className="font-semibold text-lg">{providerLabel} {seriesName}</span>
</div> </div>
<button type="button" onClick={handleClose} className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg"> <button type="button" onClick={handleClose} className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="x" size="md" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { useState, useTransition, useRef, useEffect } from "react"; import { useState, useTransition, useRef, useEffect } from "react";
import type { UserDto } from "@/lib/api"; import type { UserDto } from "@/lib/api";
import { Icon } from "./ui";
export function UserSwitcher({ export function UserSwitcher({
users, users,
@@ -63,9 +64,7 @@ export function UserSwitcher({
<span className="max-w-[80px] truncate hidden sm:inline"> <span className="max-w-[80px] truncate hidden sm:inline">
{activeUser ? activeUser.username : "Admin"} {activeUser ? activeUser.username : "Admin"}
</span> </span>
<svg className="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="chevronDown" size="sm" className="!w-3 !h-3 opacity-60" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
{open && ( {open && (
@@ -84,9 +83,7 @@ export function UserSwitcher({
</svg> </svg>
Admin Admin
{!isImpersonating && ( {!isImpersonating && (
<svg className="w-3.5 h-3.5 ml-auto text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="check" size="sm" className="!w-3.5 !h-3.5 ml-auto text-primary" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)} )}
</button> </button>
@@ -108,9 +105,7 @@ export function UserSwitcher({
</svg> </svg>
<span className="truncate">{user.username}</span> <span className="truncate">{user.username}</span>
{activeUserId === user.id && ( {activeUserId === user.id && (
<svg className="w-3.5 h-3.5 ml-auto text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Icon name="check" size="sm" className="!w-3.5 !h-3.5 ml-auto text-primary shrink-0" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)} )}
</button> </button>
))} ))}

View File

@@ -38,7 +38,8 @@ type IconName =
| "bell" | "bell"
| "link" | "link"
| "eye" | "eye"
| "download"; | "download"
| "lock";
type IconSize = "sm" | "md" | "lg" | "xl"; type IconSize = "sm" | "md" | "lg" | "xl";
@@ -96,6 +97,7 @@ const icons: Record<IconName, string> = {
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", 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", 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", 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<Record<IconName, string>> = { const colorClasses: Partial<Record<IconName, string>> = {