diff --git a/src/app/admin/_components/ArrayEditor.tsx b/src/app/admin/_components/ArrayEditor.tsx index 903c56b..fdc8812 100644 --- a/src/app/admin/_components/ArrayEditor.tsx +++ b/src/app/admin/_components/ArrayEditor.tsx @@ -1,6 +1,8 @@ "use client"; -import { Plus, Trash2, ChevronUp, ChevronDown } from "lucide-react"; +import { useState, useRef, useCallback, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { Plus, Trash2, GripVertical } from "lucide-react"; interface ArrayEditorProps { items: T[]; @@ -19,6 +21,16 @@ export function ArrayEditor({ label, addLabel = "Добавить", }: ArrayEditorProps) { + 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)[]>([]); + const [mounted, setMounted] = useState(false); + + useEffect(() => { setMounted(true); }, []); + function updateItem(index: number, item: T) { const updated = [...items]; updated[index] = item; @@ -29,12 +41,159 @@ export function ArrayEditor({ onChange(items.filter((_, i) => i !== index)); } - function moveItem(index: number, direction: -1 | 1) { - const newIndex = index + direction; - if (newIndex < 0 || newIndex >= items.length) return; - const updated = [...items]; - [updated[index], updated[newIndex]] = [updated[newIndex], updated[index]]; - onChange(updated); + const handleMouseDown = useCallback( + (e: React.MouseEvent, index: number) => { + e.preventDefault(); + const el = itemRefs.current[index]; + if (!el) return; + const rect = el.getBoundingClientRect(); + setDragIndex(index); + setInsertAt(index); + setMousePos({ x: e.clientX, y: e.clientY }); + setDragSize({ w: rect.width, h: rect.height }); + setGrabOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + }, + [] + ); + + useEffect(() => { + if (dragIndex === null) return; + + function onMouseMove(e: MouseEvent) { + setMousePos({ x: e.clientX, y: e.clientY }); + + let newInsert = items.length; + for (let i = 0; i < items.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; + 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 = [...items]; + const [moved] = updated.splice(prevDrag, 1); + updated.splice(targetIndex, 0, moved); + onChange(updated); + } + } + return null; + }); + return null; + }); + } + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [dragIndex, items, onChange]); + + function renderList() { + if (dragIndex === null || insertAt === null) { + return items.map((item, i) => ( +
{ itemRefs.current[i] = el; }} + className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3" + > +
+
handleMouseDown(e, i)} + > + +
+ +
+ {renderItem(item, i, (updated) => updateItem(i, updated))} +
+ )); + } + + const elements: React.ReactNode[] = []; + let visualIndex = 0; + let placeholderPos = insertAt; + if (insertAt > dragIndex) placeholderPos = insertAt - 1; + + for (let i = 0; i < items.length; i++) { + if (i === dragIndex) { + elements.push( +
{ itemRefs.current[i] = el; }} className="hidden" /> + ); + continue; + } + + if (visualIndex === placeholderPos) { + elements.push( +
+ ); + } + + const item = items[i]; + elements.push( +
{ itemRefs.current[i] = el; }} + className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3" + > +
+
handleMouseDown(e, i)} + > + +
+ +
+ {renderItem(item, i, (updated) => updateItem(i, updated))} +
+ ); + visualIndex++; + } + + if (visualIndex === placeholderPos) { + elements.push( +
+ ); + } + + return elements; } return ( @@ -43,42 +202,8 @@ export function ArrayEditor({

{label}

)} -
- {items.map((item, i) => ( -
-
-
- - -
- -
- {renderItem(item, i, (updated) => updateItem(i, updated))} -
- ))} +
+ {renderList()}
+ + {/* Floating clone following cursor */} + {mounted && dragIndex !== null && + createPortal( +
+
+ + Перемещение элемента... +
+
, + document.body + )}
); } diff --git a/src/app/admin/_components/SectionEditor.tsx b/src/app/admin/_components/SectionEditor.tsx index 2a67562..aeb4ca9 100644 --- a/src/app/admin/_components/SectionEditor.tsx +++ b/src/app/admin/_components/SectionEditor.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { Save, Loader2, Check } from "lucide-react"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { Loader2, Check, AlertCircle } from "lucide-react"; interface SectionEditorProps { sectionKey: string; @@ -9,6 +9,8 @@ interface SectionEditorProps { children: (data: T, update: (data: T) => void) => React.ReactNode; } +const DEBOUNCE_MS = 800; + export function SectionEditor({ sectionKey, title, @@ -16,9 +18,10 @@ export function SectionEditor({ }: SectionEditorProps) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); + const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle"); const [error, setError] = useState(""); + const timerRef = useRef | null>(null); + const initialLoadRef = useRef(true); useEffect(() => { fetch(`/api/admin/sections/${sectionKey}`) @@ -31,27 +34,42 @@ export function SectionEditor({ .finally(() => setLoading(false)); }, [sectionKey]); - const handleSave = useCallback(async () => { - if (!data) return; - setSaving(true); - setSaved(false); + const save = useCallback(async (dataToSave: T) => { + setStatus("saving"); setError(""); try { const res = await fetch(`/api/admin/sections/${sectionKey}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), + body: JSON.stringify(dataToSave), }); if (!res.ok) throw new Error("Failed to save"); - setSaved(true); - setTimeout(() => setSaved(false), 2000); + setStatus("saved"); + setTimeout(() => setStatus((s) => (s === "saved" ? "idle" : s)), 2000); } catch { + setStatus("error"); setError("Ошибка сохранения"); - } finally { - setSaving(false); } - }, [data, sectionKey]); + }, [sectionKey]); + + // Auto-save with debounce whenever data changes (skip initial load) + useEffect(() => { + if (!data) return; + if (initialLoadRef.current) { + initialLoadRef.current = false; + return; + } + + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + save(data); + }, DEBOUNCE_MS); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [data, save]); if (loading) { return ( @@ -70,24 +88,28 @@ export function SectionEditor({

{title}

- + {status === "saved" && ( + <> + + Сохранено + + )} + {status === "error" && ( + <> + + {error} + + )} +
- {error &&

{error}

} -
{children(data, setData)}
); diff --git a/src/app/admin/schedule/page.tsx b/src/app/admin/schedule/page.tsx index a5b5cf5..8f95140 100644 --- a/src/app/admin/schedule/page.tsx +++ b/src/app/admin/schedule/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { SectionEditor } from "../_components/SectionEditor"; import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField"; -import { Plus, X, Trash2, GripVertical } from "lucide-react"; +import { Plus, X, Trash2 } from "lucide-react"; import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content"; interface ScheduleData { @@ -31,29 +31,31 @@ const LEVELS = [ { value: "Продвинутый", label: "Продвинутый" }, ]; -const CLASS_TYPES = [ - "Exotic Pole Dance", - "Pole Dance", - "Body Plastic", - "Stretching", - "Pole Exotic", - "Twerk", +const COLOR_PALETTE = [ + "bg-rose-500/80 border-rose-400", + "bg-violet-500/80 border-violet-400", + "bg-amber-500/80 border-amber-400", + "bg-emerald-500/80 border-emerald-400", + "bg-pink-500/80 border-pink-400", + "bg-sky-500/80 border-sky-400", + "bg-indigo-500/80 border-indigo-400", + "bg-orange-500/80 border-orange-400", + "bg-teal-500/80 border-teal-400", + "bg-fuchsia-500/80 border-fuchsia-400", ]; -const TYPE_COLORS: Record = { - "Exotic Pole Dance": "bg-rose-500/80 border-rose-400", - "Pole Dance": "bg-violet-500/80 border-violet-400", - "Body Plastic": "bg-amber-500/80 border-amber-400", - "Stretching": "bg-emerald-500/80 border-emerald-400", - "Pole Exotic": "bg-pink-500/80 border-pink-400", - "Twerk": "bg-sky-500/80 border-sky-400", -}; +function getTypeColor(type: string, classTypes: string[]): string { + const idx = classTypes.indexOf(type); + if (idx >= 0) return COLOR_PALETTE[idx % COLOR_PALETTE.length]; + return "bg-neutral-600/80 border-neutral-500"; +} // Calendar config const HOUR_START = 9; const HOUR_END = 23; const HOUR_HEIGHT = 60; // px per hour const TOTAL_HOURS = HOUR_END - HOUR_START; +const SNAP_MINUTES = 15; function parseTime(timeStr: string): { h: number; m: number } | null { const [h, m] = (timeStr || "").split(":").map(Number); @@ -103,17 +105,39 @@ function getOverlaps(classes: ScheduleClass[], index: number): boolean { return false; } +// ---------- Drag state ---------- +interface DragState { + sourceDayIndex: number; + classIndex: number; + /** offset from top of block where user grabbed */ + grabOffsetY: number; + /** duration in minutes (preserved during drag) */ + durationMin: number; + /** current preview: snapped start minute */ + previewStartMin: number; + /** current preview: target day index */ + previewDayIndex: number; + /** did the pointer actually move? (to distinguish click from drag) */ + moved: boolean; +} + // ---------- Class Block on Calendar ---------- function ClassBlock({ cls, index, isOverlapping, + isDragging, + classTypes, onClick, + onDragStart, }: { cls: ScheduleClass; index: number; isOverlapping: boolean; + isDragging: boolean; + classTypes: string[]; onClick: () => void; + onDragStart: (e: React.MouseEvent) => void; }) { const parts = cls.time.split("–"); const startMin = timeToMinutes(parts[0]?.trim() || ""); @@ -123,16 +147,26 @@ function ClassBlock({ const top = minutesToY(startMin); const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20); - const colors = TYPE_COLORS[cls.type] || "bg-neutral-600/80 border-neutral-500"; + const colors = getTypeColor(cls.type, classTypes); return ( - +
); } @@ -155,19 +189,21 @@ function ClassBlock({ function ClassModal({ cls, trainers, + classTypes, onSave, onDelete, onClose, }: { cls: ScheduleClass; trainers: string[]; + classTypes: string[]; onSave: (cls: ScheduleClass) => void; onDelete?: () => void; onClose: () => void; }) { const [draft, setDraft] = useState(cls); const trainerOptions = trainers.map((t) => ({ value: t, label: t })); - const typeOptions = CLASS_TYPES.map((t) => ({ value: t, label: t })); + const typeOptions = classTypes.map((t) => ({ value: t, label: t })); return (
@@ -257,10 +293,14 @@ function ClassModal({ function CalendarGrid({ location, trainers, + addresses, + classTypes, onChange, }: { location: ScheduleLocation; trainers: string[]; + addresses: string[]; + classTypes: string[]; onChange: (loc: ScheduleLocation) => void; }) { const [editingClass, setEditingClass] = useState<{ @@ -272,6 +312,16 @@ function CalendarGrid({ cls: ScheduleClass; } | null>(null); + // Hover highlight state + const [hover, setHover] = useState<{ dayIndex: number; startMin: number } | null>(null); + + // Drag state + const [drag, setDrag] = useState(null); + const dragRef = useRef(null); + const justDraggedRef = useRef(false); + const columnRefs = useRef<(HTMLDivElement | null)[]>([]); + const gridRef = useRef(null); + const sortedDays = sortDaysByWeekday(location.days); const usedDays = new Set(location.days.map((d) => d.day)); const availableDays = DAYS.filter((d) => !usedDays.has(d.day)); @@ -281,15 +331,157 @@ function CalendarGrid({ hours.push(h); } + // --- Drag handlers --- + function startDrag(dayIndex: number, classIndex: number, e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + + const col = columnRefs.current[dayIndex]; + if (!col) return; + + const cls = sortedDays[dayIndex].classes[classIndex]; + const parts = cls.time.split("–"); + const startMin = timeToMinutes(parts[0]?.trim() || ""); + const endMin = timeToMinutes(parts[1]?.trim() || ""); + if (!startMin || !endMin) return; + + const colRect = col.getBoundingClientRect(); + const blockTop = minutesToY(startMin); + const grabOffsetY = e.clientY - colRect.top - blockTop; + + const state: DragState = { + sourceDayIndex: dayIndex, + classIndex, + grabOffsetY, + durationMin: endMin - startMin, + previewStartMin: startMin, + previewDayIndex: dayIndex, + moved: false, + }; + dragRef.current = state; + setDrag(state); + } + + const handleMouseMove = useCallback((e: MouseEvent) => { + const d = dragRef.current; + if (!d) return; + + // Determine which day column the mouse is over + let targetDayIndex = d.previewDayIndex; + for (let i = 0; i < columnRefs.current.length; i++) { + const col = columnRefs.current[i]; + if (!col) continue; + const rect = col.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX < rect.right) { + targetDayIndex = i; + break; + } + } + + // Calculate Y position in the target column + const col = columnRefs.current[targetDayIndex]; + if (!col) return; + const colRect = col.getBoundingClientRect(); + const y = e.clientY - colRect.top - d.grabOffsetY; + const rawMinutes = yToMinutes(y); + const snapped = Math.round(rawMinutes / SNAP_MINUTES) * SNAP_MINUTES; + // Clamp to grid bounds + const clamped = Math.max( + HOUR_START * 60, + Math.min(snapped, HOUR_END * 60 - d.durationMin) + ); + + const hasMoved = + clamped !== d.previewStartMin || targetDayIndex !== d.previewDayIndex || d.moved; + + const updated: DragState = { + ...d, + previewStartMin: clamped, + previewDayIndex: targetDayIndex, + moved: hasMoved, + }; + dragRef.current = updated; + setDrag(updated); + }, []); + + const handleMouseUp = useCallback(() => { + const d = dragRef.current; + dragRef.current = null; + + if (!d) { + setDrag(null); + return; + } + + if (d.moved) { + // Suppress the click event that fires right after mouseup + justDraggedRef.current = true; + requestAnimationFrame(() => { + justDraggedRef.current = false; + }); + + // Commit the move + const newStart = formatMinutes(d.previewStartMin); + const newEnd = formatMinutes(d.previewStartMin + d.durationMin); + const sourceDay = sortedDays[d.sourceDayIndex]; + const cls = sourceDay.classes[d.classIndex]; + const updatedCls: ScheduleClass = { ...cls, time: `${newStart}–${newEnd}` }; + + if (d.previewDayIndex === d.sourceDayIndex) { + // Same day — just update time + const classes = [...sourceDay.classes]; + classes[d.classIndex] = updatedCls; + commitDayUpdate(d.sourceDayIndex, { ...sourceDay, classes }); + } else { + // Move to different day + const targetDay = sortedDays[d.previewDayIndex]; + + // Remove from source + const sourceClasses = sourceDay.classes.filter((_, i) => i !== d.classIndex); + // Add to target + const targetClasses = [...targetDay.classes, updatedCls]; + + const days = [...location.days]; + const sourceActual = days.findIndex((dd) => dd.day === sourceDay.day); + const targetActual = days.findIndex((dd) => dd.day === targetDay.day); + if (sourceActual !== -1) days[sourceActual] = { ...sourceDay, classes: sourceClasses }; + if (targetActual !== -1) days[targetActual] = { ...targetDay, classes: targetClasses }; + onChange({ ...location, days }); + } + } + + setDrag(null); + }, [sortedDays, location, onChange]); + + // Attach global mouse listeners while dragging + useEffect(() => { + if (!drag) return; + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [drag, handleMouseMove, handleMouseUp]); + function handleCellClick(dayIndex: number, e: React.MouseEvent) { - const rect = e.currentTarget.getBoundingClientRect(); - const y = e.clientY - rect.top; - const minutes = yToMinutes(y); - // Snap to 15-min intervals - const snapped = Math.round(minutes / 15) * 15; + if (drag || justDraggedRef.current) return; + + // Use hover position if available, otherwise calculate from click + let snapped: number; + if (hover && hover.dayIndex === dayIndex) { + snapped = hover.startMin; + } else { + const rect = e.currentTarget.getBoundingClientRect(); + const y = e.clientY - rect.top; + const rawMin = yToMinutes(y); + snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES; + snapped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60)); + } const startTime = formatMinutes(snapped); const endTime = formatMinutes(snapped + 60); + setHover(null); setNewClass({ dayIndex, cls: { @@ -300,8 +492,7 @@ function CalendarGrid({ }); } - function updateDay(dayIndex: number, updatedDay: ScheduleDay) { - // Find the actual index in location.days (since we display sorted) + function commitDayUpdate(dayIndex: number, updatedDay: ScheduleDay) { const actualDay = sortedDays[dayIndex]; const actualIndex = location.days.findIndex((d) => d.day === actualDay.day); if (actualIndex === -1) return; @@ -311,6 +502,10 @@ function CalendarGrid({ onChange({ ...location, days }); } + function updateDay(dayIndex: number, updatedDay: ScheduleDay) { + commitDayUpdate(dayIndex, updatedDay); + } + function deleteDay(dayIndex: number) { const actualDay = sortedDays[dayIndex]; const days = location.days.filter((d) => d.day !== actualDay.day); @@ -335,6 +530,18 @@ function CalendarGrid({ } : null; + // Build drag ghost preview + const dragPreview = drag?.moved ? (() => { + const sourceDay = sortedDays[drag.sourceDayIndex]; + const cls = sourceDay.classes[drag.classIndex]; + const colors = getTypeColor(cls.type, classTypes); + const top = minutesToY(drag.previewStartMin); + const height = (drag.durationMin / 60) * HOUR_HEIGHT; + const newStart = formatMinutes(drag.previewStartMin); + const newEnd = formatMinutes(drag.previewStartMin + drag.durationMin); + return { colors, top, height, dayIndex: drag.previewDayIndex, newStart, newEnd, type: cls.type }; + })() : null; + return (
{/* Location name/address */} @@ -344,17 +551,19 @@ function CalendarGrid({ value={location.name} onChange={(v) => onChange({ ...location, name: v })} /> - onChange({ ...location, address: v })} + options={addresses.map((a) => ({ value: a, label: a }))} + placeholder="Выберите адрес" />
{/* Legend */}
- {CLASS_TYPES.map((type) => { - const colors = TYPE_COLORS[type] || ""; + {classTypes.map((type) => { + const colors = getTypeColor(type, classTypes); const bgClass = colors.split(" ")[0] || "bg-neutral-600/80"; return (
@@ -366,12 +575,8 @@ function CalendarGrid({
{/* Calendar */} - {sortedDays.length === 0 ? ( -
- Добавьте дни недели чтобы увидеть расписание -
- ) : ( -
+ {sortedDays.length > 0 && ( +
{/* Day headers */}
@@ -384,14 +589,6 @@ function CalendarGrid({
{day.dayShort} ({day.classes.length}) -
))} @@ -413,75 +610,119 @@ function CalendarGrid({
{/* Day columns */} - {sortedDays.map((day, di) => ( -
{ - // Only add if clicking on empty space (not on a class block) - if ((e.target as HTMLElement).closest("button")) return; - handleCellClick(di, e); - }} - > - {/* Hour lines */} - {hours.slice(0, -1).map((h) => ( -
- ))} - {/* Half-hour lines */} - {hours.slice(0, -1).map((h) => ( -
- ))} + {sortedDays.map((day, di) => { + const showHover = hover && hover.dayIndex === di && !drag && !newClass && !editingClass; + const hoverTop = showHover ? minutesToY(hover.startMin) : 0; + const hoverHeight = HOUR_HEIGHT; // 1 hour + const hoverEndMin = showHover ? hover.startMin + 60 : 0; - {/* Class blocks */} - {day.classes.map((cls, ci) => ( - setEditingClass({ dayIndex: di, classIndex: ci })} - /> - ))} -
- ))} + return ( +
{ columnRefs.current[di] = el; }} + className={`flex-1 border-l border-white/10 relative ${drag ? "cursor-grabbing" : "cursor-pointer"}`} + style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }} + onMouseMove={(e) => { + if (drag) return; + // Ignore if hovering over a class block + if ((e.target as HTMLElement).closest("[data-class-block]")) { + setHover(null); + return; + } + const rect = e.currentTarget.getBoundingClientRect(); + const y = e.clientY - rect.top; + const rawMin = yToMinutes(y); + // Snap to 15-min and offset so the block is centered on cursor + const snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES; + const clamped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60)); + setHover({ dayIndex: di, startMin: clamped }); + }} + onMouseLeave={() => setHover(null)} + onClick={(e) => { + if ((e.target as HTMLElement).closest("[data-class-block]")) return; + handleCellClick(di, e); + }} + > + {/* Hour lines */} + {hours.slice(0, -1).map((h) => ( +
+ ))} + {/* Half-hour lines */} + {hours.slice(0, -1).map((h) => ( +
+ ))} + + {/* Hover highlight — 1h preview */} + {showHover && ( +
+
+ {formatMinutes(hover.startMin)}–{formatMinutes(hoverEndMin)} +
+
Нажмите чтобы добавить
+
+ )} + + {/* Class blocks */} + {day.classes.map((cls, ci) => ( + { + if (justDraggedRef.current) return; + setEditingClass({ dayIndex: di, classIndex: ci }); + }} + onDragStart={(e) => startDrag(di, ci, e)} + /> + ))} + + {/* Drag preview ghost */} + {dragPreview && dragPreview.dayIndex === di && ( +
+
+ {dragPreview.newStart}–{dragPreview.newEnd} +
+ {dragPreview.height > 30 && ( +
{dragPreview.type}
+ )} +
+ )} +
+ ); + })}
)} - {/* Add day buttons */} - {availableDays.length > 0 && ( -
- - Добавить день: - - {availableDays.map((d) => ( - - ))} -
- )} - {/* Edit modal */} {editingData?.cls && editingClass && ( { const day = sortedDays[editingClass.dayIndex]; const classes = [...day.classes]; @@ -502,6 +743,7 @@ function CalendarGrid({ { const day = sortedDays[newClass.dayIndex]; const classes = [...day.classes, created]; @@ -518,6 +760,8 @@ function CalendarGrid({ export default function ScheduleEditorPage() { const [activeLocation, setActiveLocation] = useState(0); const [trainers, setTrainers] = useState([]); + const [addresses, setAddresses] = useState([]); + const [classTypes, setClassTypes] = useState([]); useEffect(() => { fetch("/api/admin/team") @@ -526,6 +770,20 @@ export default function ScheduleEditorPage() { setTrainers(members.map((m) => m.name)); }) .catch(() => {}); + + fetch("/api/admin/sections/contact") + .then((r) => r.json()) + .then((contact: { addresses?: string[] }) => { + setAddresses(contact.addresses ?? []); + }) + .catch(() => {}); + + fetch("/api/admin/sections/classes") + .then((r) => r.json()) + .then((classes: { items?: { name: string }[] }) => { + setClassTypes((classes.items ?? []).map((c) => c.name)); + }) + .catch(() => {}); }, []); return ( @@ -588,15 +846,14 @@ export default function ScheduleEditorPage() { ))} +
+
+ )); + } + + // 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; }} + className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2" + > +
handleMouseDown(e, i)} + > + +
+
+ {member.name} +
+
+

{member.name}

+

{member.role}

+
+
+ + + + +
+
+ ); + visualIndex++; + } + + // Placeholder at the end + if (visualIndex === placeholderPos) { + elements.push( +
+ ); + } + + return elements; + } + return (
@@ -91,61 +263,43 @@ export default function TeamEditorPage() {
-
- {members.map((member, i) => ( -
-
- - -
- -
- {member.name} -
- -
-

{member.name}

-

{member.role}

-
- -
- - - - -
-
- ))} +
+ {renderList()}
+ + {/* Floating card following cursor */} + {dragIndex !== null && + draggedMember && + createPortal( +
+
+
+ +
+
+ {draggedMember.name} +
+
+

{draggedMember.name}

+

{draggedMember.role}

+
+
+
, + document.body + )}
); }