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:
@@ -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<string | null>(null);
|
||||
const [filterType, setFilterType] = useState<string | null>(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<string>();
|
||||
const typeSet = new Set<string>();
|
||||
@@ -108,6 +112,7 @@ export function Schedule({ data: schedule }: ScheduleProps) {
|
||||
{/* Compact filters — desktop only */}
|
||||
<Reveal>
|
||||
<ScheduleFilters
|
||||
typeDots={typeDots}
|
||||
types={types}
|
||||
trainers={trainers}
|
||||
hasAnySlots={hasAnySlots}
|
||||
@@ -129,6 +134,7 @@ export function Schedule({ data: schedule }: ScheduleProps) {
|
||||
{/* Mobile: compact agenda list with tap-to-filter */}
|
||||
<Reveal>
|
||||
<MobileSchedule
|
||||
typeDots={typeDots}
|
||||
filteredDays={filteredDays}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
@@ -151,7 +157,7 @@ export function Schedule({ data: schedule }: ScheduleProps) {
|
||||
key={day.day}
|
||||
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
|
||||
>
|
||||
<DayCard day={day} />
|
||||
<DayCard day={day} typeDots={typeDots} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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<string, string> }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden">
|
||||
{/* Day header */}
|
||||
@@ -43,7 +42,7 @@ export function DayCard({ day }: { day: ScheduleDay }) {
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
|
||||
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
|
||||
</div>
|
||||
{cls.level && (
|
||||
|
||||
@@ -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<string, string>;
|
||||
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 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${TYPE_DOT[filterType] ?? "bg-white/30"}`} />
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${typeDots[filterType] ?? "bg-white/30"}`} />
|
||||
{filterType}
|
||||
</span>
|
||||
)}
|
||||
@@ -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" : ""}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
|
||||
<span className={`text-[11px] ${filterType === cls.type ? "text-gold underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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<string, string>;
|
||||
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}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${TYPE_DOT[type] ?? "bg-white/30"}`} />
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,67 @@
|
||||
export const TYPE_DOT: Record<string, string> = {
|
||||
/** Hardcoded fallback — overridden by admin-chosen colors when available */
|
||||
export const TYPE_DOT_FALLBACK: Record<string, string> = {
|
||||
"Exotic Pole Dance": "bg-gold",
|
||||
"Pole Dance": "bg-rose-500",
|
||||
"Body Plastic": "bg-purple-500",
|
||||
"Трюковые комбинации с пилоном": "bg-amber-500",
|
||||
};
|
||||
|
||||
const COLOR_KEY_TO_DOT: Record<string, string> = {
|
||||
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<string, string> {
|
||||
if (!classItems?.length) return TYPE_DOT_FALLBACK;
|
||||
const map: Record<string, string> = {};
|
||||
const usedSlots = new Set<number>();
|
||||
|
||||
// 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 =
|
||||
|
||||
Reference in New Issue
Block a user