Files
blackheart-website/src/app/admin/_components/ui.tsx
T
diana.dolgolyova 3621503470 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.
2026-04-12 15:27:40 +03:00

204 lines
8.2 KiB
TypeScript

/**
* 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<HTMLInputElement, AdminInputProps>(
function AdminInput({ variant = "default", className = "", ...props }, ref) {
const base =
variant === "sm" ? adminStyles.inputSm
: variant === "dashed" ? adminStyles.inputDashed
: adminStyles.input;
return <input ref={ref} className={`${base} ${className}`} {...props} />;
},
);
interface AdminTextareaProps extends ComponentPropsWithoutRef<"textarea"> {
autoResize?: boolean;
}
export const AdminTextarea = forwardRef<HTMLTextAreaElement, AdminTextareaProps>(
function AdminTextarea({ autoResize, className = "", ...props }, ref) {
function handleInput(e: React.FormEvent<HTMLTextAreaElement>) {
if (autoResize) {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
}
return (
<textarea
ref={ref}
className={`${adminStyles.textarea} ${className}`}
{...props}
{...(autoResize ? { onInput: handleInput } : {})}
/>
);
},
);
interface AdminSelectProps extends ComponentPropsWithoutRef<"select"> {
children: React.ReactNode;
}
export const AdminSelect = forwardRef<HTMLSelectElement, AdminSelectProps>(
function AdminSelect({ className = "", children, ...props }, ref) {
return (
<select ref={ref} className={`${adminStyles.select} ${className}`} {...props}>
{children}
</select>
);
},
);
/* ============================== */
/* Button components */
/* ============================== */
interface AdminButtonProps extends ComponentPropsWithoutRef<"button"> {
variant?: "primary" | "secondary" | "danger" | "goldSm" | "cancelSm";
}
export function AdminButton({ variant = "primary", className = "", ...props }: AdminButtonProps) {
const base =
variant === "secondary" ? adminStyles.btnSecondary
: variant === "danger" ? adminStyles.btnDanger
: variant === "goldSm" ? adminStyles.btnGoldSm
: variant === "cancelSm" ? adminStyles.btnCancelSm
: adminStyles.btnPrimary;
return <button className={`${base} ${className}`} {...props} />;
}
/* ============================== */
/* Modal component */
/* ============================== */
interface AdminModalProps {
open: boolean;
onClose: () => void;
title?: string;
maxWidth?: string;
children: React.ReactNode;
}
export function AdminModal({ open, onClose, title, maxWidth = "max-w-sm", children }: AdminModalProps) {
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
if (!open) return null;
return (
<div className={adminStyles.modalOverlay} onClick={onClose}>
<div className={adminStyles.modalBackdrop} />
<div
ref={focusTrapRef}
className={`${adminStyles.modalContent} ${maxWidth}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={title}
>
<button onClick={onClose} className={adminStyles.modalClose} aria-label="Закрыть">
<X size={16} />
</button>
{title && <h3 className="text-sm font-bold text-neutral-900 dark:text-white mb-4">{title}</h3>}
{children}
</div>
</div>
);
}