3621503470
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.
204 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|