From 2670969d7ef61355ce05bdaae65eaf860b629dfc Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sun, 29 Mar 2026 12:23:50 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20Phase=20D=20=E2=80=94=20composant?= =?UTF-8?q?=20Modal=20r=C3=A9utilisable=20+=20utilitaire=20searchParams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/backoffice/app/(app)/authors/page.tsx | 11 ++-- apps/backoffice/app/(app)/books/page.tsx | 19 +++--- apps/backoffice/app/(app)/series/page.tsx | 21 ++++--- .../app/components/DeleteBookButton.tsx | 47 ++++++-------- .../app/components/EditBookForm.tsx | 40 ++---------- .../app/components/EditSeriesForm.tsx | 38 ++--------- .../app/components/MetadataSearchModal.tsx | 30 ++------- apps/backoffice/app/components/ui/Modal.tsx | 63 +++++++++++++++++++ apps/backoffice/app/components/ui/index.ts | 1 + apps/backoffice/lib/searchParams.ts | 25 ++++++++ 10 files changed, 150 insertions(+), 145 deletions(-) create mode 100644 apps/backoffice/app/components/ui/Modal.tsx create mode 100644 apps/backoffice/lib/searchParams.ts diff --git a/apps/backoffice/app/(app)/authors/page.tsx b/apps/backoffice/app/(app)/authors/page.tsx index 8e2a373..830ed69 100644 --- a/apps/backoffice/app/(app)/authors/page.tsx +++ b/apps/backoffice/app/(app)/authors/page.tsx @@ -1,5 +1,6 @@ import { fetchAuthors, AuthorsPageDto } from "@/lib/api"; import { getServerTranslations } from "@/lib/i18n/server"; +import { paramString, paramStringOr, paramInt } from "@/lib/searchParams"; import { LiveSearchForm } from "@/app/components/LiveSearchForm"; import { Card, CardContent, OffsetPagination } from "@/app/components/ui"; import Link from "next/link"; @@ -12,11 +13,11 @@ export default async function AuthorsPage({ searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { const { t } = await getServerTranslations(); - const searchParamsAwaited = await searchParams; - const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : ""; - const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined; - const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1; - const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20; + const sp = await searchParams; + const searchQuery = paramStringOr(sp, "q", ""); + const sort = paramString(sp, "sort"); + const page = paramInt(sp, "page", 1); + const limit = paramInt(sp, "limit", 20); const authorsPage = await fetchAuthors( searchQuery || undefined, diff --git a/apps/backoffice/app/(app)/books/page.tsx b/apps/backoffice/app/(app)/books/page.tsx index fba2374..43fc6bb 100644 --- a/apps/backoffice/app/(app)/books/page.tsx +++ b/apps/backoffice/app/(app)/books/page.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, OffsetPagination } from "@/app/components/ui"; import Link from "next/link"; import Image from "next/image"; import { getServerTranslations } from "@/lib/i18n/server"; +import { paramString, paramStringOr, paramInt } from "@/lib/searchParams"; export const dynamic = "force-dynamic"; @@ -14,15 +15,15 @@ export default async function BooksPage({ searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { const { t } = await getServerTranslations(); - const searchParamsAwaited = await searchParams; - const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined; - const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : ""; - const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined; - const format = typeof searchParamsAwaited.format === "string" ? searchParamsAwaited.format : undefined; - const metadataProvider = typeof searchParamsAwaited.metadata === "string" ? searchParamsAwaited.metadata : undefined; - const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined; - const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1; - const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20; + const sp = await searchParams; + const libraryId = paramString(sp, "library"); + const searchQuery = paramStringOr(sp, "q", ""); + const readingStatus = paramString(sp, "status"); + const format = paramString(sp, "format"); + const metadataProvider = paramString(sp, "metadata"); + const sort = paramString(sp, "sort"); + const page = paramInt(sp, "page", 1); + const limit = paramInt(sp, "limit", 20); const [libraries] = await Promise.all([ fetchLibraries().catch(() => [] as LibraryDto[]) diff --git a/apps/backoffice/app/(app)/series/page.tsx b/apps/backoffice/app/(app)/series/page.tsx index 5e8df80..d84d876 100644 --- a/apps/backoffice/app/(app)/series/page.tsx +++ b/apps/backoffice/app/(app)/series/page.tsx @@ -1,5 +1,6 @@ import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api"; import { getServerTranslations } from "@/lib/i18n/server"; +import { paramString, paramStringOr, paramInt, paramBool } from "@/lib/searchParams"; import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton"; import { LiveSearchForm } from "@/app/components/LiveSearchForm"; import { Card, CardContent, OffsetPagination } from "@/app/components/ui"; @@ -16,16 +17,16 @@ export default async function SeriesPage({ searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { const { t } = await getServerTranslations(); - const searchParamsAwaited = await searchParams; - const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined; - const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : ""; - const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined; - const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined; - const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined; - const hasMissing = searchParamsAwaited.has_missing === "true"; - const metadataProvider = typeof searchParamsAwaited.metadata_provider === "string" ? searchParamsAwaited.metadata_provider : undefined; - const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1; - const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20; + const sp = await searchParams; + const libraryId = paramString(sp, "library"); + const searchQuery = paramStringOr(sp, "q", ""); + const readingStatus = paramString(sp, "status"); + const sort = paramString(sp, "sort"); + const seriesStatus = paramString(sp, "series_status"); + const hasMissing = paramBool(sp, "has_missing"); + const metadataProvider = paramString(sp, "metadata_provider"); + const page = paramInt(sp, "page", 1); + const limit = paramInt(sp, "limit", 20); const [libraries, seriesPage, dbStatuses] = await Promise.all([ fetchLibraries().catch(() => [] as LibraryDto[]), diff --git a/apps/backoffice/app/components/DeleteBookButton.tsx b/apps/backoffice/app/components/DeleteBookButton.tsx index 0a6dc66..d1dd475 100644 --- a/apps/backoffice/app/components/DeleteBookButton.tsx +++ b/apps/backoffice/app/components/DeleteBookButton.tsx @@ -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 {t("bookDetail.delete")} - {showConfirm && createPortal( - <> -
setShowConfirm(false)} /> -
-
-
-

- {t("bookDetail.delete")} -

-

- {t("bookDetail.confirmDelete")} -

-
-
- - -
-
-
- , - document.body - )} + setShowConfirm(false)} maxWidth="sm"> +
+

+ {t("bookDetail.delete")} +

+

+ {t("bookDetail.confirmDelete")} +

+
+
+ + +
+
); } diff --git a/apps/backoffice/app/components/EditBookForm.tsx b/apps/backoffice/app/components/EditBookForm.tsx index f65ac38..595f020 100644 --- a/apps/backoffice/app/components/EditBookForm.tsx +++ b/apps/backoffice/app/components/EditBookForm.tsx @@ -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 */} -
!isPending && handleClose()} - /> - - {/* Modal */} -
-
- {/* Header */} -
-

{t("editBook.editMetadata")}

- -
- - {/* Body */} -
+ const modal = ( + +
@@ -361,11 +336,8 @@ export function EditBookForm({ book }: EditBookFormProps) {
-
-
- , - document.body - ) : null; + + ); return ( <> diff --git a/apps/backoffice/app/components/EditSeriesForm.tsx b/apps/backoffice/app/components/EditSeriesForm.tsx index 9728aad..0371268 100644 --- a/apps/backoffice/app/components/EditSeriesForm.tsx +++ b/apps/backoffice/app/components/EditSeriesForm.tsx @@ -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 */} -
!isPending && handleClose()} - /> - - {/* Modal */} -
-
- {/* Header */} -
-

{t("editSeries.title")}

- -
- - {/* Body */} + const modal = ( +
@@ -497,11 +472,8 @@ export function EditSeriesForm({
-
-
- , - document.body - ) : null; + + ); return ( <> diff --git a/apps/backoffice/app/components/MetadataSearchModal.tsx b/apps/backoffice/app/components/MetadataSearchModal.tsx index b49e255..7acd250 100644 --- a/apps/backoffice/app/components/MetadataSearchModal.tsx +++ b/apps/backoffice/app/components/MetadataSearchModal.tsx @@ -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( - <> -
-
-
- {/* Header */} -
-

- {step === "linked" ? t("metadata.metadataLink") : t("metadata.searchExternal")} -

- -
- + ? ( +
{/* Provider selector — visible during searching & results */} {(step === "searching" || step === "results") && ( @@ -687,10 +668,7 @@ export function MetadataSearchModal({
)}
-
-
- , - document.body, + ) : null; diff --git a/apps/backoffice/app/components/ui/Modal.tsx b/apps/backoffice/app/components/ui/Modal.tsx new file mode 100644 index 0000000..53728e2 --- /dev/null +++ b/apps/backoffice/app/components/ui/Modal.tsx @@ -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 */} +
!disableClose && onClose()} + /> + + {/* Container */} +
+
+ {/* Header */} + {title && ( +
+

{title}

+ +
+ )} + + {/* Body */} + {children} +
+
+ , + document.body + ); +} diff --git a/apps/backoffice/app/components/ui/index.ts b/apps/backoffice/app/components/ui/index.ts index d8f9ce3..5abc7bd 100644 --- a/apps/backoffice/app/components/ui/index.ts +++ b/apps/backoffice/app/components/ui/index.ts @@ -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"; diff --git a/apps/backoffice/lib/searchParams.ts b/apps/backoffice/lib/searchParams.ts new file mode 100644 index 0000000..046a7e2 --- /dev/null +++ b/apps/backoffice/lib/searchParams.ts @@ -0,0 +1,25 @@ +type RawSearchParams = { [key: string]: string | string[] | undefined }; + +/** Extract a string param, returning undefined if missing or not a string. */ +export function paramString(params: RawSearchParams, key: string): string | undefined { + const v = params[key]; + return typeof v === "string" ? v : undefined; +} + +/** Extract a string param with a default value. */ +export function paramStringOr(params: RawSearchParams, key: string, defaultValue: string): string { + return paramString(params, key) ?? defaultValue; +} + +/** Extract a numeric param, returning a default if missing or not parseable. */ +export function paramInt(params: RawSearchParams, key: string, defaultValue: number): number { + const v = params[key]; + if (typeof v !== "string") return defaultValue; + const n = parseInt(v, 10); + return Number.isNaN(n) ? defaultValue : n; +} + +/** Extract a boolean param (true only if value is exactly "true"). */ +export function paramBool(params: RawSearchParams, key: string): boolean { + return params[key] === "true"; +}