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:
+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