From 5c23b622f93a9fa97adfa4b2fc34b80449d61caa Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Wed, 11 Mar 2026 20:16:26 +0300 Subject: [PATCH] feat: unique color picker for class types in schedule editor - Add clickable color picker in schedule legend (16 distinct colors) - Two-pass smart assignment: explicit colors first, then unused palette slots - Hide already-used colors from the picker (both explicit and fallback) - Colors saved to classes section and flow to public site schedule dots - Expanded palette: rose, orange, amber, yellow, lime, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, red Co-Authored-By: Claude Opus 4.6 --- src/app/admin/classes/page.tsx | 1 + src/app/admin/schedule/page.tsx | 190 +++++++++++++++--- src/app/page.tsx | 2 +- src/components/sections/Schedule.tsx | 10 +- src/components/sections/schedule/DayCard.tsx | 5 +- .../sections/schedule/MobileSchedule.tsx | 7 +- .../sections/schedule/ScheduleFilters.tsx | 5 +- src/components/sections/schedule/constants.ts | 59 +++++- src/types/content.ts | 1 + 9 files changed, 240 insertions(+), 40 deletions(-) diff --git a/src/app/admin/classes/page.tsx b/src/app/admin/classes/page.tsx index 89b2da7..71e7e03 100644 --- a/src/app/admin/classes/page.tsx +++ b/src/app/admin/classes/page.tsx @@ -17,6 +17,7 @@ interface ClassesData { icon: string; detailedDescription?: string; images?: string[]; + color?: string; }[]; } diff --git a/src/app/admin/schedule/page.tsx b/src/app/admin/schedule/page.tsx index 8f95140..47dc79e 100644 --- a/src/app/admin/schedule/page.tsx +++ b/src/app/admin/schedule/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { SectionEditor } from "../_components/SectionEditor"; import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField"; import { Plus, X, Trash2 } from "lucide-react"; @@ -31,23 +31,75 @@ const LEVELS = [ { value: "Продвинутый", label: "Продвинутый" }, ]; -const COLOR_PALETTE = [ - "bg-rose-500/80 border-rose-400", - "bg-violet-500/80 border-violet-400", - "bg-amber-500/80 border-amber-400", - "bg-emerald-500/80 border-emerald-400", - "bg-pink-500/80 border-pink-400", - "bg-sky-500/80 border-sky-400", - "bg-indigo-500/80 border-indigo-400", - "bg-orange-500/80 border-orange-400", - "bg-teal-500/80 border-teal-400", - "bg-fuchsia-500/80 border-fuchsia-400", +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" }, ]; -function getTypeColor(type: string, classTypes: string[]): string { - const idx = classTypes.indexOf(type); - if (idx >= 0) return COLOR_PALETTE[idx % COLOR_PALETTE.length]; - return "bg-neutral-600/80 border-neutral-500"; +const COLOR_MAP: Record = { + rose: "bg-rose-500/80 border-rose-400", + orange: "bg-orange-500/80 border-orange-400", + amber: "bg-amber-500/80 border-amber-400", + yellow: "bg-yellow-400/80 border-yellow-300", + lime: "bg-lime-500/80 border-lime-400", + emerald: "bg-emerald-500/80 border-emerald-400", + teal: "bg-teal-500/80 border-teal-400", + cyan: "bg-cyan-500/80 border-cyan-400", + sky: "bg-sky-500/80 border-sky-400", + blue: "bg-blue-500/80 border-blue-400", + indigo: "bg-indigo-500/80 border-indigo-400", + violet: "bg-violet-500/80 border-violet-400", + purple: "bg-purple-500/80 border-purple-400", + fuchsia: "bg-fuchsia-500/80 border-fuchsia-400", + pink: "bg-pink-500/80 border-pink-400", + red: "bg-red-500/80 border-red-400", +}; + +const FALLBACK_PALETTE = COLOR_SWATCHES.map((c) => COLOR_MAP[c.value]); + +function buildColorAssignments(classTypes: string[], classColors: Record): Record { + const result: Record = {}; + const usedPalette = new Set(); + + // First pass: assign explicit colors + for (const type of classTypes) { + const c = classColors[type]; + if (c && COLOR_MAP[c]) { + result[type] = COLOR_MAP[c]; + const idx = COLOR_SWATCHES.findIndex((s) => s.value === c); + if (idx >= 0) usedPalette.add(idx); + } + } + + // Second pass: assign remaining types to unused palette slots + let nextSlot = 0; + for (const type of classTypes) { + if (result[type]) continue; + while (usedPalette.has(nextSlot) && nextSlot < FALLBACK_PALETTE.length) nextSlot++; + result[type] = FALLBACK_PALETTE[nextSlot % FALLBACK_PALETTE.length]; + usedPalette.add(nextSlot); + nextSlot++; + } + + return result; +} + +function getTypeColor(type: string, assignments: Record): string { + return assignments[type] ?? "bg-neutral-600/80 border-neutral-500"; } // Calendar config @@ -127,7 +179,7 @@ function ClassBlock({ index, isOverlapping, isDragging, - classTypes, + colorAssignments, onClick, onDragStart, }: { @@ -135,7 +187,7 @@ function ClassBlock({ index: number; isOverlapping: boolean; isDragging: boolean; - classTypes: string[]; + colorAssignments: Record; onClick: () => void; onDragStart: (e: React.MouseEvent) => void; }) { @@ -147,7 +199,7 @@ function ClassBlock({ const top = minutesToY(startMin); const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20); - const colors = getTypeColor(cls.type, classTypes); + const colors = getTypeColor(cls.type, colorAssignments); return (
; + onColorChange: (typeName: string, color: string) => void; onChange: (loc: ScheduleLocation) => void; }) { const [editingClass, setEditingClass] = useState<{ @@ -312,6 +368,15 @@ function CalendarGrid({ cls: ScheduleClass; } | null>(null); + // Compute color assignments (explicit + smart fallback) + const colorAssignments = useMemo( + () => buildColorAssignments(classTypes, classColors), + [classTypes, classColors] + ); + + // Color picker popup + const [colorPicker, setColorPicker] = useState(null); + // Hover highlight state const [hover, setHover] = useState<{ dayIndex: number; startMin: number } | null>(null); @@ -534,7 +599,7 @@ function CalendarGrid({ const dragPreview = drag?.moved ? (() => { const sourceDay = sortedDays[drag.sourceDayIndex]; const cls = sourceDay.classes[drag.classIndex]; - const colors = getTypeColor(cls.type, classTypes); + const colors = getTypeColor(cls.type, colorAssignments); const top = minutesToY(drag.previewStartMin); const height = (drag.durationMin / 60) * HOUR_HEIGHT; const newStart = formatMinutes(drag.previewStartMin); @@ -560,15 +625,56 @@ function CalendarGrid({ />
- {/* Legend */} + {/* Legend with color picker */}
{classTypes.map((type) => { - const colors = getTypeColor(type, classTypes); + const colors = getTypeColor(type, colorAssignments); const bgClass = colors.split(" ")[0] || "bg-neutral-600/80"; + const isOpen = colorPicker === type; return ( -
-
- {type} +
+ + {isOpen && (() => { + // Build used set from resolved assignments (includes both explicit & fallback) + const usedColors = new Set( + Object.entries(colorAssignments) + .filter(([t]) => t !== type) + .map(([, cls]) => COLOR_SWATCHES.find((s) => COLOR_MAP[s.value] === cls)?.value) + .filter(Boolean) + ); + return ( +
+
+ {COLOR_SWATCHES.filter((c) => !usedColors.has(c.value)).map((c) => ( +
+
+ ); + })()}
); })} @@ -686,7 +792,7 @@ function CalendarGrid({ drag.classIndex === ci && drag.moved } - classTypes={classTypes} + colorAssignments={colorAssignments} onClick={() => { if (justDraggedRef.current) return; setEditingClass({ dayIndex: di, classIndex: ci }); @@ -762,6 +868,8 @@ export default function ScheduleEditorPage() { const [trainers, setTrainers] = useState([]); const [addresses, setAddresses] = useState([]); const [classTypes, setClassTypes] = useState([]); + const [classColors, setClassColors] = useState>({}); + const classesDataRef = useRef<{ title: string; items: { name: string; color?: string; [k: string]: unknown }[] } | null>(null); useEffect(() => { fetch("/api/admin/team") @@ -780,12 +888,36 @@ export default function ScheduleEditorPage() { fetch("/api/admin/sections/classes") .then((r) => r.json()) - .then((classes: { items?: { name: string }[] }) => { - setClassTypes((classes.items ?? []).map((c) => c.name)); + .then((classes: { title: string; items?: { name: string; color?: string }[] }) => { + const items = classes.items ?? []; + classesDataRef.current = { title: classes.title, items }; + setClassTypes(items.map((c) => c.name)); + const colors: Record = {}; + for (const item of items) { + if (item.color) colors[item.name] = item.color; + } + setClassColors(colors); }) .catch(() => {}); }, []); + const handleColorChange = useCallback((typeName: string, color: string) => { + setClassColors((prev) => ({ ...prev, [typeName]: color })); + + // Save to classes section + const data = classesDataRef.current; + if (!data) return; + const updatedItems = data.items.map((item) => + item.name === typeName ? { ...item, color } : item + ); + classesDataRef.current = { ...data, items: updatedItems }; + fetch("/api/admin/sections/classes", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...data, items: updatedItems }), + }).catch(() => {}); + }, []); + return ( sectionKey="schedule" title="Расписание"> {(data, update) => { @@ -866,6 +998,8 @@ export default function ScheduleEditorPage() { trainers={trainers} addresses={addresses} classTypes={classTypes} + classColors={classColors} + onColorChange={handleColorChange} onChange={updateLocation} /> )} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9084d60..32553cf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -29,7 +29,7 @@ export default function HomePage() { /> - + diff --git a/src/components/sections/Schedule.tsx b/src/components/sections/Schedule.tsx index 88585a2..846cec9 100644 --- a/src/components/sections/Schedule.tsx +++ b/src/components/sections/Schedule.tsx @@ -7,14 +7,16 @@ import { Reveal } from "@/components/ui/Reveal"; import { DayCard } from "./schedule/DayCard"; import { ScheduleFilters } from "./schedule/ScheduleFilters"; import { MobileSchedule } from "./schedule/MobileSchedule"; +import { buildTypeDots } from "./schedule/constants"; import type { StatusFilter } from "./schedule/constants"; import type { SiteContent } from "@/types/content"; interface ScheduleProps { data: SiteContent["schedule"]; + classItems?: { name: string; color?: string }[]; } -export function Schedule({ data: schedule }: ScheduleProps) { +export function Schedule({ data: schedule, classItems }: ScheduleProps) { const [locationIndex, setLocationIndex] = useState(0); const [filterTrainer, setFilterTrainer] = useState(null); const [filterType, setFilterType] = useState(null); @@ -22,6 +24,8 @@ export function Schedule({ data: schedule }: ScheduleProps) { const [showTrainers, setShowTrainers] = useState(false); const location = schedule.locations[locationIndex]; + const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]); + const { trainers, types, hasAnySlots, hasAnyRecruiting } = useMemo(() => { const trainerSet = new Set(); const typeSet = new Set(); @@ -108,6 +112,7 @@ export function Schedule({ data: schedule }: ScheduleProps) { {/* Compact filters — desktop only */} - +
))} diff --git a/src/components/sections/schedule/DayCard.tsx b/src/components/sections/schedule/DayCard.tsx index 03b23cb..80e7208 100644 --- a/src/components/sections/schedule/DayCard.tsx +++ b/src/components/sections/schedule/DayCard.tsx @@ -1,8 +1,7 @@ import { Clock, User } from "lucide-react"; import type { ScheduleDay } from "@/types/content"; -import { TYPE_DOT } from "./constants"; -export function DayCard({ day }: { day: ScheduleDay }) { +export function DayCard({ day, typeDots }: { day: ScheduleDay; typeDots: Record }) { return (
{/* Day header */} @@ -43,7 +42,7 @@ export function DayCard({ day }: { day: ScheduleDay }) {
- + {cls.type}
{cls.level && ( diff --git a/src/components/sections/schedule/MobileSchedule.tsx b/src/components/sections/schedule/MobileSchedule.tsx index 42e1bf5..de5711b 100644 --- a/src/components/sections/schedule/MobileSchedule.tsx +++ b/src/components/sections/schedule/MobileSchedule.tsx @@ -2,9 +2,9 @@ import { User, X } from "lucide-react"; import type { ScheduleDay } from "@/types/content"; -import { TYPE_DOT } from "./constants"; interface MobileScheduleProps { + typeDots: Record; filteredDays: ScheduleDay[]; filterType: string | null; setFilterType: (type: string | null) => void; @@ -15,6 +15,7 @@ interface MobileScheduleProps { } export function MobileSchedule({ + typeDots, filteredDays, filterType, setFilterType, @@ -37,7 +38,7 @@ export function MobileSchedule({ )} {filterType && ( - + {filterType} )} @@ -107,7 +108,7 @@ export function MobileSchedule({ onClick={() => setFilterType(filterType === cls.type ? null : cls.type)} className={`mt-0.5 flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`} > - + {cls.type}
diff --git a/src/components/sections/schedule/ScheduleFilters.tsx b/src/components/sections/schedule/ScheduleFilters.tsx index ee255f8..eca7f04 100644 --- a/src/components/sections/schedule/ScheduleFilters.tsx +++ b/src/components/sections/schedule/ScheduleFilters.tsx @@ -2,7 +2,6 @@ import { User, X, ChevronDown } from "lucide-react"; import { - TYPE_DOT, pillBase, pillActive, pillInactive, @@ -10,6 +9,7 @@ import { } from "./constants"; interface ScheduleFiltersProps { + typeDots: Record; types: string[]; trainers: string[]; hasAnySlots: boolean; @@ -27,6 +27,7 @@ interface ScheduleFiltersProps { } export function ScheduleFilters({ + typeDots, types, trainers, hasAnySlots, @@ -52,7 +53,7 @@ export function ScheduleFilters({ onClick={() => setFilterType(filterType === type ? null : type)} className={`${pillBase} ${filterType === type ? pillActive : pillInactive}`} > - + {type} ))} diff --git a/src/components/sections/schedule/constants.ts b/src/components/sections/schedule/constants.ts index af9cd1b..db5c40a 100644 --- a/src/components/sections/schedule/constants.ts +++ b/src/components/sections/schedule/constants.ts @@ -1,10 +1,67 @@ -export const TYPE_DOT: Record = { +/** Hardcoded fallback — overridden by admin-chosen colors when available */ +export const TYPE_DOT_FALLBACK: Record = { "Exotic Pole Dance": "bg-gold", "Pole Dance": "bg-rose-500", "Body Plastic": "bg-purple-500", "Трюковые комбинации с пилоном": "bg-amber-500", }; +const COLOR_KEY_TO_DOT: Record = { + rose: "bg-rose-500", + orange: "bg-orange-500", + amber: "bg-amber-500", + yellow: "bg-yellow-400", + lime: "bg-lime-500", + emerald: "bg-emerald-500", + teal: "bg-teal-500", + cyan: "bg-cyan-500", + sky: "bg-sky-500", + blue: "bg-blue-500", + indigo: "bg-indigo-500", + violet: "bg-violet-500", + purple: "bg-purple-500", + fuchsia: "bg-fuchsia-500", + pink: "bg-pink-500", + red: "bg-red-500", +}; + +const FALLBACK_DOTS = [ + "bg-rose-500", "bg-orange-500", "bg-amber-500", "bg-yellow-400", + "bg-lime-500", "bg-emerald-500", "bg-teal-500", "bg-cyan-500", + "bg-sky-500", "bg-blue-500", "bg-indigo-500", "bg-violet-500", + "bg-purple-500", "bg-fuchsia-500", "bg-pink-500", "bg-red-500", +]; + +/** Build a type→dot map from class items with optional color field */ +export function buildTypeDots( + classItems?: { name: string; color?: string }[] +): Record { + if (!classItems?.length) return TYPE_DOT_FALLBACK; + const map: Record = {}; + const usedSlots = new Set(); + + // First pass: explicit colors + classItems.forEach((item) => { + if (item.color && COLOR_KEY_TO_DOT[item.color]) { + map[item.name] = COLOR_KEY_TO_DOT[item.color]; + const idx = FALLBACK_DOTS.indexOf(COLOR_KEY_TO_DOT[item.color]); + if (idx >= 0) usedSlots.add(idx); + } + }); + + // Second pass: assign remaining to unused slots + let nextSlot = 0; + classItems.forEach((item) => { + if (map[item.name]) return; + while (usedSlots.has(nextSlot) && nextSlot < FALLBACK_DOTS.length) nextSlot++; + map[item.name] = FALLBACK_DOTS[nextSlot % FALLBACK_DOTS.length]; + usedSlots.add(nextSlot); + nextSlot++; + }); + + return map; +} + export type StatusFilter = "all" | "hasSlots" | "recruiting"; export const pillBase = diff --git a/src/types/content.ts b/src/types/content.ts index 338863f..00aa2a4 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -4,6 +4,7 @@ export interface ClassItem { icon: string; detailedDescription?: string; images?: string[]; + color?: string; } export interface TeamMember {