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.
This commit is contained in:
@@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||||
|
|
||||||
|
interface ModalBaseProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
maxWidth?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Hide the default close button (e.g. when content has its own) */
|
||||||
|
hideClose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalBase({ open, onClose, ariaLabel, maxWidth = "max-w-md", children, hideClose }: ModalBaseProps) {
|
||||||
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
document.removeEventListener("keydown", handleKey);
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="modal-overlay fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
<div
|
||||||
|
ref={focusTrapRef}
|
||||||
|
className={`modal-content relative w-full ${maxWidth} rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-neutral-950 p-6 sm:p-8 shadow-2xl`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{!hideClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full text-neutral-500 dark:text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface TabButtonProps {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeClass = "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]";
|
||||||
|
const inactiveClass =
|
||||||
|
"border border-neutral-300 text-neutral-600 hover:border-neutral-400 hover:text-neutral-800 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20";
|
||||||
|
|
||||||
|
export function TabButton({ active, onClick, children, className = "" }: TabButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
|
||||||
|
active ? activeClass : inactiveClass
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user