From 64e923460faa2846fc26c5f5ecbe0df3904e8463 Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Thu, 26 Mar 2026 00:43:09 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20MC=20admin=20=E2=80=94=20collapsible=20?= =?UTF-8?q?cards,=20filters,=20photo=20preview,=20validation,=20archive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapsible cards with title + hall in label, archive badge - Archived MCs sorted to bottom, dimmed with "Архив" badge - Cards have hover + focus-within gold border highlight - Date validation: error text for missing dates and invalid time ranges - Search by title/trainer + filter by date (upcoming/past) and hall - Photo preview with hover overlay (like trainer page) - ArrayEditor: hiddenItems, getItemBadge props, focus-within styles --- src/app/admin/_components/ArrayEditor.tsx | 12 +- src/app/admin/master-classes/page.tsx | 554 +++++++++++++++------- 2 files changed, 386 insertions(+), 180 deletions(-) diff --git a/src/app/admin/_components/ArrayEditor.tsx b/src/app/admin/_components/ArrayEditor.tsx index 102731d..df95621 100644 --- a/src/app/admin/_components/ArrayEditor.tsx +++ b/src/app/admin/_components/ArrayEditor.tsx @@ -13,6 +13,8 @@ interface ArrayEditorProps { addLabel?: string; collapsible?: boolean; getItemTitle?: (item: T, index: number) => string; + getItemBadge?: (item: T, index: number) => React.ReactNode; + hiddenItems?: Set; } export function ArrayEditor({ @@ -24,6 +26,8 @@ export function ArrayEditor({ addLabel = "Добавить", collapsible = false, getItemTitle, + getItemBadge, + hiddenItems, }: ArrayEditorProps) { const [dragIndex, setDragIndex] = useState(null); const [insertAt, setInsertAt] = useState(null); @@ -146,14 +150,15 @@ export function ArrayEditor({ if (dragIndex === null || insertAt === null) { return items.map((item, i) => { const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i; + const isHidden = hiddenItems?.has(i) ?? false; const title = getItemTitle?.(item, i) || `#${i + 1}`; return (
{ 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" - }`} + } ${isHidden ? "hidden" : ""}`} >
@@ -170,6 +175,7 @@ export function ArrayEditor({ className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group" > {title} + {getItemBadge?.(item, i)} )} @@ -231,7 +237,7 @@ export function ArrayEditor({
{ 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 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 focus-within:border-gold/50 focus-within:bg-neutral-800 transition-colors" >
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 }) { const raw = value.replace(/\s*BYN\s*$/i, "").trim(); return ( @@ -37,7 +69,6 @@ interface MasterClassesData { items: MasterClassItem[]; } - // --- Location Select --- function LocationSelect({ value, @@ -92,6 +123,13 @@ function calcDurationText(startTime: string, endTime: string): string { 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({ slots, onChange, @@ -123,41 +161,54 @@ function SlotsField({
{slots.map((slot, i) => { const dur = calcDurationText(slot.startTime, slot.endTime); + const timeError = hasTimeError(slot.startTime, slot.endTime); return ( -
- updateSlot(i, { date: e.target.value })} - 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" - }`} - /> - updateSlot(i, { startTime: e.target.value })} - 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]" - /> - - updateSlot(i, { endTime: e.target.value })} - 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]" - /> - {dur && ( - - {dur} - +
+
+ updateSlot(i, { date: e.target.value })} + 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" + }`} + /> + 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] ${ + timeError ? "border-red-500/50" : "border-white/10 focus:border-gold" + }`} + /> + + updateSlot(i, { endTime: 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] ${ + timeError ? "border-red-500/50" : "border-white/10 focus:border-gold" + }`} + /> + {dur && ( + + {dur} + + )} + +
+ {!slot.date && ( +

Укажите дату

+ )} + {timeError && ( +

Время окончания должно быть позже начала

)} -
); })} @@ -174,8 +225,8 @@ function SlotsField({ ); } -// --- Image Upload --- -function ImageUploadField({ +// --- Photo Preview (like trainer page) --- +function PhotoPreview({ value, onChange, }: { @@ -183,7 +234,6 @@ function ImageUploadField({ onChange: (path: string) => void; }) { const [uploading, setUploading] = useState(false); - const inputRef = useRef(null); async function handleUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -208,54 +258,48 @@ function ImageUploadField({ return (
- + {value ? ( -
-
- - - {value.split("/").pop()} - -
+
+ -
) : ( -
@@ -339,11 +383,113 @@ function ValidationHint({ fields }: { fields: Record }) { ); } +// --- Filter bar --- +type DateFilter = "all" | "upcoming" | "past"; + +const DATE_FILTER_LABELS: Record = { + 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 ( +
+
+ + 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 && ( + + )} +
+
+
+ {(Object.keys(DATE_FILTER_LABELS) as DateFilter[]).map((key) => ( + + ))} +
+ {locations.length > 0 && ( + <> + | +
+ {locations.map((loc) => ( + + ))} +
+ + )} + {visibleCount < totalCount && ( + + {visibleCount} из {totalCount} + + )} +
+
+ ); +} + // --- Main page --- export default function MasterClassesEditorPage() { const [trainers, setTrainers] = useState([]); const [styles, setStyles] = useState([]); const [locations, setLocations] = useState<{ name: string; address: string }[]>([]); + const [search, setSearch] = useState(""); + const [dateFilter, setDateFilter] = useState("all"); + const [locationFilter, setLocationFilter] = useState(""); useEffect(() => { // Fetch trainers from team @@ -376,119 +522,173 @@ export default function MasterClassesEditorPage() { sectionKey="masterClasses" title="Мастер-классы" > - {(data, update) => ( - <> - update({ ...data, title: v })} - /> + {(data, update) => { + // Sort: active first, archived at bottom + const displayItems = [...data.items].sort((a, b) => { + const aArch = isItemArchived(a); + const bArch = isItemArchived(b); + if (aArch === bArch) return 0; + return aArch ? 1 : -1; + }); - update({ ...data, items })} - renderItem={(item, _i, updateItem) => ( -
- 0 ? "ok" : "", - }} - /> + const hiddenItems = new Set(); + displayItems.forEach((item, i) => { + if ( + !itemMatchesSearch(item, search) || + !itemMatchesDateFilter(item, dateFilter) || + !itemMatchesLocation(item, locationFilter) + ) { + hiddenItems.add(i); + } + }); - updateItem({ ...item, title: v })} - placeholder="Мастер-класс от Анны Тарыбы" - /> + const visibleCount = data.items.length - hiddenItems.size; - updateItem({ ...item, image: v })} - /> + return ( + <> + update({ ...data, title: v })} + /> -
- updateItem({ ...item, trainer: v })} - options={trainers} - placeholder="Добавить тренера..." - /> - updateItem({ ...item, style: v })} - options={styles} - placeholder="Добавить стиль..." - /> -
+ - updateItem({ ...item, cost: v })} - placeholder="40" - /> + update({ ...data, items })} + collapsible + hiddenItems={hiddenItems} + getItemTitle={(item) => { + const base = item.location + ? `${item.title || "Без названия"} · ${item.location}` + : item.title || "Без названия"; + return base; + }} + getItemBadge={(item) => + isItemArchived(item) ? ( + + Архив + + ) : null + } + renderItem={(item, _i, updateItem) => { + const archived = isItemArchived(item); + return ( +
- {locations.length > 0 && ( - - updateItem({ ...item, location: v || undefined }) - } - locations={locations} - /> - )} + 0 ? "ok" : "", + }} + /> - updateItem({ ...item, slots })} - /> + updateItem({ ...item, title: v })} + placeholder="Мастер-класс от Анны Тарыбы" + /> - - updateItem({ ...item, description: v || undefined }) - } - placeholder="Описание мастер-класса, трек, стиль..." - rows={3} - /> + updateItem({ ...item, image: v })} + /> - - updateItem({ ...item, instagramUrl: v || undefined }) - } - /> +
+ updateItem({ ...item, trainer: v })} + options={trainers} + placeholder="Добавить тренера..." + /> + updateItem({ ...item, style: v })} + options={styles} + placeholder="Добавить стиль..." + /> +
- updateItem({ ...item, minParticipants: v })} - onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })} - /> + updateItem({ ...item, cost: v })} + placeholder="40" + /> -
- )} - createItem={() => ({ - title: "", - image: "", - slots: [], - trainer: "", - cost: "", - style: "", - })} - addLabel="Добавить мастер-класс" - /> - - )} + {locations.length > 0 && ( + + updateItem({ ...item, location: v || undefined }) + } + locations={locations} + /> + )} + + updateItem({ ...item, slots })} + /> + + + updateItem({ ...item, description: v || undefined }) + } + placeholder="Описание мастер-класса, трек, стиль..." + rows={3} + /> + + + updateItem({ ...item, instagramUrl: v || undefined }) + } + /> + + updateItem({ ...item, minParticipants: v })} + onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })} + /> + +
+ ); + }} + createItem={() => ({ + title: "", + image: "", + slots: [], + trainer: "", + cost: "", + style: "", + })} + addLabel="Добавить мастер-класс" + /> + + ); + }} ); }