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:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
import { Plus, Trash2, GripVertical, ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
interface ArrayEditorProps<T> {
|
interface ArrayEditorProps<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
@@ -11,6 +11,8 @@ interface ArrayEditorProps<T> {
|
|||||||
createItem: () => T;
|
createItem: () => T;
|
||||||
label?: string;
|
label?: string;
|
||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
getItemTitle?: (item: T, index: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArrayEditor<T>({
|
export function ArrayEditor<T>({
|
||||||
@@ -20,6 +22,8 @@ export function ArrayEditor<T>({
|
|||||||
createItem,
|
createItem,
|
||||||
label,
|
label,
|
||||||
addLabel = "Добавить",
|
addLabel = "Добавить",
|
||||||
|
collapsible = false,
|
||||||
|
getItemTitle,
|
||||||
}: 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);
|
||||||
@@ -29,6 +33,16 @@ 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 [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); }, []);
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
@@ -130,32 +144,63 @@ export function ArrayEditor<T>({
|
|||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
if (dragIndex === null || insertAt === null) {
|
if (dragIndex === null || insertAt === null) {
|
||||||
return items.map((item, i) => (
|
return items.map((item, i) => {
|
||||||
<div
|
const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i;
|
||||||
key={i}
|
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||||
ref={(el) => { itemRefs.current[i] = el; }}
|
return (
|
||||||
className={`rounded-lg border bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-all ${
|
<div
|
||||||
newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
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 ${
|
||||||
<div className="flex items-start justify-between gap-2 mb-3">
|
newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
||||||
<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)}
|
<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">
|
||||||
<GripVertical size={16} />
|
<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>
|
</div>
|
||||||
<button
|
{collapsible ? (
|
||||||
type="button"
|
<div
|
||||||
onClick={() => removeItem(i)}
|
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<div className="overflow-hidden">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
);
|
||||||
</div>
|
});
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const elements: React.ReactNode[] = [];
|
const elements: React.ReactNode[] = [];
|
||||||
|
|||||||
@@ -4,21 +4,63 @@ import { useState, useRef, useEffect, useMemo } from "react";
|
|||||||
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 { 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"
|
// PascalCase "HeartPulse" → kebab "heart-pulse"
|
||||||
function toKebab(name: string) {
|
function toKebab(name: string) {
|
||||||
return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
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]) => ({
|
const ALL_ICONS = Object.entries(icons).map(([name, Icon]) => ({
|
||||||
key: toKebab(name),
|
key: toKebab(name),
|
||||||
Icon: Icon as LucideIcon,
|
Icon: Icon as LucideIcon,
|
||||||
label: name,
|
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({
|
function IconPicker({
|
||||||
value,
|
value,
|
||||||
@@ -46,9 +88,12 @@ function IconPicker({
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return ALL_ICONS.slice(0, 60);
|
if (!search) return CURATED_ICONS;
|
||||||
const q = search.toLowerCase();
|
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]);
|
}, [search]);
|
||||||
|
|
||||||
const SelectedIcon = selected?.Icon;
|
const SelectedIcon = selected?.Icon;
|
||||||
@@ -85,7 +130,7 @@ function IconPicker({
|
|||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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"
|
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>
|
||||||
@@ -188,18 +233,21 @@ export default function ClassesEditorPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{COLOR_SWATCHES.map((c) => {
|
{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
|
(other) => other !== item && other.color === c.value
|
||||||
);
|
);
|
||||||
if (isUsed) return null;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={c.value}
|
key={c.value}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isUsed}
|
||||||
onClick={() => updateItem({ ...item, color: c.value })}
|
onClick={() => updateItem({ ...item, color: c.value })}
|
||||||
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
|
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"
|
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-900 scale-110"
|
||||||
|
: isUsed
|
||||||
|
? "opacity-15 cursor-not-allowed"
|
||||||
: "opacity-50 hover:opacity-100"
|
: "opacity-50 hover:opacity-100"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -231,6 +279,8 @@ export default function ClassesEditorPage() {
|
|||||||
images: [],
|
images: [],
|
||||||
})}
|
})}
|
||||||
addLabel="Добавить направление"
|
addLabel="Добавить направление"
|
||||||
|
collapsible
|
||||||
|
getItemTitle={(item) => item.name || "Без названия"}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||||
|
|
||||||
{/* Icon + name overlay */}
|
{/* Icon + name overlay */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-6">
|
<div className="absolute bottom-0 left-0 right-0 p-6 flex items-center gap-3">
|
||||||
<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="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)}
|
{getIcon(item.icon)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-white">
|
<h3 className="text-2xl font-bold text-white">
|
||||||
|
|||||||
@@ -43,14 +43,24 @@ export function ShowcaseLayout<T>({
|
|||||||
}
|
}
|
||||||
}, [displayIndex, fading]);
|
}, [displayIndex, fading]);
|
||||||
|
|
||||||
|
const [fadingIn, setFadingIn] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeIndex === displayIndex) return;
|
if (activeIndex === displayIndex) return;
|
||||||
setFading(true);
|
setFading(true);
|
||||||
const timeout = setTimeout(() => {
|
// Wait for fade-out, then swap content while hidden
|
||||||
|
const fadeOut = setTimeout(() => {
|
||||||
setDisplayIndex(activeIndex);
|
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);
|
}, UI_CONFIG.showcase.fadeMs);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(fadeOut);
|
||||||
}, [activeIndex, displayIndex]);
|
}, [activeIndex, displayIndex]);
|
||||||
|
|
||||||
// Auto-scroll selector only when item is out of view
|
// Auto-scroll selector only when item is out of view
|
||||||
@@ -136,7 +146,7 @@ export function ShowcaseLayout<T>({
|
|||||||
<div
|
<div
|
||||||
ref={detailRef}
|
ref={detailRef}
|
||||||
className={`transition-all duration-300 ease-out ${
|
className={`transition-all duration-300 ease-out ${
|
||||||
fading
|
fading || fadingIn
|
||||||
? "opacity-0 translate-y-2"
|
? "opacity-0 translate-y-2"
|
||||||
: "opacity-100 translate-y-0"
|
: "opacity-100 translate-y-0"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user