From 3621503470e9bd0f9dbef4bbe087c2719c0eea86 Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Sun, 12 Apr 2026 15:27:40 +0300 Subject: [PATCH] feat: add reusable UI primitives for admin and public site Admin (ui.tsx): AdminInput, AdminSelect, AdminTextarea, AdminButton, AdminModal + adminStyles token object for consistent styling. Public (ModalBase.tsx): Shared modal with overlay, backdrop blur, focus trap, ESC handling, and portal rendering. Public (TabButton.tsx): Unified active/inactive tab with gold styling. --- src/app/admin/_components/ui.tsx | 203 +++++++++++++++++++++++++++++++ src/components/ui/ModalBase.tsx | 64 ++++++++++ src/components/ui/TabButton.tsx | 26 ++++ 3 files changed, 293 insertions(+) create mode 100644 src/app/admin/_components/ui.tsx create mode 100644 src/components/ui/ModalBase.tsx create mode 100644 src/components/ui/TabButton.tsx diff --git a/src/app/admin/_components/ui.tsx b/src/app/admin/_components/ui.tsx new file mode 100644 index 0000000..f22f6cc --- /dev/null +++ b/src/app/admin/_components/ui.tsx @@ -0,0 +1,203 @@ +/** + * Admin UI Primitives + * + * Single source of truth for admin panel styling. + * Every input, select, button, badge, card, and modal in /admin should use these. + */ + +import { type ComponentPropsWithoutRef, forwardRef } from "react"; +import { X } from "lucide-react"; +import { useFocusTrap } from "@/hooks/useFocusTrap"; + +/* ============================== */ +/* Style tokens */ +/* ============================== */ + +export const adminStyles = { + /** Standard input — full width, rounded-lg */ + input: + "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500", + + /** Compact input — smaller padding, text-sm */ + inputSm: + "rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600", + + /** Dashed input — for "add new" fields */ + inputDashed: + "flex-1 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-4 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors dark:border-white/10 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-600", + + /** Textarea — same as input + resize-none */ + textarea: + "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500", + + /** Native select */ + select: + "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]", + + /** Select option */ + option: "bg-white dark:bg-neutral-900", + + /** Primary button — gold solid */ + btnPrimary: + "inline-flex items-center justify-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-all hover:bg-gold-light hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed", + + /** Secondary button — outline */ + btnSecondary: + "inline-flex items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700", + + /** Small gold accent button */ + btnGoldSm: + "rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed dark:text-gold", + + /** Cancel/muted small button */ + btnCancelSm: + "rounded-md border border-neutral-200 px-3 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25", + + /** Danger button */ + btnDanger: + "inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-red-500", + + /** Card container */ + card: + "rounded-xl border border-neutral-200 bg-white p-5 dark:border-white/10 dark:bg-neutral-900", + + /** Modal overlay */ + modalOverlay: + "fixed inset-0 z-50 flex items-center justify-center p-4", + + /** Modal backdrop */ + modalBackdrop: + "absolute inset-0 bg-black/70 backdrop-blur-sm", + + /** Modal content */ + modalContent: + "relative w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]", + + /** Modal close button */ + modalClose: + "absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 transition-colors cursor-pointer dark:hover:bg-white/[0.06] dark:hover:text-white", + + /** Label */ + label: + "block text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400", + + /** Section heading in admin */ + sectionTitle: + "text-lg font-bold text-neutral-900 dark:text-white", + + /** Dashed add-item button */ + addButton: + "flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40", +} as const; + +/* ============================== */ +/* Input components */ +/* ============================== */ + +interface AdminInputProps extends ComponentPropsWithoutRef<"input"> { + variant?: "default" | "sm" | "dashed"; +} + +export const AdminInput = forwardRef( + function AdminInput({ variant = "default", className = "", ...props }, ref) { + const base = + variant === "sm" ? adminStyles.inputSm + : variant === "dashed" ? adminStyles.inputDashed + : adminStyles.input; + return ; + }, +); + +interface AdminTextareaProps extends ComponentPropsWithoutRef<"textarea"> { + autoResize?: boolean; +} + +export const AdminTextarea = forwardRef( + function AdminTextarea({ autoResize, className = "", ...props }, ref) { + function handleInput(e: React.FormEvent) { + if (autoResize) { + const el = e.currentTarget; + el.style.height = "auto"; + el.style.height = el.scrollHeight + "px"; + } + } + return ( +