feat: classes admin collapsible cards, icon curation, color fix + user-side polish

Admin classes:
- Collapsible cards in ArrayEditor (start collapsed, expand on click)
- Curated 29 dance-relevant icons shown by default, full search as fallback
- Color swatches: used colors dimmed instead of hidden (no layout shift)

User side:
- Classes: icon + name side by side on photo overlay
- ShowcaseLayout: fix image flash during transition (2-frame swap while hidden)
- Team bio: section headings gold, admin cards focus-within highlight
This commit is contained in:
2026-03-25 23:26:15 +03:00
parent 24d48a9409
commit 4805c3b9ea
4 changed files with 145 additions and 40 deletions

View File

@@ -2,7 +2,7 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus, Trash2, GripVertical } from "lucide-react";
import { Plus, Trash2, GripVertical, ChevronDown } from "lucide-react";
interface ArrayEditorProps<T> {
items: T[];
@@ -11,6 +11,8 @@ interface ArrayEditorProps<T> {
createItem: () => T;
label?: string;
addLabel?: string;
collapsible?: boolean;
getItemTitle?: (item: T, index: number) => string;
}
export function ArrayEditor<T>({
@@ -20,6 +22,8 @@ export function ArrayEditor<T>({
createItem,
label,
addLabel = "Добавить",
collapsible = false,
getItemTitle,
}: ArrayEditorProps<T>) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [insertAt, setInsertAt] = useState<number | null>(null);
@@ -29,6 +33,16 @@ export function ArrayEditor<T>({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const [mounted, setMounted] = useState(false);
const [newItemIndex, setNewItemIndex] = useState<number | null>(null);
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
function toggleCollapse(index: number) {
setCollapsed(prev => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
}
useEffect(() => { setMounted(true); }, []);
@@ -130,32 +144,63 @@ export function ArrayEditor<T>({
function renderList() {
if (dragIndex === null || insertAt === null) {
return items.map((item, i) => (
<div
key={i}
ref={(el) => { itemRefs.current[i] = el; }}
className={`rounded-lg border bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-all ${
newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
}`}
>
<div className="flex items-start justify-between gap-2 mb-3">
<div
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)}
>
<GripVertical size={16} />
return items.map((item, i) => {
const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i;
const title = getItemTitle?.(item, i) || `#${i + 1}`;
return (
<div
key={i}
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 ${
newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
}`}
>
<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="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)}
>
<GripVertical size={16} />
</div>
{collapsible && (
<button
type="button"
onClick={() => toggleCollapse(i)}
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>
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
</button>
)}
</div>
<button
type="button"
onClick={() => removeItem(i)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
>
<Trash2 size={16} />
</button>
</div>
<button
type="button"
onClick={() => removeItem(i)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
>
<Trash2 size={16} />
</button>
{collapsible ? (
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
>
<div className="overflow-hidden">
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
</div>
</div>
) : (
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
)}
</div>
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
));
);
});
}
const elements: React.ReactNode[] = [];

View File

@@ -4,21 +4,63 @@ 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";
import {
icons, type LucideIcon,
Flame, Heart, HeartPulse, Star, Sparkles, Music, Zap, Crown,
Dumbbell, Wind, Moon, Sun, Ribbon, Gem, Feather, CircleDot,
Activity, Drama, PersonStanding, Footprints, PartyPopper, Flower2,
Waves, Eye, Orbit, Brush, Palette, HandMetal, Theater,
} from "lucide-react";
// Curated icons for dance school
const CURATED_ICONS: { key: string; Icon: LucideIcon; label: string }[] = [
{ key: "flame", Icon: Flame, label: "Flame" },
{ key: "heart", Icon: Heart, label: "Heart" },
{ key: "heart-pulse", Icon: HeartPulse, label: "HeartPulse" },
{ key: "star", Icon: Star, label: "Star" },
{ key: "sparkles", Icon: Sparkles, label: "Sparkles" },
{ key: "music", Icon: Music, label: "Music" },
{ key: "zap", Icon: Zap, label: "Zap" },
{ key: "crown", Icon: Crown, label: "Crown" },
{ key: "dumbbell", Icon: Dumbbell, label: "Dumbbell" },
{ key: "wind", Icon: Wind, label: "Wind" },
{ key: "moon", Icon: Moon, label: "Moon" },
{ key: "sun", Icon: Sun, label: "Sun" },
{ key: "ribbon", Icon: Ribbon, label: "Ribbon" },
{ key: "gem", Icon: Gem, label: "Gem" },
{ key: "feather", Icon: Feather, label: "Feather" },
{ key: "circle-dot", Icon: CircleDot, label: "CircleDot" },
{ key: "activity", Icon: Activity, label: "Activity" },
{ key: "drama", Icon: Drama, label: "Drama" },
{ key: "person-standing", Icon: PersonStanding, label: "PersonStanding" },
{ key: "footprints", Icon: Footprints, label: "Footprints" },
{ key: "party-popper", Icon: PartyPopper, label: "PartyPopper" },
{ key: "flower-2", Icon: Flower2, label: "Flower" },
{ key: "waves", Icon: Waves, label: "Waves" },
{ key: "eye", Icon: Eye, label: "Eye" },
{ key: "orbit", Icon: Orbit, label: "Orbit" },
{ key: "brush", Icon: Brush, label: "Brush" },
{ key: "palette", Icon: Palette, label: "Palette" },
{ key: "hand-metal", Icon: HandMetal, label: "HandMetal" },
{ key: "theater", Icon: Theater, label: "Theater" },
];
// 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 }
// Full icon list for search fallback
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]));
const ICON_BY_KEY = Object.fromEntries([
...CURATED_ICONS.map((i) => [i.key, i]),
...ALL_ICONS.map((i) => [i.key, i]),
]);
function IconPicker({
value,
@@ -46,9 +88,12 @@ function IconPicker({
}, [open]);
const filtered = useMemo(() => {
if (!search) return ALL_ICONS.slice(0, 60);
if (!search) return CURATED_ICONS;
const q = search.toLowerCase();
return ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 60);
// Search curated first, then all icons
const curated = CURATED_ICONS.filter((i) => i.label.toLowerCase().includes(q));
const rest = ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q) && !curated.some((c) => c.key === i.key));
return [...curated, ...rest].slice(0, 40);
}, [search]);
const SelectedIcon = selected?.Icon;
@@ -85,7 +130,7 @@ function IconPicker({
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск иконки... (flame, heart, star...)"
placeholder="Поиск..."
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>
@@ -188,18 +233,21 @@ export default function ClassesEditorPage() {
</label>
<div className="flex flex-wrap gap-1.5">
{COLOR_SWATCHES.map((c) => {
const isUsed = data.items.some(
const isSelected = item.color === c.value;
const isUsed = !isSelected && data.items.some(
(other) => other !== item && other.color === c.value
);
if (isUsed) return null;
return (
<button
key={c.value}
type="button"
disabled={isUsed}
onClick={() => updateItem({ ...item, color: c.value })}
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
item.color === c.value
isSelected
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-900 scale-110"
: isUsed
? "opacity-15 cursor-not-allowed"
: "opacity-50 hover:opacity-100"
}`}
/>
@@ -231,6 +279,8 @@ export default function ClassesEditorPage() {
images: [],
})}
addLabel="Добавить направление"
collapsible
getItemTitle={(item) => item.name || "Без названия"}
/>
</>
)}

View File

@@ -61,8 +61,8 @@ export function Classes({ data: classes }: ClassesProps) {
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{/* Icon + name overlay */}
<div className="absolute bottom-0 left-0 right-0 p-6">
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm">
<div className="absolute bottom-0 left-0 right-0 p-6 flex items-center gap-3">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm">
{getIcon(item.icon)}
</div>
<h3 className="text-2xl font-bold text-white">

View File

@@ -43,14 +43,24 @@ export function ShowcaseLayout<T>({
}
}, [displayIndex, fading]);
const [fadingIn, setFadingIn] = useState(false);
useEffect(() => {
if (activeIndex === displayIndex) return;
setFading(true);
const timeout = setTimeout(() => {
// Wait for fade-out, then swap content while hidden
const fadeOut = setTimeout(() => {
setDisplayIndex(activeIndex);
setFading(false);
// Keep hidden for one frame so new content renders before fade-in
setFadingIn(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setFading(false);
setFadingIn(false);
});
});
}, UI_CONFIG.showcase.fadeMs);
return () => clearTimeout(timeout);
return () => clearTimeout(fadeOut);
}, [activeIndex, displayIndex]);
// Auto-scroll selector only when item is out of view
@@ -136,7 +146,7 @@ export function ShowcaseLayout<T>({
<div
ref={detailRef}
className={`transition-all duration-300 ease-out ${
fading
fading || fadingIn
? "opacity-0 translate-y-2"
: "opacity-100 translate-y-0"
}`}