refactor: Phase D — composant Modal réutilisable + utilitaire searchParams

- Crée Modal.tsx dans components/ui (backdrop, container, header sticky, close button)
- Remplace le scaffolding modal dupliqué dans EditBookForm, EditSeriesForm,
  DeleteBookButton, MetadataSearchModal (4 composants)
- Crée lib/searchParams.ts avec paramString, paramStringOr, paramInt, paramBool
- Simplifie le parsing des query params dans books, series, authors pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 12:23:50 +02:00
parent 13b1e1768e
commit 2670969d7e
10 changed files with 150 additions and 145 deletions

View File

@@ -1,9 +1,8 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { Button, Icon } from "./ui";
import { Button, Icon, Modal } from "./ui";
import { useTranslation } from "@/lib/i18n/context";
export function DeleteBookButton({ bookId, libraryId }: { bookId: string; libraryId: string }) {
@@ -37,32 +36,24 @@ export function DeleteBookButton({ bookId, libraryId }: { bookId: string; librar
<span className="ml-1.5">{t("bookDetail.delete")}</span>
</Button>
{showConfirm && createPortal(
<>
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50" onClick={() => setShowConfirm(false)} />
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("bookDetail.delete")}
</h3>
<p className="text-sm text-muted-foreground">
{t("bookDetail.confirmDelete")}
</p>
</div>
<div className="flex justify-end gap-2 px-6 pb-6">
<Button variant="outline" size="sm" onClick={() => setShowConfirm(false)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete}>
{t("bookDetail.delete")}
</Button>
</div>
</div>
</div>
</>,
document.body
)}
<Modal isOpen={showConfirm} onClose={() => setShowConfirm(false)} maxWidth="sm">
<div className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("bookDetail.delete")}
</h3>
<p className="text-sm text-muted-foreground">
{t("bookDetail.confirmDelete")}
</p>
</div>
<div className="flex justify-end gap-2 px-6 pb-6">
<Button variant="outline" size="sm" onClick={() => setShowConfirm(false)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete}>
{t("bookDetail.delete")}
</Button>
</div>
</Modal>
</>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useTransition, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Modal } from "./ui/Modal";
import { useRouter } from "next/navigation";
import { BookDto } from "@/lib/api";
import { FormField, FormLabel, FormInput } from "./ui/Form";
@@ -153,34 +153,9 @@ export function EditBookForm({ book }: EditBookFormProps) {
});
};
const modal = isOpen ? createPortal(
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={() => !isPending && handleClose()}
/>
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">{t("editBook.editMetadata")}</h3>
<button
type="button"
onClick={handleClose}
disabled={isPending}
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>
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="p-5 space-y-5">
const modal = (
<Modal isOpen={isOpen} onClose={handleClose} title={t("editBook.editMetadata")} disableClose={isPending}>
<form onSubmit={handleSubmit} className="p-5 space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField className="sm:col-span-2">
<div className="flex items-center gap-1">
@@ -361,11 +336,8 @@ export function EditBookForm({ book }: EditBookFormProps) {
</button>
</div>
</form>
</div>
</div>
</>,
document.body
) : null;
</Modal>
);
return (
<>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useTransition, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Modal } from "./ui/Modal";
import { useRouter } from "next/navigation";
import { FormField, FormLabel, FormInput } from "./ui/Form";
import { useTranslation } from "../../lib/i18n/context";
@@ -225,33 +225,8 @@ export function EditSeriesForm({
});
};
const modal = isOpen ? createPortal(
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={() => !isPending && handleClose()}
/>
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">{t("editSeries.title")}</h3>
<button
type="button"
onClick={handleClose}
disabled={isPending}
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>
</button>
</div>
{/* Body */}
const modal = (
<Modal isOpen={isOpen} onClose={handleClose} title={t("editSeries.title")} disableClose={isPending}>
<form onSubmit={handleSubmit} className="p-5 space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField>
@@ -497,11 +472,8 @@ export function EditSeriesForm({
</button>
</div>
</form>
</div>
</div>
</>,
document.body
) : null;
</Modal>
);
return (
<>

View File

@@ -1,9 +1,8 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { Icon } from "./ui";
import { Icon, Modal } from "./ui";
import { ProviderIcon, PROVIDERS, providerLabel } from "./ProviderIcon";
import type { ExternalMetadataLinkDto, SeriesCandidateDto, MissingBooksDto, SyncReport } from "../../lib/api";
import { useTranslation } from "../../lib/i18n/context";
@@ -238,26 +237,8 @@ export function MetadataSearchModal({
}
const modal = isOpen
? createPortal(
<>
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={handleClose}
/>
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">
{step === "linked" ? t("metadata.metadataLink") : t("metadata.searchExternal")}
</h3>
<button type="button" onClick={handleClose}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-muted-foreground hover:text-foreground">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
</div>
? (
<Modal isOpen={isOpen} onClose={handleClose} title={step === "linked" ? t("metadata.metadataLink") : t("metadata.searchExternal")}>
<div className="p-5 space-y-4">
{/* Provider selector — visible during searching & results */}
{(step === "searching" || step === "results") && (
@@ -687,10 +668,7 @@ export function MetadataSearchModal({
</div>
)}
</div>
</div>
</div>
</>,
document.body,
</Modal>
)
: null;

View File

@@ -0,0 +1,63 @@
"use client";
import { createPortal } from "react-dom";
import { ReactNode } from "react";
const MAX_WIDTH_MAP = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-2xl",
"3xl": "max-w-3xl",
} as const;
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
maxWidth?: keyof typeof MAX_WIDTH_MAP;
/** Disable closing via backdrop click (e.g. while a form is submitting) */
disableClose?: boolean;
}
export function Modal({ isOpen, onClose, title, children, maxWidth = "2xl", disableClose = false }: ModalProps) {
if (!isOpen) return null;
return createPortal(
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={() => !disableClose && onClose()}
/>
{/* Container */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className={`bg-card border border-border/50 rounded-xl shadow-2xl w-full ${MAX_WIDTH_MAP[maxWidth]} max-h-[90vh] overflow-y-auto animate-in fade-in zoom-in-95 duration-200`}>
{/* Header */}
{title && (
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30 sticky top-0 z-10">
<h3 className="font-semibold text-foreground">{title}</h3>
<button
type="button"
onClick={onClose}
disabled={disableClose}
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>
</button>
</div>
)}
{/* Body */}
{children}
</div>
</div>
</>,
document.body
);
}

View File

@@ -21,3 +21,4 @@ export { PageIcon, NavIcon, Icon } from "./Icon";
export { CursorPagination, OffsetPagination } from "./Pagination";
export { Tooltip } from "./Tooltip";
export { toast, Toaster } from "./Toast";
export { Modal } from "./Modal";