feat: upgrade schedule with cross-location views, day/time filters, and clickable trainers
- 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>
This commit is contained in:
@@ -1,13 +1,127 @@
|
||||
"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";
|
||||
|
||||
const ICON_OPTIONS = [
|
||||
"sparkles", "flame", "wind", "zap", "star", "monitor",
|
||||
"heart", "music", "dumbbell", "trophy",
|
||||
];
|
||||
// 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" },
|
||||
@@ -63,24 +177,10 @@ export default function ClassesEditorPage() {
|
||||
value={item.name}
|
||||
onChange={(v) => updateItem({ ...item, name: v })}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||
Иконка
|
||||
</label>
|
||||
<select
|
||||
value={item.icon}
|
||||
onChange={(e) =>
|
||||
updateItem({ ...item, icon: 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"
|
||||
>
|
||||
{ICON_OPTIONS.map((icon) => (
|
||||
<option key={icon} value={icon}>
|
||||
{icon}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<IconPicker
|
||||
value={item.icon}
|
||||
onChange={(v) => updateItem({ ...item, icon: v })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||
|
||||
Reference in New Issue
Block a user