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:
@@ -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