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 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 20:16:26 +03:00
parent 85c61cfacd
commit 5c23b622f9
9 changed files with 240 additions and 40 deletions

View File

@@ -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<string, string> = {
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<string, string>): Record<string, string> {
const result: Record<string, string> = {};
const usedPalette = new Set<number>();
// 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, string>): 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<string, string>;
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 (
<div
@@ -295,12 +347,16 @@ function CalendarGrid({
trainers,
addresses,
classTypes,
classColors,
onColorChange,
onChange,
}: {
location: ScheduleLocation;
trainers: string[];
addresses: string[];
classTypes: string[];
classColors: Record<string, string>;
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<string | null>(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({
/>
</div>
{/* Legend */}
{/* Legend with color picker */}
<div className="flex flex-wrap gap-2">
{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 (
<div key={type} className="flex items-center gap-1.5 text-xs text-neutral-300">
<div className={`h-3 w-3 rounded-sm ${bgClass}`} />
{type}
<div key={type} className="relative">
<button
type="button"
onClick={() => setColorPicker(isOpen ? null : type)}
className={`flex items-center gap-1.5 text-xs rounded-md px-2 py-1 transition-colors ${
isOpen
? "bg-white/10 text-white"
: "text-neutral-300 hover:bg-white/5 hover:text-white"
}`}
>
<div className={`h-3 w-3 rounded-sm ${bgClass}`} />
{type}
</button>
{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 (
<div className="absolute top-full left-0 mt-1 z-50 rounded-lg border border-white/10 bg-neutral-800 p-2 shadow-xl">
<div className="flex gap-1.5">
{COLOR_SWATCHES.filter((c) => !usedColors.has(c.value)).map((c) => (
<button
key={c.value}
type="button"
onClick={() => {
onColorChange(type, c.value);
setColorPicker(null);
}}
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
classColors[type] === c.value
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-800 scale-110"
: "opacity-60 hover:opacity-100"
}`}
/>
))}
</div>
</div>
);
})()}
</div>
);
})}
@@ -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<string[]>([]);
const [addresses, setAddresses] = useState<string[]>([]);
const [classTypes, setClassTypes] = useState<string[]>([]);
const [classColors, setClassColors] = useState<Record<string, string>>({});
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<string, string> = {};
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 (
<SectionEditor<ScheduleData> 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}
/>
)}