feat: drag-and-drop reordering + auto-save for admin editors
Replace arrow buttons with mouse-based drag-and-drop in ArrayEditor and team page. Dragged card follows cursor with floating clone, empty placeholder shows at drop position. SectionEditor now auto-saves with 800ms debounce instead of manual save button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"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<T> {
|
interface ArrayEditorProps<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
@@ -19,6 +21,16 @@ export function ArrayEditor<T>({
|
|||||||
label,
|
label,
|
||||||
addLabel = "Добавить",
|
addLabel = "Добавить",
|
||||||
}: ArrayEditorProps<T>) {
|
}: ArrayEditorProps<T>) {
|
||||||
|
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)[]>([]);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
function updateItem(index: number, item: T) {
|
function updateItem(index: number, item: T) {
|
||||||
const updated = [...items];
|
const updated = [...items];
|
||||||
updated[index] = item;
|
updated[index] = item;
|
||||||
@@ -29,44 +41,83 @@ export function ArrayEditor<T>({
|
|||||||
onChange(items.filter((_, i) => i !== index));
|
onChange(items.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveItem(index: number, direction: -1 | 1) {
|
const handleMouseDown = useCallback(
|
||||||
const newIndex = index + direction;
|
(e: React.MouseEvent, index: number) => {
|
||||||
if (newIndex < 0 || newIndex >= items.length) return;
|
e.preventDefault();
|
||||||
const updated = [...items];
|
const el = itemRefs.current[index];
|
||||||
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
|
if (!el) return;
|
||||||
onChange(updated);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function onMouseUp() {
|
||||||
<div>
|
setDragIndex((prevDrag) => {
|
||||||
{label && (
|
setInsertAt((prevInsert) => {
|
||||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
<div className="space-y-3">
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
{items.map((item, i) => (
|
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) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4"
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 mb-3">
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
<div className="flex gap-1">
|
<div
|
||||||
<button
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
type="button"
|
onMouseDown={(e) => handleMouseDown(e, i)}
|
||||||
onClick={() => moveItem(i, -1)}
|
|
||||||
disabled={i === 0}
|
|
||||||
className="rounded p-1 text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
|
|
||||||
>
|
>
|
||||||
<ChevronUp size={16} />
|
<GripVertical size={16} />
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => moveItem(i, 1)}
|
|
||||||
disabled={i === items.length - 1}
|
|
||||||
className="rounded p-1 text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronDown size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -78,7 +129,81 @@ export function ArrayEditor<T>({
|
|||||||
</div>
|
</div>
|
||||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
<div key={`hidden-${i}`} 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-3"
|
||||||
|
style={{ height: dragSize.h }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = items[i];
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleMouseDown(e, i)}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(i)}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
visualIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visualIndex === placeholderPos) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key="placeholder"
|
||||||
|
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
||||||
|
style={{ height: dragSize.h }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && (
|
||||||
|
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{renderList()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -89,6 +214,26 @@ export function ArrayEditor<T>({
|
|||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{addLabel}
|
{addLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Floating clone following cursor */}
|
||||||
|
{mounted && dragIndex !== null &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[9999] pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: mousePos.x - grabOffset.x,
|
||||||
|
top: mousePos.y - grabOffset.y,
|
||||||
|
width: dragSize.w,
|
||||||
|
height: dragSize.h,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-full rounded-lg border-2 border-rose-500 bg-neutral-900/95 shadow-2xl shadow-rose-500/20 flex items-center gap-3 px-4">
|
||||||
|
<GripVertical size={16} className="text-rose-400 shrink-0" />
|
||||||
|
<span className="text-sm text-neutral-300">Перемещение элемента...</span>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Save, Loader2, Check } from "lucide-react";
|
import { Loader2, Check, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
interface SectionEditorProps<T> {
|
interface SectionEditorProps<T> {
|
||||||
sectionKey: string;
|
sectionKey: string;
|
||||||
@@ -9,6 +9,8 @@ interface SectionEditorProps<T> {
|
|||||||
children: (data: T, update: (data: T) => void) => React.ReactNode;
|
children: (data: T, update: (data: T) => void) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
export function SectionEditor<T>({
|
export function SectionEditor<T>({
|
||||||
sectionKey,
|
sectionKey,
|
||||||
title,
|
title,
|
||||||
@@ -16,9 +18,10 @@ export function SectionEditor<T>({
|
|||||||
}: SectionEditorProps<T>) {
|
}: SectionEditorProps<T>) {
|
||||||
const [data, setData] = useState<T | null>(null);
|
const [data, setData] = useState<T | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const initialLoadRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/admin/sections/${sectionKey}`)
|
fetch(`/api/admin/sections/${sectionKey}`)
|
||||||
@@ -31,27 +34,42 @@ export function SectionEditor<T>({
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [sectionKey]);
|
}, [sectionKey]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const save = useCallback(async (dataToSave: T) => {
|
||||||
if (!data) return;
|
setStatus("saving");
|
||||||
setSaving(true);
|
|
||||||
setSaved(false);
|
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/sections/${sectionKey}`, {
|
const res = await fetch(`/api/admin/sections/${sectionKey}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(dataToSave),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to save");
|
if (!res.ok) throw new Error("Failed to save");
|
||||||
setSaved(true);
|
setStatus("saved");
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setStatus((s) => (s === "saved" ? "idle" : s)), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
setError("Ошибка сохранения");
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -70,23 +88,27 @@ export function SectionEditor<T>({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold">{title}</h1>
|
<h1 className="text-2xl font-bold">{title}</h1>
|
||||||
<button
|
<div className="flex items-center gap-2 text-sm text-neutral-400">
|
||||||
onClick={handleSave}
|
{status === "saving" && (
|
||||||
disabled={saving}
|
<>
|
||||||
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
<Loader2 size={14} className="animate-spin" />
|
||||||
>
|
<span>Сохранение...</span>
|
||||||
{saving ? (
|
</>
|
||||||
<Loader2 size={16} className="animate-spin" />
|
)}
|
||||||
) : saved ? (
|
{status === "saved" && (
|
||||||
<Check size={16} />
|
<>
|
||||||
) : (
|
<Check size={14} className="text-emerald-400" />
|
||||||
<Save size={16} />
|
<span className="text-emerald-400">Сохранено</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "error" && (
|
||||||
|
<>
|
||||||
|
<AlertCircle size={14} className="text-red-400" />
|
||||||
|
<span className="text-red-400">{error}</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{saving ? "Сохранение..." : saved ? "Сохранено!" : "Сохранить"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{error && <p className="mt-4 text-sm text-red-400">{error}</p>}
|
|
||||||
|
|
||||||
<div className="mt-6 space-y-6">{children(data, setData)}</div>
|
<div className="mt-6 space-y-6">{children(data, setData)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
|
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";
|
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
||||||
|
|
||||||
interface ScheduleData {
|
interface ScheduleData {
|
||||||
@@ -31,29 +31,31 @@ const LEVELS = [
|
|||||||
{ value: "Продвинутый", label: "Продвинутый" },
|
{ value: "Продвинутый", label: "Продвинутый" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CLASS_TYPES = [
|
const COLOR_PALETTE = [
|
||||||
"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",
|
||||||
|
"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<string, string> = {
|
function getTypeColor(type: string, classTypes: string[]): string {
|
||||||
"Exotic Pole Dance": "bg-rose-500/80 border-rose-400",
|
const idx = classTypes.indexOf(type);
|
||||||
"Pole Dance": "bg-violet-500/80 border-violet-400",
|
if (idx >= 0) return COLOR_PALETTE[idx % COLOR_PALETTE.length];
|
||||||
"Body Plastic": "bg-amber-500/80 border-amber-400",
|
return "bg-neutral-600/80 border-neutral-500";
|
||||||
"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",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calendar config
|
// Calendar config
|
||||||
const HOUR_START = 9;
|
const HOUR_START = 9;
|
||||||
const HOUR_END = 23;
|
const HOUR_END = 23;
|
||||||
const HOUR_HEIGHT = 60; // px per hour
|
const HOUR_HEIGHT = 60; // px per hour
|
||||||
const TOTAL_HOURS = HOUR_END - HOUR_START;
|
const TOTAL_HOURS = HOUR_END - HOUR_START;
|
||||||
|
const SNAP_MINUTES = 15;
|
||||||
|
|
||||||
function parseTime(timeStr: string): { h: number; m: number } | null {
|
function parseTime(timeStr: string): { h: number; m: number } | null {
|
||||||
const [h, m] = (timeStr || "").split(":").map(Number);
|
const [h, m] = (timeStr || "").split(":").map(Number);
|
||||||
@@ -103,17 +105,39 @@ function getOverlaps(classes: ScheduleClass[], index: number): boolean {
|
|||||||
return false;
|
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 ----------
|
// ---------- Class Block on Calendar ----------
|
||||||
function ClassBlock({
|
function ClassBlock({
|
||||||
cls,
|
cls,
|
||||||
index,
|
index,
|
||||||
isOverlapping,
|
isOverlapping,
|
||||||
|
isDragging,
|
||||||
|
classTypes,
|
||||||
onClick,
|
onClick,
|
||||||
|
onDragStart,
|
||||||
}: {
|
}: {
|
||||||
cls: ScheduleClass;
|
cls: ScheduleClass;
|
||||||
index: number;
|
index: number;
|
||||||
isOverlapping: boolean;
|
isOverlapping: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
classTypes: string[];
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onDragStart: (e: React.MouseEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
const parts = cls.time.split("–");
|
const parts = cls.time.split("–");
|
||||||
const startMin = timeToMinutes(parts[0]?.trim() || "");
|
const startMin = timeToMinutes(parts[0]?.trim() || "");
|
||||||
@@ -123,16 +147,26 @@ function ClassBlock({
|
|||||||
|
|
||||||
const top = minutesToY(startMin);
|
const top = minutesToY(startMin);
|
||||||
const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20);
|
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 (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
data-class-block
|
||||||
onClick={onClick}
|
onMouseDown={onDragStart}
|
||||||
style={{ top: `${top}px`, height: `${height}px` }}
|
onClick={(e) => {
|
||||||
className={`absolute left-1 right-1 rounded-md border-l-3 px-2 py-0.5 text-left text-xs text-white transition-opacity hover:opacity-90 cursor-pointer overflow-hidden ${colors} ${
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
top: `${top}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
...(isOverlapping
|
||||||
|
? { backgroundImage: "repeating-linear-gradient(135deg, transparent, transparent 4px, rgba(239,68,68,0.35) 4px, rgba(239,68,68,0.35) 8px)" }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
className={`absolute left-1 right-1 rounded-md border-l-3 px-2 py-0.5 text-left text-xs text-white cursor-grab active:cursor-grabbing overflow-hidden select-none ${colors} ${
|
||||||
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : ""
|
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : ""
|
||||||
}`}
|
} ${isDragging ? "opacity-30" : "hover:opacity-90"}`}
|
||||||
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` (${cls.level})` : ""}`}
|
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` (${cls.level})` : ""}`}
|
||||||
>
|
>
|
||||||
<div className="font-semibold truncate leading-tight">
|
<div className="font-semibold truncate leading-tight">
|
||||||
@@ -144,10 +178,10 @@ function ClassBlock({
|
|||||||
{height > 48 && (
|
{height > 48 && (
|
||||||
<div className="truncate text-white/70 leading-tight">{cls.trainer}</div>
|
<div className="truncate text-white/70 leading-tight">{cls.trainer}</div>
|
||||||
)}
|
)}
|
||||||
{isOverlapping && height > 30 && (
|
{isOverlapping && (
|
||||||
<div className="text-red-200 font-medium leading-tight">⚠ Пересечение</div>
|
<div className="text-red-200 font-bold leading-tight">⚠ Пересечение</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,19 +189,21 @@ function ClassBlock({
|
|||||||
function ClassModal({
|
function ClassModal({
|
||||||
cls,
|
cls,
|
||||||
trainers,
|
trainers,
|
||||||
|
classTypes,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
cls: ScheduleClass;
|
cls: ScheduleClass;
|
||||||
trainers: string[];
|
trainers: string[];
|
||||||
|
classTypes: string[];
|
||||||
onSave: (cls: ScheduleClass) => void;
|
onSave: (cls: ScheduleClass) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<ScheduleClass>(cls);
|
const [draft, setDraft] = useState<ScheduleClass>(cls);
|
||||||
const trainerOptions = trainers.map((t) => ({ value: t, label: t }));
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||||
@@ -257,10 +293,14 @@ function ClassModal({
|
|||||||
function CalendarGrid({
|
function CalendarGrid({
|
||||||
location,
|
location,
|
||||||
trainers,
|
trainers,
|
||||||
|
addresses,
|
||||||
|
classTypes,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
location: ScheduleLocation;
|
location: ScheduleLocation;
|
||||||
trainers: string[];
|
trainers: string[];
|
||||||
|
addresses: string[];
|
||||||
|
classTypes: string[];
|
||||||
onChange: (loc: ScheduleLocation) => void;
|
onChange: (loc: ScheduleLocation) => void;
|
||||||
}) {
|
}) {
|
||||||
const [editingClass, setEditingClass] = useState<{
|
const [editingClass, setEditingClass] = useState<{
|
||||||
@@ -272,6 +312,16 @@ function CalendarGrid({
|
|||||||
cls: ScheduleClass;
|
cls: ScheduleClass;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// Hover highlight state
|
||||||
|
const [hover, setHover] = useState<{ dayIndex: number; startMin: number } | null>(null);
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
const [drag, setDrag] = useState<DragState | null>(null);
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
const justDraggedRef = useRef(false);
|
||||||
|
const columnRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const sortedDays = sortDaysByWeekday(location.days);
|
const sortedDays = sortDaysByWeekday(location.days);
|
||||||
const usedDays = new Set(location.days.map((d) => d.day));
|
const usedDays = new Set(location.days.map((d) => d.day));
|
||||||
const availableDays = DAYS.filter((d) => !usedDays.has(d.day));
|
const availableDays = DAYS.filter((d) => !usedDays.has(d.day));
|
||||||
@@ -281,15 +331,157 @@ function CalendarGrid({
|
|||||||
hours.push(h);
|
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<HTMLDivElement>) {
|
function handleCellClick(dayIndex: number, e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
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 rect = e.currentTarget.getBoundingClientRect();
|
||||||
const y = e.clientY - rect.top;
|
const y = e.clientY - rect.top;
|
||||||
const minutes = yToMinutes(y);
|
const rawMin = yToMinutes(y);
|
||||||
// Snap to 15-min intervals
|
snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES;
|
||||||
const snapped = Math.round(minutes / 15) * 15;
|
snapped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60));
|
||||||
|
}
|
||||||
const startTime = formatMinutes(snapped);
|
const startTime = formatMinutes(snapped);
|
||||||
const endTime = formatMinutes(snapped + 60);
|
const endTime = formatMinutes(snapped + 60);
|
||||||
|
|
||||||
|
setHover(null);
|
||||||
setNewClass({
|
setNewClass({
|
||||||
dayIndex,
|
dayIndex,
|
||||||
cls: {
|
cls: {
|
||||||
@@ -300,8 +492,7 @@ function CalendarGrid({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDay(dayIndex: number, updatedDay: ScheduleDay) {
|
function commitDayUpdate(dayIndex: number, updatedDay: ScheduleDay) {
|
||||||
// Find the actual index in location.days (since we display sorted)
|
|
||||||
const actualDay = sortedDays[dayIndex];
|
const actualDay = sortedDays[dayIndex];
|
||||||
const actualIndex = location.days.findIndex((d) => d.day === actualDay.day);
|
const actualIndex = location.days.findIndex((d) => d.day === actualDay.day);
|
||||||
if (actualIndex === -1) return;
|
if (actualIndex === -1) return;
|
||||||
@@ -311,6 +502,10 @@ function CalendarGrid({
|
|||||||
onChange({ ...location, days });
|
onChange({ ...location, days });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateDay(dayIndex: number, updatedDay: ScheduleDay) {
|
||||||
|
commitDayUpdate(dayIndex, updatedDay);
|
||||||
|
}
|
||||||
|
|
||||||
function deleteDay(dayIndex: number) {
|
function deleteDay(dayIndex: number) {
|
||||||
const actualDay = sortedDays[dayIndex];
|
const actualDay = sortedDays[dayIndex];
|
||||||
const days = location.days.filter((d) => d.day !== actualDay.day);
|
const days = location.days.filter((d) => d.day !== actualDay.day);
|
||||||
@@ -335,6 +530,18 @@ function CalendarGrid({
|
|||||||
}
|
}
|
||||||
: null;
|
: 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Location name/address */}
|
{/* Location name/address */}
|
||||||
@@ -344,17 +551,19 @@ function CalendarGrid({
|
|||||||
value={location.name}
|
value={location.name}
|
||||||
onChange={(v) => onChange({ ...location, name: v })}
|
onChange={(v) => onChange({ ...location, name: v })}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<SelectField
|
||||||
label="Адрес"
|
label="Адрес"
|
||||||
value={location.address}
|
value={location.address}
|
||||||
onChange={(v) => onChange({ ...location, address: v })}
|
onChange={(v) => onChange({ ...location, address: v })}
|
||||||
|
options={addresses.map((a) => ({ value: a, label: a }))}
|
||||||
|
placeholder="Выберите адрес"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{CLASS_TYPES.map((type) => {
|
{classTypes.map((type) => {
|
||||||
const colors = TYPE_COLORS[type] || "";
|
const colors = getTypeColor(type, classTypes);
|
||||||
const bgClass = colors.split(" ")[0] || "bg-neutral-600/80";
|
const bgClass = colors.split(" ")[0] || "bg-neutral-600/80";
|
||||||
return (
|
return (
|
||||||
<div key={type} className="flex items-center gap-1.5 text-xs text-neutral-300">
|
<div key={type} className="flex items-center gap-1.5 text-xs text-neutral-300">
|
||||||
@@ -366,12 +575,8 @@ function CalendarGrid({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar */}
|
{/* Calendar */}
|
||||||
{sortedDays.length === 0 ? (
|
{sortedDays.length > 0 && (
|
||||||
<div className="rounded-lg border border-dashed border-white/20 p-8 text-center text-neutral-500">
|
<div className="overflow-x-auto rounded-lg border border-white/10" ref={gridRef}>
|
||||||
Добавьте дни недели чтобы увидеть расписание
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto rounded-lg border border-white/10">
|
|
||||||
<div className="min-w-[600px]">
|
<div className="min-w-[600px]">
|
||||||
{/* Day headers */}
|
{/* Day headers */}
|
||||||
<div className="flex border-b border-white/10 bg-neutral-800/50">
|
<div className="flex border-b border-white/10 bg-neutral-800/50">
|
||||||
@@ -384,14 +589,6 @@ function CalendarGrid({
|
|||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<span className="text-sm font-medium text-white">{day.dayShort}</span>
|
<span className="text-sm font-medium text-white">{day.dayShort}</span>
|
||||||
<span className="text-xs text-neutral-500">({day.classes.length})</span>
|
<span className="text-xs text-neutral-500">({day.classes.length})</span>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => deleteDay(di)}
|
|
||||||
className="ml-1 rounded p-0.5 text-neutral-600 hover:text-red-400 transition-colors"
|
|
||||||
title="Удалить день"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -413,14 +610,36 @@ function CalendarGrid({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day columns */}
|
{/* Day columns */}
|
||||||
{sortedDays.map((day, di) => (
|
{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;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.day}
|
key={day.day}
|
||||||
className="flex-1 border-l border-white/10 relative cursor-crosshair"
|
ref={(el) => { 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` }}
|
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) => {
|
onClick={(e) => {
|
||||||
// Only add if clicking on empty space (not on a class block)
|
if ((e.target as HTMLElement).closest("[data-class-block]")) return;
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
|
||||||
handleCellClick(di, e);
|
handleCellClick(di, e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -441,6 +660,19 @@ function CalendarGrid({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Hover highlight — 1h preview */}
|
||||||
|
{showHover && (
|
||||||
|
<div
|
||||||
|
style={{ top: `${hoverTop}px`, height: `${hoverHeight}px` }}
|
||||||
|
className="absolute left-1 right-1 rounded-md border border-dashed border-gold/40 bg-gold/10 px-2 py-1 text-xs text-gold/70 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatMinutes(hover.startMin)}–{formatMinutes(hoverEndMin)}
|
||||||
|
</div>
|
||||||
|
<div className="text-gold/50 text-[10px]">Нажмите чтобы добавить</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Class blocks */}
|
{/* Class blocks */}
|
||||||
{day.classes.map((cls, ci) => (
|
{day.classes.map((cls, ci) => (
|
||||||
<ClassBlock
|
<ClassBlock
|
||||||
@@ -448,32 +680,40 @@ function CalendarGrid({
|
|||||||
cls={cls}
|
cls={cls}
|
||||||
index={ci}
|
index={ci}
|
||||||
isOverlapping={getOverlaps(day.classes, ci)}
|
isOverlapping={getOverlaps(day.classes, ci)}
|
||||||
onClick={() => setEditingClass({ dayIndex: di, classIndex: ci })}
|
isDragging={
|
||||||
|
drag !== null &&
|
||||||
|
drag.sourceDayIndex === di &&
|
||||||
|
drag.classIndex === ci &&
|
||||||
|
drag.moved
|
||||||
|
}
|
||||||
|
classTypes={classTypes}
|
||||||
|
onClick={() => {
|
||||||
|
if (justDraggedRef.current) return;
|
||||||
|
setEditingClass({ dayIndex: di, classIndex: ci });
|
||||||
|
}}
|
||||||
|
onDragStart={(e) => startDrag(di, ci, e)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Drag preview ghost */}
|
||||||
|
{dragPreview && dragPreview.dayIndex === di && (
|
||||||
|
<div
|
||||||
|
style={{ top: `${dragPreview.top}px`, height: `${dragPreview.height}px` }}
|
||||||
|
className={`absolute left-1 right-1 rounded-md border-l-3 border-dashed px-2 py-0.5 text-xs text-white/80 pointer-events-none ${dragPreview.colors} opacity-60`}
|
||||||
|
>
|
||||||
|
<div className="font-semibold truncate leading-tight">
|
||||||
|
{dragPreview.newStart}–{dragPreview.newEnd}
|
||||||
</div>
|
</div>
|
||||||
))}
|
{dragPreview.height > 30 && (
|
||||||
</div>
|
<div className="truncate text-white/60 leading-tight">{dragPreview.type}</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{/* Add day buttons */}
|
);
|
||||||
{availableDays.length > 0 && (
|
})}
|
||||||
<div className="flex flex-wrap gap-2">
|
</div>
|
||||||
<span className="flex items-center text-sm text-neutral-500">
|
</div>
|
||||||
<Plus size={14} className="mr-1" /> Добавить день:
|
|
||||||
</span>
|
|
||||||
{availableDays.map((d) => (
|
|
||||||
<button
|
|
||||||
key={d.day}
|
|
||||||
type="button"
|
|
||||||
onClick={() => addDay(d.day, d.dayShort)}
|
|
||||||
className="rounded-lg border border-dashed border-white/20 px-3 py-1.5 text-xs text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
|
||||||
>
|
|
||||||
{d.dayShort}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -482,6 +722,7 @@ function CalendarGrid({
|
|||||||
<ClassModal
|
<ClassModal
|
||||||
cls={editingData.cls}
|
cls={editingData.cls}
|
||||||
trainers={trainers}
|
trainers={trainers}
|
||||||
|
classTypes={classTypes}
|
||||||
onSave={(updated) => {
|
onSave={(updated) => {
|
||||||
const day = sortedDays[editingClass.dayIndex];
|
const day = sortedDays[editingClass.dayIndex];
|
||||||
const classes = [...day.classes];
|
const classes = [...day.classes];
|
||||||
@@ -502,6 +743,7 @@ function CalendarGrid({
|
|||||||
<ClassModal
|
<ClassModal
|
||||||
cls={newClass.cls}
|
cls={newClass.cls}
|
||||||
trainers={trainers}
|
trainers={trainers}
|
||||||
|
classTypes={classTypes}
|
||||||
onSave={(created) => {
|
onSave={(created) => {
|
||||||
const day = sortedDays[newClass.dayIndex];
|
const day = sortedDays[newClass.dayIndex];
|
||||||
const classes = [...day.classes, created];
|
const classes = [...day.classes, created];
|
||||||
@@ -518,6 +760,8 @@ function CalendarGrid({
|
|||||||
export default function ScheduleEditorPage() {
|
export default function ScheduleEditorPage() {
|
||||||
const [activeLocation, setActiveLocation] = useState(0);
|
const [activeLocation, setActiveLocation] = useState(0);
|
||||||
const [trainers, setTrainers] = useState<string[]>([]);
|
const [trainers, setTrainers] = useState<string[]>([]);
|
||||||
|
const [addresses, setAddresses] = useState<string[]>([]);
|
||||||
|
const [classTypes, setClassTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/team")
|
fetch("/api/admin/team")
|
||||||
@@ -526,6 +770,20 @@ export default function ScheduleEditorPage() {
|
|||||||
setTrainers(members.map((m) => m.name));
|
setTrainers(members.map((m) => m.name));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.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 (
|
return (
|
||||||
@@ -588,15 +846,14 @@ export default function ScheduleEditorPage() {
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
update({
|
const newLocations = [
|
||||||
...data,
|
|
||||||
locations: [
|
|
||||||
...data.locations,
|
...data.locations,
|
||||||
{ name: "Новая локация", address: "", days: [] },
|
{ name: "Новая локация", address: "", days: DAYS.map((d) => ({ day: d.day, dayShort: d.dayShort, classes: [] })) },
|
||||||
],
|
];
|
||||||
})
|
update({ ...data, locations: newLocations });
|
||||||
}
|
setActiveLocation(newLocations.length - 1);
|
||||||
|
}}
|
||||||
className="rounded-lg border border-dashed border-white/20 px-4 py-2 text-sm text-neutral-500 hover:text-white transition-colors"
|
className="rounded-lg border border-dashed border-white/20 px-4 py-2 text-sm text-neutral-500 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={14} className="inline" /> Локация
|
<Plus size={14} className="inline" /> Локация
|
||||||
@@ -607,6 +864,8 @@ export default function ScheduleEditorPage() {
|
|||||||
<CalendarGrid
|
<CalendarGrid
|
||||||
location={location}
|
location={location}
|
||||||
trainers={trainers}
|
trainers={trainers}
|
||||||
|
addresses={addresses}
|
||||||
|
classTypes={classTypes}
|
||||||
onChange={updateLocation}
|
onChange={updateLocation}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
ChevronUp,
|
GripVertical,
|
||||||
ChevronDown,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
Save,
|
|
||||||
Check,
|
Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TeamMember } from "@/types/content";
|
import type { TeamMember } from "@/types/content";
|
||||||
@@ -23,6 +22,13 @@ export default function TeamEditorPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = 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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/team")
|
fetch("/api/admin/team")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -43,13 +49,68 @@ export default function TeamEditorPage() {
|
|||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function moveItem(index: number, direction: -1 | 1) {
|
const handleMouseDown = useCallback(
|
||||||
const newIndex = index + direction;
|
(e: React.MouseEvent, index: number) => {
|
||||||
if (newIndex < 0 || newIndex >= members.length) return;
|
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 = 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 updated = [...members];
|
||||||
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
|
const [moved] = updated.splice(prevDrag, 1);
|
||||||
|
updated.splice(targetIndex, 0, moved);
|
||||||
saveOrder(updated);
|
saveOrder(updated);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
}, [dragIndex, members, saveOrder]);
|
||||||
|
|
||||||
async function deleteMember(id: number) {
|
async function deleteMember(id: number) {
|
||||||
if (!confirm("Удалить этого участника?")) return;
|
if (!confirm("Удалить этого участника?")) return;
|
||||||
@@ -66,6 +127,117 @@ 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; }}
|
||||||
|
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleMouseDown(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>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link href={`/admin/team/${member.id}`} className="rounded p-2 text-neutral-400 hover:text-white transition-colors">
|
||||||
|
<Pencil size={16} />
|
||||||
|
</Link>
|
||||||
|
<button onClick={() => deleteMember(member.id)} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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; }}
|
||||||
|
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleMouseDown(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>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link href={`/admin/team/${member.id}`} className="rounded p-2 text-neutral-400 hover:text-white transition-colors">
|
||||||
|
<Pencil size={16} />
|
||||||
|
</Link>
|
||||||
|
<button onClick={() => deleteMember(member.id)} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
@@ -91,61 +263,43 @@ export default function TeamEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 space-y-2">
|
<div className="mt-6">
|
||||||
{members.map((member, i) => (
|
{renderList()}
|
||||||
<div
|
|
||||||
key={member.id}
|
|
||||||
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => moveItem(i, -1)}
|
|
||||||
disabled={i === 0}
|
|
||||||
className="text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronUp size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => moveItem(i, 1)}
|
|
||||||
disabled={i === members.length - 1}
|
|
||||||
className="text-neutral-500 hover:text-white disabled:opacity-30 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronDown size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</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">
|
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||||
<Image
|
<Image
|
||||||
src={member.image}
|
src={draggedMember.image}
|
||||||
alt={member.name}
|
alt={draggedMember.name}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="48px"
|
sizes="48px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium text-white truncate">{member.name}</p>
|
<p className="font-medium text-white truncate">{draggedMember.name}</p>
|
||||||
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
<p className="text-sm text-neutral-400 truncate">{draggedMember.role}</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Link
|
|
||||||
href={`/admin/team/${member.id}`}
|
|
||||||
className="rounded p-2 text-neutral-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Pencil size={16} />
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => deleteMember(member.id)}
|
|
||||||
className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>,
|
||||||
</div>
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user