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:
@@ -1,5 +1,6 @@
|
|||||||
import { fetchAuthors, AuthorsPageDto } from "@/lib/api";
|
import { fetchAuthors, AuthorsPageDto } from "@/lib/api";
|
||||||
import { getServerTranslations } from "@/lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
import { paramString, paramStringOr, paramInt } from "@/lib/searchParams";
|
||||||
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||||
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -12,11 +13,11 @@ export default async function AuthorsPage({
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = await getServerTranslations();
|
const { t } = await getServerTranslations();
|
||||||
const searchParamsAwaited = await searchParams;
|
const sp = await searchParams;
|
||||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
const searchQuery = paramStringOr(sp, "q", "");
|
||||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
const sort = paramString(sp, "sort");
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = paramInt(sp, "page", 1);
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
const limit = paramInt(sp, "limit", 20);
|
||||||
|
|
||||||
const authorsPage = await fetchAuthors(
|
const authorsPage = await fetchAuthors(
|
||||||
searchQuery || undefined,
|
searchQuery || undefined,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getServerTranslations } from "@/lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
import { paramString, paramStringOr, paramInt } from "@/lib/searchParams";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -14,15 +15,15 @@ export default async function BooksPage({
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = await getServerTranslations();
|
const { t } = await getServerTranslations();
|
||||||
const searchParamsAwaited = await searchParams;
|
const sp = await searchParams;
|
||||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
const libraryId = paramString(sp, "library");
|
||||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
const searchQuery = paramStringOr(sp, "q", "");
|
||||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
const readingStatus = paramString(sp, "status");
|
||||||
const format = typeof searchParamsAwaited.format === "string" ? searchParamsAwaited.format : undefined;
|
const format = paramString(sp, "format");
|
||||||
const metadataProvider = typeof searchParamsAwaited.metadata === "string" ? searchParamsAwaited.metadata : undefined;
|
const metadataProvider = paramString(sp, "metadata");
|
||||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
const sort = paramString(sp, "sort");
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = paramInt(sp, "page", 1);
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
const limit = paramInt(sp, "limit", 20);
|
||||||
|
|
||||||
const [libraries] = await Promise.all([
|
const [libraries] = await Promise.all([
|
||||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
|
import { fetchAllSeries, fetchLibraries, fetchSeriesStatuses, LibraryDto, SeriesDto, SeriesPageDto, getBookCoverUrl } from "@/lib/api";
|
||||||
import { getServerTranslations } from "@/lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
|
import { paramString, paramStringOr, paramInt, paramBool } from "@/lib/searchParams";
|
||||||
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
import { MarkSeriesReadButton } from "@/app/components/MarkSeriesReadButton";
|
||||||
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
import { LiveSearchForm } from "@/app/components/LiveSearchForm";
|
||||||
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
import { Card, CardContent, OffsetPagination } from "@/app/components/ui";
|
||||||
@@ -16,16 +17,16 @@ export default async function SeriesPage({
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = await getServerTranslations();
|
const { t } = await getServerTranslations();
|
||||||
const searchParamsAwaited = await searchParams;
|
const sp = await searchParams;
|
||||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
const libraryId = paramString(sp, "library");
|
||||||
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
|
const searchQuery = paramStringOr(sp, "q", "");
|
||||||
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
|
const readingStatus = paramString(sp, "status");
|
||||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
const sort = paramString(sp, "sort");
|
||||||
const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined;
|
const seriesStatus = paramString(sp, "series_status");
|
||||||
const hasMissing = searchParamsAwaited.has_missing === "true";
|
const hasMissing = paramBool(sp, "has_missing");
|
||||||
const metadataProvider = typeof searchParamsAwaited.metadata_provider === "string" ? searchParamsAwaited.metadata_provider : undefined;
|
const metadataProvider = paramString(sp, "metadata_provider");
|
||||||
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
const page = paramInt(sp, "page", 1);
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
const limit = paramInt(sp, "limit", 20);
|
||||||
|
|
||||||
const [libraries, seriesPage, dbStatuses] = await Promise.all([
|
const [libraries, seriesPage, dbStatuses] = await Promise.all([
|
||||||
fetchLibraries().catch(() => [] as LibraryDto[]),
|
fetchLibraries().catch(() => [] as LibraryDto[]),
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button, Icon } from "./ui";
|
import { Button, Icon, Modal } from "./ui";
|
||||||
import { useTranslation } from "@/lib/i18n/context";
|
import { useTranslation } from "@/lib/i18n/context";
|
||||||
|
|
||||||
export function DeleteBookButton({ bookId, libraryId }: { bookId: string; libraryId: string }) {
|
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>
|
<span className="ml-1.5">{t("bookDetail.delete")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showConfirm && createPortal(
|
<Modal isOpen={showConfirm} onClose={() => setShowConfirm(false)} maxWidth="sm">
|
||||||
<>
|
<div className="p-6">
|
||||||
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50" onClick={() => setShowConfirm(false)} />
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
{t("bookDetail.delete")}
|
||||||
<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">
|
</h3>
|
||||||
<div className="p-6">
|
<p className="text-sm text-muted-foreground">
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
{t("bookDetail.confirmDelete")}
|
||||||
{t("bookDetail.delete")}
|
</p>
|
||||||
</h3>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex justify-end gap-2 px-6 pb-6">
|
||||||
{t("bookDetail.confirmDelete")}
|
<Button variant="outline" size="sm" onClick={() => setShowConfirm(false)}>
|
||||||
</p>
|
{t("common.cancel")}
|
||||||
</div>
|
</Button>
|
||||||
<div className="flex justify-end gap-2 px-6 pb-6">
|
<Button variant="destructive" size="sm" onClick={handleDelete}>
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowConfirm(false)}>
|
{t("bookDetail.delete")}
|
||||||
{t("common.cancel")}
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
<Button variant="destructive" size="sm" onClick={handleDelete}>
|
</Modal>
|
||||||
{t("bookDetail.delete")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition, useEffect, useCallback } from "react";
|
import { useState, useTransition, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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";
|
||||||
@@ -153,34 +153,9 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const modal = isOpen ? createPortal(
|
const modal = (
|
||||||
<>
|
<Modal isOpen={isOpen} onClose={handleClose} title={t("editBook.editMetadata")} disableClose={isPending}>
|
||||||
{/* Backdrop */}
|
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||||
<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">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<FormField className="sm:col-span-2">
|
<FormField className="sm:col-span-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -361,11 +336,8 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
);
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition, useEffect, useCallback } from "react";
|
import { useState, useTransition, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
@@ -225,33 +225,8 @@ export function EditSeriesForm({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const modal = isOpen ? createPortal(
|
const modal = (
|
||||||
<>
|
<Modal isOpen={isOpen} onClose={handleClose} title={t("editSeries.title")} disableClose={isPending}>
|
||||||
{/* 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 */}
|
|
||||||
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
<form onSubmit={handleSubmit} className="p-5 space-y-5">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<FormField>
|
<FormField>
|
||||||
@@ -497,11 +472,8 @@ export function EditSeriesForm({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
);
|
||||||
</>,
|
|
||||||
document.body
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Icon } from "./ui";
|
import { Icon, Modal } from "./ui";
|
||||||
import { ProviderIcon, PROVIDERS, providerLabel } from "./ProviderIcon";
|
import { ProviderIcon, PROVIDERS, providerLabel } from "./ProviderIcon";
|
||||||
import type { ExternalMetadataLinkDto, SeriesCandidateDto, MissingBooksDto, SyncReport } from "../../lib/api";
|
import type { ExternalMetadataLinkDto, SeriesCandidateDto, MissingBooksDto, SyncReport } from "../../lib/api";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
@@ -238,26 +237,8 @@ export function MetadataSearchModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modal = isOpen
|
const modal = isOpen
|
||||||
? createPortal(
|
? (
|
||||||
<>
|
<Modal isOpen={isOpen} onClose={handleClose} title={step === "linked" ? t("metadata.metadataLink") : t("metadata.searchExternal")}>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
{/* Provider selector — visible during searching & results */}
|
{/* Provider selector — visible during searching & results */}
|
||||||
{(step === "searching" || step === "results") && (
|
{(step === "searching" || step === "results") && (
|
||||||
@@ -687,10 +668,7 @@ export function MetadataSearchModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
</>,
|
|
||||||
document.body,
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
63
apps/backoffice/app/components/ui/Modal.tsx
Normal file
63
apps/backoffice/app/components/ui/Modal.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,3 +21,4 @@ export { PageIcon, NavIcon, Icon } from "./Icon";
|
|||||||
export { CursorPagination, OffsetPagination } from "./Pagination";
|
export { CursorPagination, OffsetPagination } from "./Pagination";
|
||||||
export { Tooltip } from "./Tooltip";
|
export { Tooltip } from "./Tooltip";
|
||||||
export { toast, Toaster } from "./Toast";
|
export { toast, Toaster } from "./Toast";
|
||||||
|
export { Modal } from "./Modal";
|
||||||
|
|||||||
25
apps/backoffice/lib/searchParams.ts
Normal file
25
apps/backoffice/lib/searchParams.ts
Normal file
@@ -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";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user