diff --git a/src/app/admin/_components/ArrayEditor.tsx b/src/app/admin/_components/ArrayEditor.tsx index a97d156..77c8a1a 100644 --- a/src/app/admin/_components/ArrayEditor.tsx +++ b/src/app/admin/_components/ArrayEditor.tsx @@ -17,6 +17,10 @@ interface ArrayEditorProps { getItemBadge?: (item: T, index: number) => React.ReactNode; hiddenItems?: Set; 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({ @@ -31,6 +35,8 @@ export function ArrayEditor({ getItemBadge, hiddenItems, addPosition = "bottom", + inline = false, + hideAdd = false, }: ArrayEditorProps) { const [confirmDelete, setConfirmDelete] = useState(null); const [dragIndex, setDragIndex] = useState(null); @@ -167,52 +173,80 @@ export function ArrayEditor({ newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10" } ${isHidden ? "hidden" : ""}`} > -
-
+ {inline ? ( + /* Inline: grip + content + delete on one row */ +
handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button" > - +
- {collapsible && ( - - )} -
- -
- {collapsible ? ( -
-
-
- {renderItem(item, i, (updated) => updateItem(i, updated))} -
+
+ {renderItem(item, i, (updated) => updateItem(i, updated))}
+
) : ( -
- {renderItem(item, i, (updated) => updateItem(i, updated))} + <> +
+
+
handleGripMouseDown(e, i)} + aria-label="Перетащить для сортировки" + role="button" + > + +
+ {collapsible && ( + + )} +
+
+ {collapsible ? ( +
+
+
+ {renderItem(item, i, (updated) => updateItem(i, updated))} +
+
+
+ ) : ( +
+ {renderItem(item, i, (updated) => updateItem(i, updated))} +
+ )} + )}
); @@ -243,22 +277,34 @@ export function ArrayEditor({ } 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(
{ 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 ? ( -
- - {dragTitle} - {getItemBadge?.(item, i)} + {inline ? ( +
+
handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button"> + +
+
+ {renderItem(item, i, (updated) => updateItem(i, updated))} +
+
) : ( <> -
+
+
handleGripMouseDown(e, i)} @@ -267,18 +313,32 @@ export function ArrayEditor({ >
- + {collapsible && ( + + )}
+ +
+ {collapsible ? ( +
+
+
+ {renderItem(item, i, (updated) => updateItem(i, updated))} +
+
+
+ ) : (
{renderItem(item, i, (updated) => updateItem(i, updated))}
+ )} )}
@@ -312,6 +372,7 @@ export function ArrayEditor({ 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 ? "Развернуть все" : "Свернуть все"} > @@ -320,7 +381,7 @@ export function ArrayEditor({
)} - {addPosition === "top" && ( + {!hideAdd && addPosition === "top" && (
- {addPosition === "bottom" && ( + {!hideAdd && addPosition === "bottom" && ( diff --git a/src/app/admin/_components/SectionEditor.tsx b/src/app/admin/_components/SectionEditor.tsx index 53d3ec7..215f95a 100644 --- a/src/app/admin/_components/SectionEditor.tsx +++ b/src/app/admin/_components/SectionEditor.tsx @@ -92,7 +92,7 @@ export function SectionEditor({ {/* Fixed toast popup */} {(status === "saved" || status === "error") && ( -
{children} {toasts.length > 0 && ( -
+
{toasts.map((t) => (
: } {t.message}
-
+
{SLOTS.map((slot, i) => (
-
{error && ( -

{error}

+ )}
@@ -158,16 +172,28 @@ function EventSettings({
+ {!event.active && (!event.date || event.date < new Date().toISOString().slice(0, 10)) && ( + Укажите будущую дату для публикации + )} {event.pricePerClass} BYN / занятие{event.discountPrice > 0 && event.discountThreshold > 0 && `, от ${event.discountThreshold} — ${event.discountPrice} BYN`} diff --git a/src/app/admin/pricing/page.tsx b/src/app/admin/pricing/page.tsx index ed109f3..08c6b38 100644 --- a/src/app/admin/pricing/page.tsx +++ b/src/app/admin/pricing/page.tsx @@ -192,8 +192,15 @@ function PricingContent({ data, update }: { data: PricingData; update: (d: Prici update({ ...data, rules })} + inline renderItem={(rule, _i, updateItem) => ( - + 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="Добавить правило" diff --git a/src/app/admin/team/page.tsx b/src/app/admin/team/page.tsx index 05c8c32..de4114c 100644 --- a/src/app/admin/team/page.tsx +++ b/src/app/admin/team/page.tsx @@ -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(null); - const [insertAt, setInsertAt] = useState(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) => ( -
{ 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" - > -
handleGripMouseDown(e, i)} - > - -
-
- {member.name} -
-
-

{member.name}

-

{member.role}

-
- -
- )); - } - - // 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( -
{ itemRefs.current[i] = el; }} className="hidden" /> - ); - continue; - } - - if (visualIndex === placeholderPos) { - elements.push( -
- ); - } - - const member = members[i]; - elements.push( -
{ 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" - > -
handleGripMouseDown(e, i)} - > - -
-
- {member.name} -
-
-

{member.name}

-

{member.role}

-
- -
- ); - visualIndex++; - } - - // Placeholder at the end - if (visualIndex === placeholderPos) { - elements.push( -
- ); - } - - return elements; - } - return (
@@ -302,42 +76,33 @@ export default function TeamEditorPage() {
- {renderList()} -
- - {/* Floating card following cursor */} - {dragIndex !== null && - draggedMember && - createPortal( -
-
-
- -
-
- {draggedMember.name} + ({ id: 0, name: "", role: "", image: "" })} + inline + hideAdd + getItemTitle={(m) => m.name || "Новый участник"} + renderItem={(member) => ( + +
+ {member.image ? ( + {member.name} + ) : ( +
+ )}
-

{draggedMember.name}

-

{draggedMember.role}

+

{member.name}

+

{member.role}

-
-
, - document.body - )} + + )} + /> +
); } diff --git a/src/app/api/admin/open-day/route.ts b/src/app/api/admin/open-day/route.ts index 30b7fec..286ec12 100644 --- a/src/app/api/admin/open-day/route.ts +++ b/src/app/api/admin/open-day/route.ts @@ -27,11 +27,6 @@ export async function POST(request: NextRequest) { if (!body.date || typeof body.date !== "string") { return NextResponse.json({ error: "date is required" }, { status: 400 }); } - // Warn if date is in the past - const eventDate = new Date(body.date + "T23:59:59"); - if (eventDate < new Date()) { - return NextResponse.json({ error: "Дата не может быть в прошлом" }, { status: 400 }); - } const id = createOpenDayEvent(body); return NextResponse.json({ ok: true, id }); } catch (err) { @@ -45,12 +40,6 @@ export async function PUT(request: NextRequest) { const body = await request.json(); if (!body.id) return NextResponse.json({ error: "id is required" }, { status: 400 }); const { id, ...data } = body; - if (data.date) { - const eventDate = new Date(data.date + "T23:59:59"); - if (eventDate < new Date()) { - return NextResponse.json({ error: "Дата не может быть в прошлом" }, { status: 400 }); - } - } updateOpenDayEvent(id, data); return NextResponse.json({ ok: true }); } catch (err) { diff --git a/src/app/page.tsx b/src/app/page.tsx index fea595c..c880aa3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -32,7 +32,7 @@ export default function HomePage() { <>
-
+
(null); + const firstNavLinkRef = useRef(null); useEffect(() => { let ticking = false; @@ -30,6 +32,33 @@ export function Header() { return () => window.removeEventListener("scroll", handleScroll); }, []); + // Close mobile menu on Escape key + useEffect(() => { + if (!menuOpen) return; + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + setMenuOpen(false); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [menuOpen]); + + // Focus management: focus first nav link when menu opens, return focus to button when it closes + const prevMenuOpenRef = useRef(false); + useEffect(() => { + if (menuOpen && !prevMenuOpenRef.current) { + // Menu just opened — focus first nav link + requestAnimationFrame(() => { + firstNavLinkRef.current?.focus(); + }); + } else if (!menuOpen && prevMenuOpenRef.current) { + // Menu just closed — return focus to menu button + menuButtonRef.current?.focus(); + } + prevMenuOpenRef.current = menuOpen; + }, [menuOpen]); + // Filter out nav links whose target section doesn't exist on the page const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS); useEffect(() => { @@ -45,7 +74,12 @@ export function Header() { if (hero) { const heroObserver = new IntersectionObserver( ([entry]) => { - if (entry.isIntersecting) setActiveSection(""); + if (entry.isIntersecting) { + setActiveSection(""); + if (window.location.hash) { + history.replaceState(null, "", window.location.pathname); + } + } }, { rootMargin: "-20% 0px -70% 0px" }, ); @@ -61,6 +95,10 @@ export function Header() { ([entry]) => { if (entry.isIntersecting) { setActiveSection(id); + // Sync URL hash so refresh returns to the current section + if (window.location.hash !== `#${id}`) { + history.replaceState(null, "", `#${id}`); + } } }, { rootMargin: "-40% 0px -55% 0px" }, @@ -80,6 +118,7 @@ export function Header() { : "bg-transparent" }`} > + Перейти к содержимому
@@ -99,14 +138,15 @@ export function Header() { -