diff --git a/src/components/StageBadge.tsx b/src/components/StageBadge.tsx new file mode 100644 index 0000000..726d9eb --- /dev/null +++ b/src/components/StageBadge.tsx @@ -0,0 +1,38 @@ +import { MapPin } from "lucide-react"; + +interface StageBadgeProps { + stageName: string; + stageColor?: string; + size?: "sm" | "md"; + showIcon?: boolean; +} + +export function StageBadge({ + stageName, + stageColor, + size = "sm", + showIcon = true, +}: StageBadgeProps) { + const sizeClasses = { + sm: "text-xs px-2 py-1 gap-1", + md: "text-sm px-3 py-1.5 gap-2", + }; + + const iconSize = { + sm: "h-3 w-3", + md: "h-4 w-4", + }; + + return ( +
+ {showIcon && } + {stageName} +
+ ); +} diff --git a/src/hooks/queries/stages/useCreateStage.ts b/src/hooks/queries/stages/useCreateStage.ts index b2a4f9c..51e4679 100644 --- a/src/hooks/queries/stages/useCreateStage.ts +++ b/src/hooks/queries/stages/useCreateStage.ts @@ -7,6 +7,8 @@ import { generateSlug } from "@/lib/slug"; async function createStage(stageData: { name: string; festival_edition_id: string; + stage_order?: number; + color?: string; }) { const { data, error } = await supabase .from("stages") @@ -27,8 +29,10 @@ export function useCreateStageMutation() { return useMutation({ mutationFn: createStage, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: stagesKeys.all }); + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: stagesKeys.all, + }); toast({ title: "Success", description: "Stage created successfully", diff --git a/src/hooks/queries/stages/useDeleteStage.ts b/src/hooks/queries/stages/useDeleteStage.ts index 2e536f7..2882577 100644 --- a/src/hooks/queries/stages/useDeleteStage.ts +++ b/src/hooks/queries/stages/useDeleteStage.ts @@ -18,8 +18,10 @@ export function useDeleteStageMutation() { return useMutation({ mutationFn: deleteStage, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: stagesKeys.all }); + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: stagesKeys.all, + }); toast({ title: "Success", description: "Stage deleted successfully", diff --git a/src/hooks/queries/stages/useStagesByEdition.ts b/src/hooks/queries/stages/useStagesByEdition.ts index 6da8ef2..f2a16d0 100644 --- a/src/hooks/queries/stages/useStagesByEdition.ts +++ b/src/hooks/queries/stages/useStagesByEdition.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; import { Stage, stagesKeys } from "./types"; +import { sortStagesByOrder } from "@/lib/stageUtils"; async function fetchStagesByEdition(editionId: string): Promise { const { data, error } = await supabase @@ -14,7 +15,8 @@ async function fetchStagesByEdition(editionId: string): Promise { throw new Error("Failed to load stages for edition"); } - return data || []; + // Apply custom sorting using shared utility + return sortStagesByOrder(data || []); } export function useStagesByEditionQuery(editionId: string | undefined) { diff --git a/src/hooks/queries/stages/useUpdateStage.ts b/src/hooks/queries/stages/useUpdateStage.ts index 38ef358..b1b28c9 100644 --- a/src/hooks/queries/stages/useUpdateStage.ts +++ b/src/hooks/queries/stages/useUpdateStage.ts @@ -3,7 +3,10 @@ import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import { stagesKeys } from "./types"; -async function updateStage(stageId: string, stageData: { name: string }) { +async function updateStage( + stageId: string, + stageData: { name: string; stage_order?: number; color?: string }, +) { const { data, error } = await supabase .from("stages") .update(stageData) @@ -25,10 +28,12 @@ export function useUpdateStageMutation() { stageData, }: { stageId: string; - stageData: { name: string }; + stageData: { name: string; stage_order?: number; color?: string }; }) => updateStage(stageId, stageData), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: stagesKeys.all }); + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: stagesKeys.all, + }); toast({ title: "Success", description: "Stage updated successfully", diff --git a/src/hooks/useScheduleData.ts b/src/hooks/useScheduleData.ts index f01117e..1196187 100644 --- a/src/hooks/useScheduleData.ts +++ b/src/hooks/useScheduleData.ts @@ -3,6 +3,7 @@ import { formatDateTime } from "@/lib/timeUtils"; import { format, startOfDay } from "date-fns"; import type { FestivalSet } from "@/hooks/queries/sets/useSets"; import { Stage } from "./queries/stages/types"; +import { sortStagesByOrder } from "@/lib/stageUtils"; export interface ScheduleDay { date: string; @@ -13,6 +14,7 @@ export interface ScheduleDay { export interface ScheduleStage { id: string; name: string; + stage_order: number; sets: ScheduleSet[]; } @@ -127,18 +129,19 @@ export function useScheduleData( return { id: stageId, name: stage?.name, + stage_order: stage?.stage_order, sets: stageSets.sort((a, b) => { if (!a.startTime || !b.startTime) return 0; return a.startTime.getTime() - b.startTime.getTime(); }), - }; + } satisfies ScheduleStage; }) .filter((v: ScheduleStage | null): v is ScheduleStage => !!v); return { date: dateKey, displayDate: format(date, "EEEE, MMM d"), - stages: scheduleStages.sort((a, b) => a.name.localeCompare(b.name)), + stages: sortStagesByOrder(scheduleStages), }; }); diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 5c85de3..a25007b 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -640,29 +640,35 @@ export type Database = { stages: { Row: { archived: boolean; + color: string | null; created_at: string; festival_edition_id: string; id: string; name: string; slug: string; + stage_order: number; updated_at: string; }; Insert: { archived?: boolean; + color?: string | null; created_at?: string; festival_edition_id: string; id?: string; name: string; slug: string; + stage_order?: number; updated_at?: string; }; Update: { archived?: boolean; + color?: string | null; created_at?: string; festival_edition_id?: string; id?: string; name?: string; slug?: string; + stage_order?: number; updated_at?: string; }; Relationships: []; diff --git a/src/lib/constants/stages.ts b/src/lib/constants/stages.ts new file mode 100644 index 0000000..c71f8c4 --- /dev/null +++ b/src/lib/constants/stages.ts @@ -0,0 +1 @@ +export const DEFAULT_STAGE_COLOR = "#6b7280"; diff --git a/src/lib/stageUtils.ts b/src/lib/stageUtils.ts new file mode 100644 index 0000000..4cdb387 --- /dev/null +++ b/src/lib/stageUtils.ts @@ -0,0 +1,28 @@ +import type { Stage } from "@/hooks/queries/stages/types"; + +/** + * Sorts stages by priority: stages with order > 0 come first (sorted by order), + * then stages with order 0 come last (sorted alphabetically by name) + */ +export function sortStagesByOrder< + T extends Pick, +>(items: T[]): T[] { + return items.sort((stageA, stageB) => { + const orderA = stageA.stage_order ?? 0; + const orderB = stageB.stage_order ?? 0; + + // Stages with order > 0 come first, sorted by order + // Stages with order 0 come last, sorted by name + if (orderA > 0 && orderB > 0) { + return orderA - orderB; + } + if (orderA > 0 && orderB === 0) { + return -1; // A comes before B + } + if (orderA === 0 && orderB > 0) { + return 1; // B comes before A + } + // Both are 0, sort by name + return stageA.name.localeCompare(stageB.name); + }); +} diff --git a/src/lib/timelineCalculator.ts b/src/lib/timelineCalculator.ts index 82f822c..b7cc2db 100644 --- a/src/lib/timelineCalculator.ts +++ b/src/lib/timelineCalculator.ts @@ -1,5 +1,7 @@ import { differenceInMinutes } from "date-fns"; import type { ScheduleSet, ScheduleDay } from "@/hooks/useScheduleData"; +import type { Stage } from "@/hooks/queries/stages/types"; +import { sortStagesByOrder } from "@/lib/stageUtils"; export interface HorizontalTimelineSet extends ScheduleSet { horizontalPosition?: { @@ -8,17 +10,11 @@ export interface HorizontalTimelineSet extends ScheduleSet { }; } -export interface VerticalTimelineSet extends ScheduleSet { - verticalPosition?: { - top: number; - height: number; - }; -} - export interface TimelineData { timeSlots: Date[]; stages: Array<{ name: string; + color?: string; sets: HorizontalTimelineSet[]; }>; totalWidth: number; @@ -26,98 +22,61 @@ export interface TimelineData { festivalEnd: Date; } -export interface VerticalTimelineData { - timeSlots: Date[]; - stages: Array<{ - name: string; - sets: VerticalTimelineSet[]; - }>; - totalHeight: number; - festivalStart: Date; - festivalEnd: Date; -} - export function calculateTimelineData( festivalStartDate: Date, festivalEndDate: Date, scheduleDays: ScheduleDay[], + stages: Stage[], ): TimelineData | null { - if (!scheduleDays || scheduleDays.length === 0) return null; - - // Require festival dates to be provided - if (!festivalStartDate || !festivalEndDate) { + if (!scheduleDays || scheduleDays.length === 0) { return null; } - // Find the earliest set time from all scheduled sets - const earliestSetTime = calculateEarliestSetTime(scheduleDays); - const earliestTime = earliestSetTime || new Date(festivalStartDate); - - const latestSetTime = calculateLatestSetTime(scheduleDays); - const latestTime = latestSetTime || new Date(festivalEndDate); - - // Create unified time grid from festival start to end - const timeSlots = []; - const totalMinutes = differenceInMinutes(latestTime, earliestTime); - const totalHours = Math.ceil(totalMinutes / 60); - - for (let i = 0; i <= totalHours; i++) { - const timeSlot = new Date(earliestTime.getTime() + i * 60 * 60 * 1000); - timeSlots.push(timeSlot); + if (!festivalStartDate || !festivalEndDate) { + return null; } - // Collect all sets from all days/stages into unified stage groups - const allStageGroups: Record = {}; - - scheduleDays.forEach((day) => { - day.stages.forEach((stage) => { - if (!allStageGroups[stage.name]) { - allStageGroups[stage.name] = []; - } - - // Calculate positions for sets relative to festival start - const enhancedSets = stage.sets.map((set): HorizontalTimelineSet => { - if (!set.startTime || !set.endTime) return set; - - const startMinutes = differenceInMinutes(set.startTime, earliestTime); - const duration = differenceInMinutes(set.endTime, set.startTime); - - // Calculate positions (1 minute = 2px) - const left = startMinutes * 2; - const width = Math.max(duration * 2, 100); // Minimum width of 100px - - return { - ...set, - horizontalPosition: { - left, - width, - }, - }; - }); - - allStageGroups[stage.name].push(...enhancedSets); - }); - }); - - // Create unified stages array - stages will be sorted alphabetically by default - const unifiedStages = Object.entries(allStageGroups) - .map(([stageName, sets]) => ({ - name: stageName, - sets: sets.sort((a, b) => { - if (!a.startTime || !b.startTime) return 0; - return a.startTime.getTime() - b.startTime.getTime(); - }), - })) - .sort((a, b) => a.name.localeCompare(b.name)); + const { earliestTime, latestTime, totalHours } = calculateTimeBoundaries( + festivalStartDate, + festivalEndDate, + scheduleDays, + ); + + const timeSlots = generateTimeSlots(earliestTime, totalHours); + + const unifiedStages = processStageGroups( + scheduleDays, + stages, + earliestTime, + ( + set: ScheduleSet, + startMinutes: number, + duration: number, + ): HorizontalTimelineSet => { + if (!set.startTime || !set.endTime) return set; + + const left = startMinutes * 2; + const width = Math.max(duration * 2, 100); + + return { + ...set, + horizontalPosition: { + left, + width, + }, + }; + }, + ); return { timeSlots, stages: unifiedStages, - totalWidth: totalHours * 120, // 120px per hour + totalWidth: totalHours * 120, festivalStart: earliestTime, festivalEnd: latestTime, }; } + function calculateEarliestSetTime(scheduleDays: ScheduleDay[]) { let earliestSetTime: Date | null = null; @@ -166,78 +125,102 @@ function calculateLatestSetTime(scheduleDays: ScheduleDay[]) { return latestTime; } -export function calculateVerticalTimelineData( +interface TimeBoundaries { + earliestTime: Date; + latestTime: Date; + totalHours: number; +} + +function calculateTimeBoundaries( festivalStartDate: Date, festivalEndDate: Date, scheduleDays: ScheduleDay[], -): VerticalTimelineData | null { - if (!scheduleDays || scheduleDays.length === 0) return null; - - if (!festivalStartDate || !festivalEndDate) { - return null; - } - +): TimeBoundaries { const earliestSetTime = calculateEarliestSetTime(scheduleDays); const earliestTime = earliestSetTime || new Date(festivalStartDate); const latestSetTime = calculateLatestSetTime(scheduleDays); const latestTime = latestSetTime || new Date(festivalEndDate); - const timeSlots = []; const totalMinutes = differenceInMinutes(latestTime, earliestTime); const totalHours = Math.ceil(totalMinutes / 60); + return { earliestTime, latestTime, totalHours }; +} + +function generateTimeSlots(earliestTime: Date, totalHours: number): Date[] { + const timeSlots = []; for (let i = 0; i <= totalHours; i++) { const timeSlot = new Date(earliestTime.getTime() + i * 60 * 60 * 1000); timeSlots.push(timeSlot); } + return timeSlots; +} - const allStageGroups: Record = {}; +function processStageGroups( + scheduleDays: ScheduleDay[], + stages: Stage[], + earliestTime: Date, + positionCalculator: ( + set: ScheduleSet, + startMinutes: number, + duration: number, + ) => T, +): Array<{ + name: string; + color: string | undefined; + stage_order: number; + sets: T[]; +}> { + const allStageGroups: Record = {}; scheduleDays.forEach((day) => { day.stages.forEach((stage) => { - if (!allStageGroups[stage.name]) { - allStageGroups[stage.name] = []; + if (!allStageGroups[stage.id]) { + allStageGroups[stage.id] = []; } - const enhancedSets = stage.sets.map((set): VerticalTimelineSet => { - if (!set.startTime || !set.endTime) return set; + const enhancedSets = stage.sets.map((set): T => { + if (!set.startTime || !set.endTime) + return positionCalculator(set, 0, 0); const startMinutes = differenceInMinutes(set.startTime, earliestTime); const duration = differenceInMinutes(set.endTime, set.startTime); - // Calculate vertical positions (1 minute = 2px) - const top = startMinutes * 2; - const height = Math.max(duration * 2, 60); // Minimum height of 60px - - return { - ...set, - verticalPosition: { - top, - height, - }, - }; + return positionCalculator(set, startMinutes, duration); }); - allStageGroups[stage.name].push(...enhancedSets); + allStageGroups[stage.id].push(...enhancedSets); }); }); - const unifiedStages = Object.entries(allStageGroups) - .map(([stageName, sets]) => ({ - name: stageName, - sets: sets.sort((a, b) => { - if (!a.startTime || !b.startTime) return 0; - return a.startTime.getTime() - b.startTime.getTime(); - }), - })) - .sort((a, b) => a.name.localeCompare(b.name)); + const unifiedStagesUnsorted = Object.entries(allStageGroups) + .map(([stageId, sets]) => { + const stage = stages.find((s) => s.id === stageId); + if (!stage) { + return null; + } - return { - timeSlots, - stages: unifiedStages, - totalHeight: totalHours * 120, // 120px per hour - festivalStart: earliestTime, - festivalEnd: latestTime, - }; + return { + name: stage.name, + color: stage.color || undefined, + stage_order: stage.stage_order || 0, + sets: sets.sort((a, b) => { + if (!a.startTime || !b.startTime) return 0; + return a.startTime.getTime() - b.startTime.getTime(); + }), + }; + }) + .filter( + ( + s, + ): s is { + name: string; + color: string | undefined; + stage_order: number; + sets: T[]; + } => !!s, + ); + + return sortStagesByOrder(unifiedStagesUnsorted); } diff --git a/src/pages/EditionView/tabs/ArtistsTab/SetCard/SetMetadata.tsx b/src/pages/EditionView/tabs/ArtistsTab/SetCard/SetMetadata.tsx index c08d4fb..3915019 100644 --- a/src/pages/EditionView/tabs/ArtistsTab/SetCard/SetMetadata.tsx +++ b/src/pages/EditionView/tabs/ArtistsTab/SetCard/SetMetadata.tsx @@ -1,6 +1,7 @@ -import { MapPin, Clock } from "lucide-react"; +import { Clock } from "lucide-react"; import { formatTimeRange } from "@/lib/timeUtils"; import { GenreBadge } from "@/components/GenreBadge"; +import { StageBadge } from "@/components/StageBadge"; import { useFestivalSet } from "../FestivalSetContext"; import { useStageQuery } from "@/hooks/queries/stages/useStageQuery"; @@ -39,10 +40,11 @@ export function SetMetadata() { {/* Stage and Time Information */}
{stageQuery.data && ( -
- - {stageQuery.data.name} -
+ )} {timeRangeFormatted && (
diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/StageLabels.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/StageLabels.tsx index a56527f..db1a18e 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/StageLabels.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/StageLabels.tsx @@ -1,5 +1,7 @@ +import { DEFAULT_STAGE_COLOR } from "@/lib/constants/stages"; + interface StageLabelsProps { - stages: Array<{ name: string }>; + stages: Array<{ name: string; color?: string }>; } export function StageLabels({ stages }: StageLabelsProps) { @@ -7,7 +9,12 @@ export function StageLabels({ stages }: StageLabelsProps) {
{stages.map((stage) => (
-
+
{stage.name}
diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/StageRow.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/StageRow.tsx index 5340ac7..c227a93 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/StageRow.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/StageRow.tsx @@ -4,6 +4,7 @@ import type { HorizontalTimelineSet } from "@/lib/timelineCalculator"; interface StageRowProps { stage: { name: string; + color?: string; sets: HorizontalTimelineSet[]; }; totalWidth: number; @@ -14,8 +15,11 @@ export function StageRow({ stage, totalWidth }: StageRowProps) {
{/* Timeline Track */}
{stage.sets.map((set) => { if (!set.horizontalPosition) return null; diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx index 2dcbed2..f625036 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -74,8 +74,16 @@ export function Timeline() { new Date(edition.start_date), new Date(edition.end_date), filteredScheduleDays, + stagesQuery.data || [], ); - }, [edition, scheduleDays, selectedDay, selectedTime, selectedStages]); + }, [ + edition, + scheduleDays, + selectedDay, + selectedTime, + selectedStages, + stagesQuery.data, + ]); if (loading || setsLoading) { return ( diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx index bd0aa59..a5d6f7b 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx @@ -10,7 +10,7 @@ import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEditi interface TimeSlot { time: Date; - sets: (ScheduleSet & { stageName: string })[]; + sets: (ScheduleSet & { stageName: string; stageColor?: string })[]; } export function ListSchedule() { @@ -62,7 +62,10 @@ export function ListSchedule() { } // Collect all unique start times with filtering - const allSets: (ScheduleSet & { stageName: string })[] = []; + const allSets: (ScheduleSet & { + stageName: string; + stageColor?: string; + })[] = []; scheduleDays.forEach((day) => { day.stages.forEach((stage) => { @@ -71,11 +74,15 @@ export function ListSchedule() { return; } + // Find the stage data to get color information + const stageData = stagesQuery.data?.find((s) => s.id === stage.id); + stage.sets.forEach((set) => { if (set.startTime && matchesDay(set) && matchesTime(set)) { allSets.push({ ...set, stageName: stage.name, + stageColor: stageData?.color || undefined, }); } }); @@ -85,7 +92,7 @@ export function ListSchedule() { // Group sets by start time const timeGroups = new Map< string, - (ScheduleSet & { stageName: string })[] + (ScheduleSet & { stageName: string; stageColor?: string })[] >(); allSets.forEach((set) => { @@ -107,7 +114,13 @@ export function ListSchedule() { .sort((a, b) => a.time.getTime() - b.time.getTime()); return slots; - }, [scheduleDays, selectedDay, selectedTime, selectedStages]); + }, [ + scheduleDays, + selectedDay, + selectedTime, + selectedStages, + stagesQuery.data, + ]); if (loading || setsLoading) { return ( diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/MobileSetCard.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/MobileSetCard.tsx index 6125839..513b244 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/MobileSetCard.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/MobileSetCard.tsx @@ -1,12 +1,13 @@ import { Card, CardContent } from "@/components/ui/card"; import { Link } from "react-router-dom"; -import { Clock, MapPin } from "lucide-react"; +import { Clock } from "lucide-react"; import { format, differenceInMinutes } from "date-fns"; import { VoteButtons } from "../VoteButtons"; +import { StageBadge } from "@/components/StageBadge"; import type { ScheduleSet } from "@/hooks/useScheduleData"; interface MobileSetCardProps { - set: ScheduleSet & { stageName: string }; + set: ScheduleSet & { stageName: string; stageColor?: string }; } export function MobileSetCard({ set }: MobileSetCardProps) { @@ -30,10 +31,11 @@ export function MobileSetCard({ set }: MobileSetCardProps) { {/* Stage and duration info */}
-
- - {set.stageName} -
+ {duration && (
diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/TimeSlotGroup.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/TimeSlotGroup.tsx index ac0719a..0d226cf 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/TimeSlotGroup.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/TimeSlotGroup.tsx @@ -5,7 +5,7 @@ import type { ScheduleSet } from "@/hooks/useScheduleData"; interface TimeSlot { time: Date; - sets: (ScheduleSet & { stageName: string })[]; + sets: (ScheduleSet & { stageName: string; stageColor?: string })[]; } interface TimeSlotGroupProps { diff --git a/src/pages/admin/festivals/StageManagement.tsx b/src/pages/admin/festivals/StageManagement.tsx index ccb14e1..3898b09 100644 --- a/src/pages/admin/festivals/StageManagement.tsx +++ b/src/pages/admin/festivals/StageManagement.tsx @@ -2,106 +2,34 @@ import { useState } from "react"; import { useOutletContext } from "react-router-dom"; import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; import { FestivalEdition } from "@/hooks/queries/festivals/editions/types"; -import { useCreateStageMutation } from "@/hooks/queries/stages/useCreateStage"; -import { useUpdateStageMutation } from "@/hooks/queries/stages/useUpdateStage"; import { useDeleteStageMutation } from "@/hooks/queries/stages/useDeleteStage"; import { Stage } from "@/hooks/queries/stages/types"; -import { useToast } from "@/hooks/use-toast"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Loader2, Plus, Edit2, Trash2, MapPin, Upload } from "lucide-react"; +import { Loader2, MapPin, Upload } from "lucide-react"; import { CSVImportDialog } from "./CSVImportDialog"; - -interface StageFormData { - name: string; -} +import { StagesTable } from "./StageManagement/StagesTable"; +import { CreateStageDialog } from "./StageManagement/CreateStageDialog"; +import { EditStageDialog } from "./StageManagement/EditStageDialog"; interface StageManagementProps {} export function StageManagement(_props: StageManagementProps) { - // All hooks must be at the top level const { edition } = useOutletContext<{ edition: FestivalEdition }>(); const { data: stages = [], isLoading } = useStagesByEditionQuery(edition.id); - const createStageMutation = useCreateStageMutation(); - const updateStageMutation = useUpdateStageMutation(); const deleteStageMutation = useDeleteStageMutation(); - const { toast } = useToast(); - const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingStage, setEditingStage] = useState(null); - const [formData, setFormData] = useState({ - name: "", - }); - const [isSubmitting, setIsSubmitting] = useState(false); - - function resetForm() { - setFormData({ - name: "", - }); - setEditingStage(null); - } - - function handleCreate() { - resetForm(); - setIsDialogOpen(true); - } + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); function handleEdit(stage: Stage) { - setFormData({ - name: stage.name, - }); setEditingStage(stage); - setIsDialogOpen(true); + setIsEditDialogOpen(true); } - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!formData.name.trim()) { - toast({ - title: "Error", - description: "Stage name is required", - variant: "destructive", - }); - return; - } - - setIsSubmitting(true); - try { - if (editingStage) { - await updateStageMutation.mutateAsync({ - stageId: editingStage.id, - stageData: formData, - }); - } else { - await createStageMutation.mutateAsync({ - ...formData, - festival_edition_id: edition.id, - }); - } - - setIsDialogOpen(false); - resetForm(); - } finally { - setIsSubmitting(false); - } + function handleCloseEditDialog() { + setIsEditDialogOpen(false); + setEditingStage(null); } async function handleDelete(stage: Stage) { @@ -147,111 +75,22 @@ export function StageManagement(_props: StageManagementProps) { Import CSV - - - - - - - - {editingStage ? "Edit Stage" : "Create New Stage"} - - - {editingStage - ? "Update the stage name and details." - : "Create a new stage where artists will perform."} - - -
-
- - - setFormData({ ...formData, name: e.target.value }) - } - placeholder="e.g., Dance Temple, Sacred Ground" - required - /> -
-
- - -
-
-
-
+
-
- - - - Stage Name - - Created - Actions - - - - {filteredStages.map((stage) => ( - - {stage.name} - - - {new Date(stage.created_at).toLocaleDateString()} - - -
- - -
-
-
- ))} -
-
- - {filteredStages.length === 0 && ( -
- No stages found for the selected edition. -
- )} -
+ + +
); diff --git a/src/pages/admin/festivals/StageManagement/CreateStageDialog.tsx b/src/pages/admin/festivals/StageManagement/CreateStageDialog.tsx new file mode 100644 index 0000000..34556a4 --- /dev/null +++ b/src/pages/admin/festivals/StageManagement/CreateStageDialog.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { useCreateStageMutation } from "@/hooks/queries/stages/useCreateStage"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Plus } from "lucide-react"; +import { StageForm, StageFormData } from "./StageForm"; + +interface CreateStageDialogProps { + editionId: string; +} + +export function CreateStageDialog({ editionId }: CreateStageDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const createStageMutation = useCreateStageMutation(); + + async function handleSubmit(data: StageFormData) { + await createStageMutation.mutateAsync({ + ...data, + festival_edition_id: editionId, + }); + setIsOpen(false); + } + + function handleCancel() { + setIsOpen(false); + } + + return ( + + + + + + + Create New Stage + + Create a new stage where artists will perform. + + + + + + ); +} diff --git a/src/pages/admin/festivals/StageManagement/EditStageDialog.tsx b/src/pages/admin/festivals/StageManagement/EditStageDialog.tsx new file mode 100644 index 0000000..f3823cd --- /dev/null +++ b/src/pages/admin/festivals/StageManagement/EditStageDialog.tsx @@ -0,0 +1,64 @@ +import { useUpdateStageMutation } from "@/hooks/queries/stages/useUpdateStage"; +import { Stage } from "@/hooks/queries/stages/types"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { StageForm, StageFormData } from "./StageForm"; +import { DEFAULT_STAGE_COLOR } from "@/lib/constants/stages"; + +interface EditStageDialogProps { + stage: Stage | null; + isOpen: boolean; + onClose: () => void; +} + +export function EditStageDialog({ + stage, + isOpen, + onClose, +}: EditStageDialogProps) { + const updateStageMutation = useUpdateStageMutation(); + + async function handleSubmit(data: StageFormData) { + if (!stage) return; + + await updateStageMutation.mutateAsync({ + stageId: stage.id, + stageData: { + name: data.name, + stage_order: data.stage_order, + color: data.color, + }, + }); + onClose(); + } + + if (!stage) return null; + + return ( + + + + Edit Stage + + Update the stage name and details. + + + + + + ); +} diff --git a/src/pages/admin/festivals/StageManagement/StageForm.tsx b/src/pages/admin/festivals/StageManagement/StageForm.tsx new file mode 100644 index 0000000..2ed59ad --- /dev/null +++ b/src/pages/admin/festivals/StageManagement/StageForm.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { DEFAULT_STAGE_COLOR } from "@/lib/constants/stages"; + +export interface StageFormData { + name: string; + stage_order: number; + color: string; +} + +interface StageFormProps { + initialData?: Partial; + onSubmit: (data: StageFormData) => Promise; + onCancel: () => void; + submitLabel: string; +} + +export function StageForm({ + initialData, + onSubmit, + onCancel, + submitLabel, +}: StageFormProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const { toast } = useToast(); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + defaultValues: { + name: initialData?.name || "", + stage_order: initialData?.stage_order || 0, + color: initialData?.color || DEFAULT_STAGE_COLOR, + }, + }); + + const colorValue = watch("color"); + + async function handleFormSubmit(data: StageFormData) { + if (!data.name.trim()) { + toast({ + title: "Error", + description: "Stage name is required", + variant: "destructive", + }); + return; + } + + setIsSubmitting(true); + try { + await onSubmit(data); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + + {errors.stage_order && ( +

+ {errors.stage_order.message} +

+ )} +
+ +
+ +
+ setValue("color", e.target.value)} + className="w-12 h-10 border border-gray-300 rounded cursor-pointer" + /> + +
+ {errors.color && ( +

+ {errors.color.message} +

+ )} +
+ +
+ + +
+
+ ); +} diff --git a/src/pages/admin/festivals/StageManagement/StagesTable.tsx b/src/pages/admin/festivals/StageManagement/StagesTable.tsx new file mode 100644 index 0000000..1a45ff2 --- /dev/null +++ b/src/pages/admin/festivals/StageManagement/StagesTable.tsx @@ -0,0 +1,83 @@ +import { Stage } from "@/hooks/queries/stages/types"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Edit2, Trash2 } from "lucide-react"; +import { DEFAULT_STAGE_COLOR } from "@/lib/constants/stages"; + +interface StagesTableProps { + stages: Stage[]; + onEdit: (stage: Stage) => void; + onDelete: (stage: Stage) => void; +} + +export function StagesTable({ stages, onEdit, onDelete }: StagesTableProps) { + return ( +
+ + + + Stage Name + Order + Color + Created + Actions + + + + {stages.map((stage) => ( + + {stage.name} + {stage.stage_order || 0} + +
+
+ {stage.color || DEFAULT_STAGE_COLOR} +
+ + + {new Date(stage.created_at).toLocaleDateString()} + + +
+ + +
+
+ + ))} + +
+ + {stages.length === 0 && ( +
+ No stages found for the selected edition. +
+ )} +
+ ); +} diff --git a/supabase/migrations/20250816000000_add_stage_order.sql b/supabase/migrations/20250816000000_add_stage_order.sql new file mode 100644 index 0000000..9a3da35 --- /dev/null +++ b/supabase/migrations/20250816000000_add_stage_order.sql @@ -0,0 +1,29 @@ +-- Add order column to stages table +ALTER TABLE public.stages +ADD COLUMN stage_order INTEGER NOT NULL DEFAULT 0; + +-- Update existing stages with default order values +-- This gives each stage a unique order number based on their creation order +DO $$ +DECLARE + stage_record RECORD; + order_counter INTEGER := 1; +BEGIN + FOR stage_record IN + SELECT id FROM public.stages + ORDER BY created_at ASC + LOOP + UPDATE public.stages + SET stage_order = order_counter + WHERE id = stage_record.id; + + order_counter := order_counter + 1; + END LOOP; +END $$; + +-- Create index for efficient ordering +CREATE INDEX idx_stages_order ON public.stages (stage_order); + +-- Add color column to stages table +ALTER TABLE public.stages +ADD COLUMN color VARCHAR(7); \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index da2286f..0a0672f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,30 +21,6 @@ export default defineConfig(({ mode }) => ({ workbox: { globPatterns: ["**/*.{js,css,html,ico,png,svg,webp,woff,woff2}"], navigateFallback: "/index.html", - runtimeCaching: [ - { - urlPattern: /^https:\/\/qssmaz.*\.supabase\.co\/rest\/v1\//, - handler: "StaleWhileRevalidate", - options: { - cacheName: "supabase-api-cache", - expiration: { - maxEntries: 1000, - maxAgeSeconds: 60 * 60 * 24, // 24 hours - }, - }, - }, - { - urlPattern: /^https:\/\/.*\.(png|jpg|jpeg|svg|gif|webp)$/, - handler: "CacheFirst", - options: { - cacheName: "images-cache", - expiration: { - maxEntries: 500, - maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days - }, - }, - }, - ], }, includeAssets: [ "favicon.svg",