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:
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
133
apps/api/src/responses.rs
Normal 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"}));
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Icon name="lock" size="sm" />
|
||||
) : (
|
||||
<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" />
|
||||
@@ -306,9 +305,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
||||
{/* Lock legend */}
|
||||
{Object.values(lockedFields).some(Boolean) && (
|
||||
<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">
|
||||
<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>
|
||||
<Icon name="lock" size="sm" className="!w-3.5 !h-3.5 shrink-0" />
|
||||
{t("editBook.lockedFieldsNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Icon name="lock" size="sm" />
|
||||
) : (
|
||||
<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" />
|
||||
@@ -444,9 +443,7 @@ export function EditSeriesForm({
|
||||
{/* Lock legend */}
|
||||
{Object.values(lockedFields).some(Boolean) && (
|
||||
<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">
|
||||
<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>
|
||||
<Icon name="lock" size="sm" className="!w-3.5 !h-3.5 shrink-0" />
|
||||
{t("editBook.lockedFieldsNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
<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
|
||||
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>
|
||||
<Icon name="chevronRight" size="sm" className={`!w-3 !h-3 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
@@ -153,9 +147,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
|
||||
|
||||
{/* Selected indicator */}
|
||||
{isSelected && (
|
||||
<svg className="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<Icon name="check" size="sm" className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -57,9 +55,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Icon name="folder" size="sm" />
|
||||
{t("common.browse")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -79,9 +75,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
{/* 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 gap-2">
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Icon name="folder" size="md" className="text-primary" />
|
||||
<span className="font-medium">{t("folder.selectFolderTitle")}</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -89,9 +83,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
||||
onClick={() => setIsOpen(false)}
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -279,9 +279,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, onReplay, form
|
||||
size="xs"
|
||||
onClick={() => onCancel(job.id)}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<Icon name="x" size="sm" className="!w-3.5 !h-3.5 mr-1.5" />
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -147,9 +145,7 @@ export function LibraryActions({
|
||||
{/* Section: Indexation */}
|
||||
<div className="space-y-5">
|
||||
<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">
|
||||
<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>
|
||||
<Icon name="folder" size="sm" className="text-primary" />
|
||||
{t("libraryActions.sectionIndexation")}
|
||||
</h3>
|
||||
|
||||
@@ -322,9 +318,7 @@ export function LibraryActions({
|
||||
{/* Section: Prowlarr */}
|
||||
<div className="space-y-5">
|
||||
<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">
|
||||
<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>
|
||||
<Icon name="download" size="sm" className="text-primary" />
|
||||
{t("libraryActions.sectionProwlarr")}
|
||||
</h3>
|
||||
<div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -137,14 +138,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
||||
{/* Search input with icon */}
|
||||
{textFields.map((field) => (
|
||||
<div key={field.name} className="relative">
|
||||
<svg
|
||||
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>
|
||||
<Icon name="search" size="md" className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
name={field.name}
|
||||
type="text"
|
||||
@@ -205,9 +199,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
||||
transition-colors duration-200
|
||||
"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<Icon name="x" size="sm" className="!w-3.5 !h-3.5" />
|
||||
{t("common.clear")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Icon name="link" size="sm" />
|
||||
{t("readingStatus.button")}
|
||||
</button>
|
||||
|
||||
@@ -130,15 +128,11 @@ export function ReadingStatusModal({
|
||||
{/* 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 gap-2.5">
|
||||
<svg className="w-5 h-5 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Icon name="link" size="md" className="text-cyan-500" />
|
||||
<span className="font-semibold text-lg">{providerLabel} — {seriesName}</span>
|
||||
</div>
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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({
|
||||
<span className="max-w-[80px] truncate hidden sm:inline">
|
||||
{activeUser ? activeUser.username : "Admin"}
|
||||
</span>
|
||||
<svg className="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<Icon name="chevronDown" size="sm" className="!w-3 !h-3 opacity-60" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
@@ -84,9 +83,7 @@ export function UserSwitcher({
|
||||
</svg>
|
||||
Admin
|
||||
{!isImpersonating && (
|
||||
<svg className="w-3.5 h-3.5 ml-auto text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<Icon name="check" size="sm" className="!w-3.5 !h-3.5 ml-auto text-primary" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -108,9 +105,7 @@ export function UserSwitcher({
|
||||
</svg>
|
||||
<span className="truncate">{user.username}</span>
|
||||
{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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<Icon name="check" size="sm" className="!w-3.5 !h-3.5 ml-auto text-primary shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -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<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",
|
||||
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<Record<IconName, string>> = {
|
||||
|
||||
Reference in New Issue
Block a user