fix: comprehensive UI/UX accessibility and usability improvements
Public site: skip-to-content link, mobile menu focus trap + Escape key, aria-current on nav, keyboard navigation for carousels/tabs/articles, ARIA roles (tablist/tab/tabpanel, combobox/listbox, region, dialog), form labels + aria-describedby, 44px touch targets, semantic HTML (<time>, <del>), prefers-reduced-motion on Hero scroll hijack, mobile schedule filters, URL hash sync on scroll for correct refresh. Admin panel: password toggle aria-label, toast aria-live regions, SelectField keyboard navigation (Arrow/Enter/Escape), aria-invalid on validation errors, sidebar hamburger aria-label/expanded, nav aria-label, ArrayEditor aria-expanded on collapsible items.
This commit is contained in:
@@ -17,6 +17,10 @@ interface ArrayEditorProps<T> {
|
||||
getItemBadge?: (item: T, index: number) => React.ReactNode;
|
||||
hiddenItems?: Set<number>;
|
||||
addPosition?: "top" | "bottom";
|
||||
/** Render grip + content + delete on a single row (compact mode) */
|
||||
inline?: boolean;
|
||||
/** Hide the add button (when parent manages adding) */
|
||||
hideAdd?: boolean;
|
||||
}
|
||||
|
||||
export function ArrayEditor<T>({
|
||||
@@ -31,6 +35,8 @@ export function ArrayEditor<T>({
|
||||
getItemBadge,
|
||||
hiddenItems,
|
||||
addPosition = "bottom",
|
||||
inline = false,
|
||||
hideAdd = false,
|
||||
}: ArrayEditorProps<T>) {
|
||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
@@ -167,52 +173,80 @@ export function ArrayEditor<T>({
|
||||
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
||||
} ${isHidden ? "hidden" : ""}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{inline ? (
|
||||
/* Inline: grip + content + delete on one row */
|
||||
<div className="flex items-start gap-1.5 p-1.5">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-white transition-colors select-none shrink-0"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapse(i)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
<>
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapse(i)}
|
||||
aria-expanded={!isCollapsed}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -243,22 +277,34 @@ export function ArrayEditor<T>({
|
||||
}
|
||||
|
||||
const item = items[i];
|
||||
const dragTitle = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
const isCollapsed = collapsible && collapsed.has(i);
|
||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
elements.push(
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className="rounded-lg border border-white/10 bg-neutral-900/50 mb-3 transition-colors"
|
||||
className={`rounded-lg border bg-neutral-900/50 mb-3 transition-colors ${
|
||||
"border-white/10"
|
||||
}`}
|
||||
>
|
||||
{collapsible ? (
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<GripVertical size={16} className="text-neutral-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-neutral-300 truncate">{dragTitle}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
{inline ? (
|
||||
<div className="flex items-start gap-1.5 p-1.5">
|
||||
<div className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-white transition-colors select-none shrink-0"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button">
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
|
||||
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2 p-4 pb-0 mb-3">
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
@@ -267,18 +313,32 @@ export function ArrayEditor<T>({
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
{collapsible && (
|
||||
<button type="button" onClick={() => toggleCollapse(i)} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group">
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div className="grid transition-[grid-template-rows] duration-300 ease-out" style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -312,6 +372,7 @@ export function ArrayEditor<T>({
|
||||
onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))}
|
||||
className="rounded p-1 text-neutral-500 hover:text-white transition-colors"
|
||||
title={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||
aria-label={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||
>
|
||||
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allCollapsed ? "" : "rotate-90"}`} />
|
||||
</button>
|
||||
@@ -320,7 +381,7 @@ export function ArrayEditor<T>({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addPosition === "top" && (
|
||||
{!hideAdd && addPosition === "top" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -344,7 +405,7 @@ export function ArrayEditor<T>({
|
||||
{renderList()}
|
||||
</div>
|
||||
|
||||
{addPosition === "bottom" && (
|
||||
{!hideAdd && addPosition === "bottom" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -86,16 +86,20 @@ export function ParticipantLimits({
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
|
||||
<input type="number" min={0} value={minStr} onChange={(e) => handleMin(e.target.value)}
|
||||
aria-describedby="min-hint"
|
||||
aria-invalid={minEmpty || undefined}
|
||||
className={`${inputCls} ${minEmpty ? "!border-red-500/50" : ""}`} />
|
||||
<p className={`text-[10px] mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
<p id="min-hint" className={`text-xs mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
{minEmpty ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label>
|
||||
<input type="number" min={0} value={maxStr} onChange={(e) => handleMax(e.target.value)}
|
||||
aria-describedby="max-hint"
|
||||
aria-invalid={(maxEmpty || (maxLocal > 0 && minLocal > maxLocal)) || undefined}
|
||||
className={`${inputCls} ${maxEmpty || (maxLocal > 0 && minLocal > maxLocal) ? "!border-red-500/50" : ""}`} />
|
||||
<p className={`text-[10px] mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
<p id="max-hint" className={`text-xs mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
{maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -172,6 +176,7 @@ export function SelectField({
|
||||
}: SelectFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -185,6 +190,31 @@ export function SelectField({
|
||||
|
||||
const showSearch = options.length > 3;
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!open) { setOpen(true); setHighlightIndex(0); return; }
|
||||
setHighlightIndex((prev) => (prev + 1) % filtered.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!open) { setOpen(true); setHighlightIndex(filtered.length - 1); return; }
|
||||
setHighlightIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
|
||||
}
|
||||
if (e.key === "Enter" && open && highlightIndex >= 0 && highlightIndex < filtered.length) {
|
||||
e.preventDefault();
|
||||
onChange(filtered[highlightIndex].value);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
setHighlightIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handle(e: MouseEvent) {
|
||||
@@ -217,8 +247,12 @@ export function SelectField({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={open ? search : selectedLabel}
|
||||
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); }}
|
||||
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); setHighlightIndex(0); }}
|
||||
onFocus={() => { setOpen(true); setSearch(""); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
placeholder={placeholder || "Выберите..."}
|
||||
className={`w-full rounded-lg border bg-neutral-800 outline-none transition-colors ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
@@ -228,6 +262,9 @@ export function SelectField({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
|
||||
@@ -237,7 +274,7 @@ export function SelectField({
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
|
||||
@@ -246,16 +283,20 @@ export function SelectField({
|
||||
<button
|
||||
key={opt.value || `opt-${idx}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightIndex(idx)}
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
setHighlightIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
|
||||
opt.value === value ? "text-gold bg-gold/5" : "text-white"
|
||||
}`}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
|
||||
idx === highlightIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
} ${opt.value === value ? "text-gold bg-gold/5" : "text-white"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
|
||||
@@ -92,7 +92,7 @@ export function SectionEditor<T>({
|
||||
|
||||
{/* Fixed toast popup */}
|
||||
{(status === "saved" || status === "error") && (
|
||||
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||
status === "saved"
|
||||
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
||||
: "bg-red-950/90 border-red-500/30 text-red-200"
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
<ToastContext.Provider value={{ showError, showSuccess }}>
|
||||
{children}
|
||||
{toasts.length > 0 && (
|
||||
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
|
||||
<div role="status" aria-live="polite" className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
@@ -54,6 +54,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
|
||||
<span className="flex-1">{t.message}</span>
|
||||
<button
|
||||
aria-label="Закрыть уведомление"
|
||||
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
|
||||
className="shrink-0 text-neutral-400 hover:text-white"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField } from "../_components/FormField";
|
||||
import { InputField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
|
||||
interface AboutData {
|
||||
@@ -23,12 +23,14 @@ export default function AboutEditorPage() {
|
||||
label="Параграфы"
|
||||
items={data.paragraphs}
|
||||
onChange={(paragraphs) => update({ ...data, paragraphs })}
|
||||
inline
|
||||
renderItem={(text, _i, updateItem) => (
|
||||
<TextareaField
|
||||
label={`Параграф`}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={updateItem}
|
||||
rows={3}
|
||||
onChange={(e) => updateItem(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none"
|
||||
placeholder="Текст параграфа..."
|
||||
/>
|
||||
)}
|
||||
createItem={() => ""}
|
||||
|
||||
@@ -115,6 +115,7 @@ function VideoSlot({
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
aria-label={`Удалить видео: ${label}`}
|
||||
className="absolute top-2 right-2 rounded-full bg-black/70 p-1.5 text-neutral-400 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
|
||||
title="Удалить"
|
||||
>
|
||||
@@ -300,7 +301,7 @@ function VideoManager({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-3 max-w-3xl">
|
||||
{SLOTS.map((slot, i) => (
|
||||
<VideoSlot
|
||||
key={slot.key}
|
||||
|
||||
@@ -107,13 +107,14 @@ export default function AdminLayout({
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Закрыть меню"
|
||||
className="lg:hidden text-neutral-400 hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||
<nav aria-label="Навигация панели управления" className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
@@ -131,7 +132,7 @@ export default function AdminLayout({
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
{item.href === "/admin/bookings" && unreadTotal > 0 && (
|
||||
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
<span aria-label={`${unreadTotal} непрочитанных`} className="ml-auto rounded-full bg-red-500 text-white text-xs font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
{unreadTotal > 99 ? "99+" : unreadTotal}
|
||||
</span>
|
||||
)}
|
||||
@@ -165,6 +166,8 @@ export default function AdminLayout({
|
||||
<header className="flex items-center gap-3 border-b border-white/10 px-4 py-3 lg:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Открыть меню"
|
||||
aria-expanded={sidebarOpen}
|
||||
className="text-neutral-400 hover:text-white"
|
||||
>
|
||||
<Menu size={24} />
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -48,19 +50,31 @@ export default function AdminLoginPage() {
|
||||
<label htmlFor="password" className="block text-sm text-neutral-400 mb-2">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||
placeholder="Введите пароль"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 pr-11 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||
placeholder="Введите пароль"
|
||||
autoFocus
|
||||
aria-describedby={error ? "login-error" : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-400 text-center">{error}</p>
|
||||
<p id="login-error" role="alert" className="text-sm text-red-400 text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
@@ -84,9 +84,23 @@ function EventSettings({
|
||||
<input
|
||||
type="date"
|
||||
value={event.date}
|
||||
onChange={(e) => onChange({ date: e.target.value })}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||
onChange={(e) => {
|
||||
const newDate = e.target.value;
|
||||
const isPast = newDate && newDate < new Date().toISOString().slice(0, 10);
|
||||
onChange({ date: newDate, ...(isPast || !newDate ? { active: false } : {}) });
|
||||
}}
|
||||
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white outline-none transition-colors [color-scheme:dark] ${
|
||||
event.date && event.date < new Date().toISOString().slice(0, 10)
|
||||
? "border-amber-500/50"
|
||||
: "border-white/10 focus:border-gold"
|
||||
}`}
|
||||
/>
|
||||
{!event.date && (
|
||||
<p className="mt-1 text-[11px] text-amber-400">Укажите дату для публикации</p>
|
||||
)}
|
||||
{event.date && event.date < new Date().toISOString().slice(0, 10) && (
|
||||
<p className="mt-1 text-[11px] text-amber-400">Дата в прошлом — переведено в черновик</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -158,16 +172,28 @@ function EventSettings({
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ active: !event.active })}
|
||||
onClick={() => {
|
||||
const isPast = !event.date || event.date < new Date().toISOString().slice(0, 10);
|
||||
if (!event.active && isPast) return;
|
||||
onChange({ active: !event.active });
|
||||
}}
|
||||
className={`relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
||||
event.active
|
||||
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10"
|
||||
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 cursor-pointer"
|
||||
: !event.date || event.date < new Date().toISOString().slice(0, 10)
|
||||
? "bg-neutral-800 text-neutral-500 border border-white/5 opacity-50 cursor-not-allowed"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 cursor-pointer hover:border-gold/40 hover:text-gold hover:bg-gold/5"
|
||||
}`}
|
||||
>
|
||||
{event.active ? <CheckCircle2 size={14} /> : <Ban size={14} />}
|
||||
{event.active ? "Опубликовано" : "Черновик"}
|
||||
{event.active ? (
|
||||
<><CheckCircle2 size={14} /> Опубликовано</>
|
||||
) : (
|
||||
<><Ban size={14} /> Черновик</>
|
||||
)}
|
||||
</button>
|
||||
{!event.active && (!event.date || event.date < new Date().toISOString().slice(0, 10)) && (
|
||||
<span className="text-[11px] text-amber-400">Укажите будущую дату для публикации</span>
|
||||
)}
|
||||
<span className="text-xs text-neutral-500">
|
||||
{event.pricePerClass} BYN / занятие{event.discountPrice > 0 && event.discountThreshold > 0 && `, от ${event.discountThreshold} — ${event.discountPrice} BYN`}
|
||||
</span>
|
||||
|
||||
@@ -192,8 +192,15 @@ function PricingContent({ data, update }: { data: PricingData; update: (d: Prici
|
||||
<ArrayEditor
|
||||
items={data.rules}
|
||||
onChange={(rules) => update({ ...data, rules })}
|
||||
inline
|
||||
renderItem={(rule, _i, updateItem) => (
|
||||
<InputField label="Правило" value={rule} onChange={updateItem} />
|
||||
<input
|
||||
type="text"
|
||||
value={rule}
|
||||
onChange={(e) => updateItem(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors"
|
||||
placeholder="Текст правила..."
|
||||
/>
|
||||
)}
|
||||
createItem={() => ""}
|
||||
addLabel="Добавить правило"
|
||||
|
||||
+27
-262
@@ -1,17 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { Loader2, Plus, Check } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import type { TeamMember } from "@/types/content";
|
||||
|
||||
type Member = TeamMember & { id: number };
|
||||
@@ -22,13 +16,6 @@ export default function TeamEditorPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
const [dragSize, setDragSize] = useState({ w: 0, h: 0 });
|
||||
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/team")
|
||||
.then((r) => r.json())
|
||||
@@ -49,117 +36,7 @@ export default function TeamEditorPage() {
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}, []);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(clientX: number, clientY: number, index: number) => {
|
||||
const el = itemRefs.current[index];
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setDragIndex(index);
|
||||
setInsertAt(index);
|
||||
setMousePos({ x: clientX, y: clientY });
|
||||
setDragSize({ w: rect.width, h: rect.height });
|
||||
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleGripMouseDown = useCallback(
|
||||
(e: React.MouseEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
startDrag(e.clientX, e.clientY, index);
|
||||
},
|
||||
[startDrag]
|
||||
);
|
||||
|
||||
const handleCardMouseDown = useCallback(
|
||||
(e: React.MouseEvent, index: number) => {
|
||||
const tag = (e.target as HTMLElement).closest("input, textarea, select, button, a, [role='switch']");
|
||||
if (tag) return;
|
||||
e.preventDefault();
|
||||
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
const pendingIndex = index;
|
||||
let moved = false;
|
||||
|
||||
function onMove(ev: MouseEvent) {
|
||||
const dx = ev.clientX - x;
|
||||
const dy = ev.clientY - y;
|
||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||
moved = true;
|
||||
cleanup();
|
||||
startDrag(ev.clientX, ev.clientY, pendingIndex);
|
||||
}
|
||||
}
|
||||
function onUp() {
|
||||
cleanup();
|
||||
if (!moved) {
|
||||
window.location.href = `/admin/team/${members[pendingIndex].id}`;
|
||||
}
|
||||
}
|
||||
function cleanup() {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[startDrag, members]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragIndex === null) return;
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
|
||||
let newInsert = members.length;
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
if (i === dragIndex) continue;
|
||||
const el = itemRefs.current[i];
|
||||
if (!el) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (e.clientY < midY) {
|
||||
newInsert = i > dragIndex! ? i : i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setInsertAt(newInsert);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
setDragIndex((prevDrag) => {
|
||||
setInsertAt((prevInsert) => {
|
||||
if (prevDrag !== null && prevInsert !== null) {
|
||||
let targetIndex = prevInsert;
|
||||
if (prevDrag < targetIndex) targetIndex -= 1;
|
||||
if (prevDrag !== targetIndex) {
|
||||
const updated = [...members];
|
||||
const [moved] = updated.splice(prevDrag, 1);
|
||||
updated.splice(targetIndex, 0, moved);
|
||||
saveOrder(updated);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [dragIndex, members, saveOrder]);
|
||||
|
||||
async function deleteMember(id: number) {
|
||||
if (!confirm("Удалить этого участника?")) return;
|
||||
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
||||
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||
}
|
||||
@@ -173,109 +50,6 @@ export default function TeamEditorPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const draggedMember = dragIndex !== null ? members[dragIndex] : null;
|
||||
|
||||
// Build the visual order: remove dragged item, insert placeholder at insertAt
|
||||
function renderList() {
|
||||
if (dragIndex === null || insertAt === null) {
|
||||
// Normal render — no drag
|
||||
return members.map((member, i) => (
|
||||
<div
|
||||
key={member.id}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
>
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white truncate">{member.name}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||
</div>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
// During drag: build list without the dragged item, with placeholder inserted
|
||||
const elements: React.ReactNode[] = [];
|
||||
let visualIndex = 0;
|
||||
|
||||
// Determine where to insert placeholder relative to non-dragged items
|
||||
let placeholderPos = insertAt;
|
||||
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
|
||||
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
if (i === dragIndex) {
|
||||
// Keep a hidden ref so midpoint detection still works
|
||||
elements.push(
|
||||
<div key={`hidden-${members[i].id}`} ref={(el) => { itemRefs.current[i] = el; }} className="hidden" />
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (visualIndex === placeholderPos) {
|
||||
elements.push(
|
||||
<div
|
||||
key="placeholder"
|
||||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
|
||||
style={{ height: dragSize.h }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const member = members[i];
|
||||
elements.push(
|
||||
<div
|
||||
key={member.id}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
>
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white truncate">{member.name}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||
</div>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
visualIndex++;
|
||||
}
|
||||
|
||||
// Placeholder at the end
|
||||
if (visualIndex === placeholderPos) {
|
||||
elements.push(
|
||||
<div
|
||||
key="placeholder"
|
||||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
|
||||
style={{ height: dragSize.h }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@@ -302,42 +76,33 @@ export default function TeamEditorPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{renderList()}
|
||||
</div>
|
||||
|
||||
{/* Floating card following cursor */}
|
||||
{dragIndex !== null &&
|
||||
draggedMember &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] pointer-events-none"
|
||||
style={{
|
||||
left: mousePos.x - grabOffset.x,
|
||||
top: mousePos.y - grabOffset.y,
|
||||
width: dragSize.w,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 rounded-lg border-2 border-rose-500 bg-neutral-900 p-3 shadow-2xl shadow-rose-500/20">
|
||||
<div className="text-rose-400">
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={draggedMember.image}
|
||||
alt={draggedMember.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="48px"
|
||||
/>
|
||||
<ArrayEditor
|
||||
items={members}
|
||||
onChange={saveOrder}
|
||||
createItem={() => ({ id: 0, name: "", role: "", image: "" })}
|
||||
inline
|
||||
hideAdd
|
||||
getItemTitle={(m) => m.name || "Новый участник"}
|
||||
renderItem={(member) => (
|
||||
<Link
|
||||
href={`/admin/team/${member.id}`}
|
||||
className="flex items-center gap-4 flex-1 min-w-0 rounded-lg px-2 py-1.5 -my-1.5 hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg">
|
||||
{member.image ? (
|
||||
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-neutral-800" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white truncate">{draggedMember.name}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{draggedMember.role}</p>
|
||||
<p className="font-medium text-white truncate">{member.name}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user