- Add "Все студии" tab merging all locations by weekday with location sub-headers - Location tabs show hall name + address subtitle for clarity - Add day multi-select and time-of-day preset filters (Утро/День/Вечер) behind collapsible "Когда" button - Make trainer and type names clickable in day cards for inline filtering - Add group view clustering classes by trainer+type+location - Remove trainer dropdown from filter bar — filter by clicking names in schedule - Add searchable icon picker and lucide-react icon rendering for classes admin/section Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
240 lines
8.4 KiB
TypeScript
240 lines
8.4 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect, useMemo } from "react";
|
||
import { SectionEditor } from "../_components/SectionEditor";
|
||
import { InputField, TextareaField } from "../_components/FormField";
|
||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||
import { icons, type LucideIcon } from "lucide-react";
|
||
|
||
// PascalCase "HeartPulse" → kebab "heart-pulse"
|
||
function toKebab(name: string) {
|
||
return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
||
}
|
||
|
||
// All icons as { key: kebab-name, Icon: component, label: PascalCase }
|
||
const ALL_ICONS = Object.entries(icons).map(([name, Icon]) => ({
|
||
key: toKebab(name),
|
||
Icon: Icon as LucideIcon,
|
||
label: name,
|
||
}));
|
||
|
||
const ICON_BY_KEY = Object.fromEntries(ALL_ICONS.map((i) => [i.key, i]));
|
||
|
||
function IconPicker({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [search, setSearch] = useState("");
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const selected = ICON_BY_KEY[value];
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
function handle(e: MouseEvent) {
|
||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||
setOpen(false);
|
||
setSearch("");
|
||
}
|
||
}
|
||
document.addEventListener("mousedown", handle);
|
||
return () => document.removeEventListener("mousedown", handle);
|
||
}, [open]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!search) return ALL_ICONS.slice(0, 60);
|
||
const q = search.toLowerCase();
|
||
return ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 60);
|
||
}, [search]);
|
||
|
||
const SelectedIcon = selected?.Icon;
|
||
|
||
return (
|
||
<div ref={ref} className="relative">
|
||
<label className="block text-sm text-neutral-400 mb-1.5">Иконка</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setOpen(!open);
|
||
setSearch("");
|
||
setTimeout(() => inputRef.current?.focus(), 0);
|
||
}}
|
||
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-800 px-4 py-2.5 text-left text-white outline-none transition-colors ${
|
||
open ? "border-gold" : "border-white/10"
|
||
}`}
|
||
>
|
||
{SelectedIcon ? (
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-gold/20 text-gold-light">
|
||
<SelectedIcon size={16} />
|
||
</span>
|
||
) : (
|
||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-white/10 text-neutral-500">?</span>
|
||
)}
|
||
<span className="text-sm">{selected?.label || value}</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||
<div className="p-2 pb-0">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="Поиск иконки... (flame, heart, star...)"
|
||
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
|
||
/>
|
||
</div>
|
||
<div className="p-2 max-h-56 overflow-y-auto">
|
||
{filtered.length === 0 ? (
|
||
<div className="py-3 text-center text-sm text-neutral-500">Ничего не найдено</div>
|
||
) : (
|
||
<div className="grid grid-cols-6 gap-1">
|
||
{filtered.map(({ key, Icon, label }) => (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
title={label}
|
||
onClick={() => {
|
||
onChange(key);
|
||
setOpen(false);
|
||
setSearch("");
|
||
}}
|
||
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 transition-colors ${
|
||
key === value
|
||
? "bg-gold/20 text-gold-light"
|
||
: "text-neutral-400 hover:bg-white/5 hover:text-white"
|
||
}`}
|
||
>
|
||
<Icon size={20} />
|
||
<span className="text-[10px] leading-tight truncate w-full text-center">{label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const COLOR_SWATCHES: { value: string; bg: string }[] = [
|
||
{ value: "rose", bg: "bg-rose-500" },
|
||
{ value: "orange", bg: "bg-orange-500" },
|
||
{ value: "amber", bg: "bg-amber-500" },
|
||
{ value: "yellow", bg: "bg-yellow-400" },
|
||
{ value: "lime", bg: "bg-lime-500" },
|
||
{ value: "emerald", bg: "bg-emerald-500" },
|
||
{ value: "teal", bg: "bg-teal-500" },
|
||
{ value: "cyan", bg: "bg-cyan-500" },
|
||
{ value: "sky", bg: "bg-sky-500" },
|
||
{ value: "blue", bg: "bg-blue-500" },
|
||
{ value: "indigo", bg: "bg-indigo-500" },
|
||
{ value: "violet", bg: "bg-violet-500" },
|
||
{ value: "purple", bg: "bg-purple-500" },
|
||
{ value: "fuchsia", bg: "bg-fuchsia-500" },
|
||
{ value: "pink", bg: "bg-pink-500" },
|
||
{ value: "red", bg: "bg-red-500" },
|
||
];
|
||
|
||
interface ClassesData {
|
||
title: string;
|
||
items: {
|
||
name: string;
|
||
description: string;
|
||
icon: string;
|
||
detailedDescription?: string;
|
||
images?: string[];
|
||
color?: string;
|
||
}[];
|
||
}
|
||
|
||
export default function ClassesEditorPage() {
|
||
return (
|
||
<SectionEditor<ClassesData> sectionKey="classes" title="Направления">
|
||
{(data, update) => (
|
||
<>
|
||
<InputField
|
||
label="Заголовок секции"
|
||
value={data.title}
|
||
onChange={(v) => update({ ...data, title: v })}
|
||
/>
|
||
|
||
<ArrayEditor
|
||
label="Направления"
|
||
items={data.items}
|
||
onChange={(items) => update({ ...data, items })}
|
||
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 })}
|
||
/>
|
||
<IconPicker
|
||
value={item.icon}
|
||
onChange={(v) => updateItem({ ...item, icon: v })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">
|
||
Цвет в расписании
|
||
</label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{COLOR_SWATCHES.map((c) => {
|
||
const isUsed = data.items.some(
|
||
(other) => other !== item && other.color === c.value
|
||
);
|
||
if (isUsed) return null;
|
||
return (
|
||
<button
|
||
key={c.value}
|
||
type="button"
|
||
onClick={() => updateItem({ ...item, color: c.value })}
|
||
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
|
||
item.color === c.value
|
||
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-900 scale-110"
|
||
: "opacity-50 hover:opacity-100"
|
||
}`}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<TextareaField
|
||
label="Краткое описание"
|
||
value={item.description}
|
||
onChange={(v) => updateItem({ ...item, description: v })}
|
||
rows={2}
|
||
/>
|
||
<TextareaField
|
||
label="Подробное описание"
|
||
value={item.detailedDescription || ""}
|
||
onChange={(v) =>
|
||
updateItem({ ...item, detailedDescription: v })
|
||
}
|
||
rows={4}
|
||
/>
|
||
</div>
|
||
)}
|
||
createItem={() => ({
|
||
name: "",
|
||
description: "",
|
||
icon: "sparkles",
|
||
detailedDescription: "",
|
||
images: [],
|
||
})}
|
||
addLabel="Добавить направление"
|
||
/>
|
||
</>
|
||
)}
|
||
</SectionEditor>
|
||
);
|
||
}
|