Compare commits
6 Commits
6c485872b0
...
4b6443c867
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b6443c867 | |||
| bc0f23df34 | |||
| ad1715acb8 | |||
| 30398d2aeb | |||
| 95c33391e5 | |||
| 64e923460f |
@@ -13,6 +13,9 @@ interface ArrayEditorProps<T> {
|
|||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
collapsible?: boolean;
|
collapsible?: boolean;
|
||||||
getItemTitle?: (item: T, index: number) => string;
|
getItemTitle?: (item: T, index: number) => string;
|
||||||
|
getItemBadge?: (item: T, index: number) => React.ReactNode;
|
||||||
|
hiddenItems?: Set<number>;
|
||||||
|
addPosition?: "top" | "bottom";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArrayEditor<T>({
|
export function ArrayEditor<T>({
|
||||||
@@ -24,6 +27,9 @@ export function ArrayEditor<T>({
|
|||||||
addLabel = "Добавить",
|
addLabel = "Добавить",
|
||||||
collapsible = false,
|
collapsible = false,
|
||||||
getItemTitle,
|
getItemTitle,
|
||||||
|
getItemBadge,
|
||||||
|
hiddenItems,
|
||||||
|
addPosition = "bottom",
|
||||||
}: ArrayEditorProps<T>) {
|
}: ArrayEditorProps<T>) {
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
const [insertAt, setInsertAt] = useState<number | null>(null);
|
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||||
@@ -33,6 +39,7 @@ export function ArrayEditor<T>({
|
|||||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [newItemIndex, setNewItemIndex] = useState<number | null>(null);
|
const [newItemIndex, setNewItemIndex] = useState<number | null>(null);
|
||||||
|
const [droppedIndex, setDroppedIndex] = useState<number | null>(null);
|
||||||
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
|
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
|
||||||
|
|
||||||
function toggleCollapse(index: number) {
|
function toggleCollapse(index: number) {
|
||||||
@@ -128,6 +135,8 @@ export function ArrayEditor<T>({
|
|||||||
const [moved] = updated.splice(capturedDrag, 1);
|
const [moved] = updated.splice(capturedDrag, 1);
|
||||||
updated.splice(targetIndex, 0, moved);
|
updated.splice(targetIndex, 0, moved);
|
||||||
onChange(updated);
|
onChange(updated);
|
||||||
|
setDroppedIndex(targetIndex);
|
||||||
|
setTimeout(() => setDroppedIndex(null), 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -146,14 +155,15 @@ export function ArrayEditor<T>({
|
|||||||
if (dragIndex === null || insertAt === null) {
|
if (dragIndex === null || insertAt === null) {
|
||||||
return items.map((item, i) => {
|
return items.map((item, i) => {
|
||||||
const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i;
|
const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i;
|
||||||
|
const isHidden = hiddenItems?.has(i) ?? false;
|
||||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
ref={(el) => { itemRefs.current[i] = el; }}
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
className={`rounded-lg border bg-neutral-900/50 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-all ${
|
className={`rounded-lg border bg-neutral-900/50 mb-3 hover:border-white/25 hover:bg-neutral-800/50 focus-within:border-gold/50 focus-within:bg-neutral-800 transition-all ${
|
||||||
newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
||||||
}`}
|
} ${isHidden ? "hidden" : ""}`}
|
||||||
>
|
>
|
||||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
@@ -170,6 +180,7 @@ export function ArrayEditor<T>({
|
|||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||||
|
{getItemBadge?.(item, i)}
|
||||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -220,35 +231,48 @@ export function ArrayEditor<T>({
|
|||||||
elements.push(
|
elements.push(
|
||||||
<div
|
<div
|
||||||
key="placeholder"
|
key="placeholder"
|
||||||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
|
||||||
style={{ height: dragSize.h }}
|
style={{ height: collapsible ? 48 : dragSize.h }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
|
const dragTitle = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||||
elements.push(
|
elements.push(
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
ref={(el) => { itemRefs.current[i] = el; }}
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
|
className="rounded-lg border border-white/10 bg-neutral-900/50 mb-3 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 mb-3">
|
{collapsible ? (
|
||||||
<div
|
<div className="flex items-center gap-2 p-4">
|
||||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
<GripVertical size={16} className="text-neutral-500 shrink-0" />
|
||||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
<span className="text-sm font-medium text-neutral-300 truncate">{dragTitle}</span>
|
||||||
>
|
{getItemBadge?.(item, i)}
|
||||||
<GripVertical size={16} />
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
) : (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => removeItem(i)}
|
<div className="flex items-start justify-between gap-2 p-4 pb-0 mb-3">
|
||||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
<div
|
||||||
>
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
<Trash2 size={16} />
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
</button>
|
>
|
||||||
</div>
|
<GripVertical size={16} />
|
||||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
</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>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
visualIndex++;
|
visualIndex++;
|
||||||
@@ -258,8 +282,8 @@ export function ArrayEditor<T>({
|
|||||||
elements.push(
|
elements.push(
|
||||||
<div
|
<div
|
||||||
key="placeholder"
|
key="placeholder"
|
||||||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
|
||||||
style={{ height: dragSize.h }}
|
style={{ height: collapsible ? 48 : dragSize.h }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -273,18 +297,44 @@ export function ArrayEditor<T>({
|
|||||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
|
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{addPosition === "top" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange([createItem(), ...items]);
|
||||||
|
setNewItemIndex(0);
|
||||||
|
// Shift collapsed indices and ensure new item is expanded
|
||||||
|
setCollapsed(prev => {
|
||||||
|
const next = new Set<number>();
|
||||||
|
for (const idx of prev) next.add(idx + 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="mb-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{renderList()}
|
{renderList()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{addPosition === "bottom" && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => { onChange([...items, createItem()]); setNewItemIndex(items.length); }}
|
type="button"
|
||||||
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
onClick={() => {
|
||||||
>
|
onChange([...items, createItem()]);
|
||||||
<Plus size={16} />
|
setNewItemIndex(items.length);
|
||||||
{addLabel}
|
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
|
||||||
</button>
|
}}
|
||||||
|
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Floating clone following cursor */}
|
{/* Floating clone following cursor */}
|
||||||
{mounted && dragIndex !== null &&
|
{mounted && dragIndex !== null &&
|
||||||
@@ -298,9 +348,9 @@ export function ArrayEditor<T>({
|
|||||||
height: dragSize.h,
|
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">
|
<div className="h-full rounded-lg border-2 border-gold/60 bg-neutral-900/95 shadow-2xl shadow-gold/20 flex items-center gap-3 px-4">
|
||||||
<GripVertical size={16} className="text-rose-400 shrink-0" />
|
<GripVertical size={16} className="text-gold shrink-0" />
|
||||||
<span className="text-sm text-neutral-300">Перемещение элемента...</span>
|
<span className="text-sm text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
+218
-32
@@ -1,54 +1,240 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, Plus, X, AlertCircle, Check } from "lucide-react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, TextareaField } from "../_components/FormField";
|
import { InputField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
|
||||||
import type { ContactInfo } from "@/types/content";
|
import type { ContactInfo } from "@/types/content";
|
||||||
|
|
||||||
|
// --- Phone input with mask ---
|
||||||
|
function PhoneField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
function formatPhone(raw: string): string {
|
||||||
|
const digits = raw.replace(/\D/g, "").slice(0, 12);
|
||||||
|
if (digits.length === 0) return "+375 ";
|
||||||
|
let result = "+";
|
||||||
|
for (let i = 0; i < digits.length; i++) {
|
||||||
|
if (i === 3) result += " (";
|
||||||
|
if (i === 5) result += ") ";
|
||||||
|
if (i === 8) result += "-";
|
||||||
|
if (i === 10) result += "-";
|
||||||
|
result += digits[i];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const formatted = formatPhone(e.target.value);
|
||||||
|
onChange(formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
const isComplete = digits.length === 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Телефон</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="+375 (XX) XXX-XX-XX"
|
||||||
|
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||||
|
value && !isComplete ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{isComplete && (
|
||||||
|
<Check size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{value && !isComplete && (
|
||||||
|
<p className="mt-1 text-[11px] text-red-400">Формат: +375 (XX) XXX-XX-XX</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Instagram field with validation ---
|
||||||
|
function InstagramField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
function getError(): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
const host = url.hostname.replace("www.", "");
|
||||||
|
if (host !== "instagram.com" && host !== "instagr.am") {
|
||||||
|
return "Ссылка должна вести на instagram.com";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return "Некорректная ссылка";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = getError();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="https://instagram.com/blackheartdancehouse"
|
||||||
|
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||||
|
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{value && !error && (
|
||||||
|
<Check size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400" />
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<AlertCircle size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-[11px] text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Compact address list ---
|
||||||
|
function AddressList({ items, onChange }: { items: string[]; onChange: (items: string[]) => void }) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
const val = draft.trim();
|
||||||
|
if (!val) return;
|
||||||
|
onChange([...items, val]);
|
||||||
|
setDraft("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(index: number, value: string) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? value : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((addr, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addr}
|
||||||
|
onChange={(e) => update(i, e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
|
onBlur={add}
|
||||||
|
placeholder="Добавить адрес..."
|
||||||
|
className="flex-1 rounded-lg border border-dashed border-white/15 bg-neutral-800/50 px-4 py-2.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/50 transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={add}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Collapsible section ---
|
||||||
|
function CollapsibleSection({
|
||||||
|
title,
|
||||||
|
defaultOpen = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-white/[0.02] transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">{title}</h3>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||||
|
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="px-5 pb-5 space-y-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContactEditorPage() {
|
export default function ContactEditorPage() {
|
||||||
return (
|
return (
|
||||||
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
|
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
|
||||||
{(data, update) => (
|
{(data, update) => (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
<InputField
|
<InputField
|
||||||
label="Заголовок секции"
|
label="Заголовок секции"
|
||||||
value={data.title}
|
value={data.title}
|
||||||
onChange={(v) => update({ ...data, title: v })}
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
/>
|
/>
|
||||||
<InputField
|
|
||||||
label="Телефон"
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
value={data.phone}
|
<PhoneField
|
||||||
onChange={(v) => update({ ...data, phone: v })}
|
value={data.phone}
|
||||||
type="tel"
|
onChange={(v) => update({ ...data, phone: v })}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InstagramField
|
||||||
label="Instagram"
|
value={data.instagram}
|
||||||
value={data.instagram}
|
onChange={(v) => update({ ...data, instagram: v })}
|
||||||
onChange={(v) => update({ ...data, instagram: v })}
|
/>
|
||||||
type="url"
|
</div>
|
||||||
/>
|
|
||||||
<InputField
|
<InputField
|
||||||
label="Часы работы"
|
label="Часы работы"
|
||||||
value={data.workingHours}
|
value={data.workingHours}
|
||||||
onChange={(v) => update({ ...data, workingHours: v })}
|
onChange={(v) => update({ ...data, workingHours: v })}
|
||||||
/>
|
/>
|
||||||
<ArrayEditor
|
|
||||||
label="Адреса"
|
<CollapsibleSection title="Адреса">
|
||||||
items={data.addresses}
|
<AddressList
|
||||||
onChange={(addresses) => update({ ...data, addresses })}
|
items={data.addresses}
|
||||||
renderItem={(addr, _i, updateItem) => (
|
onChange={(addresses) => update({ ...data, addresses })}
|
||||||
<InputField label="Адрес" value={addr} onChange={updateItem} />
|
/>
|
||||||
)}
|
</CollapsibleSection>
|
||||||
createItem={() => ""}
|
|
||||||
addLabel="Добавить адрес"
|
</div>
|
||||||
/>
|
|
||||||
<TextareaField
|
|
||||||
label="URL карты (Yandex Maps iframe)"
|
|
||||||
value={data.mapEmbedUrl}
|
|
||||||
onChange={(v) => update({ ...data, mapEmbedUrl: v })}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</SectionEditor>
|
</SectionEditor>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export default function FAQEditorPage() {
|
|||||||
label="Вопросы и ответы"
|
label="Вопросы и ответы"
|
||||||
items={data.items}
|
items={data.items}
|
||||||
onChange={(items) => update({ ...data, items })}
|
onChange={(items) => update({ ...data, items })}
|
||||||
|
collapsible
|
||||||
|
getItemTitle={(item) => item.question || "Без вопроса"}
|
||||||
renderItem={(item, _i, updateItem) => (
|
renderItem={(item, _i, updateItem) => (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<InputField
|
<InputField
|
||||||
|
|||||||
@@ -1,13 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
|
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
|
import { Plus, X, Upload, Loader2, AlertCircle, Check, Search } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function isItemArchived(item: MasterClassItem): boolean {
|
||||||
|
const slots = item.slots ?? [];
|
||||||
|
if (slots.length === 0) return false;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
return slots.every((s) => s.date && s.date < today);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemMatchesSearch(item: MasterClassItem, query: string): boolean {
|
||||||
|
if (!query) return true;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
(item.title || "").toLowerCase().includes(q) ||
|
||||||
|
(item.trainer || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemMatchesDateFilter(item: MasterClassItem, filter: "all" | "upcoming" | "past"): boolean {
|
||||||
|
if (filter === "all") return true;
|
||||||
|
const archived = isItemArchived(item);
|
||||||
|
return filter === "past" ? archived : !archived;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemMatchesLocation(item: MasterClassItem, locationFilter: string): boolean {
|
||||||
|
if (!locationFilter) return true;
|
||||||
|
return (item.location || "") === locationFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Price Field ---
|
||||||
|
|
||||||
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||||
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +69,6 @@ interface MasterClassesData {
|
|||||||
items: MasterClassItem[];
|
items: MasterClassItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Location Select ---
|
// --- Location Select ---
|
||||||
function LocationSelect({
|
function LocationSelect({
|
||||||
value,
|
value,
|
||||||
@@ -92,6 +123,13 @@ function calcDurationText(startTime: string, endTime: string): string {
|
|||||||
return `${m} мин`;
|
return `${m} мин`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasTimeError(startTime: string, endTime: string): boolean {
|
||||||
|
if (!startTime || !endTime) return false;
|
||||||
|
const [sh, sm] = startTime.split(":").map(Number);
|
||||||
|
const [eh, em] = endTime.split(":").map(Number);
|
||||||
|
return (eh * 60 + em) <= (sh * 60 + sm);
|
||||||
|
}
|
||||||
|
|
||||||
function SlotsField({
|
function SlotsField({
|
||||||
slots,
|
slots,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -123,41 +161,54 @@ function SlotsField({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{slots.map((slot, i) => {
|
{slots.map((slot, i) => {
|
||||||
const dur = calcDurationText(slot.startTime, slot.endTime);
|
const dur = calcDurationText(slot.startTime, slot.endTime);
|
||||||
|
const timeError = hasTimeError(slot.startTime, slot.endTime);
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2 flex-wrap">
|
<div key={i}>
|
||||||
<input
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
type="date"
|
<input
|
||||||
value={slot.date}
|
type="date"
|
||||||
onChange={(e) => updateSlot(i, { date: e.target.value })}
|
value={slot.date}
|
||||||
className={`w-[140px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
onChange={(e) => updateSlot(i, { date: e.target.value })}
|
||||||
!slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
className={`w-[140px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||||
}`}
|
!slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
/>
|
}`}
|
||||||
<input
|
/>
|
||||||
type="time"
|
<input
|
||||||
value={slot.startTime}
|
type="time"
|
||||||
onChange={(e) => updateSlot(i, { startTime: e.target.value })}
|
value={slot.startTime}
|
||||||
className="w-[100px] rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
onChange={(e) => updateSlot(i, { startTime: e.target.value })}
|
||||||
/>
|
className={`w-[100px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||||
<span className="text-neutral-500 text-xs">–</span>
|
timeError ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
<input
|
}`}
|
||||||
type="time"
|
/>
|
||||||
value={slot.endTime}
|
<span className="text-neutral-500 text-xs">–</span>
|
||||||
onChange={(e) => updateSlot(i, { endTime: e.target.value })}
|
<input
|
||||||
className="w-[100px] rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
type="time"
|
||||||
/>
|
value={slot.endTime}
|
||||||
{dur && (
|
onChange={(e) => updateSlot(i, { endTime: e.target.value })}
|
||||||
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5">
|
className={`w-[100px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||||
{dur}
|
timeError ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
</span>
|
}`}
|
||||||
|
/>
|
||||||
|
{dur && (
|
||||||
|
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5">
|
||||||
|
{dur}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeSlot(i)}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!slot.date && (
|
||||||
|
<p className="mt-0.5 ml-1 text-[11px] text-red-400">Укажите дату</p>
|
||||||
|
)}
|
||||||
|
{timeError && (
|
||||||
|
<p className="mt-0.5 ml-1 text-[11px] text-red-400">Время окончания должно быть позже начала</p>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeSlot(i)}
|
|
||||||
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -174,8 +225,8 @@ function SlotsField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Image Upload ---
|
// --- Photo Preview (like trainer page) ---
|
||||||
function ImageUploadField({
|
function PhotoPreview({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -183,7 +234,6 @@ function ImageUploadField({
|
|||||||
onChange: (path: string) => void;
|
onChange: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -208,54 +258,48 @@ function ImageUploadField({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
<label className="block text-sm text-neutral-400 mb-1.5">Изображение</label>
|
||||||
Изображение
|
|
||||||
</label>
|
|
||||||
{value ? (
|
{value ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative">
|
||||||
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
|
<label className="relative block w-full aspect-[16/9] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
|
||||||
<ImageIcon size={14} className="text-gold" />
|
<Image
|
||||||
<span className="max-w-[200px] truncate">
|
src={value}
|
||||||
{value.split("/").pop()}
|
alt="Превью"
|
||||||
</span>
|
fill
|
||||||
</div>
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 500px"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin text-white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={20} className="text-white" />
|
||||||
|
<span className="text-[11px] text-white/80">Изменить</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange("")}
|
onClick={() => onChange("")}
|
||||||
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
className="absolute top-2 right-2 rounded-lg bg-black/60 p-1.5 text-neutral-400 hover:text-red-400 transition-colors"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
|
||||||
{uploading ? (
|
|
||||||
<Loader2 size={14} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Upload size={14} />
|
|
||||||
)}
|
|
||||||
Заменить
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
<label className="flex cursor-pointer items-center justify-center gap-2 w-full aspect-[16/9] rounded-xl border-2 border-dashed border-white/20 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Upload size={16} />
|
<>
|
||||||
|
<Upload size={20} />
|
||||||
|
<span>Загрузить изображение</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{uploading ? "Загрузка..." : "Загрузить изображение"}
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -339,11 +383,113 @@ function ValidationHint({ fields }: { fields: Record<string, string> }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Filter bar ---
|
||||||
|
type DateFilter = "all" | "upcoming" | "past";
|
||||||
|
|
||||||
|
const DATE_FILTER_LABELS: Record<DateFilter, string> = {
|
||||||
|
all: "Все",
|
||||||
|
upcoming: "Предстоящие",
|
||||||
|
past: "Прошедшие",
|
||||||
|
};
|
||||||
|
|
||||||
|
function FilterBar({
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
dateFilter,
|
||||||
|
onDateFilterChange,
|
||||||
|
locationFilter,
|
||||||
|
onLocationFilterChange,
|
||||||
|
locations,
|
||||||
|
totalCount,
|
||||||
|
visibleCount,
|
||||||
|
}: {
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (v: string) => void;
|
||||||
|
dateFilter: DateFilter;
|
||||||
|
onDateFilterChange: (v: DateFilter) => void;
|
||||||
|
locationFilter: string;
|
||||||
|
onLocationFilterChange: (v: string) => void;
|
||||||
|
locations: { name: string; address: string }[];
|
||||||
|
totalCount: number;
|
||||||
|
visibleCount: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Поиск по названию или тренеру..."
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 pl-10 pr-4 py-2.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(Object.keys(DATE_FILTER_LABELS) as DateFilter[]).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDateFilterChange(key)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||||
|
dateFilter === key
|
||||||
|
? "bg-gold/20 text-gold border border-gold/40"
|
||||||
|
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{DATE_FILTER_LABELS[key]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{locations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-600 text-xs">|</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{locations.map((loc) => (
|
||||||
|
<button
|
||||||
|
key={loc.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLocationFilterChange(locationFilter === loc.name ? "" : loc.name)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||||
|
locationFilter === loc.name
|
||||||
|
? "bg-gold/20 text-gold border border-gold/40"
|
||||||
|
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loc.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{visibleCount < totalCount && (
|
||||||
|
<span className="text-xs text-neutral-500 ml-auto">
|
||||||
|
{visibleCount} из {totalCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Main page ---
|
// --- Main page ---
|
||||||
export default function MasterClassesEditorPage() {
|
export default function MasterClassesEditorPage() {
|
||||||
const [trainers, setTrainers] = useState<string[]>([]);
|
const [trainers, setTrainers] = useState<string[]>([]);
|
||||||
const [styles, setStyles] = useState<string[]>([]);
|
const [styles, setStyles] = useState<string[]>([]);
|
||||||
const [locations, setLocations] = useState<{ name: string; address: string }[]>([]);
|
const [locations, setLocations] = useState<{ name: string; address: string }[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [dateFilter, setDateFilter] = useState<DateFilter>("all");
|
||||||
|
const [locationFilter, setLocationFilter] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch trainers from team
|
// Fetch trainers from team
|
||||||
@@ -376,119 +522,173 @@ export default function MasterClassesEditorPage() {
|
|||||||
sectionKey="masterClasses"
|
sectionKey="masterClasses"
|
||||||
title="Мастер-классы"
|
title="Мастер-классы"
|
||||||
>
|
>
|
||||||
{(data, update) => (
|
{(data, update) => {
|
||||||
<>
|
// Sort: active first, archived at bottom
|
||||||
<InputField
|
const displayItems = [...data.items].sort((a, b) => {
|
||||||
label="Заголовок секции"
|
const aArch = isItemArchived(a);
|
||||||
value={data.title}
|
const bArch = isItemArchived(b);
|
||||||
onChange={(v) => update({ ...data, title: v })}
|
if (aArch === bArch) return 0;
|
||||||
/>
|
return aArch ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
<ArrayEditor
|
const hiddenItems = new Set<number>();
|
||||||
label="Мастер-классы"
|
displayItems.forEach((item, i) => {
|
||||||
items={data.items}
|
if (
|
||||||
onChange={(items) => update({ ...data, items })}
|
!itemMatchesSearch(item, search) ||
|
||||||
renderItem={(item, _i, updateItem) => (
|
!itemMatchesDateFilter(item, dateFilter) ||
|
||||||
<div className="space-y-3">
|
!itemMatchesLocation(item, locationFilter)
|
||||||
<ValidationHint
|
) {
|
||||||
fields={{
|
hiddenItems.add(i);
|
||||||
Название: item.title,
|
}
|
||||||
Тренер: item.trainer,
|
});
|
||||||
Стиль: item.style,
|
|
||||||
Стоимость: item.cost,
|
|
||||||
"Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputField
|
const visibleCount = data.items.length - hiddenItems.size;
|
||||||
label="Название"
|
|
||||||
value={item.title}
|
|
||||||
onChange={(v) => updateItem({ ...item, title: v })}
|
|
||||||
placeholder="Мастер-класс от Анны Тарыбы"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImageUploadField
|
return (
|
||||||
value={item.image}
|
<>
|
||||||
onChange={(v) => updateItem({ ...item, image: v })}
|
<InputField
|
||||||
/>
|
label="Заголовок секции"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<FilterBar
|
||||||
<AutocompleteMulti
|
search={search}
|
||||||
label="Тренер"
|
onSearchChange={setSearch}
|
||||||
value={item.trainer}
|
dateFilter={dateFilter}
|
||||||
onChange={(v) => updateItem({ ...item, trainer: v })}
|
onDateFilterChange={setDateFilter}
|
||||||
options={trainers}
|
locationFilter={locationFilter}
|
||||||
placeholder="Добавить тренера..."
|
onLocationFilterChange={setLocationFilter}
|
||||||
/>
|
locations={locations}
|
||||||
<AutocompleteMulti
|
totalCount={data.items.length}
|
||||||
label="Стиль"
|
visibleCount={visibleCount}
|
||||||
value={item.style}
|
/>
|
||||||
onChange={(v) => updateItem({ ...item, style: v })}
|
|
||||||
options={styles}
|
|
||||||
placeholder="Добавить стиль..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PriceField
|
<ArrayEditor
|
||||||
label="Стоимость"
|
label="Мастер-классы"
|
||||||
value={item.cost}
|
items={displayItems}
|
||||||
onChange={(v) => updateItem({ ...item, cost: v })}
|
onChange={(items) => update({ ...data, items })}
|
||||||
placeholder="40"
|
collapsible
|
||||||
/>
|
hiddenItems={hiddenItems}
|
||||||
|
getItemTitle={(item) => {
|
||||||
|
const base = item.location
|
||||||
|
? `${item.title || "Без названия"} · ${item.location}`
|
||||||
|
: item.title || "Без названия";
|
||||||
|
return base;
|
||||||
|
}}
|
||||||
|
getItemBadge={(item) =>
|
||||||
|
isItemArchived(item) ? (
|
||||||
|
<span className="shrink-0 rounded-full bg-neutral-700/50 px-2 py-0.5 text-[10px] font-medium text-neutral-500">
|
||||||
|
Архив
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
renderItem={(item, _i, updateItem) => {
|
||||||
|
const archived = isItemArchived(item);
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${archived ? "opacity-50" : ""}`}>
|
||||||
|
|
||||||
{locations.length > 0 && (
|
<ValidationHint
|
||||||
<LocationSelect
|
fields={{
|
||||||
value={item.location || ""}
|
Название: item.title,
|
||||||
onChange={(v) =>
|
Тренер: item.trainer,
|
||||||
updateItem({ ...item, location: v || undefined })
|
Стиль: item.style,
|
||||||
}
|
Стоимость: item.cost,
|
||||||
locations={locations}
|
"Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
|
||||||
/>
|
}}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
<SlotsField
|
<InputField
|
||||||
slots={item.slots ?? []}
|
label="Название"
|
||||||
onChange={(slots) => updateItem({ ...item, slots })}
|
value={item.title}
|
||||||
/>
|
onChange={(v) => updateItem({ ...item, title: v })}
|
||||||
|
placeholder="Мастер-класс от Анны Тарыбы"
|
||||||
|
/>
|
||||||
|
|
||||||
<TextareaField
|
<PhotoPreview
|
||||||
label="Описание"
|
value={item.image}
|
||||||
value={item.description || ""}
|
onChange={(v) => updateItem({ ...item, image: v })}
|
||||||
onChange={(v) =>
|
/>
|
||||||
updateItem({ ...item, description: v || undefined })
|
|
||||||
}
|
|
||||||
placeholder="Описание мастер-класса, трек, стиль..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InstagramLinkField
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
value={item.instagramUrl || ""}
|
<AutocompleteMulti
|
||||||
onChange={(v) =>
|
label="Тренер"
|
||||||
updateItem({ ...item, instagramUrl: v || undefined })
|
value={item.trainer}
|
||||||
}
|
onChange={(v) => updateItem({ ...item, trainer: v })}
|
||||||
/>
|
options={trainers}
|
||||||
|
placeholder="Добавить тренера..."
|
||||||
|
/>
|
||||||
|
<AutocompleteMulti
|
||||||
|
label="Стиль"
|
||||||
|
value={item.style}
|
||||||
|
onChange={(v) => updateItem({ ...item, style: v })}
|
||||||
|
options={styles}
|
||||||
|
placeholder="Добавить стиль..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ParticipantLimits
|
<PriceField
|
||||||
min={item.minParticipants ?? 0}
|
label="Стоимость"
|
||||||
max={item.maxParticipants ?? 0}
|
value={item.cost}
|
||||||
onMinChange={(v) => updateItem({ ...item, minParticipants: v })}
|
onChange={(v) => updateItem({ ...item, cost: v })}
|
||||||
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })}
|
placeholder="40"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
{locations.length > 0 && (
|
||||||
)}
|
<LocationSelect
|
||||||
createItem={() => ({
|
value={item.location || ""}
|
||||||
title: "",
|
onChange={(v) =>
|
||||||
image: "",
|
updateItem({ ...item, location: v || undefined })
|
||||||
slots: [],
|
}
|
||||||
trainer: "",
|
locations={locations}
|
||||||
cost: "",
|
/>
|
||||||
style: "",
|
)}
|
||||||
})}
|
|
||||||
addLabel="Добавить мастер-класс"
|
<SlotsField
|
||||||
/>
|
slots={item.slots ?? []}
|
||||||
</>
|
onChange={(slots) => updateItem({ ...item, slots })}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
|
<TextareaField
|
||||||
|
label="Описание"
|
||||||
|
value={item.description || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateItem({ ...item, description: v || undefined })
|
||||||
|
}
|
||||||
|
placeholder="Описание мастер-класса, трек, стиль..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InstagramLinkField
|
||||||
|
value={item.instagramUrl || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateItem({ ...item, instagramUrl: v || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ParticipantLimits
|
||||||
|
min={item.minParticipants ?? 0}
|
||||||
|
max={item.maxParticipants ?? 0}
|
||||||
|
onMinChange={(v) => updateItem({ ...item, minParticipants: v })}
|
||||||
|
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
createItem={() => ({
|
||||||
|
title: "",
|
||||||
|
image: "",
|
||||||
|
slots: [],
|
||||||
|
trainer: "",
|
||||||
|
cost: "",
|
||||||
|
style: "",
|
||||||
|
})}
|
||||||
|
addLabel="Добавить мастер-класс"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</SectionEditor>
|
</SectionEditor>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+142
-77
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, TextareaField } from "../_components/FormField";
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
import { Upload, Loader2, ImageIcon, X } from "lucide-react";
|
import { Upload, Loader2, X, AlertCircle } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { NewsItem } from "@/types/content";
|
import type { NewsItem } from "@/types/content";
|
||||||
|
|
||||||
@@ -13,15 +14,22 @@ interface NewsData {
|
|||||||
items: NewsItem[];
|
items: NewsItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageUploadField({
|
function CropPreview({
|
||||||
value,
|
image,
|
||||||
onChange,
|
focalX,
|
||||||
|
focalY,
|
||||||
|
onImageChange,
|
||||||
|
onFocalChange,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
image: string;
|
||||||
onChange: (path: string) => void;
|
focalX: number;
|
||||||
|
focalY: number;
|
||||||
|
onImageChange: (path: string) => void;
|
||||||
|
onFocalChange: (x: number, y: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -36,7 +44,7 @@ function ImageUploadField({
|
|||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.path) onChange(result.path);
|
if (result.path) onImageChange(result.path);
|
||||||
} catch {
|
} catch {
|
||||||
/* upload failed */
|
/* upload failed */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -44,56 +52,83 @@ function ImageUploadField({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateFocalFromEvent(clientX: number, clientY: number) {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
||||||
|
const y = Math.max(0, Math.min(100, ((clientY - rect.top) / rect.height) * 100));
|
||||||
|
onFocalChange(Math.round(x), Math.round(y));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(e: React.PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
setDragging(true);
|
||||||
|
updateFocalFromEvent(e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: React.PointerEvent) {
|
||||||
|
if (!dragging) return;
|
||||||
|
updateFocalFromEvent(e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp() {
|
||||||
|
setDragging(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||||
Изображение
|
Изображение <span className="text-neutral-600">(перетащите для кадрирования)</span>
|
||||||
</label>
|
</label>
|
||||||
{value ? (
|
{image ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
|
{/* Crop area — drag to reposition */}
|
||||||
<ImageIcon size={14} className="text-gold" />
|
<div
|
||||||
<span className="max-w-[200px] truncate">
|
ref={containerRef}
|
||||||
{value.split("/").pop()}
|
className="relative w-full aspect-[21/9] overflow-hidden rounded-xl border border-white/10 cursor-grab active:cursor-grabbing select-none"
|
||||||
</span>
|
onPointerDown={handlePointerDown}
|
||||||
</div>
|
onPointerMove={handlePointerMove}
|
||||||
<button
|
onPointerUp={handlePointerUp}
|
||||||
type="button"
|
onPointerCancel={handlePointerUp}
|
||||||
onClick={() => onChange("")}
|
|
||||||
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<Image
|
||||||
</button>
|
src={image}
|
||||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
alt="Превью"
|
||||||
{uploading ? (
|
fill
|
||||||
<Loader2 size={14} className="animate-spin" />
|
className="object-cover pointer-events-none"
|
||||||
) : (
|
style={{ objectPosition: `${focalX}% ${focalY}%` }}
|
||||||
<Upload size={14} />
|
sizes="(max-width: 768px) 100vw, 600px"
|
||||||
)}
|
|
||||||
Заменить
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
||||||
|
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||||
|
Заменить
|
||||||
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onImageChange("")}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-xs text-neutral-500 hover:text-red-400 transition-colors ml-auto"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
<label className="flex cursor-pointer items-center justify-center gap-2 w-full aspect-[16/9] rounded-xl border-2 border-dashed border-white/20 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<Loader2 size={16} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Upload size={16} />
|
<>
|
||||||
|
<Upload size={20} />
|
||||||
|
<span>Загрузить изображение</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{uploading ? "Загрузка..." : "Загрузить изображение"}
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -115,49 +150,79 @@ export default function NewsEditorPage() {
|
|||||||
label="Новости"
|
label="Новости"
|
||||||
items={data.items}
|
items={data.items}
|
||||||
onChange={(items) => update({ ...data, items })}
|
onChange={(items) => update({ ...data, items })}
|
||||||
renderItem={(item, _i, updateItem) => (
|
collapsible
|
||||||
|
getItemTitle={(item) => {
|
||||||
|
const title = item.title || "Без заголовка";
|
||||||
|
if (item.date) {
|
||||||
|
try {
|
||||||
|
const d = new Date(item.date);
|
||||||
|
const date = d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||||||
|
const time = item.date.includes("T") ? ` ${d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}` : "";
|
||||||
|
return `${title} · ${date}${time}`;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
}}
|
||||||
|
getItemBadge={(item) => {
|
||||||
|
const missing = [
|
||||||
|
!item.title.trim() && "заголовок",
|
||||||
|
!item.text.trim() && "текст",
|
||||||
|
!item.image && "фото",
|
||||||
|
].filter(Boolean);
|
||||||
|
if (missing.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 rounded-full bg-red-500/10 border border-red-500/20 px-2 py-0.5 text-[10px] font-medium text-red-400">
|
||||||
|
Черновик
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderItem={(item, _i, updateItem) => {
|
||||||
|
const missing = [
|
||||||
|
!item.title.trim() && "Заголовок",
|
||||||
|
!item.text.trim() && "Текст",
|
||||||
|
!item.image && "Изображение",
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
{missing.length > 0 && (
|
||||||
<InputField
|
<div className="flex items-start gap-1.5 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-xs text-red-400">
|
||||||
label="Заголовок"
|
<AlertCircle size={12} className="shrink-0 mt-0.5" />
|
||||||
value={item.title}
|
<span>Не опубликовано — не заполнено: {missing.join(", ")}</span>
|
||||||
onChange={(v) => updateItem({ ...item, title: v })}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={item.date}
|
|
||||||
onChange={(e) => updateItem({ ...item, date: e.target.value })}
|
|
||||||
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 [color-scheme:dark]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
<InputField
|
||||||
|
label="Заголовок"
|
||||||
|
value={item.title}
|
||||||
|
onChange={(v) => updateItem({ ...item, title: v })}
|
||||||
|
/>
|
||||||
<TextareaField
|
<TextareaField
|
||||||
label="Текст"
|
label="Текст"
|
||||||
value={item.text}
|
value={item.text}
|
||||||
onChange={(v) => updateItem({ ...item, text: v })}
|
onChange={(v) => updateItem({ ...item, text: v })}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<CropPreview
|
||||||
<ImageUploadField
|
image={item.image || ""}
|
||||||
value={item.image || ""}
|
focalX={item.imageFocalX ?? 50}
|
||||||
onChange={(v) => updateItem({ ...item, image: v || undefined })}
|
focalY={item.imageFocalY ?? 50}
|
||||||
/>
|
onImageChange={(v) => updateItem({ ...item, image: v || undefined })}
|
||||||
<InputField
|
onFocalChange={(x, y) => updateItem({ ...item, imageFocalX: x, imageFocalY: y })}
|
||||||
label="Ссылка (необязательно)"
|
/>
|
||||||
value={item.link || ""}
|
<InputField
|
||||||
onChange={(v) => updateItem({ ...item, link: v || undefined })}
|
label="Ссылка (необязательно)"
|
||||||
placeholder="https://instagram.com/p/..."
|
value={item.link || ""}
|
||||||
/>
|
onChange={(v) => updateItem({ ...item, link: v || undefined })}
|
||||||
</div>
|
placeholder="https://instagram.com/p/..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
createItem={(): NewsItem => ({
|
createItem={(): NewsItem => ({
|
||||||
title: "",
|
title: "",
|
||||||
text: "",
|
text: "",
|
||||||
date: new Date().toISOString().slice(0, 10),
|
date: new Date().toISOString(),
|
||||||
})}
|
})}
|
||||||
addLabel="Добавить новость"
|
addLabel="Добавить новость"
|
||||||
|
addPosition="top"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+188
-139
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, SelectField } from "../_components/FormField";
|
import { InputField, SelectField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
@@ -23,7 +25,6 @@ interface PricingData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||||
// Strip "BYN" suffix for editing, add back on save
|
|
||||||
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,157 +49,205 @@ function PriceField({ label, value, onChange }: { label: string; value: string;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CollapsibleSection({
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
defaultOpen = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
count?: number;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-white/[0.02] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">{title}</h3>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span className="text-xs text-neutral-500">{count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||||
|
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="px-5 pb-5 space-y-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function PricingEditorPage() {
|
export default function PricingEditorPage() {
|
||||||
return (
|
return (
|
||||||
<SectionEditor<PricingData> sectionKey="pricing" title="Цены">
|
<SectionEditor<PricingData> sectionKey="pricing" title="Цены">
|
||||||
{(data, update) => (
|
{(data, update) => (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
<InputField
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
label="Заголовок секции"
|
<InputField
|
||||||
value={data.title}
|
label="Заголовок секции"
|
||||||
onChange={(v) => update({ ...data, title: v })}
|
value={data.title}
|
||||||
/>
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
<InputField
|
/>
|
||||||
label="Подзаголовок"
|
<InputField
|
||||||
value={data.subtitle}
|
label="Подзаголовок"
|
||||||
onChange={(v) => update({ ...data, subtitle: v })}
|
value={data.subtitle}
|
||||||
/>
|
onChange={(v) => update({ ...data, subtitle: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
{/* Абонементы */}
|
||||||
<button
|
<CollapsibleSection title="Абонементы" count={data.items.length}>
|
||||||
type="button"
|
{(() => {
|
||||||
role="switch"
|
const itemOptions = data.items
|
||||||
aria-checked={data.showContactHint !== false}
|
.map((it, idx) => ({ value: String(idx), label: it.name }))
|
||||||
onClick={() => update({ ...data, showContactHint: data.showContactHint === false })}
|
.filter((o) => o.label.trim() !== "");
|
||||||
className={`relative h-5 w-9 rounded-full transition-colors ${
|
const noneOption = { value: "", label: "— Нет —" };
|
||||||
data.showContactHint !== false ? "bg-gold" : "bg-neutral-600"
|
const featuredIdx = data.items.findIndex((it) => it.featured);
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
|
|
||||||
data.showContactHint !== false ? "translate-x-4" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span className="text-sm text-neutral-400">Показывать контакты для записи (Instagram, Telegram, телефон)</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Featured selector */}
|
return (
|
||||||
{(() => {
|
<SelectField
|
||||||
const itemOptions = data.items
|
label="Выделенный абонемент (безлимит)"
|
||||||
.map((it, idx) => ({ value: String(idx), label: it.name }))
|
value={featuredIdx >= 0 ? String(featuredIdx) : ""}
|
||||||
.filter((o) => o.label.trim() !== "");
|
onChange={(v) => {
|
||||||
const noneOption = { value: "", label: "— Нет —" };
|
const items = data.items.map((it, idx) => ({
|
||||||
const featuredIdx = data.items.findIndex((it) => it.featured);
|
...it,
|
||||||
|
featured: v ? idx === Number(v) : false,
|
||||||
|
}));
|
||||||
|
update({ ...data, items });
|
||||||
|
}}
|
||||||
|
options={[noneOption, ...itemOptions]}
|
||||||
|
placeholder="Выберите..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
return (
|
<ArrayEditor
|
||||||
<SelectField
|
items={data.items}
|
||||||
label="Выделенный абонемент (безлимит)"
|
onChange={(items) => update({ ...data, items })}
|
||||||
value={featuredIdx >= 0 ? String(featuredIdx) : ""}
|
collapsible
|
||||||
onChange={(v) => {
|
getItemTitle={(item) => item.name || "Без названия"}
|
||||||
const items = data.items.map((it, idx) => ({
|
getItemBadge={(item) =>
|
||||||
...it,
|
item.popular ? (
|
||||||
featured: v ? idx === Number(v) : false,
|
<span className="shrink-0 rounded-full bg-gold/20 px-2 py-0.5 text-[10px] font-medium text-gold">
|
||||||
}));
|
Популярный
|
||||||
update({ ...data, items });
|
</span>
|
||||||
}}
|
) : null
|
||||||
options={[noneOption, ...itemOptions]}
|
}
|
||||||
placeholder="Выберите..."
|
renderItem={(item, _i, updateItem) => (
|
||||||
/>
|
<div className="space-y-3">
|
||||||
);
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
})()}
|
<InputField
|
||||||
|
label="Название"
|
||||||
<ArrayEditor
|
value={item.name}
|
||||||
label="Абонементы"
|
onChange={(v) => updateItem({ ...item, name: v })}
|
||||||
items={data.items}
|
/>
|
||||||
onChange={(items) => update({ ...data, items })}
|
<PriceField
|
||||||
renderItem={(item, _i, updateItem) => (
|
label="Цена"
|
||||||
<div className="space-y-3">
|
value={item.price}
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
onChange={(v) => updateItem({ ...item, price: v })}
|
||||||
<InputField
|
/>
|
||||||
label="Название"
|
</div>
|
||||||
value={item.name}
|
|
||||||
onChange={(v) => updateItem({ ...item, name: v })}
|
|
||||||
/>
|
|
||||||
<PriceField
|
|
||||||
label="Цена"
|
|
||||||
value={item.price}
|
|
||||||
onChange={(v) => updateItem({ ...item, price: v })}
|
|
||||||
/>
|
|
||||||
<InputField
|
<InputField
|
||||||
label="Примечание"
|
label="Примечание"
|
||||||
value={item.note || ""}
|
value={item.note || ""}
|
||||||
onChange={(v) => updateItem({ ...item, note: v })}
|
onChange={(v) => updateItem({ ...item, note: v || undefined })}
|
||||||
|
placeholder="Например: 8 занятий, срок 30 дней"
|
||||||
|
/>
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!!item.popular}
|
||||||
|
onClick={() => updateItem({ ...item, popular: !item.popular })}
|
||||||
|
className={`relative h-5 w-9 rounded-full transition-colors ${
|
||||||
|
item.popular ? "bg-gold" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||||
|
item.popular ? "translate-x-4" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-neutral-400">Популярный</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
createItem={() => ({ name: "", price: "", note: "" })}
|
||||||
|
addLabel="Добавить абонемент"
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Аренда */}
|
||||||
|
<CollapsibleSection title="Аренда" count={data.rentalItems.length}>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок"
|
||||||
|
value={data.rentalTitle}
|
||||||
|
onChange={(v) => update({ ...data, rentalTitle: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArrayEditor
|
||||||
|
items={data.rentalItems}
|
||||||
|
onChange={(rentalItems) => update({ ...data, rentalItems })}
|
||||||
|
collapsible
|
||||||
|
getItemTitle={(item) => item.name || "Без названия"}
|
||||||
|
renderItem={(item, _i, updateItem) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<InputField
|
||||||
|
label="Название"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(v) => updateItem({ ...item, name: v })}
|
||||||
|
/>
|
||||||
|
<PriceField
|
||||||
|
label="Цена"
|
||||||
|
value={item.price}
|
||||||
|
onChange={(v) => updateItem({ ...item, price: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InputField
|
||||||
|
label="Примечание"
|
||||||
|
value={item.note || ""}
|
||||||
|
onChange={(v) => updateItem({ ...item, note: v || undefined })}
|
||||||
|
placeholder="Например: за 1 час"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
)}
|
||||||
<button
|
createItem={() => ({ name: "", price: "", note: "" })}
|
||||||
type="button"
|
addLabel="Добавить вариант аренды"
|
||||||
role="switch"
|
/>
|
||||||
aria-checked={!!item.popular}
|
</CollapsibleSection>
|
||||||
onClick={() => updateItem({ ...item, popular: !item.popular })}
|
|
||||||
className={`relative h-5 w-9 rounded-full transition-colors ${
|
|
||||||
item.popular ? "bg-gold" : "bg-neutral-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
|
|
||||||
item.popular ? "translate-x-4" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span className="text-sm text-neutral-400">Популярный</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
createItem={() => ({ name: "", price: "", note: "" })}
|
|
||||||
addLabel="Добавить абонемент"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputField
|
{/* Правила */}
|
||||||
label="Заголовок аренды"
|
<CollapsibleSection title="Правила" count={data.rules.length} defaultOpen={false}>
|
||||||
value={data.rentalTitle}
|
<ArrayEditor
|
||||||
onChange={(v) => update({ ...data, rentalTitle: v })}
|
items={data.rules}
|
||||||
/>
|
onChange={(rules) => update({ ...data, rules })}
|
||||||
|
renderItem={(rule, _i, updateItem) => (
|
||||||
<ArrayEditor
|
<InputField label="Правило" value={rule} onChange={updateItem} />
|
||||||
label="Аренда"
|
)}
|
||||||
items={data.rentalItems}
|
createItem={() => ""}
|
||||||
onChange={(rentalItems) => update({ ...data, rentalItems })}
|
addLabel="Добавить правило"
|
||||||
renderItem={(item, _i, updateItem) => (
|
/>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
</CollapsibleSection>
|
||||||
<InputField
|
</div>
|
||||||
label="Название"
|
|
||||||
value={item.name}
|
|
||||||
onChange={(v) => updateItem({ ...item, name: v })}
|
|
||||||
/>
|
|
||||||
<PriceField
|
|
||||||
label="Цена"
|
|
||||||
value={item.price}
|
|
||||||
onChange={(v) => updateItem({ ...item, price: v })}
|
|
||||||
/>
|
|
||||||
<InputField
|
|
||||||
label="Примечание"
|
|
||||||
value={item.note || ""}
|
|
||||||
onChange={(v) => updateItem({ ...item, note: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
createItem={() => ({ name: "", price: "", note: "" })}
|
|
||||||
addLabel="Добавить вариант аренды"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ArrayEditor
|
|
||||||
label="Правила"
|
|
||||||
items={data.rules}
|
|
||||||
onChange={(rules) => update({ ...data, rules })}
|
|
||||||
renderItem={(rule, _i, updateItem) => (
|
|
||||||
<InputField label="Правило" value={rule} onChange={updateItem} />
|
|
||||||
)}
|
|
||||||
createItem={() => ""}
|
|
||||||
addLabel="Добавить правило"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</SectionEditor>
|
</SectionEditor>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,11 +14,18 @@ interface NewsProps {
|
|||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleDateString("ru-RU", {
|
const d = new Date(iso);
|
||||||
|
const date = d.toLocaleDateString("ru-RU", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
// Show time only if it's a full ISO timestamp (not just date)
|
||||||
|
if (iso.includes("T")) {
|
||||||
|
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
return `${date}, ${time}`;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
} catch {
|
} catch {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
@@ -45,6 +52,7 @@ function FeaturedArticle({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
sizes="(min-width: 768px) 80vw, 100vw"
|
sizes="(min-width: 768px) 80vw, 100vw"
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
|
style={{ objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%` }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
@@ -88,6 +96,7 @@ function CompactArticle({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
sizes="112px"
|
sizes="112px"
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
style={{ objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -106,12 +115,22 @@ function CompactArticle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INITIAL_VISIBLE = 4;
|
||||||
|
|
||||||
export function News({ data }: NewsProps) {
|
export function News({ data }: NewsProps) {
|
||||||
const [selected, setSelected] = useState<NewsItem | null>(null);
|
const [selected, setSelected] = useState<NewsItem | null>(null);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
if (!data.items || data.items.length === 0) return null;
|
if (!data.items || data.items.length === 0) return null;
|
||||||
|
|
||||||
const [featured, ...rest] = data.items;
|
// Filter out empty/draft items, sort by date newest first
|
||||||
|
const sorted = [...data.items]
|
||||||
|
.filter((item) => item.title.trim() && item.text.trim() && item.image)
|
||||||
|
.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
|
||||||
|
if (sorted.length === 0) return null;
|
||||||
|
const [featured, ...rest] = sorted;
|
||||||
|
const visibleRest = showAll ? rest : rest.slice(0, INITIAL_VISIBLE - 1);
|
||||||
|
const hasMore = rest.length > INITIAL_VISIBLE - 1 && !showAll;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="news" className="section-glow relative section-padding">
|
<section id="news" className="section-glow relative section-padding">
|
||||||
@@ -129,12 +148,12 @@ export function News({ data }: NewsProps) {
|
|||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{rest.length > 0 && (
|
{visibleRest.length > 0 && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]">
|
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]">
|
||||||
{rest.map((item) => (
|
{visibleRest.map((item) => (
|
||||||
<CompactArticle
|
<CompactArticle
|
||||||
key={item.title}
|
key={item.title + item.date}
|
||||||
item={item}
|
item={item}
|
||||||
onClick={() => setSelected(item)}
|
onClick={() => setSelected(item)}
|
||||||
/>
|
/>
|
||||||
@@ -142,6 +161,19 @@ export function News({ data }: NewsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<Reveal>
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(true)}
|
||||||
|
className="rounded-full border border-white/10 bg-white/[0.03] px-6 py-2.5 text-sm font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Показать ещё ({rest.length - INITIAL_VISIBLE + 1})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,17 @@ interface NewsModalProps {
|
|||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleDateString("ru-RU", {
|
const d = new Date(iso);
|
||||||
|
const date = d.toLocaleDateString("ru-RU", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
if (iso.includes("T")) {
|
||||||
|
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
return `${date}, ${time}`;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
} catch {
|
} catch {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
@@ -76,6 +82,7 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
|
|||||||
fill
|
fill
|
||||||
sizes="(min-width: 768px) 672px, 100vw"
|
sizes="(min-width: 768px) 672px, 100vw"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
|
style={{ objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%` }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0a0a] via-transparent to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0a0a] via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export interface NewsItem {
|
|||||||
text: string;
|
text: string;
|
||||||
date: string;
|
date: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
|
imageFocalX?: number; // 0-100, default 50
|
||||||
|
imageFocalY?: number; // 0-100, default 50
|
||||||
link?: string;
|
link?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user