feat: admin panel with SQLite, auth, and calendar-style schedule editor
Complete admin panel for content management: - SQLite database with better-sqlite3, seed script from content.ts - Simple password auth with HMAC-signed cookies (Edge + Node compatible) - 9 section editors: meta, hero, about, team, classes, schedule, pricing, FAQ, contact - Team CRUD with image upload and drag reorder - Schedule editor with Google Calendar-style visual timeline (colored blocks, overlap detection, click-to-add) - All public components refactored to accept data props from DB (with fallback to static content) - Middleware protecting /admin/* and /api/admin/* routes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
618
src/app/admin/schedule/page.tsx
Normal file
618
src/app/admin/schedule/page.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
|
||||
import { Plus, X, Trash2, GripVertical } from "lucide-react";
|
||||
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
||||
|
||||
interface ScheduleData {
|
||||
title: string;
|
||||
locations: ScheduleLocation[];
|
||||
}
|
||||
|
||||
const DAYS = [
|
||||
{ day: "Понедельник", dayShort: "ПН" },
|
||||
{ day: "Вторник", dayShort: "ВТ" },
|
||||
{ day: "Среда", dayShort: "СР" },
|
||||
{ day: "Четверг", dayShort: "ЧТ" },
|
||||
{ day: "Пятница", dayShort: "ПТ" },
|
||||
{ day: "Суббота", dayShort: "СБ" },
|
||||
{ day: "Воскресенье", dayShort: "ВС" },
|
||||
];
|
||||
|
||||
const DAY_ORDER: Record<string, number> = Object.fromEntries(
|
||||
DAYS.map((d, i) => [d.day, i])
|
||||
);
|
||||
|
||||
const LEVELS = [
|
||||
{ value: "", label: "Без уровня" },
|
||||
{ value: "Начинающий", label: "Начинающий" },
|
||||
{ value: "Продвинутый", label: "Продвинутый" },
|
||||
];
|
||||
|
||||
const CLASS_TYPES = [
|
||||
"Exotic Pole Dance",
|
||||
"Pole Dance",
|
||||
"Body Plastic",
|
||||
"Stretching",
|
||||
"Pole Exotic",
|
||||
"Twerk",
|
||||
];
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
"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",
|
||||
};
|
||||
|
||||
// Calendar config
|
||||
const HOUR_START = 9;
|
||||
const HOUR_END = 23;
|
||||
const HOUR_HEIGHT = 60; // px per hour
|
||||
const TOTAL_HOURS = HOUR_END - HOUR_START;
|
||||
|
||||
function parseTime(timeStr: string): { h: number; m: number } | null {
|
||||
const [h, m] = (timeStr || "").split(":").map(Number);
|
||||
if (isNaN(h) || isNaN(m)) return null;
|
||||
return { h, m };
|
||||
}
|
||||
|
||||
function timeToMinutes(timeStr: string): number {
|
||||
const t = parseTime(timeStr);
|
||||
if (!t) return 0;
|
||||
return t.h * 60 + t.m;
|
||||
}
|
||||
|
||||
function minutesToY(minutes: number): number {
|
||||
return ((minutes - HOUR_START * 60) / 60) * HOUR_HEIGHT;
|
||||
}
|
||||
|
||||
function yToMinutes(y: number): number {
|
||||
return Math.round((y / HOUR_HEIGHT) * 60 + HOUR_START * 60);
|
||||
}
|
||||
|
||||
function formatMinutes(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function sortDaysByWeekday(days: ScheduleDay[]): ScheduleDay[] {
|
||||
return [...days].sort((a, b) => (DAY_ORDER[a.day] ?? 99) - (DAY_ORDER[b.day] ?? 99));
|
||||
}
|
||||
|
||||
/** Check if two time ranges overlap */
|
||||
function hasOverlap(a: ScheduleClass, b: ScheduleClass): boolean {
|
||||
const [aStart, aEnd] = a.time.split("–").map((s) => timeToMinutes(s.trim()));
|
||||
const [bStart, bEnd] = b.time.split("–").map((s) => timeToMinutes(s.trim()));
|
||||
if (!aStart || !aEnd || !bStart || !bEnd) return false;
|
||||
return aStart < bEnd && bStart < aEnd;
|
||||
}
|
||||
|
||||
/** Get all overlapping indices for a given class in the list */
|
||||
function getOverlaps(classes: ScheduleClass[], index: number): boolean {
|
||||
const cls = classes[index];
|
||||
for (let i = 0; i < classes.length; i++) {
|
||||
if (i === index) continue;
|
||||
if (hasOverlap(cls, classes[i])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- Class Block on Calendar ----------
|
||||
function ClassBlock({
|
||||
cls,
|
||||
index,
|
||||
isOverlapping,
|
||||
onClick,
|
||||
}: {
|
||||
cls: ScheduleClass;
|
||||
index: number;
|
||||
isOverlapping: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const parts = cls.time.split("–");
|
||||
const startMin = timeToMinutes(parts[0]?.trim() || "");
|
||||
const endMin = timeToMinutes(parts[1]?.trim() || "");
|
||||
|
||||
if (!startMin || !endMin || endMin <= startMin) return null;
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{ top: `${top}px`, height: `${height}px` }}
|
||||
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} ${
|
||||
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : ""
|
||||
}`}
|
||||
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` (${cls.level})` : ""}`}
|
||||
>
|
||||
<div className="font-semibold truncate leading-tight">
|
||||
{parts[0]?.trim()}–{parts[1]?.trim()}
|
||||
</div>
|
||||
{height > 30 && (
|
||||
<div className="truncate text-white/80 leading-tight">{cls.type}</div>
|
||||
)}
|
||||
{height > 48 && (
|
||||
<div className="truncate text-white/70 leading-tight">{cls.trainer}</div>
|
||||
)}
|
||||
{isOverlapping && height > 30 && (
|
||||
<div className="text-red-200 font-medium leading-tight">⚠ Пересечение</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Edit Modal ----------
|
||||
function ClassModal({
|
||||
cls,
|
||||
trainers,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
cls: ScheduleClass;
|
||||
trainers: string[];
|
||||
onSave: (cls: ScheduleClass) => void;
|
||||
onDelete?: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<ScheduleClass>(cls);
|
||||
const trainerOptions = trainers.map((t) => ({ value: t, label: t }));
|
||||
const typeOptions = CLASS_TYPES.map((t) => ({ value: t, label: t }));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-white/10 bg-neutral-900 p-6 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{onDelete ? "Редактировать занятие" : "Новое занятие"}
|
||||
</h3>
|
||||
<button type="button" onClick={onClose} className="text-neutral-400 hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TimeRangeField
|
||||
label="Время"
|
||||
value={draft.time}
|
||||
onChange={(v) => setDraft({ ...draft, time: v })}
|
||||
/>
|
||||
<SelectField
|
||||
label="Тренер"
|
||||
value={draft.trainer}
|
||||
onChange={(v) => setDraft({ ...draft, trainer: v })}
|
||||
options={trainerOptions}
|
||||
placeholder="Выберите тренера"
|
||||
/>
|
||||
<SelectField
|
||||
label="Тип"
|
||||
value={draft.type}
|
||||
onChange={(v) => setDraft({ ...draft, type: v })}
|
||||
options={typeOptions}
|
||||
placeholder="Выберите тип"
|
||||
/>
|
||||
<SelectField
|
||||
label="Уровень"
|
||||
value={draft.level || ""}
|
||||
onChange={(v) => setDraft({ ...draft, level: v || undefined })}
|
||||
options={LEVELS}
|
||||
/>
|
||||
<div className="flex gap-6">
|
||||
<ToggleField
|
||||
label="Есть места"
|
||||
checked={draft.hasSlots ?? false}
|
||||
onChange={(v) => setDraft({ ...draft, hasSlots: v })}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Набор открыт"
|
||||
checked={draft.recruiting ?? false}
|
||||
onChange={(v) => setDraft({ ...draft, recruiting: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(draft);
|
||||
onClose();
|
||||
}}
|
||||
className="flex-1 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
onClose();
|
||||
}}
|
||||
className="rounded-lg border border-red-500/30 px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Calendar Grid for one location ----------
|
||||
function CalendarGrid({
|
||||
location,
|
||||
trainers,
|
||||
onChange,
|
||||
}: {
|
||||
location: ScheduleLocation;
|
||||
trainers: string[];
|
||||
onChange: (loc: ScheduleLocation) => void;
|
||||
}) {
|
||||
const [editingClass, setEditingClass] = useState<{
|
||||
dayIndex: number;
|
||||
classIndex: number;
|
||||
} | null>(null);
|
||||
const [newClass, setNewClass] = useState<{
|
||||
dayIndex: number;
|
||||
cls: ScheduleClass;
|
||||
} | null>(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));
|
||||
|
||||
const hours: number[] = [];
|
||||
for (let h = HOUR_START; h <= HOUR_END; h++) {
|
||||
hours.push(h);
|
||||
}
|
||||
|
||||
function handleCellClick(dayIndex: number, e: React.MouseEvent<HTMLDivElement>) {
|
||||
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;
|
||||
const startTime = formatMinutes(snapped);
|
||||
const endTime = formatMinutes(snapped + 60);
|
||||
|
||||
setNewClass({
|
||||
dayIndex,
|
||||
cls: {
|
||||
time: `${startTime}–${endTime}`,
|
||||
trainer: "",
|
||||
type: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateDay(dayIndex: number, updatedDay: ScheduleDay) {
|
||||
// Find the actual index in location.days (since we display sorted)
|
||||
const actualDay = sortedDays[dayIndex];
|
||||
const actualIndex = location.days.findIndex((d) => d.day === actualDay.day);
|
||||
if (actualIndex === -1) return;
|
||||
|
||||
const days = [...location.days];
|
||||
days[actualIndex] = updatedDay;
|
||||
onChange({ ...location, days });
|
||||
}
|
||||
|
||||
function deleteDay(dayIndex: number) {
|
||||
const actualDay = sortedDays[dayIndex];
|
||||
const days = location.days.filter((d) => d.day !== actualDay.day);
|
||||
onChange({ ...location, days });
|
||||
}
|
||||
|
||||
function addDay(dayName: string, dayShort: string) {
|
||||
onChange({
|
||||
...location,
|
||||
days: sortDaysByWeekday([
|
||||
...location.days,
|
||||
{ day: dayName, dayShort, classes: [] },
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
// Get the class being edited
|
||||
const editingData = editingClass
|
||||
? {
|
||||
day: sortedDays[editingClass.dayIndex],
|
||||
cls: sortedDays[editingClass.dayIndex]?.classes[editingClass.classIndex],
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Location name/address */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<InputField
|
||||
label="Название локации"
|
||||
value={location.name}
|
||||
onChange={(v) => onChange({ ...location, name: v })}
|
||||
/>
|
||||
<InputField
|
||||
label="Адрес"
|
||||
value={location.address}
|
||||
onChange={(v) => onChange({ ...location, address: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CLASS_TYPES.map((type) => {
|
||||
const colors = TYPE_COLORS[type] || "";
|
||||
const bgClass = colors.split(" ")[0] || "bg-neutral-600/80";
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-1.5 text-xs text-neutral-300">
|
||||
<div className={`h-3 w-3 rounded-sm ${bgClass}`} />
|
||||
{type}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
{sortedDays.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-white/20 p-8 text-center text-neutral-500">
|
||||
Добавьте дни недели чтобы увидеть расписание
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-white/10">
|
||||
<div className="min-w-[600px]">
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-white/10 bg-neutral-800/50">
|
||||
<div className="w-14 shrink-0" />
|
||||
{sortedDays.map((day, di) => (
|
||||
<div
|
||||
key={day.day}
|
||||
className="flex-1 border-l border-white/10 px-2 py-2 text-center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-sm font-medium text-white">{day.dayShort}</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>
|
||||
|
||||
{/* Time grid */}
|
||||
<div className="flex">
|
||||
{/* Time labels */}
|
||||
<div className="w-14 shrink-0 relative" style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}>
|
||||
{hours.slice(0, -1).map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="absolute left-0 right-0 text-right pr-2 text-xs text-neutral-500"
|
||||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT - 6}px` }}
|
||||
>
|
||||
{String(h).padStart(2, "0")}:00
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day columns */}
|
||||
{sortedDays.map((day, di) => (
|
||||
<div
|
||||
key={day.day}
|
||||
className="flex-1 border-l border-white/10 relative cursor-crosshair"
|
||||
style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}
|
||||
onClick={(e) => {
|
||||
// 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) => (
|
||||
<div
|
||||
key={h}
|
||||
className="absolute left-0 right-0 border-t border-white/5"
|
||||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT}px` }}
|
||||
/>
|
||||
))}
|
||||
{/* Half-hour lines */}
|
||||
{hours.slice(0, -1).map((h) => (
|
||||
<div
|
||||
key={`${h}-30`}
|
||||
className="absolute left-0 right-0 border-t border-white/[0.02]"
|
||||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2}px` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Class blocks */}
|
||||
{day.classes.map((cls, ci) => (
|
||||
<ClassBlock
|
||||
key={ci}
|
||||
cls={cls}
|
||||
index={ci}
|
||||
isOverlapping={getOverlaps(day.classes, ci)}
|
||||
onClick={() => setEditingClass({ dayIndex: di, classIndex: ci })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add day buttons */}
|
||||
{availableDays.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="flex items-center text-sm text-neutral-500">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{editingData?.cls && editingClass && (
|
||||
<ClassModal
|
||||
cls={editingData.cls}
|
||||
trainers={trainers}
|
||||
onSave={(updated) => {
|
||||
const day = sortedDays[editingClass.dayIndex];
|
||||
const classes = [...day.classes];
|
||||
classes[editingClass.classIndex] = updated;
|
||||
updateDay(editingClass.dayIndex, { ...day, classes });
|
||||
}}
|
||||
onDelete={() => {
|
||||
const day = sortedDays[editingClass.dayIndex];
|
||||
const classes = day.classes.filter((_, i) => i !== editingClass.classIndex);
|
||||
updateDay(editingClass.dayIndex, { ...day, classes });
|
||||
}}
|
||||
onClose={() => setEditingClass(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New class modal */}
|
||||
{newClass && (
|
||||
<ClassModal
|
||||
cls={newClass.cls}
|
||||
trainers={trainers}
|
||||
onSave={(created) => {
|
||||
const day = sortedDays[newClass.dayIndex];
|
||||
const classes = [...day.classes, created];
|
||||
updateDay(newClass.dayIndex, { ...day, classes });
|
||||
}}
|
||||
onClose={() => setNewClass(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Main Page ----------
|
||||
export default function ScheduleEditorPage() {
|
||||
const [activeLocation, setActiveLocation] = useState(0);
|
||||
const [trainers, setTrainers] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/team")
|
||||
.then((r) => r.json())
|
||||
.then((members: { name: string }[]) => {
|
||||
setTrainers(members.map((m) => m.name));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
|
||||
{(data, update) => {
|
||||
const location = data.locations[activeLocation];
|
||||
|
||||
function updateLocation(updated: ScheduleLocation) {
|
||||
const locations = [...data.locations];
|
||||
locations[activeLocation] = updated;
|
||||
update({ ...data, locations });
|
||||
}
|
||||
|
||||
function deleteLocation(index: number) {
|
||||
if (data.locations.length <= 1) return;
|
||||
const locations = data.locations.filter((_, i) => i !== index);
|
||||
update({ ...data, locations });
|
||||
if (activeLocation >= locations.length) {
|
||||
setActiveLocation(locations.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputField
|
||||
label="Заголовок секции"
|
||||
value={data.title}
|
||||
onChange={(v) => update({ ...data, title: v })}
|
||||
/>
|
||||
|
||||
{/* Location tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.locations.map((loc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
i === activeLocation
|
||||
? "bg-gold/10 text-gold border border-gold/30"
|
||||
: "border border-white/10 text-neutral-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveLocation(i)}
|
||||
className="text-left"
|
||||
>
|
||||
{loc.name}
|
||||
</button>
|
||||
{data.locations.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteLocation(i)}
|
||||
className="ml-1 rounded p-0.5 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
title="Удалить локацию"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
update({
|
||||
...data,
|
||||
locations: [
|
||||
...data.locations,
|
||||
{ name: "Новая локация", address: "", days: [] },
|
||||
],
|
||||
})
|
||||
}
|
||||
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" /> Локация
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{location && (
|
||||
<CalendarGrid
|
||||
location={location}
|
||||
trainers={trainers}
|
||||
onChange={updateLocation}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SectionEditor>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user