fix: schedule status labels, Open Day halls, unsaved data guards
Schedule: - Status badges use admin config labels (not hardcoded text) everywhere - DayCard: level badge moved next to status badge - Single location: hide "Все студии" tab, auto-select the only hall - Group view: hide per-card address when all share same location - Filter tooltip z-index fixed (above dropdowns) - Trainer bio: status labels from config, not raw keys Open Day: - Hall name + address shown in schedule grid headers - Only one class card editable at a time (edit/create mutually exclusive) - Bigger action buttons (cancel/delete) on class cards - Create as empty draft (not pre-filled with published status) - Fix discount threshold input (allow delete to empty) - Skip auto-save during partial date input Admin: - SectionEditor: unsaved data guard (force-save before navigation) - Open Day + Team: same navigation guards - Contact: removed working hours field - TimeRangeField: allow end time hour changes - Schedule cards: visible borders, 90min default duration - Trainer bio: RichTextarea for description - Open Day: RichTextarea for description
This commit is contained in:
@@ -585,7 +585,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
|
||||
}
|
||||
|
||||
function handleEndChange(newEnd: string) {
|
||||
if (start && newEnd && newEnd <= start) return;
|
||||
// Always allow the change — validation handles the error display
|
||||
update(start, newEnd);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export function SectionEditor<T>({
|
||||
const [error, setError] = useState("");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialLoadRef = useRef(true);
|
||||
const pendingSaveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch(`/api/admin/sections/${sectionKey}`)
|
||||
@@ -68,6 +69,7 @@ export function SectionEditor<T>({
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSaveRef.current = true;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (validate && !validate(data)) return;
|
||||
@@ -79,6 +81,41 @@ export function SectionEditor<T>({
|
||||
};
|
||||
}, [data, save]);
|
||||
|
||||
// Clear pending flag after save completes
|
||||
useEffect(() => {
|
||||
if (status === "saved") pendingSaveRef.current = false;
|
||||
}, [status]);
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (pendingSaveRef.current) e.preventDefault();
|
||||
}
|
||||
function onLinkClick(e: MouseEvent) {
|
||||
if (!pendingSaveRef.current) return;
|
||||
const link = (e.target as HTMLElement).closest("a");
|
||||
if (!link || link.target === "_blank") return;
|
||||
const href = link.getAttribute("href");
|
||||
if (!href || href.startsWith("#")) return;
|
||||
// Force save immediately before navigating
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (data && (!validate || validate(data))) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
save(data).then(() => {
|
||||
pendingSaveRef.current = false;
|
||||
window.location.href = href;
|
||||
});
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
document.addEventListener("click", onLinkClick, true);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
document.removeEventListener("click", onLinkClick, true);
|
||||
};
|
||||
}, [data, save, validate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
|
||||
@@ -230,12 +230,6 @@ export default function ContactEditorPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label="Часы работы"
|
||||
value={data.workingHours}
|
||||
onChange={(v) => update({ ...data, workingHours: v })}
|
||||
/>
|
||||
|
||||
<CollapsibleSection title="Адреса">
|
||||
<AddressList
|
||||
items={data.addresses ?? []}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles,
|
||||
} from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { ParticipantLimits, SelectField } from "../_components/FormField";
|
||||
import { ParticipantLimits, SelectField, RichTextarea } from "../_components/FormField";
|
||||
import { PriceField } from "../_components/PriceField";
|
||||
|
||||
// --- Types ---
|
||||
@@ -104,16 +104,13 @@ function EventSettings({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Описание</label>
|
||||
<textarea
|
||||
value={event.description || ""}
|
||||
onChange={(e) => onChange({ description: e.target.value || undefined })}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none"
|
||||
placeholder="Описание мероприятия..."
|
||||
/>
|
||||
</div>
|
||||
<RichTextarea
|
||||
label="Описание"
|
||||
value={event.description || ""}
|
||||
onChange={(v) => onChange({ description: v || undefined })}
|
||||
rows={3}
|
||||
placeholder="Описание мероприятия..."
|
||||
/>
|
||||
|
||||
<div className="sm:max-w-xs">
|
||||
<PriceField
|
||||
@@ -153,8 +150,8 @@ function EventSettings({
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.discountThreshold}
|
||||
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 1 })}
|
||||
value={event.discountThreshold || ""}
|
||||
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 0 })}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -244,10 +241,10 @@ function NewClassForm({
|
||||
<div ref={formRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg">
|
||||
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
|
||||
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button onClick={onCancel} className="text-[10px] text-neutral-500 hover:text-white px-1">Отмена</button>
|
||||
<div className="flex gap-2 justify-end mt-2">
|
||||
<button onClick={onCancel} className="rounded-md border border-white/10 px-3 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">Отмена</button>
|
||||
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
|
||||
className="text-[10px] text-gold hover:text-gold-light px-1 font-medium disabled:opacity-30 disabled:cursor-not-allowed">OK</button>
|
||||
className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -260,6 +257,8 @@ function ClassCell({
|
||||
minBookings,
|
||||
trainers,
|
||||
styles,
|
||||
editing,
|
||||
onEdit,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onCancel,
|
||||
@@ -268,11 +267,12 @@ function ClassCell({
|
||||
minBookings: number;
|
||||
trainers: string[];
|
||||
styles: string[];
|
||||
editing: boolean;
|
||||
onEdit: (id: number | null) => void;
|
||||
onUpdate: (id: number, data: Partial<OpenDayClass>) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onCancel: (id: number) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [trainer, setTrainer] = useState(cls.trainer);
|
||||
const [style, setStyle] = useState(cls.style);
|
||||
|
||||
@@ -281,7 +281,7 @@ function ClassCell({
|
||||
function save() {
|
||||
if (trainer.trim() && style.trim()) {
|
||||
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
|
||||
setEditing(false);
|
||||
onEdit(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,12 +290,12 @@ function ClassCell({
|
||||
<div className="p-2 space-y-1.5 rounded-lg">
|
||||
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
|
||||
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
|
||||
<div className="flex gap-2 justify-end mt-2">
|
||||
<button onClick={() => onEdit(null)} className="rounded-md border border-white/10 px-3 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
||||
Отмена
|
||||
</button>
|
||||
<button onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium">
|
||||
OK
|
||||
<button onClick={save} className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -311,7 +311,7 @@ function ClassCell({
|
||||
? "bg-red-500/5 border border-red-500/20"
|
||||
: "bg-gold/5 border border-gold/15 hover:border-gold/30"
|
||||
}`}
|
||||
onClick={() => setEditing(true)}
|
||||
onClick={() => onEdit(cls.id)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-white truncate">{cls.style}</span>
|
||||
@@ -334,20 +334,20 @@ function ClassCell({
|
||||
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
|
||||
<div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
|
||||
className={`rounded p-0.5 ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400" : "text-neutral-500 hover:text-yellow-400"}`}
|
||||
className={`rounded-md p-1 transition-colors ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400 hover:bg-emerald-400/10" : "text-neutral-500 hover:text-yellow-400 hover:bg-yellow-400/10"}`}
|
||||
title={cls.cancelled ? "Восстановить" : "Отменить"}
|
||||
>
|
||||
{cls.cancelled ? <RotateCcw size={10} /> : <Ban size={10} />}
|
||||
{cls.cancelled ? <RotateCcw size={14} /> : <Ban size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}
|
||||
className="rounded p-0.5 text-neutral-500 hover:text-red-400"
|
||||
className="rounded-md p-1 text-neutral-500 hover:text-red-400 hover:bg-red-400/10 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,6 +374,7 @@ function ScheduleGrid({
|
||||
onClassesChange: () => void;
|
||||
}) {
|
||||
const [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
|
||||
const [editingClassId, setEditingClassId] = useState<number | null>(null);
|
||||
const timeSlots = generateTimeSlots(10, 22);
|
||||
|
||||
// Build lookup: time -> class for selected hall
|
||||
@@ -469,6 +470,8 @@ function ScheduleGrid({
|
||||
minBookings={minBookings}
|
||||
trainers={trainers}
|
||||
styles={styles}
|
||||
editing={editingClassId === cls.id}
|
||||
onEdit={(id) => { setEditingClassId(id); if (id) setCreatingTime(null); }}
|
||||
onUpdate={updateClass}
|
||||
onDelete={deleteClass}
|
||||
onCancel={cancelClass}
|
||||
@@ -483,7 +486,7 @@ function ScheduleGrid({
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCreatingTime(time)}
|
||||
onClick={() => { setCreatingTime(time); setEditingClassId(null); }}
|
||||
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
|
||||
>
|
||||
<Plus size={12} className="mx-auto" />
|
||||
@@ -512,7 +515,8 @@ export default function OpenDayAdminPage() {
|
||||
const [trainers, setTrainers] = useState<string[]>([]);
|
||||
const [styles, setStyles] = useState<string[]>([]);
|
||||
const [halls, setHalls] = useState<string[]>([]);
|
||||
const saveTimerRef = { current: null as ReturnType<typeof setTimeout> | null };
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingSaveRef = useRef(false);
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
@@ -543,8 +547,11 @@ export default function OpenDayAdminPage() {
|
||||
}
|
||||
|
||||
// Auto-save event changes
|
||||
const eventRef = useRef<OpenDayEvent | null>(null);
|
||||
const saveEvent = useCallback(
|
||||
(updated: OpenDayEvent) => {
|
||||
eventRef.current = updated;
|
||||
pendingSaveRef.current = true;
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
setSaving(true);
|
||||
@@ -555,6 +562,7 @@ export default function OpenDayAdminPage() {
|
||||
body: JSON.stringify(updated),
|
||||
});
|
||||
setSaveStatus(res.ok ? "saved" : "error");
|
||||
if (res.ok) pendingSaveRef.current = false;
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
@@ -565,31 +573,66 @@ export default function OpenDayAdminPage() {
|
||||
[]
|
||||
);
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (pendingSaveRef.current) e.preventDefault();
|
||||
}
|
||||
function onLinkClick(e: MouseEvent) {
|
||||
if (!pendingSaveRef.current) return;
|
||||
const link = (e.target as HTMLElement).closest("a");
|
||||
if (!link || link.target === "_blank") return;
|
||||
const href = link.getAttribute("href");
|
||||
if (!href || href.startsWith("#")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Force save then navigate
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
const data = eventRef.current;
|
||||
if (data) {
|
||||
adminFetch("/api/admin/open-day", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}).finally(() => { window.location.href = href; });
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
document.addEventListener("click", onLinkClick, true);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
document.removeEventListener("click", onLinkClick, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleEventChange(patch: Partial<OpenDayEvent>) {
|
||||
if (!event) return;
|
||||
const updated = { ...event, ...patch };
|
||||
setEvent(updated);
|
||||
// Skip auto-save only if date is partially typed (prevents 400 errors)
|
||||
if (updated.date && updated.date.length > 0 && updated.date.length < 10) return;
|
||||
saveEvent(updated);
|
||||
}
|
||||
|
||||
async function createEvent() {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const res = await adminFetch("/api/admin/open-day", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ date: today }),
|
||||
body: JSON.stringify({ date: "" }),
|
||||
});
|
||||
const { id } = await res.json();
|
||||
setEvent({
|
||||
id,
|
||||
date: today,
|
||||
title: "День открытых дверей",
|
||||
pricePerClass: 30,
|
||||
discountPrice: 20,
|
||||
discountThreshold: 3,
|
||||
minBookings: 4,
|
||||
date: "",
|
||||
title: "",
|
||||
pricePerClass: 0,
|
||||
discountPrice: 0,
|
||||
discountThreshold: 0,
|
||||
minBookings: 0,
|
||||
maxParticipants: 0,
|
||||
active: true,
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -221,10 +221,10 @@ function ClassBlock({
|
||||
? { 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} ${
|
||||
className={`absolute left-1 right-1 rounded-md border border-white/20 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" : ""
|
||||
} ${isDragging ? "opacity-30" : "hover:opacity-90"}`}
|
||||
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` (${cls.level})` : ""}`}
|
||||
} ${isDragging ? "opacity-30" : "hover:opacity-90 hover:border-white/40"}`}
|
||||
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` · ${cls.level}` : ""}${cls.status ? ` · ${cls.status}` : ""}`}
|
||||
>
|
||||
<div className="font-semibold truncate leading-tight">
|
||||
{parts[0]?.trim()}–{parts[1]?.trim()}
|
||||
@@ -235,6 +235,11 @@ function ClassBlock({
|
||||
{height > 48 && (
|
||||
<div className="truncate text-white/70 leading-tight">{cls.trainer}</div>
|
||||
)}
|
||||
{height > 64 && (cls.level || cls.status) && (
|
||||
<div className="truncate text-white/50 leading-tight text-[10px]">
|
||||
{[cls.level, cls.status].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
{isOverlapping && (
|
||||
<div className="text-red-200 font-bold leading-tight">⚠ Пересечение</div>
|
||||
)}
|
||||
@@ -876,10 +881,10 @@ function CalendarGrid({
|
||||
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));
|
||||
snapped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 90));
|
||||
}
|
||||
const startTime = formatMinutes(snapped);
|
||||
const endTime = formatMinutes(snapped + 60);
|
||||
const endTime = formatMinutes(snapped + 90);
|
||||
|
||||
setHover(null);
|
||||
setNewClass({
|
||||
@@ -1005,8 +1010,8 @@ function CalendarGrid({
|
||||
{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;
|
||||
const hoverHeight = HOUR_HEIGHT * 1.5; // 1.5 hours
|
||||
const hoverEndMin = showHover ? hover.startMin + 90 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1026,7 +1031,7 @@ function CalendarGrid({
|
||||
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));
|
||||
const clamped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 90));
|
||||
setHover({ dayIndex: di, startMin: clamped });
|
||||
}}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
||||
import { InputField, TextareaField, VictoryListField, AutocompleteMulti } from "../../_components/FormField";
|
||||
import { InputField, TextareaField, RichTextarea, VictoryListField, AutocompleteMulti } from "../../_components/FormField";
|
||||
import { useToast } from "../../_components/Toast";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { RichListItem } from "@/types/content";
|
||||
@@ -397,7 +397,7 @@ function TeamMemberEditor() {
|
||||
rows={2}
|
||||
placeholder="1-2 предложения для карусели"
|
||||
/>
|
||||
<TextareaField
|
||||
<RichTextarea
|
||||
label="Полное описание (для страницы тренера)"
|
||||
value={data.description}
|
||||
onChange={(v) => setData({ ...data, description: v })}
|
||||
|
||||
@@ -32,11 +32,12 @@ export default function TeamEditorPage() {
|
||||
|
||||
// Auto-save section title with debounce (skip initial load)
|
||||
const titleChangeCount = useRef(0);
|
||||
const pendingSaveRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!titleLoadedRef.current) return;
|
||||
titleChangeCount.current++;
|
||||
// Skip the first change (initial load setting the value)
|
||||
if (titleChangeCount.current <= 1) return;
|
||||
pendingSaveRef.current = true;
|
||||
if (titleTimerRef.current) clearTimeout(titleTimerRef.current);
|
||||
titleTimerRef.current = setTimeout(async () => {
|
||||
const res = await adminFetch("/api/admin/sections/team", {
|
||||
@@ -45,11 +46,40 @@ export default function TeamEditorPage() {
|
||||
body: JSON.stringify({ title: sectionTitle }),
|
||||
});
|
||||
setSaveStatus(res.ok ? "saved" : "error");
|
||||
if (res.ok) pendingSaveRef.current = false;
|
||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
}, 800);
|
||||
return () => { if (titleTimerRef.current) clearTimeout(titleTimerRef.current); };
|
||||
}, [sectionTitle]);
|
||||
|
||||
// Warn before leaving with unsaved title
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (pendingSaveRef.current) e.preventDefault();
|
||||
}
|
||||
function onLinkClick(e: MouseEvent) {
|
||||
if (!pendingSaveRef.current) return;
|
||||
const link = (e.target as HTMLElement).closest("a");
|
||||
if (!link || link.target === "_blank") return;
|
||||
const href = link.getAttribute("href");
|
||||
if (!href || href.startsWith("#") || href.startsWith("/admin/team/")) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (titleTimerRef.current) clearTimeout(titleTimerRef.current);
|
||||
adminFetch("/api/admin/sections/team", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: sectionTitle }),
|
||||
}).finally(() => { window.location.href = href; });
|
||||
}
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
document.addEventListener("click", onLinkClick, true);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
document.removeEventListener("click", onLinkClick, true);
|
||||
};
|
||||
}, [sectionTitle]);
|
||||
|
||||
const saveOrder = useCallback(async (updated: Member[]) => {
|
||||
setMembers(updated);
|
||||
const res = await adminFetch("/api/admin/team/reorder", {
|
||||
|
||||
Reference in New Issue
Block a user