From 2e241e553c60b401cd7d4a9e33980df8adb60fa2 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 20 Aug 2025 17:25:15 +0100 Subject: [PATCH 01/16] --wip-- [skip ci] --- MUTATE_ASYNC_CONVERSION_PLAN.md | 150 ++++++++++++++++++ .../{ => not-used}/DateNavigation.tsx | 6 +- .../schedule/{ => not-used}/DayDivider.tsx | 7 +- .../schedule/{ => not-used}/DaySelector.tsx | 6 +- .../{ => not-used}/FloatingDateIndicator.tsx | 6 +- .../schedule/{ => not-used}/ScrollToNow.tsx | 4 +- .../{ => not-used}/TimelineProgress.tsx | 6 +- .../vertical/MobileFirstVerticalTimeline.tsx | 121 ++++++++++++++ .../vertical/ScheduleVerticalTimelineView.tsx | 79 +++++++++ .../schedule/vertical/TimeSlotGroup.tsx | 89 +++++++++++ .../vertical/VerticalArtistScheduleBlock.tsx | 71 +++++++++ .../schedule/vertical/VerticalStageColumn.tsx | 51 ++++++ .../schedule/vertical/VerticalStageLabels.tsx | 17 ++ .../schedule/vertical/VerticalTimeScale.tsx | 95 +++++++++++ .../vertical/VerticalTimelineContainer.tsx | 44 +++++ src/lib/timelineCalculator.ts | 94 +++++++++++ src/pages/EditionView.tsx | 5 +- 17 files changed, 830 insertions(+), 21 deletions(-) create mode 100644 MUTATE_ASYNC_CONVERSION_PLAN.md rename src/components/schedule/{ => not-used}/DateNavigation.tsx (96%) rename src/components/schedule/{ => not-used}/DayDivider.tsx (85%) rename src/components/schedule/{ => not-used}/DaySelector.tsx (94%) rename src/components/schedule/{ => not-used}/FloatingDateIndicator.tsx (91%) rename src/components/schedule/{ => not-used}/ScrollToNow.tsx (85%) rename src/components/schedule/{ => not-used}/TimelineProgress.tsx (92%) create mode 100644 src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx create mode 100644 src/components/schedule/vertical/ScheduleVerticalTimelineView.tsx create mode 100644 src/components/schedule/vertical/TimeSlotGroup.tsx create mode 100644 src/components/schedule/vertical/VerticalArtistScheduleBlock.tsx create mode 100644 src/components/schedule/vertical/VerticalStageColumn.tsx create mode 100644 src/components/schedule/vertical/VerticalStageLabels.tsx create mode 100644 src/components/schedule/vertical/VerticalTimeScale.tsx create mode 100644 src/components/schedule/vertical/VerticalTimelineContainer.tsx diff --git a/MUTATE_ASYNC_CONVERSION_PLAN.md b/MUTATE_ASYNC_CONVERSION_PLAN.md new file mode 100644 index 00000000..af452e57 --- /dev/null +++ b/MUTATE_ASYNC_CONVERSION_PLAN.md @@ -0,0 +1,150 @@ +# MutateAsync to Mutate Conversion Plan + +## Overview + +This document outlines the plan to convert all `mutateAsync` usage to regular `mutate` calls throughout the codebase, following the project convention that prefers `mutation.mutate(variables, {onSuccess, onError})` over `try{await mutation.mutateAsync(variables)}catch(err){}`. + +## Current Status + +Found **15 files** with `mutateAsync` usage across **16 instances**: + +## Files to Convert + +### 1. **Offline Operations** (2 files) +- `src/hooks/useOfflineVoting.ts:34` +- `src/hooks/useOfflineNotes.ts:26` +- `src/hooks/useOfflineNotes.ts:41` + +### 2. **Admin Components** (8 files) +- `src/components/Admin/FestivalManagementTable.tsx:45` +- `src/components/Admin/FestivalLogoDialog.tsx:45` +- `src/components/Admin/FestivalLogoDialog.tsx:78` +- `src/components/Admin/FestivalEditionManagement.tsx:171` +- `src/components/Admin/FestivalEditionManagement.tsx:176` +- `src/components/Admin/FestivalDialog.tsx:143` +- `src/components/Admin/FestivalDialog.tsx:148` +- `src/components/Admin/StageManagement.tsx:87` +- `src/components/Admin/StageManagement.tsx:92` +- `src/components/Admin/StageManagement.tsx:117` +- `src/components/Admin/SetFormDialog.tsx:174` +- `src/components/Admin/SetFormDialog.tsx:180` +- `src/components/Admin/SetFormDialog.tsx:193` +- `src/components/Admin/SetFormDialog.tsx:204` + +### 3. **Pages** (1 file) +- `src/pages/GroupDetail.tsx:268` + +### 4. **Utilities** (2 files) +- `src/components/Index/useInviteValidation.ts:67` +- `src/hooks/queries/knowledge/useKnowledge.ts:147` + +## Conversion Patterns + +### Pattern 1: Simple Try-Catch β†’ onSuccess/onError + +**Before:** +```typescript +try { + await mutation.mutateAsync(variables); + // success logic +} catch (error) { + // error handling +} +``` + +**After:** +```typescript +mutation.mutate(variables, { + onSuccess: () => { + // success logic + }, + onError: (error) => { + // error handling + } +}); +``` + +### Pattern 2: Complex Sequential Operations + +**Before:** +```typescript +try { + const result = await mutation1.mutateAsync(data1); + await mutation2.mutateAsync({ id: result.id, ...data2 }); + // success logic +} catch (error) { + // error handling +} +``` + +**After:** +```typescript +mutation1.mutate(data1, { + onSuccess: (result) => { + mutation2.mutate({ id: result.id, ...data2 }, { + onSuccess: () => { + // success logic + }, + onError: (error) => { + // error handling + } + }); + }, + onError: (error) => { + // error handling + } +}); +``` + +### Pattern 3: Multiple Sequential Mutations + +For files like `SetFormDialog.tsx` with multiple sequential mutations, we'll need to chain them using nested `onSuccess` callbacks or create helper functions. + +## Conversion Priority + +### **High Priority** (Simple conversions) +1. `useOfflineVoting.ts` - Single mutation +2. `useOfflineNotes.ts` - Two simple mutations +3. `FestivalManagementTable.tsx` - Single deletion +4. `StageManagement.tsx` - Create/Update/Delete operations +5. `GroupDetail.tsx` - Single remove member operation +6. `useInviteValidation.ts` - Single invite mutation +7. `useKnowledge.ts` - Single toggle mutation + +### **Medium Priority** (Moderate complexity) +8. `FestivalLogoDialog.tsx` - Upload then update operations +9. `FestivalEditionManagement.tsx` - Create/Update with form reset +10. `FestivalDialog.tsx` - Create/Update with cleanup + +### **Low Priority** (Complex sequential operations) +11. `SetFormDialog.tsx` - Multiple chained mutations with loops + +## Benefits of Conversion + +1. **Consistency** - Aligns with project conventions +2. **Better Error Handling** - Centralized in mutation hooks +3. **Cleaner Code** - No try-catch blocks needed +4. **Loading States** - Automatic `isPending` management +5. **Success Handling** - Cleaner success callback patterns + +## Implementation Steps + +1. **Phase 1**: Convert simple single-mutation files (High Priority) +2. **Phase 2**: Convert moderate complexity files (Medium Priority) +3. **Phase 3**: Refactor complex sequential operations (Low Priority) +4. **Phase 4**: Test all conversions and ensure functionality remains identical + +## Testing Strategy + +- Verify each conversion maintains identical behavior +- Test error scenarios to ensure proper error handling +- Confirm loading states work correctly +- Check that success/cleanup logic executes as expected + +## Completion Criteria + +- [ ] All 16 `mutateAsync` instances converted to `mutate` +- [ ] No remaining `mutateAsync` usage in codebase +- [ ] All functionality verified to work identically +- [ ] Consistent error handling patterns across all mutations +- [ ] Code follows project conventions for mutation usage \ No newline at end of file diff --git a/src/components/schedule/DateNavigation.tsx b/src/components/schedule/not-used/DateNavigation.tsx similarity index 96% rename from src/components/schedule/DateNavigation.tsx rename to src/components/schedule/not-used/DateNavigation.tsx index 492348e8..47f50ded 100644 --- a/src/components/schedule/DateNavigation.tsx +++ b/src/components/schedule/not-used/DateNavigation.tsx @@ -9,11 +9,11 @@ interface DateNavigationProps { containerRef: React.RefObject; } -export const DateNavigation = ({ +export function DateNavigation({ onScrollToDate, onScrollToNow, containerRef, -}: DateNavigationProps) => { +}: DateNavigationProps) { const { scheduleDays } = useScheduleData(); const scrollToTop = useCallback(() => { @@ -63,4 +63,4 @@ export const DateNavigation = ({ ); -}; +} diff --git a/src/components/schedule/DayDivider.tsx b/src/components/schedule/not-used/DayDivider.tsx similarity index 85% rename from src/components/schedule/DayDivider.tsx rename to src/components/schedule/not-used/DayDivider.tsx index c29e9887..3234d677 100644 --- a/src/components/schedule/DayDivider.tsx +++ b/src/components/schedule/not-used/DayDivider.tsx @@ -6,10 +6,7 @@ interface DayDividerProps { isFirst?: boolean; } -export const DayDivider = ({ - displayDate, - isFirst = false, -}: DayDividerProps) => { +export function DayDivider({ displayDate, isFirst = false }: DayDividerProps) { return (
@@ -21,4 +18,4 @@ export const DayDivider = ({
); -}; +} diff --git a/src/components/schedule/DaySelector.tsx b/src/components/schedule/not-used/DaySelector.tsx similarity index 94% rename from src/components/schedule/DaySelector.tsx rename to src/components/schedule/not-used/DaySelector.tsx index 8b582f23..69be636c 100644 --- a/src/components/schedule/DaySelector.tsx +++ b/src/components/schedule/not-used/DaySelector.tsx @@ -8,11 +8,11 @@ interface DaySelectorProps { onDayChange: (day: string) => void; } -export const DaySelector = ({ +export function DaySelector({ days, selectedDay, onDayChange, -}: DaySelectorProps) => { +}: DaySelectorProps) { if (days.length <= 1) return null; return ( @@ -37,4 +37,4 @@ export const DaySelector = ({ ); -}; +} diff --git a/src/components/schedule/FloatingDateIndicator.tsx b/src/components/schedule/not-used/FloatingDateIndicator.tsx similarity index 91% rename from src/components/schedule/FloatingDateIndicator.tsx rename to src/components/schedule/not-used/FloatingDateIndicator.tsx index bb06ee7d..2086f4bc 100644 --- a/src/components/schedule/FloatingDateIndicator.tsx +++ b/src/components/schedule/not-used/FloatingDateIndicator.tsx @@ -7,10 +7,10 @@ interface FloatingDateIndicatorProps { visible: boolean; } -export const FloatingDateIndicator = ({ +export function FloatingDateIndicator({ currentDate, visible, -}: FloatingDateIndicatorProps) => { +}: FloatingDateIndicatorProps) { const [show, setShow] = useState(false); useEffect(() => { @@ -32,4 +32,4 @@ export const FloatingDateIndicator = ({ ); -}; +} diff --git a/src/components/schedule/ScrollToNow.tsx b/src/components/schedule/not-used/ScrollToNow.tsx similarity index 85% rename from src/components/schedule/ScrollToNow.tsx rename to src/components/schedule/not-used/ScrollToNow.tsx index 928e99d6..086cd01d 100644 --- a/src/components/schedule/ScrollToNow.tsx +++ b/src/components/schedule/not-used/ScrollToNow.tsx @@ -6,7 +6,7 @@ interface ScrollToNowProps { visible?: boolean; } -export const ScrollToNow = ({ onClick, visible = true }: ScrollToNowProps) => { +export function ScrollToNow({ onClick, visible = true }: ScrollToNowProps) { if (!visible) return null; return ( @@ -18,4 +18,4 @@ export const ScrollToNow = ({ onClick, visible = true }: ScrollToNowProps) => { ); -}; +} diff --git a/src/components/schedule/TimelineProgress.tsx b/src/components/schedule/not-used/TimelineProgress.tsx similarity index 92% rename from src/components/schedule/TimelineProgress.tsx rename to src/components/schedule/not-used/TimelineProgress.tsx index 60a36694..4dbe7c50 100644 --- a/src/components/schedule/TimelineProgress.tsx +++ b/src/components/schedule/not-used/TimelineProgress.tsx @@ -6,11 +6,11 @@ interface TimelineProgressProps { visible?: boolean; } -export const TimelineProgress = ({ +export function TimelineProgress({ currentPosition, totalItems, visible = true, -}: TimelineProgressProps) => { +}: TimelineProgressProps) { if (!visible || totalItems === 0) return null; const progress = Math.min((currentPosition / totalItems) * 100, 100); @@ -28,4 +28,4 @@ export const TimelineProgress = ({ ); -}; +} diff --git a/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx b/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx new file mode 100644 index 00000000..96221bd2 --- /dev/null +++ b/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx @@ -0,0 +1,121 @@ +import { useMemo } from "react"; +import { useScheduleData } from "@/hooks/useScheduleData"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { useSetsByEditionQuery as useEditionSetsQuery } from "@/hooks/queries/sets/useSetsByEdition"; +import { format, isSameDay } from "date-fns"; +import { TimeSlotGroup } from "./TimeSlotGroup"; +import type { ScheduleSet } from "@/hooks/useScheduleData"; + +interface MobileFirstVerticalTimelineProps { + userVotes: Record; + onVote: (artistId: string, voteType: number) => void; +} + +interface TimeSlot { + time: Date; + sets: ScheduleSet[]; +} + +export function MobileFirstVerticalTimeline({ + userVotes, + onVote, +}: MobileFirstVerticalTimelineProps) { + const { edition } = useFestivalEdition(); + const { data: editionSets = [], isLoading: setsLoading } = + useEditionSetsQuery(edition?.id); + const { scheduleDays, loading, error } = useScheduleData(editionSets); + + const timeSlots = useMemo(() => { + if (!scheduleDays.length) return []; + + // Collect all unique start times + const allSets: (ScheduleSet & { stageName: string })[] = []; + + scheduleDays.forEach(day => { + day.stages.forEach(stage => { + stage.sets.forEach(set => { + if (set.startTime) { + allSets.push({ + ...set, + stageName: stage.name + }); + } + }); + }); + }); + + // Group sets by start time + const timeGroups = new Map(); + + allSets.forEach(set => { + if (!set.startTime) return; + + const timeKey = set.startTime.toISOString(); + if (!timeGroups.has(timeKey)) { + timeGroups.set(timeKey, []); + } + timeGroups.get(timeKey)!.push(set); + }); + + // Convert to sorted array + const slots: TimeSlot[] = Array.from(timeGroups.entries()) + .map(([timeKey, sets]) => ({ + time: new Date(timeKey), + sets: sets.map(({ stageName, ...set }) => ({ ...set, stageName })) + })) + .sort((a, b) => a.time.getTime() - b.time.getTime()); + + return slots; + }, [scheduleDays]); + + if (loading || setsLoading) { + return ( +
+

Loading schedule...

+
+ ); + } + + if (error) { + return ( +
+

Error loading schedule.

+
+ ); + } + + if (!edition?.published) { + return ( +
+

Schedule not yet published.

+
+ ); + } + + if (!timeSlots.length) { + return ( +
+

No scheduled sets found.

+
+ ); + } + + return ( +
+ {timeSlots.map((slot, index) => { + const prevSlot = index > 0 ? timeSlots[index - 1] : null; + const showDateHeader = !prevSlot || !isSameDay(slot.time, prevSlot.time); + + return ( + + ); + })} +
+ ); +} \ No newline at end of file diff --git a/src/components/schedule/vertical/ScheduleVerticalTimelineView.tsx b/src/components/schedule/vertical/ScheduleVerticalTimelineView.tsx new file mode 100644 index 00000000..96194d27 --- /dev/null +++ b/src/components/schedule/vertical/ScheduleVerticalTimelineView.tsx @@ -0,0 +1,79 @@ +import { useMemo } from "react"; +import { useScheduleData } from "@/hooks/useScheduleData"; +import { calculateVerticalTimelineData } from "@/lib/timelineCalculator"; +import { VerticalStageLabels } from "./VerticalStageLabels"; +import { VerticalTimelineContainer } from "./VerticalTimelineContainer"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { useSetsByEditionQuery as useEditionSetsQuery } from "@/hooks/queries/sets/useSetsByEdition"; + +interface ScheduleVerticalTimelineViewProps { + userVotes: Record; + onVote: (artistId: string, voteType: number) => void; +} + +export function ScheduleVerticalTimelineView({ + userVotes, + onVote, +}: ScheduleVerticalTimelineViewProps) { + const { edition } = useFestivalEdition(); + const { data: editionSets = [], isLoading: setsLoading } = + useEditionSetsQuery(edition?.id); + const { scheduleDays, loading, error } = useScheduleData(editionSets); + + const timelineData = useMemo(() => { + if (!edition || !edition.start_date || !edition.end_date) { + return null; + } + return calculateVerticalTimelineData( + new Date(edition.start_date), + new Date(edition.end_date), + scheduleDays, + ); + }, [edition, scheduleDays]); + + if (loading || setsLoading) { + return ( +
+

Loading vertical timeline...

+
+ ); + } + + if (error) { + return ( +
+

Error loading schedule.

+
+ ); + } + + if (!timelineData) { + return ( +
+

Festival dates not available yet.

+
+ ); + } + + if (!edition?.published) { + return ( +
+

Schedule not yet published.

+
+ ); + } + + return ( +
+
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/schedule/vertical/TimeSlotGroup.tsx b/src/components/schedule/vertical/TimeSlotGroup.tsx new file mode 100644 index 00000000..170c779f --- /dev/null +++ b/src/components/schedule/vertical/TimeSlotGroup.tsx @@ -0,0 +1,89 @@ +import { format } from "date-fns"; +import { Clock, Calendar } from "lucide-react"; +import { MobileSetCard } from "./MobileSetCard"; +import type { ScheduleSet } from "@/hooks/useScheduleData"; + +interface TimeSlot { + time: Date; + sets: (ScheduleSet & { stageName: string })[]; +} + +interface TimeSlotGroupProps { + timeSlot: TimeSlot; + showDateHeader: boolean; + userVotes: Record; + onVote: (artistId: string, voteType: number) => void; +} + +export function TimeSlotGroup({ + timeSlot, + showDateHeader, + userVotes, + onVote, +}: TimeSlotGroupProps) { + return ( +
+ {showDateHeader && ( +
+ +

+ {format(timeSlot.time, "EEEE, MMMM d")} +

+
+ )} + +
+ {/* Time indicator */} +
+
+ + + {format(timeSlot.time, "HH:mm")} + +
+
+
+ + {/* Sets for this time slot */} +
+ {/* Mobile: Stack all sets vertically */} +
+ {timeSlot.sets.map((set) => ( + + ))} +
+ + {/* Desktop: Show sets side by side when space allows */} +
+ {timeSlot.sets.length === 1 ? ( + // Single set - take full width + + ) : ( + // Multiple sets - grid layout +
+ {timeSlot.sets.map((set) => ( + + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/schedule/vertical/VerticalArtistScheduleBlock.tsx b/src/components/schedule/vertical/VerticalArtistScheduleBlock.tsx new file mode 100644 index 00000000..c8423136 --- /dev/null +++ b/src/components/schedule/vertical/VerticalArtistScheduleBlock.tsx @@ -0,0 +1,71 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { Link } from "react-router-dom"; +import type { ScheduleSet } from "@/hooks/useScheduleData"; +import { Clock } from "lucide-react"; +import { format } from "date-fns"; +import { VoteButtons } from "../VoteButtons"; +import { useMemo } from "react"; + +interface VerticalArtistScheduleBlockProps { + set: ScheduleSet; + userVote?: number; + onVote?: (setId: string, voteType: number) => void; +} + +export function VerticalArtistScheduleBlock({ + set, + userVote, + onVote, +}: VerticalArtistScheduleBlockProps) { + const height = set.verticalPosition?.height || 60; + const isCompact = height < 80; + const isVeryCompact = height < 60; + + const timeString = useMemo(() => { + if (!set.startTime || !set.endTime) return ""; + + if (isVeryCompact) { + return format(set.startTime, "H:mm"); + } + + const start = format(set.startTime, "H"); + const end = format(set.endTime, "H"); + const startMinutes = set.startTime.getMinutes(); + const endMinutes = set.endTime.getMinutes(); + const startStr = startMinutes === 0 ? start : format(set.startTime, "H:mm"); + const endStr = endMinutes === 0 ? end : format(set.endTime, "H:mm"); + + return `${startStr}-${endStr}`; + }, [set.startTime, set.endTime, isVeryCompact]); + + return ( + + +
+ + {set.name} + +
+ + {!isVeryCompact && timeString && ( +
+ + + {timeString} + +
+ )} + +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/schedule/vertical/VerticalStageColumn.tsx b/src/components/schedule/vertical/VerticalStageColumn.tsx new file mode 100644 index 00000000..4b9fd090 --- /dev/null +++ b/src/components/schedule/vertical/VerticalStageColumn.tsx @@ -0,0 +1,51 @@ +import { VerticalArtistScheduleBlock } from "./VerticalArtistScheduleBlock"; +import type { VerticalTimelineSet } from "@/lib/timelineCalculator"; + +interface VerticalStageColumnProps { + stage: { + name: string; + sets: VerticalTimelineSet[]; + }; + totalHeight: number; + userVotes: Record; + onVote: (artistId: string, voteType: number) => void; +} + +export function VerticalStageColumn({ + stage, + totalHeight, + userVotes, + onVote, +}: VerticalStageColumnProps) { + return ( +
+
+ {stage.sets.map((set) => { + if (!set.verticalPosition) return null; + + return ( +
+
+ +
+
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/schedule/vertical/VerticalStageLabels.tsx b/src/components/schedule/vertical/VerticalStageLabels.tsx new file mode 100644 index 00000000..c4f03294 --- /dev/null +++ b/src/components/schedule/vertical/VerticalStageLabels.tsx @@ -0,0 +1,17 @@ +interface VerticalStageLabelsProps { + stages: Array<{ name: string }>; +} + +export function VerticalStageLabels({ stages }: VerticalStageLabelsProps) { + return ( +
+ {stages.map((stage) => ( +
+
+ {stage.name} +
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/schedule/vertical/VerticalTimeScale.tsx b/src/components/schedule/vertical/VerticalTimeScale.tsx new file mode 100644 index 00000000..398fa208 --- /dev/null +++ b/src/components/schedule/vertical/VerticalTimeScale.tsx @@ -0,0 +1,95 @@ +import { format, differenceInMinutes } from "date-fns"; +import { useRef } from "react"; + +interface VerticalTimeScaleProps { + timeSlots: Date[]; + totalHeight: number; + scrollContainerRef?: React.RefObject; +} + +const dateFormat = "MMMM d"; + +export function VerticalTimeScale({ + timeSlots, + totalHeight, +}: VerticalTimeScaleProps) { + const timeScaleRef = useRef(null); + + const dateChanges = timeSlots.reduce( + (changes, timeSlot, index) => { + if (index === 0) { + changes.push({ date: timeSlot, position: 20 }); + } else { + const prevDate = format(timeSlots[index - 1], "yyyy-MM-dd"); + const currentDate = format(timeSlot, "yyyy-MM-dd"); + if (prevDate !== currentDate) { + const midnightOfNewDate = new Date(timeSlot); + midnightOfNewDate.setHours(0, 0, 0, 0); + + const festivalStart = timeSlots[0]; + const minutesFromStart = differenceInMinutes( + midnightOfNewDate, + festivalStart, + ); + const position = minutesFromStart * 2 + 20; // 2px per minute + + changes.push({ date: midnightOfNewDate, position }); + } + } + return changes; + }, + [] as Array<{ date: Date; position: number }>, + ); + + return ( +
+ {/* Date change indicators */} + {dateChanges.map((dateChange, index) => { + const nextDateChange = dateChanges[index + 1]; + const fullHeight = nextDateChange + ? nextDateChange.position - dateChange.position + : totalHeight - dateChange.position; + + const space = 5; + const height = fullHeight - space; + const top = dateChange.position; + + return ( +
+ {format(dateChange.date, dateFormat)} +
+ ); + })} + + {/* Time slots */} + {timeSlots.map((timeSlot, index) => ( +
+
+ {format(timeSlot, "HH:mm")} +
+
+
+ ))} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/schedule/vertical/VerticalTimelineContainer.tsx b/src/components/schedule/vertical/VerticalTimelineContainer.tsx new file mode 100644 index 00000000..6e60eabe --- /dev/null +++ b/src/components/schedule/vertical/VerticalTimelineContainer.tsx @@ -0,0 +1,44 @@ +import { useRef } from "react"; +import { VerticalTimeScale } from "./VerticalTimeScale"; +import { VerticalStageColumn } from "./VerticalStageColumn"; +import type { VerticalTimelineData } from "@/lib/timelineCalculator"; + +interface VerticalTimelineContainerProps { + timelineData: VerticalTimelineData; + userVotes: Record; + onVote: (artistId: string, voteType: number) => void; +} + +export function VerticalTimelineContainer({ + timelineData, + userVotes, + onVote, +}: VerticalTimelineContainerProps) { + const scrollContainerRef = useRef(null); + + return ( +
+ {/* Time Scale */} + + + {/* Stage Columns */} +
+ {timelineData.stages.map((stage) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/lib/timelineCalculator.ts b/src/lib/timelineCalculator.ts index d08b08b4..82f822ce 100644 --- a/src/lib/timelineCalculator.ts +++ b/src/lib/timelineCalculator.ts @@ -8,6 +8,13 @@ export interface HorizontalTimelineSet extends ScheduleSet { }; } +export interface VerticalTimelineSet extends ScheduleSet { + verticalPosition?: { + top: number; + height: number; + }; +} + export interface TimelineData { timeSlots: Date[]; stages: Array<{ @@ -19,6 +26,17 @@ 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, @@ -147,3 +165,79 @@ function calculateLatestSetTime(scheduleDays: ScheduleDay[]) { return latestTime; } + +export function calculateVerticalTimelineData( + festivalStartDate: Date, + festivalEndDate: Date, + scheduleDays: ScheduleDay[], +): VerticalTimelineData | null { + if (!scheduleDays || scheduleDays.length === 0) return null; + + if (!festivalStartDate || !festivalEndDate) { + return null; + } + + 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); + + for (let i = 0; i <= totalHours; i++) { + const timeSlot = new Date(earliestTime.getTime() + i * 60 * 60 * 1000); + timeSlots.push(timeSlot); + } + + const allStageGroups: Record = {}; + + scheduleDays.forEach((day) => { + day.stages.forEach((stage) => { + if (!allStageGroups[stage.name]) { + allStageGroups[stage.name] = []; + } + + const enhancedSets = stage.sets.map((set): VerticalTimelineSet => { + if (!set.startTime || !set.endTime) return set; + + 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, + }, + }; + }); + + allStageGroups[stage.name].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)); + + return { + timeSlots, + stages: unifiedStages, + totalHeight: totalHours * 120, // 120px per hour + festivalStart: earliestTime, + festivalEnd: latestTime, + }; +} diff --git a/src/pages/EditionView.tsx b/src/pages/EditionView.tsx index f1265563..40d4c170 100644 --- a/src/pages/EditionView.tsx +++ b/src/pages/EditionView.tsx @@ -6,10 +6,11 @@ import { useSetFiltering } from "@/components/Index/useSetFiltering"; import { useOfflineVoting } from "@/hooks/useOfflineVoting"; import { useUrlState } from "@/hooks/useUrlState"; import { SetsPanel } from "@/components/Index/SetsPanel"; -import { ScheduleHorizontalTimelineView } from "@/components/schedule/ScheduleHorizontalTimelineView"; import ErrorBoundary from "@/components/ErrorBoundary"; import { useSetsByEditionQuery } from "@/hooks/queries/sets/useSetsByEdition"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { ScheduleVerticalTimelineView } from "@/components/schedule/vertical/ScheduleVerticalTimelineView"; +import { MobileFirstVerticalTimeline } from "@/components/schedule/vertical/MobileFirstVerticalTimeline"; export default function EditionView() { const { user, showAuthDialog } = useAuth(); @@ -85,7 +86,7 @@ export default function EditionView() { /> )} {urlState.mainView === "timeline" && ( - From dc759c09d86a9e2a2229f542dd6cf3e2648d9ebb Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 21 Aug 2025 21:53:28 +0200 Subject: [PATCH 02/16] refactor: implement React Router-based tab navigation with mobile-first design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace URL state management with proper nested React Router routes - Create dedicated route components for each tab (Artists, Timeline, Map, Info, Social) - Implement mobile-first navigation with bottom tab bar for mobile and horizontal tabs for desktop - Add unified TAB_CONFIG for consistent navigation across platforms (Vote/Artists, Schedule/Timeline, etc.) - Create shared EditionRoutes factory for both subdomain and main domain routing - Update MainTabNavigation to use NavLink with proper active state detection - Preserve filter state functionality using search parameters - Add responsive design with proper safe area support for mobile devices - Update Schedule page redirect to use new routing structure - Remove deprecated MainViewToggle component and mainView state management - Maintain backward compatibility while providing cleaner, semantic URLs Routes structure: - Artists (default): /editions/:slug or /festivals/:festival/editions/:slug - Timeline: /editions/:slug/timeline - Map: /editions/:slug/map - Info: /editions/:slug/info - Social: /editions/:slug/social πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../Index/filters/FilterSortControls.tsx | 47 +----- .../Index/filters/MainViewToggle.tsx | 44 ----- .../navigation/MainTabNavigation.tsx | 135 +++++++++++++++ src/components/router/EditionRoutes.tsx | 54 ++++++ src/components/router/MainDomainRoutes.tsx | 23 +-- src/components/router/SubdomainRoutes.tsx | 17 +- .../vertical/MobileFirstVerticalTimeline.tsx | 34 ++-- .../schedule/vertical/MobileSetCard.tsx | 62 +++++++ .../vertical/VerticalArtistScheduleBlock.tsx | 20 ++- src/components/timeline/TimelineFilters.tsx | 156 ++++++++++++++++++ src/components/timeline/TimelineTab.tsx | 73 ++++++++ src/hooks/useUrlState.ts | 17 +- src/index.css | 15 ++ src/pages/EditionView.tsx | 67 +------- src/pages/Schedule.tsx | 4 +- src/pages/tabs/ArtistsTab.tsx | 50 ++++++ src/pages/tabs/InfoTab.tsx | 7 + src/pages/tabs/MapTab.tsx | 7 + src/pages/tabs/SocialTab.tsx | 7 + src/pages/tabs/TimelineTab.tsx | 19 +++ 20 files changed, 653 insertions(+), 205 deletions(-) delete mode 100644 src/components/Index/filters/MainViewToggle.tsx create mode 100644 src/components/navigation/MainTabNavigation.tsx create mode 100644 src/components/router/EditionRoutes.tsx create mode 100644 src/components/schedule/vertical/MobileSetCard.tsx create mode 100644 src/components/timeline/TimelineFilters.tsx create mode 100644 src/components/timeline/TimelineTab.tsx create mode 100644 src/pages/tabs/ArtistsTab.tsx create mode 100644 src/pages/tabs/InfoTab.tsx create mode 100644 src/pages/tabs/MapTab.tsx create mode 100644 src/pages/tabs/SocialTab.tsx create mode 100644 src/pages/tabs/TimelineTab.tsx diff --git a/src/components/Index/filters/FilterSortControls.tsx b/src/components/Index/filters/FilterSortControls.tsx index 49a356d0..14338778 100644 --- a/src/components/Index/filters/FilterSortControls.tsx +++ b/src/components/Index/filters/FilterSortControls.tsx @@ -11,14 +11,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { - Filter, - RefreshCw, - Users, - Calendar, - List, - ChevronDown, -} from "lucide-react"; +import { Filter, RefreshCw, Users, ChevronDown } from "lucide-react"; import { SortControls } from "./SortControls"; import { MobileFilters } from "./MobileFilters"; import { DesktopFilters } from "./DesktopFilters"; @@ -74,45 +67,13 @@ export function FilterSortControls({ {/* Primary Controls Row */}
- {/* View Toggle - Always prominent */} -
- - -
- {/* Context-Aware Controls */}
- {/* Sort Controls - Only show in list view */} - {state.mainView === "list" && ( - - )} + {/* Sort Controls */} + {/* Refresh Rankings - Only show when locked */} - {state.sortLocked && state.mainView === "list" && ( + {state.sortLocked && ( - -
- ); -}; diff --git a/src/components/navigation/MainTabNavigation.tsx b/src/components/navigation/MainTabNavigation.tsx new file mode 100644 index 00000000..6189692d --- /dev/null +++ b/src/components/navigation/MainTabNavigation.tsx @@ -0,0 +1,135 @@ +import { Calendar, List, Map, Info, MessageSquare } from "lucide-react"; +import { NavLink, useParams } from "react-router-dom"; + +export type MainTab = "artists" | "timeline" | "map" | "info" | "social"; + +const TAB_CONFIG = { + artists: { + icon: List, + label: "Vote", + shortLabel: "Vote", + emoji: "🎭", + }, + timeline: { + icon: Calendar, + label: "Schedule", + shortLabel: "Schedule", + emoji: "πŸ“…", + }, + map: { + icon: Map, + label: "Map", + shortLabel: "Map", + emoji: "πŸ—ΊοΈ", + }, + info: { + icon: Info, + label: "Info", + shortLabel: "Info", + emoji: "ℹ️", + }, + social: { + icon: MessageSquare, + label: "Social", + shortLabel: "Social", + emoji: "πŸ“±", + }, +} as const; + +export function MainTabNavigation() { + const { editionSlug, festivalSlug } = useParams(); + + // Build base path depending on whether we're on main domain or subdomain + function getBasePath(): string { + if (festivalSlug && editionSlug) { + // Main domain: /festivals/boom/editions/2024 + return `/festivals/${festivalSlug}/editions/${editionSlug}`; + } else if (editionSlug) { + // Subdomain: /editions/2024 + return `/editions/${editionSlug}`; + } + return ""; + } + + const basePath = getBasePath(); + + return ( + <> + {/* Desktop: Horizontal tabs at top */} +
+
+
+ {Object.entries(TAB_CONFIG).map(([tabKey, config]) => { + const tab = tabKey as MainTab; + const to = tab === "artists" ? basePath : `${basePath}/${tab}`; + + return ( + ` + flex items-center justify-center gap-2 + px-6 py-3 rounded-lg + transition-all duration-200 active:scale-95 + ${ + isActive + ? "bg-purple-600 text-white shadow-lg" + : "text-purple-200 hover:text-white hover:bg-white/10" + } + `} + > + + {config.label} + + ); + })} +
+
+
+ + {/* Mobile: Fixed bottom navigation */} +
+
+ {Object.entries(TAB_CONFIG).map(([tabKey, config]) => { + const tab = tabKey as MainTab; + const to = tab === "artists" ? basePath : `${basePath}/${tab}`; + + return ( + ` + flex-1 flex flex-col items-center justify-center + py-2 px-1 transition-colors duration-200 min-h-16 + ${ + isActive + ? "text-purple-400" + : "text-gray-400 active:text-purple-300" + } + `} + > + {({ isActive }) => ( + <> + + + {config.shortLabel} + + + )} + + ); + })} +
+
+ + {/* Mobile: Add bottom padding to main content to account for fixed bottom nav */} +
+ + ); +} diff --git a/src/components/router/EditionRoutes.tsx b/src/components/router/EditionRoutes.tsx new file mode 100644 index 00000000..9fc4ec9f --- /dev/null +++ b/src/components/router/EditionRoutes.tsx @@ -0,0 +1,54 @@ +import { Route } from "react-router-dom"; +import EditionView from "@/pages/EditionView"; +import { SetDetails } from "@/pages/SetDetails"; +import Schedule from "@/pages/Schedule"; + +// Tab components +import { ArtistsTab } from "@/pages/tabs/ArtistsTab"; +import { TimelineTab } from "@/pages/tabs/TimelineTab"; +import { MapTab } from "@/pages/tabs/MapTab"; +import { InfoTab } from "@/pages/tabs/InfoTab"; +import { SocialTab } from "@/pages/tabs/SocialTab"; + +interface EditionRoutesProps { + basePath: string; + WrapperComponent?: React.ComponentType; +} + +export function createEditionRoutes({ + basePath, + WrapperComponent, +}: EditionRoutesProps) { + const EditionComponent = WrapperComponent + ? (props: any) => + : EditionView; + + const SetDetailsComponent = WrapperComponent + ? (props: any) => + : SetDetails; + + const ScheduleComponent = WrapperComponent + ? (props: any) => + : Schedule; + + return [ + }> + {/* Nested tab routes */} + } /> + } /> + } /> + } /> + } /> + , + } + />, + } + />, + ]; +} diff --git a/src/components/router/MainDomainRoutes.tsx b/src/components/router/MainDomainRoutes.tsx index 3a041a9b..ddc2928b 100644 --- a/src/components/router/MainDomainRoutes.tsx +++ b/src/components/router/MainDomainRoutes.tsx @@ -3,16 +3,19 @@ import { Routes, Route } from "react-router-dom"; import { SubdomainRedirect } from "./SubdomainRedirect"; import FestivalSelection from "@/pages/FestivalSelection"; import EditionSelection from "@/pages/EditionSelection"; -import { SetDetails } from "@/pages/SetDetails"; -import EditionView from "@/pages/EditionView"; -import Schedule from "@/pages/Schedule"; import { GlobalRoutes } from "./GlobalRoutes"; +import { createEditionRoutes } from "./EditionRoutes"; /** * Routes for main domain access (getupline.com) * Includes festival selection and full admin interface */ export function MainDomainRoutes() { + const editionRoutes = createEditionRoutes({ + basePath: "/festivals/:festivalSlug/editions/:editionSlug", + WrapperComponent: SubdomainRedirect, + }); + return ( <> @@ -23,18 +26,8 @@ export function MainDomainRoutes() { path="/festivals/:festivalSlug" element={} /> - } - /> - } - /> - } - /> + {/* Edition routes with subdomain redirect wrapper */} + {editionRoutes} } /> diff --git a/src/components/router/SubdomainRoutes.tsx b/src/components/router/SubdomainRoutes.tsx index 6e284144..73a0f78b 100644 --- a/src/components/router/SubdomainRoutes.tsx +++ b/src/components/router/SubdomainRoutes.tsx @@ -1,27 +1,22 @@ import { Routes, Route } from "react-router-dom"; -import EditionView from "@/pages/EditionView"; -import Schedule from "@/pages/Schedule"; -import { SetDetails } from "@/pages/SetDetails"; - -// Legal pages import EditionSelection from "@/pages/EditionSelection"; import { GlobalRoutes } from "./GlobalRoutes"; +import { createEditionRoutes } from "./EditionRoutes"; /** * Routes for subdomain access (boom-festival.getupline.com) * Root path shows edition selection for the festival */ export function SubdomainRoutes() { + const editionRoutes = createEditionRoutes({ + basePath: "/editions/:editionSlug", + }); + return ( } /> {/* Edition-specific routes */} - } /> - } - /> - } /> + {editionRoutes} } /> diff --git a/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx b/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx index 96221bd2..6c22e966 100644 --- a/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx +++ b/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useScheduleData } from "@/hooks/useScheduleData"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { useSetsByEditionQuery as useEditionSetsQuery } from "@/hooks/queries/sets/useSetsByEdition"; -import { format, isSameDay } from "date-fns"; +import { isSameDay } from "date-fns"; import { TimeSlotGroup } from "./TimeSlotGroup"; import type { ScheduleSet } from "@/hooks/useScheduleData"; @@ -13,7 +13,7 @@ interface MobileFirstVerticalTimelineProps { interface TimeSlot { time: Date; - sets: ScheduleSet[]; + sets: (ScheduleSet & { stageName: string })[]; } export function MobileFirstVerticalTimeline({ @@ -30,14 +30,14 @@ export function MobileFirstVerticalTimeline({ // Collect all unique start times const allSets: (ScheduleSet & { stageName: string })[] = []; - - scheduleDays.forEach(day => { - day.stages.forEach(stage => { - stage.sets.forEach(set => { + + scheduleDays.forEach((day) => { + day.stages.forEach((stage) => { + stage.sets.forEach((set) => { if (set.startTime) { allSets.push({ ...set, - stageName: stage.name + stageName: stage.name, }); } }); @@ -45,11 +45,14 @@ export function MobileFirstVerticalTimeline({ }); // Group sets by start time - const timeGroups = new Map(); - - allSets.forEach(set => { + const timeGroups = new Map< + string, + (ScheduleSet & { stageName: string })[] + >(); + + allSets.forEach((set) => { if (!set.startTime) return; - + const timeKey = set.startTime.toISOString(); if (!timeGroups.has(timeKey)) { timeGroups.set(timeKey, []); @@ -61,7 +64,7 @@ export function MobileFirstVerticalTimeline({ const slots: TimeSlot[] = Array.from(timeGroups.entries()) .map(([timeKey, sets]) => ({ time: new Date(timeKey), - sets: sets.map(({ stageName, ...set }) => ({ ...set, stageName })) + sets: sets, })) .sort((a, b) => a.time.getTime() - b.time.getTime()); @@ -104,8 +107,9 @@ export function MobileFirstVerticalTimeline({
{timeSlots.map((slot, index) => { const prevSlot = index > 0 ? timeSlots[index - 1] : null; - const showDateHeader = !prevSlot || !isSameDay(slot.time, prevSlot.time); - + const showDateHeader = + !prevSlot || !isSameDay(slot.time, prevSlot.time); + return ( ); -} \ No newline at end of file +} diff --git a/src/components/schedule/vertical/MobileSetCard.tsx b/src/components/schedule/vertical/MobileSetCard.tsx new file mode 100644 index 00000000..e43692a7 --- /dev/null +++ b/src/components/schedule/vertical/MobileSetCard.tsx @@ -0,0 +1,62 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { Link } from "react-router-dom"; +import { Clock, MapPin } from "lucide-react"; +import { format, differenceInMinutes } from "date-fns"; +import { VoteButtons } from "../VoteButtons"; +import type { ScheduleSet } from "@/hooks/useScheduleData"; + +interface MobileSetCardProps { + set: ScheduleSet & { stageName: string }; + userVote?: number; + onVote?: (setId: string, voteType: number) => void; +} + +export function MobileSetCard({ set, userVote, onVote }: MobileSetCardProps) { + const duration = + set.startTime && set.endTime + ? differenceInMinutes(set.endTime, set.startTime) + : null; + + return ( + + + {/* Artist name */} +
+ + {set.name} + +
+ + {/* Stage and duration info */} +
+
+ + {set.stageName} +
+ + {duration && ( +
+ + {duration}min +
+ )} + + {set.startTime && set.endTime && ( +
+ + {format(set.startTime, "HH:mm")} -{" "} + {format(set.endTime, "HH:mm")} + +
+ )} +
+ + {/* Vote buttons */} + +
+
+ ); +} diff --git a/src/components/schedule/vertical/VerticalArtistScheduleBlock.tsx b/src/components/schedule/vertical/VerticalArtistScheduleBlock.tsx index c8423136..f2134d5a 100644 --- a/src/components/schedule/vertical/VerticalArtistScheduleBlock.tsx +++ b/src/components/schedule/vertical/VerticalArtistScheduleBlock.tsx @@ -1,13 +1,13 @@ import { Card, CardContent } from "@/components/ui/card"; import { Link } from "react-router-dom"; -import type { ScheduleSet } from "@/hooks/useScheduleData"; import { Clock } from "lucide-react"; import { format } from "date-fns"; import { VoteButtons } from "../VoteButtons"; import { useMemo } from "react"; +import { VerticalTimelineSet } from "@/lib/timelineCalculator"; interface VerticalArtistScheduleBlockProps { - set: ScheduleSet; + set: VerticalTimelineSet; userVote?: number; onVote?: (setId: string, voteType: number) => void; } @@ -23,29 +23,31 @@ export function VerticalArtistScheduleBlock({ const timeString = useMemo(() => { if (!set.startTime || !set.endTime) return ""; - + if (isVeryCompact) { return format(set.startTime, "H:mm"); } - + const start = format(set.startTime, "H"); const end = format(set.endTime, "H"); const startMinutes = set.startTime.getMinutes(); const endMinutes = set.endTime.getMinutes(); const startStr = startMinutes === 0 ? start : format(set.startTime, "H:mm"); const endStr = endMinutes === 0 ? end : format(set.endTime, "H:mm"); - + return `${startStr}-${endStr}`; }, [set.startTime, set.endTime, isVeryCompact]); return ( - +
@@ -62,10 +64,10 @@ export function VerticalArtistScheduleBlock({
)} -
+
); -} \ No newline at end of file +} diff --git a/src/components/timeline/TimelineFilters.tsx b/src/components/timeline/TimelineFilters.tsx new file mode 100644 index 00000000..bc24eb69 --- /dev/null +++ b/src/components/timeline/TimelineFilters.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Calendar, Clock, MapPin, Filter, X } from "lucide-react"; +import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; + +type DayFilter = "all" | "friday" | "saturday" | "sunday"; +type TimeFilter = "all" | "morning" | "afternoon" | "evening"; + +export function TimelineFilters() { + const [selectedDay, setSelectedDay] = useState("all"); + const [selectedTime, setSelectedTime] = useState("all"); + const [selectedStages, setSelectedStages] = useState([]); + + const { edition } = useFestivalEdition(); + const { data: stages = [] } = useStagesByEditionQuery(edition?.id); + + const handleStageToggle = (stageId: string) => { + setSelectedStages((prev) => + prev.includes(stageId) + ? prev.filter((id) => id !== stageId) + : [...prev, stageId], + ); + }; + + const clearAllFilters = () => { + setSelectedDay("all"); + setSelectedTime("all"); + setSelectedStages([]); + }; + + const hasActiveFilters = + selectedDay !== "all" || + selectedTime !== "all" || + selectedStages.length > 0; + + return ( +
+
+
+ + Timeline Filters +
+ {hasActiveFilters && ( + + )} +
+ +
+ {/* Day Filter */} +
+
+ + +
+ +
+ + {/* Time Filter */} +
+
+ + +
+ +
+ + {/* Stages Filter */} +
+
+ + +
+
+ {stages.map((stage) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/components/timeline/TimelineTab.tsx b/src/components/timeline/TimelineTab.tsx new file mode 100644 index 00000000..df44a7e5 --- /dev/null +++ b/src/components/timeline/TimelineTab.tsx @@ -0,0 +1,73 @@ +import { BarChart3, List } from "lucide-react"; +import { ScheduleHorizontalTimelineView } from "@/components/schedule/ScheduleHorizontalTimelineView"; +import { MobileFirstVerticalTimeline } from "@/components/schedule/vertical/MobileFirstVerticalTimeline"; +import { TimelineFilters } from "./TimelineFilters"; +import { useUrlState } from "@/hooks/useUrlState"; + +interface TimelineTabProps { + userVotes: Record; + onVote: (artistId: string, voteType: number) => void; +} + +export function TimelineTab({ userVotes, onVote }: TimelineTabProps) { + const { state: urlState, updateUrlState } = useUrlState(); + const timelineView = urlState.timelineView; + + return ( +
+ {/* Timeline View Toggle - Mobile-first */} +
+
+
+ + +
+
+
+ + {/* Timeline Filters */} + + + {/* Timeline Content */} + {timelineView === "horizontal" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/hooks/useUrlState.ts b/src/hooks/useUrlState.ts index 636ba8c2..ec9b5d1c 100644 --- a/src/hooks/useUrlState.ts +++ b/src/hooks/useUrlState.ts @@ -7,14 +7,15 @@ export type SortOption = | "rating-desc" | "popularity-desc" | "date-asc"; -export type MainViewOption = "list" | "timeline"; +export type TimelineView = "horizontal" | "list"; +export type MainTab = "artists" | "timeline" | "map" | "info" | "social"; export interface FilterSortState { sort: SortOption; stages: string[]; genres: string[]; minRating: number; - mainView: MainViewOption; + timelineView: TimelineView; use24Hour: boolean; groupId?: string; invite?: string; @@ -27,7 +28,7 @@ const defaultState: FilterSortState = { stages: [], genres: [], minRating: 0, - mainView: "list", + timelineView: "list", use24Hour: true, groupId: undefined, invite: undefined, @@ -50,9 +51,9 @@ export const useUrlState = () => { minRating: parseInt(searchParams.get("minRating") || "0") || defaultState.minRating, - mainView: - (searchParams.get("mainView") as MainViewOption) || - defaultState.mainView, + timelineView: + (searchParams.get("timelineView") as TimelineView) || + defaultState.timelineView, use24Hour: searchParams.get("use24Hour") === "true" || defaultState.use24Hour, groupId: searchParams.get("groupId") || defaultState.groupId, @@ -84,8 +85,8 @@ export const useUrlState = () => { if (newState.minRating > 0) { newParams.set("minRating", newState.minRating.toString()); } - if (newState.mainView !== defaultState.mainView) { - newParams.set("mainView", newState.mainView); + if (newState.timelineView !== defaultState.timelineView) { + newParams.set("timelineView", newState.timelineView); } if (newState.use24Hour !== defaultState.use24Hour) { newParams.set("use24Hour", newState.use24Hour.toString()); diff --git a/src/index.css b/src/index.css index 0378c04e..94919304 100644 --- a/src/index.css +++ b/src/index.css @@ -149,4 +149,19 @@ .bg-app-card-gradient { @apply bg-gradient-to-br from-app-accent/40 to-app-button/40; } + + /* Hide scrollbars for mobile tab navigation */ + .scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .scrollbar-hide::-webkit-scrollbar { + display: none; + } + + /* Safe area padding for mobile bottom navigation */ + .safe-area-pb { + padding-bottom: env(safe-area-inset-bottom); + } } diff --git a/src/pages/EditionView.tsx b/src/pages/EditionView.tsx index 40d4c170..ca651bce 100644 --- a/src/pages/EditionView.tsx +++ b/src/pages/EditionView.tsx @@ -1,37 +1,13 @@ -import { useAuth } from "@/contexts/AuthContext"; - -import { FilterSortControls } from "@/components/Index/filters/FilterSortControls"; import { AppHeader } from "@/components/AppHeader"; -import { useSetFiltering } from "@/components/Index/useSetFiltering"; -import { useOfflineVoting } from "@/hooks/useOfflineVoting"; -import { useUrlState } from "@/hooks/useUrlState"; -import { SetsPanel } from "@/components/Index/SetsPanel"; +import { MainTabNavigation } from "@/components/navigation/MainTabNavigation"; import ErrorBoundary from "@/components/ErrorBoundary"; -import { useSetsByEditionQuery } from "@/hooks/queries/sets/useSetsByEdition"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; -import { ScheduleVerticalTimelineView } from "@/components/schedule/vertical/ScheduleVerticalTimelineView"; -import { MobileFirstVerticalTimeline } from "@/components/schedule/vertical/MobileFirstVerticalTimeline"; +import { Outlet } from "react-router-dom"; export default function EditionView() { - const { user, showAuthDialog } = useAuth(); - - const { state: urlState, updateUrlState, clearFilters } = useUrlState(); - // Get festival/edition context const { festival, edition, isContextReady } = useFestivalEdition(); - // Fetch sets for the current edition - const { data: sets = [], isLoading: setsLoading } = useSetsByEditionQuery( - edition?.id, - ); - const { userVotes, handleVote } = useOfflineVoting(user); - - const { filteredAndSortedSets, lockCurrentOrder } = useSetFiltering( - sets || [], - urlState, - edition?.id, - ); - // Show loading while context is not ready if (!isContextReady) { return ( @@ -41,56 +17,31 @@ export default function EditionView() { ); } - if (!festival || !edition || setsLoading) { + if (!festival || !edition) { return (
-
Loading sets...
+
Loading festival...
); } - async function handleVoteAction(artistId: string, voteType: number) { - const result = await handleVote(artistId, voteType); - if (result.requiresAuth) { - showAuthDialog(); - } - } - return (
-
+
- + {/* Main Tab Navigation */} +
- {urlState.mainView === "list" && ( - showAuthDialog()} - onLockSort={() => lockCurrentOrder(updateUrlState)} - /> - )} - {urlState.mainView === "timeline" && ( - - )} +
diff --git a/src/pages/Schedule.tsx b/src/pages/Schedule.tsx index 3b959a94..15308c77 100644 --- a/src/pages/Schedule.tsx +++ b/src/pages/Schedule.tsx @@ -5,8 +5,8 @@ const Schedule = () => { const navigate = useNavigate(); useEffect(() => { - // Redirect to main page with timeline view - navigate("/?mainView=timeline", { replace: true }); + // Redirect to timeline tab + navigate("../timeline", { replace: true, relative: "path" }); }, [navigate]); return ( diff --git a/src/pages/tabs/ArtistsTab.tsx b/src/pages/tabs/ArtistsTab.tsx new file mode 100644 index 00000000..e84af4a8 --- /dev/null +++ b/src/pages/tabs/ArtistsTab.tsx @@ -0,0 +1,50 @@ +import { useAuth } from "@/contexts/AuthContext"; +import { FilterSortControls } from "@/components/Index/filters/FilterSortControls"; +import { useSetFiltering } from "@/components/Index/useSetFiltering"; +import { useUrlState } from "@/hooks/useUrlState"; +import { SetsPanel } from "@/components/Index/SetsPanel"; +import { useSetsByEditionQuery } from "@/hooks/queries/sets/useSetsByEdition"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; + +export function ArtistsTab() { + const { user, showAuthDialog } = useAuth(); + const { state: urlState, updateUrlState, clearFilters } = useUrlState(); + const { edition } = useFestivalEdition(); + + // Fetch sets for the current edition + const { data: sets = [], isLoading: setsLoading } = useSetsByEditionQuery( + edition?.id, + ); + const { filteredAndSortedSets, lockCurrentOrder } = useSetFiltering( + sets || [], + urlState, + ); + + if (setsLoading) { + return ( +
+
Loading artists...
+
+ ); + } + + return ( +
+ + +
+ showAuthDialog()} + onLockSort={() => lockCurrentOrder(updateUrlState)} + /> +
+
+ ); +} diff --git a/src/pages/tabs/InfoTab.tsx b/src/pages/tabs/InfoTab.tsx new file mode 100644 index 00000000..2f90f1a8 --- /dev/null +++ b/src/pages/tabs/InfoTab.tsx @@ -0,0 +1,7 @@ +export function InfoTab() { + return ( +
+

Festival info coming soon!

+
+ ); +} diff --git a/src/pages/tabs/MapTab.tsx b/src/pages/tabs/MapTab.tsx new file mode 100644 index 00000000..6f5f0704 --- /dev/null +++ b/src/pages/tabs/MapTab.tsx @@ -0,0 +1,7 @@ +export function MapTab() { + return ( +
+

Map view coming soon!

+
+ ); +} diff --git a/src/pages/tabs/SocialTab.tsx b/src/pages/tabs/SocialTab.tsx new file mode 100644 index 00000000..0cfcb489 --- /dev/null +++ b/src/pages/tabs/SocialTab.tsx @@ -0,0 +1,7 @@ +export function SocialTab() { + return ( +
+

Social feed coming soon!

+
+ ); +} diff --git a/src/pages/tabs/TimelineTab.tsx b/src/pages/tabs/TimelineTab.tsx new file mode 100644 index 00000000..e165c887 --- /dev/null +++ b/src/pages/tabs/TimelineTab.tsx @@ -0,0 +1,19 @@ +import { useAuth } from "@/contexts/AuthContext"; +import { useOfflineVoting } from "@/hooks/useOfflineVoting"; +import { TimelineTab as TimelineTabComponent } from "@/components/timeline/TimelineTab"; + +export function TimelineTab() { + const { user, showAuthDialog } = useAuth(); + const { userVotes, handleVote } = useOfflineVoting(user); + + async function handleVoteAction(artistId: string, voteType: number) { + const result = await handleVote(artistId, voteType); + if (result.requiresAuth) { + showAuthDialog(); + } + } + + return ( + + ); +} From 325c55d8258bd9bd5bd28c0c4147d6f52ee948d1 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 22 Aug 2025 08:26:25 +0200 Subject: [PATCH 03/16] docs: add auto-commit rule to CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rule requiring automatic commits for all user requests involving code changes. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 4 ++++ src/pages/tabs/ArtistsTab.tsx | 2 ++ 2 files changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2efee5b8..264abf1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,3 +126,7 @@ src/ - try to use mutation.mutate(variables, {onSuccess, onError}) instead of try{await mutation.mutateAsync(variables)}catch(err){} - don't add comments unless really necessary + +## Git Workflow + +- **Auto-commit Rule**: For every user message that requests code changes, automatically commit the changes after implementation with an appropriate commit message diff --git a/src/pages/tabs/ArtistsTab.tsx b/src/pages/tabs/ArtistsTab.tsx index e84af4a8..38f96626 100644 --- a/src/pages/tabs/ArtistsTab.tsx +++ b/src/pages/tabs/ArtistsTab.tsx @@ -18,6 +18,7 @@ export function ArtistsTab() { const { filteredAndSortedSets, lockCurrentOrder } = useSetFiltering( sets || [], urlState, + edition?.id, ); if (setsLoading) { @@ -34,6 +35,7 @@ export function ArtistsTab() { state={urlState} onStateChange={updateUrlState} onClear={clearFilters} + editionId={edition?.id || ""} />
From 6bbdfbf2cb75edb31ff627edfdf39c1330e0afef Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 22 Aug 2025 11:37:15 +0200 Subject: [PATCH 04/16] feat: optimize timeline page for mobile screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major mobile optimizations for better content visibility: - **Collapsible filters**: Timeline filters now collapse on mobile with expand/collapse toggle, showing active filter count badge - **Compact view toggles**: Reduced button size and padding on mobile (px-3 py-2 vs px-4 py-3) - **Reduced spacing**: Less vertical spacing throughout (space-y-3 on mobile vs space-y-6 on desktop) - **Tighter layout**: Reduced page padding (py-4 on mobile vs py-8 on desktop) - **Auto-expand on desktop**: Filters always visible on desktop, collapsible only on mobile These changes significantly reduce header/control height, allowing more schedule content to be visible without scrolling. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/components/AppHeader.tsx | 67 ++++-- .../navigation/MainTabNavigation.tsx | 3 - src/components/router/MainDomainRoutes.tsx | 11 +- src/components/router/SubdomainRoutes.tsx | 9 +- src/components/timeline/TimelineFilters.tsx | 212 ++++++++++-------- src/components/timeline/TimelineTab.tsx | 12 +- src/pages/EditionView.tsx | 10 +- 7 files changed, 191 insertions(+), 133 deletions(-) diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 187664f2..00f74a5c 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -17,12 +17,9 @@ interface AppHeaderProps { // Page content title?: string; + logoUrl?: string | null; subtitle?: string; description?: string; - logoUrl?: string | null; - - // Actions - actions?: ReactNode; // Navigation options showGroupsButton?: boolean; @@ -35,12 +32,12 @@ export function AppHeader({ showBackButton = false, backLabel = "Back", title, - subtitle, - description, + // subtitle, + // description, logoUrl, - actions, + // actions, showGroupsButton = false, - children, + // children, }: AppHeaderProps) { const navigate = useNavigate(); const { user, profile, signOut, showAuthDialog } = useAuth(); @@ -62,15 +59,17 @@ export function AppHeader({ return ( -
+
{/* Top Bar - App Branding, User Identity & Navigation */} -
+
{/* Left Side - Branding */}
-

UpLine

+

+ UpLine +

@@ -126,49 +125,71 @@ export function AppHeader({
- - {/* Page Content Section */} + {title && ( +
+ {title && ( +
+ {logoUrl ? ( + {`${title} + ) : ( + <> + +

+ {title} +

+ + + )} +
+ )} +
+ )} + {/* Page Content Section {(title || subtitle || description || children) && ( -
+
{title && ( -
+
{logoUrl ? ( {`${title} ) : ( <> - -

+ +

{title}

- + )}

)} {subtitle && ( -

+

{subtitle}

)} {description && ( -

+

{description}

)} {actions && ( -
{actions}
+
{actions}
)} {children}
- )} + )} */}
); diff --git a/src/components/navigation/MainTabNavigation.tsx b/src/components/navigation/MainTabNavigation.tsx index 6189692d..03abe868 100644 --- a/src/components/navigation/MainTabNavigation.tsx +++ b/src/components/navigation/MainTabNavigation.tsx @@ -127,9 +127,6 @@ export function MainTabNavigation() { })}
- - {/* Mobile: Add bottom padding to main content to account for fixed bottom nav */} -
); } diff --git a/src/components/router/MainDomainRoutes.tsx b/src/components/router/MainDomainRoutes.tsx index ddc2928b..a0e48892 100644 --- a/src/components/router/MainDomainRoutes.tsx +++ b/src/components/router/MainDomainRoutes.tsx @@ -5,16 +5,19 @@ import FestivalSelection from "@/pages/FestivalSelection"; import EditionSelection from "@/pages/EditionSelection"; import { GlobalRoutes } from "./GlobalRoutes"; import { createEditionRoutes } from "./EditionRoutes"; +import { useState } from "react"; /** * Routes for main domain access (getupline.com) * Includes festival selection and full admin interface */ export function MainDomainRoutes() { - const editionRoutes = createEditionRoutes({ - basePath: "/festivals/:festivalSlug/editions/:editionSlug", - WrapperComponent: SubdomainRedirect, - }); + const [editionRoutes] = useState( + createEditionRoutes({ + basePath: "/festivals/:festivalSlug/editions/:editionSlug", + WrapperComponent: SubdomainRedirect, + }), + ); return ( <> diff --git a/src/components/router/SubdomainRoutes.tsx b/src/components/router/SubdomainRoutes.tsx index 73a0f78b..51b16c19 100644 --- a/src/components/router/SubdomainRoutes.tsx +++ b/src/components/router/SubdomainRoutes.tsx @@ -2,15 +2,18 @@ import { Routes, Route } from "react-router-dom"; import EditionSelection from "@/pages/EditionSelection"; import { GlobalRoutes } from "./GlobalRoutes"; import { createEditionRoutes } from "./EditionRoutes"; +import { useState } from "react"; /** * Routes for subdomain access (boom-festival.getupline.com) * Root path shows edition selection for the festival */ export function SubdomainRoutes() { - const editionRoutes = createEditionRoutes({ - basePath: "/editions/:editionSlug", - }); + const [editionRoutes] = useState(() => + createEditionRoutes({ + basePath: "/editions/:editionSlug", + }), + ); return ( diff --git a/src/components/timeline/TimelineFilters.tsx b/src/components/timeline/TimelineFilters.tsx index bc24eb69..25bceb92 100644 --- a/src/components/timeline/TimelineFilters.tsx +++ b/src/components/timeline/TimelineFilters.tsx @@ -7,7 +7,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Calendar, Clock, MapPin, Filter, X } from "lucide-react"; +import { + Calendar, + Clock, + MapPin, + Filter, + X, + ChevronDown, + ChevronUp, +} from "lucide-react"; +import { useIsMobile } from "@/hooks/use-mobile"; import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; @@ -18,9 +27,11 @@ export function TimelineFilters() { const [selectedDay, setSelectedDay] = useState("all"); const [selectedTime, setSelectedTime] = useState("all"); const [selectedStages, setSelectedStages] = useState([]); + const [isExpanded, setIsExpanded] = useState(false); const { edition } = useFestivalEdition(); const { data: stages = [] } = useStagesByEditionQuery(edition?.id); + const isMobile = useIsMobile(); const handleStageToggle = (stageId: string) => { setSelectedStages((prev) => @@ -41,14 +52,33 @@ export function TimelineFilters() { selectedTime !== "all" || selectedStages.length > 0; + // Auto-expand on desktop, collapsible on mobile + const shouldShowFilters = !isMobile || isExpanded; + return ( -
+
-
+
- {hasActiveFilters && ( + {isMobile && + (isExpanded ? ( + + ) : ( + + ))} + {hasActiveFilters && ( + + {(selectedDay !== "all" ? 1 : 0) + + (selectedTime !== "all" ? 1 : 0) + + selectedStages.length} + + )} + + {hasActiveFilters && shouldShowFilters && ( - ))} + {/* Stages Filter */} +
+
+ + +
+
+ {stages.map((stage) => ( + + ))} +
-
+ )}
); } diff --git a/src/components/timeline/TimelineTab.tsx b/src/components/timeline/TimelineTab.tsx index df44a7e5..62a9bbb6 100644 --- a/src/components/timeline/TimelineTab.tsx +++ b/src/components/timeline/TimelineTab.tsx @@ -14,16 +14,16 @@ export function TimelineTab({ userVotes, onVote }: TimelineTabProps) { const timelineView = urlState.timelineView; return ( -
+
{/* Timeline View Toggle - Mobile-first */} -
+
- )} - - {/* Group Filter Dropdown - Always visible */} - - - - - - onStateChange({ groupId: undefined })} - className={`text-purple-100 hover:bg-purple-600/30 ${!state.groupId ? "bg-purple-600/20" : ""}`} - > - All Votes - - {groups.map((group) => ( - onStateChange({ groupId: group.id })} - className={`text-purple-100 hover:bg-purple-600/30 ${state.groupId === group.id ? "bg-purple-600/20" : ""}`} - > - {group.name} - {group.member_count && ( - - ({group.member_count}) - - )} - - ))} - - + {state.sortLocked && ( + + )} - {/* Filters Toggle */} - -
+
+ onStateChange({ groupId })} + /> + setIsFiltersExpanded(!isFiltersExpanded)} + hasActiveFilters={hasActiveFilters} + activeFilterCount={activeFilterCount} + />
- {/* Other Filters */} {isFiltersExpanded && (
- {isMobile ? ( - - ) : ( - - )} +
)}
diff --git a/src/components/Index/filters/FilterToggle.tsx b/src/components/Index/filters/FilterToggle.tsx new file mode 100644 index 00000000..bcaff18c --- /dev/null +++ b/src/components/Index/filters/FilterToggle.tsx @@ -0,0 +1,41 @@ +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Filter } from "lucide-react"; + +interface FilterToggleProps { + isExpanded: boolean; + onToggle: () => void; + hasActiveFilters: boolean; + activeFilterCount: number; +} + +export function FilterToggle({ + isExpanded, + onToggle, + hasActiveFilters, + activeFilterCount, +}: FilterToggleProps) { + return ( + + ); +} diff --git a/src/components/Index/filters/GroupFilterDropdown.tsx b/src/components/Index/filters/GroupFilterDropdown.tsx new file mode 100644 index 00000000..58e616fb --- /dev/null +++ b/src/components/Index/filters/GroupFilterDropdown.tsx @@ -0,0 +1,73 @@ +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Users, ChevronDown } from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; +import { useUserGroupsQuery } from "@/hooks/queries/groups/useUserGroups"; + +interface GroupFilterDropdownProps { + selectedGroupId?: string; + onGroupChange: (groupId: string | undefined) => void; +} + +export function GroupFilterDropdown({ + selectedGroupId, + onGroupChange, +}: GroupFilterDropdownProps) { + const { user } = useAuth(); + const { data: groups = [] } = useUserGroupsQuery(user?.id); + + const hasActiveGroupFilter = selectedGroupId; + const currentGroup = groups.find((g) => g.id === selectedGroupId); + const groupDisplayText = currentGroup ? currentGroup.name : "All Votes"; + + if (!user || groups.length === 0) { + return null; + } + + return ( + + + + + + onGroupChange(undefined)} + className={`text-purple-100 hover:bg-purple-600/30 ${!selectedGroupId ? "bg-purple-600/20" : ""}`} + > + All Votes + + {groups.map((group) => ( + onGroupChange(group.id)} + className={`text-purple-100 hover:bg-purple-600/30 ${selectedGroupId === group.id ? "bg-purple-600/20" : ""}`} + > + {group.name} + {group.member_count && ( + + ({group.member_count}) + + )} + + ))} + + + ); +} diff --git a/src/components/Index/filters/MobileFilters.tsx b/src/components/Index/filters/MobileFilters.tsx index 34724acb..583f54c7 100644 --- a/src/components/Index/filters/MobileFilters.tsx +++ b/src/components/Index/filters/MobileFilters.tsx @@ -13,7 +13,6 @@ import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEditi interface MobileFiltersProps { state: FilterSortState; genres: Array<{ id: string; name: string }>; - groups: Array<{ id: string; name: string; member_count?: number }>; onStateChange: (updates: Partial) => void; onClear: () => void; editionId: string; diff --git a/src/components/Index/filters/RefreshButton.tsx b/src/components/Index/filters/RefreshButton.tsx new file mode 100644 index 00000000..ab4828eb --- /dev/null +++ b/src/components/Index/filters/RefreshButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "@/components/ui/button"; +import { RefreshCw } from "lucide-react"; + +interface RefreshButtonProps { + onRefresh: () => void; +} + +export function RefreshButton({ onRefresh }: RefreshButtonProps) { + return ( + + ); +} diff --git a/src/pages/tabs/InfoTab.tsx b/src/pages/tabs/InfoTab.tsx index 2f90f1a8..edb2c8d4 100644 --- a/src/pages/tabs/InfoTab.tsx +++ b/src/pages/tabs/InfoTab.tsx @@ -1,7 +1,18 @@ +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; + export function InfoTab() { + const { edition } = useFestivalEdition(); + return ( -
-

Festival info coming soon!

-
+ <> +
+

+ {edition?.name} +

+
+
+

Festival info coming soon!

+
+ ); } From 2691b775120c3525913d8c956db270c0193ecc9a Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 22 Aug 2025 11:58:08 +0200 Subject: [PATCH 06/16] refactor: unify timeline filters with voting filters design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Timeline Filter Improvements:** - **Integrated View Toggle**: Moved grid/feed buttons into TimelineFilters component - **Consistent Layout**: Now matches FilterSortControls pattern with primary controls row + expandable filters - **Better Organization**: View toggle on left, filter toggle on right with active count badge - **Responsive Design**: Proper mobile/desktop styling consistent with voting filters - **Cleaner API**: TimelineTab simplified to just pass view state to unified component **New Components:** - `ViewToggle`: Extracted reusable view toggle component for grid/list modes - Enhanced `TimelineFilters`: Now handles both view switching and filtering in unified interface **Benefits:** - Consistent UX between voting and timeline pages - Less visual clutter with integrated controls - Better mobile experience with unified layout - Easier maintenance with shared design patterns πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/components/timeline/TimelineFilters.tsx | 265 +++++++++++--------- src/components/timeline/TimelineTab.tsx | 52 +--- src/components/timeline/ViewToggle.tsx | 52 ++++ 3 files changed, 210 insertions(+), 159 deletions(-) create mode 100644 src/components/timeline/ViewToggle.tsx diff --git a/src/components/timeline/TimelineFilters.tsx b/src/components/timeline/TimelineFilters.tsx index 25bceb92..74f85dcc 100644 --- a/src/components/timeline/TimelineFilters.tsx +++ b/src/components/timeline/TimelineFilters.tsx @@ -19,11 +19,21 @@ import { import { useIsMobile } from "@/hooks/use-mobile"; import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { ViewToggle } from "./ViewToggle"; +import type { TimelineView } from "@/hooks/useUrlState"; type DayFilter = "all" | "friday" | "saturday" | "sunday"; type TimeFilter = "all" | "morning" | "afternoon" | "evening"; -export function TimelineFilters() { +interface TimelineFiltersProps { + currentView: TimelineView; + onViewChange: (view: TimelineView) => void; +} + +export function TimelineFilters({ + currentView, + onViewChange, +}: TimelineFiltersProps) { const [selectedDay, setSelectedDay] = useState("all"); const [selectedTime, setSelectedTime] = useState("all"); const [selectedStages, setSelectedStages] = useState([]); @@ -51,136 +61,167 @@ export function TimelineFilters() { selectedDay !== "all" || selectedTime !== "all" || selectedStages.length > 0; + const activeFilterCount = + (selectedDay !== "all" ? 1 : 0) + + (selectedTime !== "all" ? 1 : 0) + + selectedStages.length; // Auto-expand on desktop, collapsible on mobile const shouldShowFilters = !isMobile || isExpanded; return ( -
-
- - {hasActiveFilters && shouldShowFilters && ( +
+ {/* Primary Controls Row */} +
+
+ {/* View Toggle */} + + + {/* Spacer to push filters to right */} +
+ + {/* Filters Toggle */} - )} +
+ {/* Expandable Filters */} {shouldShowFilters && ( -
- {/* Day Filter */} -
+
+
- - + + + Timeline Filters +
- + {hasActiveFilters && ( + + )}
- - {/* Time Filter */} -
-
- - +
+ {/* Day Filter */} +
+
+ + +
+
- -
- {/* Stages Filter */} -
-
- - + {/* Time Filter */} +
+
+ + +
+
-
- {stages.map((stage) => ( - - ))} + + {/* Stages Filter */} +
+
+ + +
+
+ {stages.map((stage) => ( + + ))} +
diff --git a/src/components/timeline/TimelineTab.tsx b/src/components/timeline/TimelineTab.tsx index 62a9bbb6..8a486a89 100644 --- a/src/components/timeline/TimelineTab.tsx +++ b/src/components/timeline/TimelineTab.tsx @@ -1,4 +1,3 @@ -import { BarChart3, List } from "lucide-react"; import { ScheduleHorizontalTimelineView } from "@/components/schedule/ScheduleHorizontalTimelineView"; import { MobileFirstVerticalTimeline } from "@/components/schedule/vertical/MobileFirstVerticalTimeline"; import { TimelineFilters } from "./TimelineFilters"; @@ -15,52 +14,11 @@ export function TimelineTab({ userVotes, onVote }: TimelineTabProps) { return (
- {/* Timeline View Toggle - Mobile-first */} -
-
-
- - -
-
-
- - {/* Timeline Filters */} - + {/* Timeline Controls & Filters */} + updateUrlState({ timelineView: view })} + /> {/* Timeline Content */} {timelineView === "horizontal" ? ( diff --git a/src/components/timeline/ViewToggle.tsx b/src/components/timeline/ViewToggle.tsx new file mode 100644 index 00000000..c8aad276 --- /dev/null +++ b/src/components/timeline/ViewToggle.tsx @@ -0,0 +1,52 @@ +import { BarChart3, List } from "lucide-react"; +import type { TimelineView } from "@/hooks/useUrlState"; + +interface ViewToggleProps { + currentView: TimelineView; + onViewChange: (view: TimelineView) => void; +} + +export function ViewToggle({ currentView, onViewChange }: ViewToggleProps) { + return ( +
+
+ + +
+
+ ); +} From 7ffe8e711d248ea006aa10d4fab79ff1b263b8c9 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 22 Aug 2025 15:01:10 +0200 Subject: [PATCH 07/16] feat(sets): filter sets in timeline --- CLAUDE.md | 1 + src/components/Index/shared/SetMetadata.tsx | 6 +- src/components/Index/shared/StagePin.tsx | 12 ++ .../SetDetail/MultiArtistSetInfoCard.tsx | 10 +- src/components/SetDetail/SetInfoCard.tsx | 10 +- .../ScheduleHorizontalTimelineView.tsx | 60 +++++- .../schedule/not-used/DateNavigation.tsx | 5 +- .../vertical/MobileFirstVerticalTimeline.tsx | 56 +++++- .../vertical/ScheduleVerticalTimelineView.tsx | 79 -------- src/components/timeline/DayFilterSelect.tsx | 69 +++++++ .../timeline/StageFilterButtons.tsx | 43 +++++ src/components/timeline/TimeFilterSelect.tsx | 47 +++++ src/components/timeline/TimelineFilters.tsx | 174 ++++-------------- src/components/timeline/TimelineTab.tsx | 13 +- src/hooks/queries/sets/useSetBySlug.ts | 1 - src/hooks/queries/sets/useSets.ts | 1 - src/hooks/queries/sets/useSetsByEdition.ts | 1 - src/hooks/queries/stages/types.ts | 1 + src/hooks/queries/stages/useStageQuery.ts | 30 +++ src/hooks/useScheduleData.ts | 68 ++++--- src/hooks/useTimelineUrlState.ts | 80 ++++++++ src/hooks/useUrlState.ts | 4 +- 22 files changed, 480 insertions(+), 291 deletions(-) create mode 100644 src/components/Index/shared/StagePin.tsx delete mode 100644 src/components/schedule/vertical/ScheduleVerticalTimelineView.tsx create mode 100644 src/components/timeline/DayFilterSelect.tsx create mode 100644 src/components/timeline/StageFilterButtons.tsx create mode 100644 src/components/timeline/TimeFilterSelect.tsx create mode 100644 src/hooks/queries/stages/useStageQuery.ts create mode 100644 src/hooks/useTimelineUrlState.ts diff --git a/CLAUDE.md b/CLAUDE.md index 264abf1e..b482d326 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,7 @@ src/ - **React Router**: Use future flags `v7_startTransition` and `v7_relativeSplatPath` in BrowserRouter to prepare for v7 upgrade - **Component Extraction**: When a section of JSX + logic becomes substantial (>30 lines) or reusable, extract it into a separate component. Place in appropriate directory: page-specific components in `components/PageName/`, reusable ones in `components/` - **Forms**: ALL forms must use react-hook-form with proper validation. Never use plain HTML forms or manual state management for form inputs. Use @hookform/resolvers for validation schemas when needed. +- **Long Components**: Break long components (>150 lines) into smaller focused pieces. Follow the FilterSortControls pattern of primary controls + expandable sections. ### Important Notes diff --git a/src/components/Index/shared/SetMetadata.tsx b/src/components/Index/shared/SetMetadata.tsx index bf8e5922..6e0603ea 100644 --- a/src/components/Index/shared/SetMetadata.tsx +++ b/src/components/Index/shared/SetMetadata.tsx @@ -2,9 +2,11 @@ import { MapPin, Clock } from "lucide-react"; import { formatTimeRange } from "@/lib/timeUtils"; import { GenreBadge } from "../GenreBadge"; import { useFestivalSet } from "../FestivalSetContext"; +import { useStageQuery } from "@/hooks/queries/stages/useStageQuery"; export function SetMetadata() { const { set, use24Hour } = useFestivalSet(); + const stageQuery = useStageQuery(set?.stage_id); const uniqueGenres = set.artists ?.flatMap((a) => a.artist_music_genres || []) .filter( @@ -36,10 +38,10 @@ export function SetMetadata() { {/* Stage and Time Information */}
- {set.stages?.name && ( + {stageQuery.data && (
- {set.stages.name} + {stageQuery.data.name}
)} {timeRangeFormatted && ( diff --git a/src/components/Index/shared/StagePin.tsx b/src/components/Index/shared/StagePin.tsx new file mode 100644 index 00000000..4412d689 --- /dev/null +++ b/src/components/Index/shared/StagePin.tsx @@ -0,0 +1,12 @@ +import { useStageQuery } from "@/hooks/queries/stages/useStageQuery"; +import { MapPin } from "lucide-react"; + +export function StagePin({ stageId }: { stageId: string | null }) { + const stageQuery = useStageQuery(stageId); + return stageQuery.data ? ( +
+ + {stageQuery.data.name} +
+ ) : null; +} diff --git a/src/components/SetDetail/MultiArtistSetInfoCard.tsx b/src/components/SetDetail/MultiArtistSetInfoCard.tsx index 5eb4ea08..112a9dce 100644 --- a/src/components/SetDetail/MultiArtistSetInfoCard.tsx +++ b/src/components/SetDetail/MultiArtistSetInfoCard.tsx @@ -6,12 +6,13 @@ import { CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Clock, MapPin, Users } from "lucide-react"; +import { Clock, Users } from "lucide-react"; import { ArtistVotingButtons } from "./SetVotingButtons"; import { FestivalSet } from "@/hooks/queries/sets/useSets"; import { formatTimeRange } from "@/lib/timeUtils"; import { GenreBadge } from "../Index/GenreBadge"; import { IndividualArtistCard } from "./IndividualArtistCard"; +import { StagePin } from "@/components/Index/shared/StagePin"; interface MultiArtistSetInfoCardProps { set: FestivalSet; @@ -85,12 +86,7 @@ export function MultiArtistSetInfoCard({ {/* Performance Information */}
- {set.stages?.name && ( -
- - {set.stages.name} -
- )} + {formatTimeRange(set.time_start, set.time_end, use24Hour) && (
diff --git a/src/components/SetDetail/SetInfoCard.tsx b/src/components/SetDetail/SetInfoCard.tsx index aaf17094..30522b35 100644 --- a/src/components/SetDetail/SetInfoCard.tsx +++ b/src/components/SetDetail/SetInfoCard.tsx @@ -7,11 +7,12 @@ import { } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Clock, MapPin, ExternalLink, Music, Play } from "lucide-react"; +import { Clock, ExternalLink, Music, Play } from "lucide-react"; import { ArtistVotingButtons } from "./SetVotingButtons"; import { FestivalSet } from "@/hooks/queries/sets/useSets"; import { formatTimeRange } from "@/lib/timeUtils"; import { GenreBadge } from "../Index/GenreBadge"; +import { StagePin } from "@/components/Index/shared/StagePin"; interface SetInfoCardProps { set: FestivalSet; @@ -64,12 +65,7 @@ export function SetInfoCard({ {/* Performance Information */}
- {set.stages?.name && ( -
- - {set.stages.name} -
- )} + {formatTimeRange(set.time_start, set.time_end, use24Hour) && (
diff --git a/src/components/schedule/ScheduleHorizontalTimelineView.tsx b/src/components/schedule/ScheduleHorizontalTimelineView.tsx index dd05651d..9013c12e 100644 --- a/src/components/schedule/ScheduleHorizontalTimelineView.tsx +++ b/src/components/schedule/ScheduleHorizontalTimelineView.tsx @@ -5,6 +5,9 @@ import { StageLabels } from "./StageLabels"; import { TimelineContainer } from "./TimelineContainer"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { useSetsByEditionQuery as useEditionSetsQuery } from "@/hooks/queries/sets/useSetsByEdition"; +import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import { format } from "date-fns"; +import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; interface ScheduleHorizontalTimelineViewProps { userVotes: Record; @@ -18,18 +21,69 @@ export function ScheduleHorizontalTimelineView({ const { edition } = useFestivalEdition(); const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition?.id); - const { scheduleDays, loading, error } = useScheduleData(editionSets); + const stagesQuery = useStagesByEditionQuery(edition?.id); + + const { scheduleDays, loading, error } = useScheduleData( + editionSets, + stagesQuery.data, + ); + const { state: filters } = useTimelineUrlState(); + const { selectedDay, selectedTime, selectedStages } = filters; const timelineData = useMemo(() => { if (!edition || !edition.start_date || !edition.end_date) { return null; } + + // Apply filters to scheduleDays + const filteredScheduleDays = scheduleDays.map((day) => { + // Filter by day + if (selectedDay !== "all") { + const dayDate = format(day.date, "yyyy-MM-dd"); + if (dayDate !== selectedDay) { + return { ...day, stages: [] }; // Empty day if not selected + } + } + + // Filter stages and sets + const filteredStages = day.stages + .filter((stage) => { + // Filter by stage + if (selectedStages.length > 0 && !selectedStages.includes(stage.id)) { + return false; + } + return true; + }) + .map((stage) => ({ + ...stage, + sets: stage.sets.filter((set) => { + // Filter by time + if (selectedTime !== "all" && set.startTime) { + const hour = set.startTime.getHours(); + switch (selectedTime) { + case "morning": + return hour >= 6 && hour < 12; + case "afternoon": + return hour >= 12 && hour < 18; + case "evening": + return hour >= 18 && hour < 24; + default: + return true; + } + } + return true; + }), + })); + + return { ...day, stages: filteredStages }; + }); + return calculateTimelineData( new Date(edition.start_date), new Date(edition.end_date), - scheduleDays, + filteredScheduleDays, ); - }, [edition, scheduleDays]); + }, [edition, scheduleDays, selectedDay, selectedTime, selectedStages]); if (loading || setsLoading) { return ( diff --git a/src/components/schedule/not-used/DateNavigation.tsx b/src/components/schedule/not-used/DateNavigation.tsx index 47f50ded..e60c920d 100644 --- a/src/components/schedule/not-used/DateNavigation.tsx +++ b/src/components/schedule/not-used/DateNavigation.tsx @@ -1,21 +1,20 @@ import { Button } from "@/components/ui/button"; import { Calendar, Clock, ChevronUp } from "lucide-react"; -import { useScheduleData } from "@/hooks/useScheduleData"; import { useCallback } from "react"; interface DateNavigationProps { + scheduleDays: { date: string; displayDate: string }[]; onScrollToDate: (dateIndex: number) => void; onScrollToNow: () => void; containerRef: React.RefObject; } export function DateNavigation({ + scheduleDays, onScrollToDate, onScrollToNow, containerRef, }: DateNavigationProps) { - const { scheduleDays } = useScheduleData(); - const scrollToTop = useCallback(() => { containerRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }, [containerRef]); diff --git a/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx b/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx index 6c22e966..b2e2a66b 100644 --- a/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx +++ b/src/components/schedule/vertical/MobileFirstVerticalTimeline.tsx @@ -2,9 +2,11 @@ import { useMemo } from "react"; import { useScheduleData } from "@/hooks/useScheduleData"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { useSetsByEditionQuery as useEditionSetsQuery } from "@/hooks/queries/sets/useSetsByEdition"; -import { isSameDay } from "date-fns"; +import { isSameDay, format } from "date-fns"; import { TimeSlotGroup } from "./TimeSlotGroup"; import type { ScheduleSet } from "@/hooks/useScheduleData"; +import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; interface MobileFirstVerticalTimelineProps { userVotes: Record; @@ -23,18 +25,62 @@ export function MobileFirstVerticalTimeline({ const { edition } = useFestivalEdition(); const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition?.id); - const { scheduleDays, loading, error } = useScheduleData(editionSets); + const stagesQuery = useStagesByEditionQuery(edition?.id); + const { scheduleDays, loading, error } = useScheduleData( + editionSets, + stagesQuery.data, + ); + const { state: filters } = useTimelineUrlState(); + const { selectedDay, selectedTime, selectedStages } = filters; const timeSlots = useMemo(() => { if (!scheduleDays.length) return []; - // Collect all unique start times + // Helper function to check if a set matches the day filter + function matchesDay(set: ScheduleSet) { + if (selectedDay === "all") return true; + if (!set.startTime) return false; + + const setDate = format(set.startTime, "yyyy-MM-dd"); + return setDate === selectedDay; + } + + // Helper function to check if a set matches the time filter + function matchesTime(set: ScheduleSet) { + if (selectedTime === "all") return true; + if (!set.startTime) return false; + + const hour = set.startTime.getHours(); + switch (selectedTime) { + case "morning": + return hour >= 6 && hour < 12; + case "afternoon": + return hour >= 12 && hour < 18; + case "evening": + return hour >= 18 && hour < 24; + default: + return true; + } + } + + // Helper function to check if a set matches the stage filter + function matchesStage(stageName: string) { + if (selectedStages.length === 0) return true; + return selectedStages.includes(stageName); + } + + // Collect all unique start times with filtering const allSets: (ScheduleSet & { stageName: string })[] = []; scheduleDays.forEach((day) => { day.stages.forEach((stage) => { + if (!matchesStage(stage.id)) { + console.log("Skipping stage:", stage.name, selectedStages); + return; + } + stage.sets.forEach((set) => { - if (set.startTime) { + if (set.startTime && matchesDay(set) && matchesTime(set)) { allSets.push({ ...set, stageName: stage.name, @@ -69,7 +115,7 @@ export function MobileFirstVerticalTimeline({ .sort((a, b) => a.time.getTime() - b.time.getTime()); return slots; - }, [scheduleDays]); + }, [scheduleDays, selectedDay, selectedTime, selectedStages]); if (loading || setsLoading) { return ( diff --git a/src/components/schedule/vertical/ScheduleVerticalTimelineView.tsx b/src/components/schedule/vertical/ScheduleVerticalTimelineView.tsx deleted file mode 100644 index 96194d27..00000000 --- a/src/components/schedule/vertical/ScheduleVerticalTimelineView.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useMemo } from "react"; -import { useScheduleData } from "@/hooks/useScheduleData"; -import { calculateVerticalTimelineData } from "@/lib/timelineCalculator"; -import { VerticalStageLabels } from "./VerticalStageLabels"; -import { VerticalTimelineContainer } from "./VerticalTimelineContainer"; -import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; -import { useSetsByEditionQuery as useEditionSetsQuery } from "@/hooks/queries/sets/useSetsByEdition"; - -interface ScheduleVerticalTimelineViewProps { - userVotes: Record; - onVote: (artistId: string, voteType: number) => void; -} - -export function ScheduleVerticalTimelineView({ - userVotes, - onVote, -}: ScheduleVerticalTimelineViewProps) { - const { edition } = useFestivalEdition(); - const { data: editionSets = [], isLoading: setsLoading } = - useEditionSetsQuery(edition?.id); - const { scheduleDays, loading, error } = useScheduleData(editionSets); - - const timelineData = useMemo(() => { - if (!edition || !edition.start_date || !edition.end_date) { - return null; - } - return calculateVerticalTimelineData( - new Date(edition.start_date), - new Date(edition.end_date), - scheduleDays, - ); - }, [edition, scheduleDays]); - - if (loading || setsLoading) { - return ( -
-

Loading vertical timeline...

-
- ); - } - - if (error) { - return ( -
-

Error loading schedule.

-
- ); - } - - if (!timelineData) { - return ( -
-

Festival dates not available yet.

-
- ); - } - - if (!edition?.published) { - return ( -
-

Schedule not yet published.

-
- ); - } - - return ( -
-
- - - -
-
- ); -} \ No newline at end of file diff --git a/src/components/timeline/DayFilterSelect.tsx b/src/components/timeline/DayFilterSelect.tsx new file mode 100644 index 00000000..91a72aa9 --- /dev/null +++ b/src/components/timeline/DayFilterSelect.tsx @@ -0,0 +1,69 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Calendar } from "lucide-react"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { format, parseISO, isValid } from "date-fns"; + +interface DayFilterSelectProps { + selectedDay: string; + onDayChange: (day: string) => void; +} + +export function DayFilterSelect({ + selectedDay, + onDayChange, +}: DayFilterSelectProps) { + const { edition } = useFestivalEdition(); + + // Generate day options from edition dates + const dayOptions = []; + dayOptions.push({ value: "all", label: "All Days" }); + + if (edition?.start_date && edition?.end_date) { + const startDate = parseISO(edition.start_date); + const endDate = parseISO(edition.end_date); + + if (isValid(startDate) && isValid(endDate)) { + const currentDate = new Date(startDate); + while (currentDate <= endDate) { + const dateStr = format(currentDate, "yyyy-MM-dd"); + const dayLabel = format(currentDate, "EEEE"); // e.g., "Friday" + dayOptions.push({ + value: dateStr, + label: dayLabel, + }); + currentDate.setDate(currentDate.getDate() + 1); + } + } + } + + return ( +
+
+ + +
+ +
+ ); +} diff --git a/src/components/timeline/StageFilterButtons.tsx b/src/components/timeline/StageFilterButtons.tsx new file mode 100644 index 00000000..33af7eb3 --- /dev/null +++ b/src/components/timeline/StageFilterButtons.tsx @@ -0,0 +1,43 @@ +import { Button } from "@/components/ui/button"; +import { MapPin } from "lucide-react"; +import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; + +interface StageFilterButtonsProps { + selectedStages: string[]; + onStageToggle: (stageId: string) => void; +} + +export function StageFilterButtons({ + selectedStages, + onStageToggle, +}: StageFilterButtonsProps) { + const { edition } = useFestivalEdition(); + const { data: stages = [] } = useStagesByEditionQuery(edition?.id); + + return ( +
+
+ + +
+
+ {stages.map((stage) => ( + + ))} +
+
+ ); +} diff --git a/src/components/timeline/TimeFilterSelect.tsx b/src/components/timeline/TimeFilterSelect.tsx new file mode 100644 index 00000000..ffd5a766 --- /dev/null +++ b/src/components/timeline/TimeFilterSelect.tsx @@ -0,0 +1,47 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Clock } from "lucide-react"; +import type { TimeFilter } from "@/hooks/useTimelineUrlState"; + +interface TimeFilterSelectProps { + selectedTime: TimeFilter; + onTimeChange: (time: TimeFilter) => void; +} + +export function TimeFilterSelect({ + selectedTime, + onTimeChange, +}: TimeFilterSelectProps) { + return ( +
+
+ + +
+ +
+ ); +} diff --git a/src/components/timeline/TimelineFilters.tsx b/src/components/timeline/TimelineFilters.tsx index 74f85dcc..8748d47f 100644 --- a/src/components/timeline/TimelineFilters.tsx +++ b/src/components/timeline/TimelineFilters.tsx @@ -1,61 +1,26 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Calendar, - Clock, - MapPin, - Filter, - X, - ChevronDown, - ChevronUp, -} from "lucide-react"; +import { Filter, X, ChevronDown, ChevronUp } from "lucide-react"; import { useIsMobile } from "@/hooks/use-mobile"; -import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; -import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { ViewToggle } from "./ViewToggle"; -import type { TimelineView } from "@/hooks/useUrlState"; +import { DayFilterSelect } from "./DayFilterSelect"; +import { TimeFilterSelect } from "./TimeFilterSelect"; +import { StageFilterButtons } from "./StageFilterButtons"; +import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; -type DayFilter = "all" | "friday" | "saturday" | "sunday"; -type TimeFilter = "all" | "morning" | "afternoon" | "evening"; - -interface TimelineFiltersProps { - currentView: TimelineView; - onViewChange: (view: TimelineView) => void; -} - -export function TimelineFilters({ - currentView, - onViewChange, -}: TimelineFiltersProps) { - const [selectedDay, setSelectedDay] = useState("all"); - const [selectedTime, setSelectedTime] = useState("all"); - const [selectedStages, setSelectedStages] = useState([]); +export function TimelineFilters() { const [isExpanded, setIsExpanded] = useState(false); - const { edition } = useFestivalEdition(); - const { data: stages = [] } = useStagesByEditionQuery(edition?.id); const isMobile = useIsMobile(); + const { state, updateState, clearFilters } = useTimelineUrlState(); + const { timelineView, selectedDay, selectedTime, selectedStages } = state; - const handleStageToggle = (stageId: string) => { - setSelectedStages((prev) => - prev.includes(stageId) - ? prev.filter((id) => id !== stageId) - : [...prev, stageId], - ); - }; - - const clearAllFilters = () => { - setSelectedDay("all"); - setSelectedTime("all"); - setSelectedStages([]); - }; + function handleStageToggle(stageId: string) { + const newStages = selectedStages.includes(stageId) + ? selectedStages.filter((id) => id !== stageId) + : [...selectedStages, stageId]; + updateState({ selectedStages: newStages }); + } const hasActiveFilters = selectedDay !== "all" || @@ -75,7 +40,10 @@ export function TimelineFilters({
{/* View Toggle */} - + updateState({ timelineView: view })} + /> {/* Spacer to push filters to right */}
@@ -122,7 +90,7 @@ export function TimelineFilters({
- {/* Day Filter */} -
-
- - -
- -
- - {/* Time Filter */} -
-
- - -
- -
- - {/* Stages Filter */} -
-
- - -
-
- {stages.map((stage) => ( - - ))} -
-
+ updateState({ selectedDay: day })} + /> + updateState({ selectedTime: time })} + /> +
)} diff --git a/src/components/timeline/TimelineTab.tsx b/src/components/timeline/TimelineTab.tsx index 8a486a89..fe40d52e 100644 --- a/src/components/timeline/TimelineTab.tsx +++ b/src/components/timeline/TimelineTab.tsx @@ -1,7 +1,7 @@ import { ScheduleHorizontalTimelineView } from "@/components/schedule/ScheduleHorizontalTimelineView"; import { MobileFirstVerticalTimeline } from "@/components/schedule/vertical/MobileFirstVerticalTimeline"; import { TimelineFilters } from "./TimelineFilters"; -import { useUrlState } from "@/hooks/useUrlState"; +import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; interface TimelineTabProps { userVotes: Record; @@ -9,18 +9,13 @@ interface TimelineTabProps { } export function TimelineTab({ userVotes, onVote }: TimelineTabProps) { - const { state: urlState, updateUrlState } = useUrlState(); - const timelineView = urlState.timelineView; + const { state } = useTimelineUrlState(); + const { timelineView } = state; return (
- {/* Timeline Controls & Filters */} - updateUrlState({ timelineView: view })} - /> + - {/* Timeline Content */} {timelineView === "horizontal" ? ( ) : ( diff --git a/src/hooks/queries/sets/useSetBySlug.ts b/src/hooks/queries/sets/useSetBySlug.ts index 61b0627a..bcb18a86 100644 --- a/src/hooks/queries/sets/useSetBySlug.ts +++ b/src/hooks/queries/sets/useSetBySlug.ts @@ -39,7 +39,6 @@ async function fetchSetBySlug(slug: string): Promise { votes: [], })) .filter(Boolean) || [], - stages: data.stage_id && data.stages ? data.stages : null, votes: data.votes || [], }; diff --git a/src/hooks/queries/sets/useSets.ts b/src/hooks/queries/sets/useSets.ts index 7903a4b6..82713bab 100644 --- a/src/hooks/queries/sets/useSets.ts +++ b/src/hooks/queries/sets/useSets.ts @@ -6,7 +6,6 @@ import { Artist } from "../artists/useArtists"; export type FestivalSet = Database["public"]["Tables"]["sets"]["Row"] & { artists: Artist[]; votes: { vote_type: number; user_id: string }[]; - stages?: { name: string } | null; }; export type Stage = Database["public"]["Tables"]["stages"]["Row"]; diff --git a/src/hooks/queries/sets/useSetsByEdition.ts b/src/hooks/queries/sets/useSetsByEdition.ts index 0860bce6..c00d5b8d 100644 --- a/src/hooks/queries/sets/useSetsByEdition.ts +++ b/src/hooks/queries/sets/useSetsByEdition.ts @@ -9,7 +9,6 @@ async function fetchSetsByEdition(editionId: string): Promise { .select( ` *, - stages (name), set_artists ( artists ( *, diff --git a/src/hooks/queries/stages/types.ts b/src/hooks/queries/stages/types.ts index 60aa7260..df89dff2 100644 --- a/src/hooks/queries/stages/types.ts +++ b/src/hooks/queries/stages/types.ts @@ -6,4 +6,5 @@ export type Stage = Database["public"]["Tables"]["stages"]["Row"]; export const stagesKeys = { all: ["stages"] as const, byEdition: (editionId: string) => ["stages", { editionId }] as const, + byId: (stageId: string) => ["stages", { stageId }] as const, }; diff --git a/src/hooks/queries/stages/useStageQuery.ts b/src/hooks/queries/stages/useStageQuery.ts new file mode 100644 index 00000000..d09de5ec --- /dev/null +++ b/src/hooks/queries/stages/useStageQuery.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Stage, stagesKeys } from "./types"; + +async function fetchStage(stageId: string): Promise { + const { data, error } = await supabase + .from("stages") + .select("*") + .eq("id", stageId) + .eq("archived", false) + .single(); + + if (error) { + if (error.code === "PGRST116") { + // No rows returned + return null; + } + throw new Error("Failed to load stage"); + } + + return data; +} + +export function useStageQuery(stageId: string | undefined | null) { + return useQuery({ + queryKey: stagesKeys.byId(stageId || ""), + queryFn: () => fetchStage(stageId!), + enabled: !!stageId, + }); +} diff --git a/src/hooks/useScheduleData.ts b/src/hooks/useScheduleData.ts index 07d36abd..f01117e7 100644 --- a/src/hooks/useScheduleData.ts +++ b/src/hooks/useScheduleData.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; 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"; export interface ScheduleDay { date: string; @@ -10,15 +11,16 @@ export interface ScheduleDay { } export interface ScheduleStage { + id: string; name: string; - sets: ScheduleSet[]; // Now represents sets, not individual artists + sets: ScheduleSet[]; } export interface ScheduleArtist { id: string; name: string; slug?: string; - stage?: string; + stageId?: string; startTime?: Date; endTime?: Date; votes?: { vote_type: number; user_id: string }[]; @@ -36,26 +38,20 @@ export interface ScheduleSet extends ScheduleArtist { } export function useScheduleData( - sets?: FestivalSet[], + sets: FestivalSet[] | undefined, + stages: Array | undefined, use24Hour: boolean = false, ) { const loading = false; const error = null; const scheduleDays = useMemo(() => { - // Enhanced defensive checks - if (!sets) { - return []; - } - - if (!Array.isArray(sets) || sets.length === 0) { + if (!sets || !stages || !Array.isArray(sets) || sets.length === 0) { return []; } // Filter sets with performance times and stages - const performingSets = sets.filter( - (set) => set.time_start && set.stages?.name, - ); + const performingSets = sets.filter((set) => set.time_start && set.stage_id); // Parse and enhance set data const enhancedSets: ScheduleSet[] = performingSets.map((set) => { @@ -66,7 +62,7 @@ export function useScheduleData( id: set.id, name: set.name, slug: set.slug, - stage: set.stages?.name || "", + stageId: set.stage_id || "", startTime, endTime, votes: set.votes || [], @@ -78,6 +74,8 @@ export function useScheduleData( }; }); + console.log({ enhancedSets }); + // Group sets by day const dayGroups = enhancedSets.reduce( (acc, set) => { @@ -102,36 +100,50 @@ export function useScheduleData( // Group by stage const stageGroups = daySets.reduce( (acc, set) => { - const stageName = set.stage || "Main Stage"; - if (!acc[stageName]) { - acc[stageName] = []; + const stageId = set.stageId; + if (!stageId) { + console.log("no stageId", set); + return acc; } - acc[stageName].push(set); + + if (!acc[stageId]) { + acc[stageId] = []; + } + + acc[stageId].push(set); return acc; }, {} as Record, ); // Sort sets within each stage by time - const stages: ScheduleStage[] = Object.entries(stageGroups).map( - ([stageName, stageSets]) => ({ - name: stageName, - sets: stageSets.sort((a, b) => { - if (!a.startTime || !b.startTime) return 0; - return a.startTime.getTime() - b.startTime.getTime(); - }), - }), - ); + const scheduleStages: ScheduleStage[] = Object.entries(stageGroups) + .map(([stageId, stageSets]) => { + const stage = stages.find((s) => s.id === stageId); + if (!stage) { + return null; + } + + return { + id: stageId, + name: stage?.name, + sets: stageSets.sort((a, b) => { + if (!a.startTime || !b.startTime) return 0; + return a.startTime.getTime() - b.startTime.getTime(); + }), + }; + }) + .filter((v: ScheduleStage | null): v is ScheduleStage => !!v); return { date: dateKey, displayDate: format(date, "EEEE, MMM d"), - stages: stages.sort((a, b) => a.name.localeCompare(b.name)), + stages: scheduleStages.sort((a, b) => a.name.localeCompare(b.name)), }; }); return scheduleDays; - }, [sets, use24Hour]); + }, [sets, use24Hour, stages]); const allStages = useMemo(() => { const stageSet = new Set(); diff --git a/src/hooks/useTimelineUrlState.ts b/src/hooks/useTimelineUrlState.ts new file mode 100644 index 00000000..905b6368 --- /dev/null +++ b/src/hooks/useTimelineUrlState.ts @@ -0,0 +1,80 @@ +import { useCallback } from "react"; +import { useSearchParams } from "react-router-dom"; + +export type TimelineView = "horizontal" | "list"; +export type TimeFilter = "all" | "morning" | "afternoon" | "evening"; + +export interface TimelineState { + timelineView: TimelineView; + selectedDay: string; // Dynamic based on festival dates + selectedTime: TimeFilter; + selectedStages: string[]; +} + +const defaultState: TimelineState = { + timelineView: "list", + selectedDay: "all", + selectedTime: "all", + selectedStages: [], +}; + +export function useTimelineUrlState() { + const [searchParams, setSearchParams] = useSearchParams(); + + const getStateFromUrl = useCallback((): TimelineState => { + return { + timelineView: + (searchParams.get("view") as TimelineView) || defaultState.timelineView, + selectedDay: searchParams.get("day") || defaultState.selectedDay, + selectedTime: + (searchParams.get("time") as TimeFilter) || defaultState.selectedTime, + selectedStages: + searchParams.get("stages")?.split(",").filter(Boolean) || + defaultState.selectedStages, + }; + }, [searchParams]); + + const updateTimelineState = useCallback( + (updates: Partial) => { + const currentState = getStateFromUrl(); + const newState = { ...currentState, ...updates }; + + const newParams = new URLSearchParams(); + + // Only add non-default values to URL + if (newState.timelineView !== defaultState.timelineView) { + newParams.set("view", newState.timelineView); + } + if (newState.selectedDay !== defaultState.selectedDay) { + newParams.set("day", newState.selectedDay); + } + if (newState.selectedTime !== defaultState.selectedTime) { + newParams.set("time", newState.selectedTime); + } + if (newState.selectedStages.length > 0) { + newParams.set("stages", newState.selectedStages.join(",")); + } + + setSearchParams(newParams, { replace: true }); + }, + [getStateFromUrl, setSearchParams], + ); + + const clearTimelineFilters = useCallback(() => { + const currentState = getStateFromUrl(); + const newParams = new URLSearchParams(); + + // Keep view when clearing filters + if (currentState.timelineView !== defaultState.timelineView) { + newParams.set("view", currentState.timelineView); + } + + setSearchParams(newParams, { replace: true }); + }, [getStateFromUrl, setSearchParams]); + + return { + state: getStateFromUrl(), + updateState: updateTimelineState, + clearFilters: clearTimelineFilters, + }; +} diff --git a/src/hooks/useUrlState.ts b/src/hooks/useUrlState.ts index ec9b5d1c..b52bb236 100644 --- a/src/hooks/useUrlState.ts +++ b/src/hooks/useUrlState.ts @@ -36,7 +36,7 @@ const defaultState: FilterSortState = { votePerspective: undefined, }; -export const useUrlState = () => { +export function useUrlState() { const [searchParams, setSearchParams] = useSearchParams(); const getStateFromUrl = useCallback((): FilterSortState => { @@ -126,4 +126,4 @@ export const useUrlState = () => { updateUrlState, clearFilters, }; -}; +} From 1278090f56ab1fe220774a07e36f42235ba61277 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 22 Aug 2025 15:10:14 +0200 Subject: [PATCH 08/16] feat(app): disable map and other buttons --- .../navigation/MainTabNavigation.tsx | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/navigation/MainTabNavigation.tsx b/src/components/navigation/MainTabNavigation.tsx index 03abe868..4f0f14ba 100644 --- a/src/components/navigation/MainTabNavigation.tsx +++ b/src/components/navigation/MainTabNavigation.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/lib/utils"; import { Calendar, List, Map, Info, MessageSquare } from "lucide-react"; import { NavLink, useParams } from "react-router-dom"; @@ -8,31 +9,31 @@ const TAB_CONFIG = { icon: List, label: "Vote", shortLabel: "Vote", - emoji: "🎭", + disabled: false, }, timeline: { icon: Calendar, label: "Schedule", shortLabel: "Schedule", - emoji: "πŸ“…", + disabled: false, }, map: { icon: Map, label: "Map", shortLabel: "Map", - emoji: "πŸ—ΊοΈ", + disabled: true, }, info: { icon: Info, label: "Info", shortLabel: "Info", - emoji: "ℹ️", + disabled: true, }, social: { icon: MessageSquare, label: "Social", shortLabel: "Social", - emoji: "πŸ“±", + disabled: true, }, } as const; @@ -68,16 +69,18 @@ export function MainTabNavigation() { key={tab} to={to} end={tab === "artists"} // Only match exact path for artists tab - className={({ isActive }) => ` + className={({ isActive }) => + cn( + ` flex items-center justify-center gap-2 px-6 py-3 rounded-lg - transition-all duration-200 active:scale-95 - ${ + transition-all duration-200 active:scale-95`, isActive ? "bg-purple-600 text-white shadow-lg" - : "text-purple-200 hover:text-white hover:bg-white/10" - } - `} + : "text-purple-200 hover:text-white hover:bg-white/10", + config.disabled ? "cursor-not-allowed opacity-50" : "", + ) + } > {config.label} @@ -108,6 +111,7 @@ export function MainTabNavigation() { ? "text-purple-400" : "text-gray-400 active:text-purple-300" } + ${config.disabled ? "cursor-not-allowed opacity-50" : ""} `} > {({ isActive }) => ( From baf37ce622cbbb3c218c5b42b396ba86288d44d1 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 22 Aug 2025 15:13:55 +0200 Subject: [PATCH 09/16] fix(app): sort width --- src/components/Index/filters/SortControls.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Index/filters/SortControls.tsx b/src/components/Index/filters/SortControls.tsx index ba8858b2..8ae24b34 100644 --- a/src/components/Index/filters/SortControls.tsx +++ b/src/components/Index/filters/SortControls.tsx @@ -36,14 +36,14 @@ const SORT_ICONS = { "date-asc": Calendar, } as const; -export const SortControls = ({ sort, onSortChange }: SortControlsProps) => { +export function SortControls({ sort, onSortChange }: SortControlsProps) { const CurrentSortIcon = SORT_ICONS[sort]; return (
- - {hasActiveFilters && ( - - )}
); } diff --git a/src/components/common/filters/FilterContainer.tsx b/src/components/common/filters/FilterContainer.tsx new file mode 100644 index 00000000..2f813de0 --- /dev/null +++ b/src/components/common/filters/FilterContainer.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; + +interface FilterContainerProps { + children: ReactNode; +} + +export function FilterContainer({ children }: FilterContainerProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/common/filters/FilterHeader.tsx b/src/components/common/filters/FilterHeader.tsx new file mode 100644 index 00000000..0e37274e --- /dev/null +++ b/src/components/common/filters/FilterHeader.tsx @@ -0,0 +1,34 @@ +import { Button } from "@/components/ui/button"; +import { Filter, X } from "lucide-react"; + +interface FilterHeaderProps { + hasActiveFilters: boolean; + onClearFilters: () => void; + title?: string; +} + +export function FilterHeader({ + hasActiveFilters, + onClearFilters, + title = "Filters", +}: FilterHeaderProps) { + return ( +
+
+ + {title} +
+ {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/src/components/common/filters/FilterToggle.tsx b/src/components/common/filters/FilterToggle.tsx new file mode 100644 index 00000000..352c144f --- /dev/null +++ b/src/components/common/filters/FilterToggle.tsx @@ -0,0 +1,64 @@ +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Filter, ChevronDown, ChevronUp, X } from "lucide-react"; + +interface FilterToggleProps { + isExpanded: boolean; + onToggle: () => void; + hasActiveFilters: boolean; + activeFilterCount: number; + label?: string; + onClearFilters?: () => void; +} + +export function FilterToggle({ + isExpanded, + onToggle, + hasActiveFilters, + activeFilterCount, + label = "Filters", + onClearFilters, +}: FilterToggleProps) { + return ( +
+ {hasActiveFilters && onClearFilters && ( + + )} + + +
+ ); +} diff --git a/src/components/timeline/TimelineFilters.tsx b/src/components/timeline/TimelineFilters.tsx index 7807ae73..bcd335b6 100644 --- a/src/components/timeline/TimelineFilters.tsx +++ b/src/components/timeline/TimelineFilters.tsx @@ -1,17 +1,15 @@ import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Filter, X, ChevronDown, ChevronUp } from "lucide-react"; -import { useIsMobile } from "@/hooks/use-mobile"; import { ViewToggle } from "./ViewToggle"; import { DayFilterSelect } from "./DayFilterSelect"; import { TimeFilterSelect } from "./TimeFilterSelect"; import { StageFilterButtons } from "./StageFilterButtons"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import { FilterToggle } from "@/components/common/filters/FilterToggle"; +import { FilterContainer } from "@/components/common/filters/FilterContainer"; export function TimelineFilters() { const [isExpanded, setIsExpanded] = useState(false); - const isMobile = useIsMobile(); const { state, updateState, clearFilters } = useTimelineUrlState(); const { timelineView, selectedDay, selectedTime, selectedStages } = state; @@ -32,61 +30,29 @@ export function TimelineFilters() { return (
-
-
- updateState({ timelineView: view })} - /> + +
+
+ updateState({ timelineView: view })} + /> +
- + setIsExpanded(!isExpanded)} + hasActiveFilters={hasActiveFilters} + activeFilterCount={activeFilterCount} + onClearFilters={hasActiveFilters ? clearFilters : undefined} + />
-
+
{shouldShowFilters && ( -
-
-
- - Filters -
- {hasActiveFilters && ( - - )} -
+
-
+ )}
); diff --git a/src/components/timeline/ViewToggle.tsx b/src/components/timeline/ViewToggle.tsx index dbed02e0..a6cf8a25 100644 --- a/src/components/timeline/ViewToggle.tsx +++ b/src/components/timeline/ViewToggle.tsx @@ -1,5 +1,6 @@ import { BarChart3, List } from "lucide-react"; import type { TimelineView } from "@/hooks/useUrlState"; +import { ViewToggleOption } from "./ViewToggleOption"; interface ViewToggleProps { currentView: TimelineView; @@ -10,36 +11,22 @@ export function ViewToggle({ currentView, onViewChange }: ViewToggleProps) { return (
- - + viewId="list" + title="List View" + icon={List} + label="List" + />
); diff --git a/src/components/timeline/ViewToggleOption.tsx b/src/components/timeline/ViewToggleOption.tsx new file mode 100644 index 00000000..053d1276 --- /dev/null +++ b/src/components/timeline/ViewToggleOption.tsx @@ -0,0 +1,41 @@ +import { LucideIcon } from "lucide-react"; +import type { TimelineView } from "@/hooks/useUrlState"; + +interface ViewToggleOptionProps { + currentView: TimelineView; + onClick: () => void; + viewId: TimelineView; + title: string; + icon: LucideIcon; + label: string; +} + +export function ViewToggleOption({ + currentView, + onClick, + viewId, + title, + icon: Icon, + label, +}: ViewToggleOptionProps) { + const isActive = currentView === viewId; + + return ( + + ); +} From ee781cff2fa1ab4f9561f3fdd39506a6690183e1 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 24 Aug 2025 10:01:34 +0200 Subject: [PATCH 13/16] fix(timeline): separate into view --- .../{TimelineFilters.tsx => ListFilters.tsx} | 46 ++++------- src/components/timeline/TimelineControls.tsx | 55 +++++++++++++ .../timeline/TimelineNavigation.tsx | 82 +++++++++++++++++++ src/components/timeline/TimelineTab.tsx | 15 +++- 4 files changed, 166 insertions(+), 32 deletions(-) rename src/components/timeline/{TimelineFilters.tsx => ListFilters.tsx} (63%) create mode 100644 src/components/timeline/TimelineControls.tsx create mode 100644 src/components/timeline/TimelineNavigation.tsx diff --git a/src/components/timeline/TimelineFilters.tsx b/src/components/timeline/ListFilters.tsx similarity index 63% rename from src/components/timeline/TimelineFilters.tsx rename to src/components/timeline/ListFilters.tsx index bcd335b6..b3c27962 100644 --- a/src/components/timeline/TimelineFilters.tsx +++ b/src/components/timeline/ListFilters.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import { ViewToggle } from "./ViewToggle"; import { DayFilterSelect } from "./DayFilterSelect"; import { TimeFilterSelect } from "./TimeFilterSelect"; import { StageFilterButtons } from "./StageFilterButtons"; @@ -7,11 +6,10 @@ import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; import { FilterToggle } from "@/components/common/filters/FilterToggle"; import { FilterContainer } from "@/components/common/filters/FilterContainer"; -export function TimelineFilters() { +export function ListFilters() { const [isExpanded, setIsExpanded] = useState(false); - const { state, updateState, clearFilters } = useTimelineUrlState(); - const { timelineView, selectedDay, selectedTime, selectedStages } = state; + const { selectedDay, selectedTime, selectedStages } = state; function handleStageToggle(stageId: string) { const newStages = selectedStages.includes(stageId) @@ -25,34 +23,26 @@ export function TimelineFilters() { (selectedTime !== "all" ? 1 : 0) + selectedStages.length; const hasActiveFilters = activeFilterCount > 0; - const shouldShowFilters = isExpanded; return ( -
- -
-
- updateState({ timelineView: view })} - /> -
+ +
+

Filters

+
-
- - setIsExpanded(!isExpanded)} - hasActiveFilters={hasActiveFilters} - activeFilterCount={activeFilterCount} - onClearFilters={hasActiveFilters ? clearFilters : undefined} - /> -
- + setIsExpanded(!isExpanded)} + hasActiveFilters={hasActiveFilters} + activeFilterCount={activeFilterCount} + label="Filters" + onClearFilters={hasActiveFilters ? clearFilters : undefined} + /> +
{shouldShowFilters && ( - +
- +
)} -
+
); } diff --git a/src/components/timeline/TimelineControls.tsx b/src/components/timeline/TimelineControls.tsx new file mode 100644 index 00000000..e2654de2 --- /dev/null +++ b/src/components/timeline/TimelineControls.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { TimelineNavigation } from "./TimelineNavigation"; +import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import { FilterToggle } from "@/components/common/filters/FilterToggle"; +import { FilterContainer } from "@/components/common/filters/FilterContainer"; + +export function TimelineControls() { + const [isExpanded, setIsExpanded] = useState(false); + const { state, updateState, clearFilters } = useTimelineUrlState(); + const { selectedStages } = state; + + function handleStageToggle(stageId: string) { + const newStages = selectedStages.includes(stageId) + ? selectedStages.filter((id) => id !== stageId) + : [...selectedStages, stageId]; + updateState({ selectedStages: newStages }); + } + + const activeFilterCount = selectedStages.length; + const hasActiveFilters = activeFilterCount > 0; + + return ( + +
+
+ + setIsExpanded(!isExpanded)} + hasActiveFilters={hasActiveFilters} + activeFilterCount={activeFilterCount} + label="Navigation" + onClearFilters={hasActiveFilters ? clearFilters : undefined} + /> +
+ + {isExpanded && ( +
+ { + // TODO: Implement jump to today functionality + console.log("Jump to today"); + }} + onJumpToTime={(timeOfDay) => { + // TODO: Implement jump to time functionality + console.log("Jump to", timeOfDay); + }} + /> +
+ )} + + ); +} diff --git a/src/components/timeline/TimelineNavigation.tsx b/src/components/timeline/TimelineNavigation.tsx new file mode 100644 index 00000000..6d5fbe3c --- /dev/null +++ b/src/components/timeline/TimelineNavigation.tsx @@ -0,0 +1,82 @@ +import { Button } from "@/components/ui/button"; +import { Clock, Sun, Sunset, Calendar } from "lucide-react"; +import { StageFilterButtons } from "./StageFilterButtons"; + +interface TimelineNavigationProps { + selectedStages: string[]; + onStageToggle: (stageId: string) => void; + onJumpToToday?: () => void; + onJumpToTime?: (timeOfDay: "morning" | "afternoon" | "evening") => void; +} + +export function TimelineNavigation({ + selectedStages, + onStageToggle, + onJumpToToday, + onJumpToTime, +}: TimelineNavigationProps) { + function handleJumpToToday() { + onJumpToToday?.(); + } + + function handleJumpToTime(timeOfDay: "morning" | "afternoon" | "evening") { + onJumpToTime?.(timeOfDay); + } + + return ( +
+ {/* Quick Navigation */} +
+

+ Quick Navigation +

+
+ + + + +
+
+ +
+

Filters

+ +
+
+ ); +} diff --git a/src/components/timeline/TimelineTab.tsx b/src/components/timeline/TimelineTab.tsx index e0ad74ee..087b885d 100644 --- a/src/components/timeline/TimelineTab.tsx +++ b/src/components/timeline/TimelineTab.tsx @@ -1,7 +1,8 @@ import { Timeline } from "@/components/timeline/horizontal/Timeline"; import { ListSchedule } from "@/components/timeline/list/ListSchedule"; -import { TimelineFilters } from "./TimelineFilters"; +import { ListFilters } from "./ListFilters"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import { ViewToggle } from "./ViewToggle"; interface TimelineTabProps { userVotes: Record; @@ -9,17 +10,23 @@ interface TimelineTabProps { } export function TimelineTab({ userVotes, onVote }: TimelineTabProps) { - const { state } = useTimelineUrlState(); + const { state, updateState } = useTimelineUrlState(); const { timelineView } = state; return (
- + updateState({ timelineView })} + /> {timelineView === "horizontal" ? ( ) : ( - + <> + + + )}
); From 5f2c4c0402de81befa5afac8ea9bcb3ff5cc850a Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 24 Aug 2025 13:17:10 +0200 Subject: [PATCH 14/16] fix(timeline): add navigation --- .../navigation/MainTabNavigation.tsx | 33 ++++----------- src/components/router/EditionRoutes.tsx | 29 ++++++------- .../timeline/ScheduleNavigation.tsx | 17 ++++++++ .../timeline/ScheduleNavigationItem.tsx | 36 ++++++++++++++++ src/components/timeline/TimelineTab.tsx | 33 --------------- src/components/timeline/ViewToggle.tsx | 33 --------------- src/components/timeline/ViewToggleOption.tsx | 41 ------------------- src/contexts/FestivalEditionContext.tsx | 13 ++++++ src/pages/tabs/ScheduleTab.tsx | 12 ++++++ src/pages/tabs/ScheduleTabList.tsx | 23 +++++++++++ ...imelineTab.tsx => ScheduleTabTimeline.tsx} | 8 ++-- 11 files changed, 125 insertions(+), 153 deletions(-) create mode 100644 src/components/timeline/ScheduleNavigation.tsx create mode 100644 src/components/timeline/ScheduleNavigationItem.tsx delete mode 100644 src/components/timeline/TimelineTab.tsx delete mode 100644 src/components/timeline/ViewToggle.tsx delete mode 100644 src/components/timeline/ViewToggleOption.tsx create mode 100644 src/pages/tabs/ScheduleTab.tsx create mode 100644 src/pages/tabs/ScheduleTabList.tsx rename src/pages/tabs/{TimelineTab.tsx => ScheduleTabTimeline.tsx} (66%) diff --git a/src/components/navigation/MainTabNavigation.tsx b/src/components/navigation/MainTabNavigation.tsx index 4f0f14ba..223900a1 100644 --- a/src/components/navigation/MainTabNavigation.tsx +++ b/src/components/navigation/MainTabNavigation.tsx @@ -1,17 +1,18 @@ import { cn } from "@/lib/utils"; import { Calendar, List, Map, Info, MessageSquare } from "lucide-react"; -import { NavLink, useParams } from "react-router-dom"; +import { NavLink } from "react-router-dom"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; -export type MainTab = "artists" | "timeline" | "map" | "info" | "social"; +export type MainTab = "sets" | "schedule" | "map" | "info" | "social"; const TAB_CONFIG = { - artists: { + sets: { icon: List, label: "Vote", shortLabel: "Vote", disabled: false, }, - timeline: { + schedule: { icon: Calendar, label: "Schedule", shortLabel: "Schedule", @@ -38,21 +39,7 @@ const TAB_CONFIG = { } as const; export function MainTabNavigation() { - const { editionSlug, festivalSlug } = useParams(); - - // Build base path depending on whether we're on main domain or subdomain - function getBasePath(): string { - if (festivalSlug && editionSlug) { - // Main domain: /festivals/boom/editions/2024 - return `/festivals/${festivalSlug}/editions/${editionSlug}`; - } else if (editionSlug) { - // Subdomain: /editions/2024 - return `/editions/${editionSlug}`; - } - return ""; - } - - const basePath = getBasePath(); + const { basePath } = useFestivalEdition(); return ( <> @@ -62,13 +49,11 @@ export function MainTabNavigation() {
{Object.entries(TAB_CONFIG).map(([tabKey, config]) => { const tab = tabKey as MainTab; - const to = tab === "artists" ? basePath : `${basePath}/${tab}`; return ( cn( ` @@ -96,13 +81,11 @@ export function MainTabNavigation() {
{Object.entries(TAB_CONFIG).map(([tabKey, config]) => { const tab = tabKey as MainTab; - const to = tab === "artists" ? basePath : `${basePath}/${tab}`; return ( ` flex-1 flex flex-col items-center justify-center py-2 px-1 transition-colors duration-200 min-h-16 diff --git a/src/components/router/EditionRoutes.tsx b/src/components/router/EditionRoutes.tsx index 9fc4ec9f..89e8526b 100644 --- a/src/components/router/EditionRoutes.tsx +++ b/src/components/router/EditionRoutes.tsx @@ -1,14 +1,15 @@ -import { Route } from "react-router-dom"; +import { Navigate, Route } from "react-router-dom"; import EditionView from "@/pages/EditionView"; import { SetDetails } from "@/pages/SetDetails"; -import Schedule from "@/pages/Schedule"; // Tab components import { ArtistsTab } from "@/pages/tabs/ArtistsTab"; -import { TimelineTab } from "@/pages/tabs/TimelineTab"; import { MapTab } from "@/pages/tabs/MapTab"; import { InfoTab } from "@/pages/tabs/InfoTab"; import { SocialTab } from "@/pages/tabs/SocialTab"; +import { ScheduleTabTimeline } from "@/pages/tabs/ScheduleTabTimeline"; +import { ScheduleTabList } from "@/pages/tabs/ScheduleTabList"; +import { ScheduleTab } from "@/pages/tabs/ScheduleTab"; interface EditionRoutesProps { basePath: string; @@ -20,35 +21,31 @@ export function createEditionRoutes({ WrapperComponent, }: EditionRoutesProps) { const EditionComponent = WrapperComponent - ? (props: any) => + ? () => : EditionView; const SetDetailsComponent = WrapperComponent - ? (props: any) => + ? () => : SetDetails; - const ScheduleComponent = WrapperComponent - ? (props: any) => - : Schedule; - return [ }> {/* Nested tab routes */} - } /> - } /> + } /> + } /> } /> } /> } /> + }> + } /> + } /> + } /> + , } />, - } - />, ]; } diff --git a/src/components/timeline/ScheduleNavigation.tsx b/src/components/timeline/ScheduleNavigation.tsx new file mode 100644 index 00000000..48ce88c3 --- /dev/null +++ b/src/components/timeline/ScheduleNavigation.tsx @@ -0,0 +1,17 @@ +import { Calendar, List } from "lucide-react"; +import { ScheduleNavigationItem } from "./ScheduleNavigationItem"; + +export function ScheduleNavigation() { + return ( +
+
+ + +
+
+ ); +} diff --git a/src/components/timeline/ScheduleNavigationItem.tsx b/src/components/timeline/ScheduleNavigationItem.tsx new file mode 100644 index 00000000..e498bf3e --- /dev/null +++ b/src/components/timeline/ScheduleNavigationItem.tsx @@ -0,0 +1,36 @@ +import { NavLink } from "react-router-dom"; +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; + +interface ScheduleNavigationItemProps { + view: "timeline" | "list"; + label: string; + icon: LucideIcon; +} + +export function ScheduleNavigationItem({ + view, + label, + icon: Icon, +}: ScheduleNavigationItemProps) { + const { basePath } = useFestivalEdition(); + + return ( + + cn( + `flex gap-2 items-center justify-center py-2 md:py-3 rounded-lg + w-1/2 md:min-w-[100px] transition-all duration-200 active:scale-95`, + isActive + ? "bg-purple-600 text-white shadow-lg" + : "text-purple-200 hover:text-white hover:bg-white/10", + ) + } + > + + {label} + + ); +} diff --git a/src/components/timeline/TimelineTab.tsx b/src/components/timeline/TimelineTab.tsx deleted file mode 100644 index 087b885d..00000000 --- a/src/components/timeline/TimelineTab.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Timeline } from "@/components/timeline/horizontal/Timeline"; -import { ListSchedule } from "@/components/timeline/list/ListSchedule"; -import { ListFilters } from "./ListFilters"; -import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; -import { ViewToggle } from "./ViewToggle"; - -interface TimelineTabProps { - userVotes: Record; - onVote: (artistId: string, voteType: number) => void; -} - -export function TimelineTab({ userVotes, onVote }: TimelineTabProps) { - const { state, updateState } = useTimelineUrlState(); - const { timelineView } = state; - - return ( -
- updateState({ timelineView })} - /> - - {timelineView === "horizontal" ? ( - - ) : ( - <> - - - - )} -
- ); -} diff --git a/src/components/timeline/ViewToggle.tsx b/src/components/timeline/ViewToggle.tsx deleted file mode 100644 index a6cf8a25..00000000 --- a/src/components/timeline/ViewToggle.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { BarChart3, List } from "lucide-react"; -import type { TimelineView } from "@/hooks/useUrlState"; -import { ViewToggleOption } from "./ViewToggleOption"; - -interface ViewToggleProps { - currentView: TimelineView; - onViewChange: (view: TimelineView) => void; -} - -export function ViewToggle({ currentView, onViewChange }: ViewToggleProps) { - return ( -
-
- onViewChange("horizontal")} - viewId="horizontal" - title="Horizontal Timeline View" - icon={BarChart3} - label="Timeline" - /> - onViewChange("list")} - viewId="list" - title="List View" - icon={List} - label="List" - /> -
-
- ); -} diff --git a/src/components/timeline/ViewToggleOption.tsx b/src/components/timeline/ViewToggleOption.tsx deleted file mode 100644 index 053d1276..00000000 --- a/src/components/timeline/ViewToggleOption.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { LucideIcon } from "lucide-react"; -import type { TimelineView } from "@/hooks/useUrlState"; - -interface ViewToggleOptionProps { - currentView: TimelineView; - onClick: () => void; - viewId: TimelineView; - title: string; - icon: LucideIcon; - label: string; -} - -export function ViewToggleOption({ - currentView, - onClick, - viewId, - title, - icon: Icon, - label, -}: ViewToggleOptionProps) { - const isActive = currentView === viewId; - - return ( - - ); -} diff --git a/src/contexts/FestivalEditionContext.tsx b/src/contexts/FestivalEditionContext.tsx index aa85f025..e95dc6b2 100644 --- a/src/contexts/FestivalEditionContext.tsx +++ b/src/contexts/FestivalEditionContext.tsx @@ -13,6 +13,7 @@ interface FestivalEditionContextType { // Utils isContextReady: boolean; + basePath: string; } const FestivalEditionContext = createContext< @@ -124,10 +125,22 @@ export function FestivalEditionProvider({ ) ); + const basePath = useMemo(() => { + if (festivalSlug && editionSlug) { + // Main domain: /festivals/boom/editions/2024 + return `/festivals/${festivalSlug}/editions/${editionSlug}`; + } else if (editionSlug) { + // Subdomain: /editions/2024 + return `/editions/${editionSlug}`; + } + return ""; + }, [festivalSlug, editionSlug]); + const contextValue: FestivalEditionContextType = { festival: festival || null, edition: edition || null, isContextReady, + basePath, }; return ( diff --git a/src/pages/tabs/ScheduleTab.tsx b/src/pages/tabs/ScheduleTab.tsx new file mode 100644 index 00000000..d8612cf1 --- /dev/null +++ b/src/pages/tabs/ScheduleTab.tsx @@ -0,0 +1,12 @@ +import { ScheduleNavigation } from "@/components/timeline/ScheduleNavigation"; +import { Outlet } from "react-router-dom"; + +export function ScheduleTab() { + return ( +
+ + + +
+ ); +} diff --git a/src/pages/tabs/ScheduleTabList.tsx b/src/pages/tabs/ScheduleTabList.tsx new file mode 100644 index 00000000..1f4663aa --- /dev/null +++ b/src/pages/tabs/ScheduleTabList.tsx @@ -0,0 +1,23 @@ +import { ListSchedule } from "@/components/timeline/list/ListSchedule"; +import { ListFilters } from "@/components/timeline/ListFilters"; +import { useAuth } from "@/contexts/AuthContext"; +import { useOfflineVoting } from "@/hooks/useOfflineVoting"; + +export function ScheduleTabList() { + const { user, showAuthDialog } = useAuth(); + const { userVotes, handleVote } = useOfflineVoting(user); + + async function handleVoteAction(artistId: string, voteType: number) { + const result = await handleVote(artistId, voteType); + if (result.requiresAuth) { + showAuthDialog(); + } + } + + return ( + <> + + + + ); +} diff --git a/src/pages/tabs/TimelineTab.tsx b/src/pages/tabs/ScheduleTabTimeline.tsx similarity index 66% rename from src/pages/tabs/TimelineTab.tsx rename to src/pages/tabs/ScheduleTabTimeline.tsx index e165c887..127475c6 100644 --- a/src/pages/tabs/TimelineTab.tsx +++ b/src/pages/tabs/ScheduleTabTimeline.tsx @@ -1,8 +1,8 @@ import { useAuth } from "@/contexts/AuthContext"; import { useOfflineVoting } from "@/hooks/useOfflineVoting"; -import { TimelineTab as TimelineTabComponent } from "@/components/timeline/TimelineTab"; +import { Timeline } from "@/components/timeline/horizontal/Timeline"; -export function TimelineTab() { +export function ScheduleTabTimeline() { const { user, showAuthDialog } = useAuth(); const { userVotes, handleVote } = useOfflineVoting(user); @@ -13,7 +13,5 @@ export function TimelineTab() { } } - return ( - - ); + return ; } From 8e8a1b88dcccd635e45a4de9662a9f92f0c284b1 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 24 Aug 2025 13:31:59 +0200 Subject: [PATCH 15/16] fix(routing): show the correct path --- src/components/Index/useSetFiltering.ts | 6 +++--- src/contexts/FestivalEditionContext.tsx | 18 ++++++------------ 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/components/Index/useSetFiltering.ts b/src/components/Index/useSetFiltering.ts index 91c08243..07997b92 100644 --- a/src/components/Index/useSetFiltering.ts +++ b/src/components/Index/useSetFiltering.ts @@ -115,9 +115,9 @@ export function useSetFiltering( const bFollowers = Math.max( ...setB.artists.map((artist) => artist.soundcloud_followers || 0), ); - console.log( - `Sorting by followers: ${setA.name} (${aFollowers}) vs ${setB.name} (${bFollowers})`, - ); + // console.log( + // `Sorting by followers: ${setA.name} (${aFollowers}) vs ${setB.name} (${bFollowers})`, + // ); if (aFollowers && bFollowers) { return bFollowers - aFollowers; } else if (aFollowers) { diff --git a/src/contexts/FestivalEditionContext.tsx b/src/contexts/FestivalEditionContext.tsx index e95dc6b2..864f1f86 100644 --- a/src/contexts/FestivalEditionContext.tsx +++ b/src/contexts/FestivalEditionContext.tsx @@ -42,17 +42,20 @@ function getSlugs(pathname: string) { isMainDomain: subdomainInfo.isMainDomain, }); + let basePath = ""; // For main domain, extract festival slug from URL path if (pathname.includes("/festivals/")) { const match = matchPath({ path: "/festivals/:festivalSlug/*" }, pathname); festivalSlug = match?.params.festivalSlug || festivalSlug || ""; pathname = pathname.replace(`/festivals/${festivalSlug}`, ""); + basePath = `/festivals/${festivalSlug}`; } if (!pathname.includes("/editions")) { console.log("πŸ” No editions in pathname, returning:", { festivalSlug }); return { festivalSlug, + basePath, }; } @@ -69,6 +72,7 @@ function getSlugs(pathname: string) { editionSlug, }); return { + basePath: basePath + `/editions/${editionSlug}`, festivalSlug, editionSlug, }; @@ -87,6 +91,7 @@ function getSlugs(pathname: string) { pathname, }); return { + basePath: basePath + `/editions/${editionSlug}`, festivalSlug, editionSlug, }; @@ -102,7 +107,7 @@ function useParseSlugs() { export function FestivalEditionProvider({ children, }: PropsWithChildren) { - const { festivalSlug, editionSlug } = useParseSlugs(); + const { festivalSlug, editionSlug, basePath } = useParseSlugs(); const festivalQuery = useFestivalBySlugQuery(festivalSlug); @@ -125,17 +130,6 @@ export function FestivalEditionProvider({ ) ); - const basePath = useMemo(() => { - if (festivalSlug && editionSlug) { - // Main domain: /festivals/boom/editions/2024 - return `/festivals/${festivalSlug}/editions/${editionSlug}`; - } else if (editionSlug) { - // Subdomain: /editions/2024 - return `/editions/${editionSlug}`; - } - return ""; - }, [festivalSlug, editionSlug]); - const contextValue: FestivalEditionContextType = { festival: festival || null, edition: edition || null, From 1d7cfbc813354ae879037d1a1f1c691688409603 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 24 Aug 2025 13:57:47 +0200 Subject: [PATCH 16/16] feat(header): show festival icon --- src/components/AppHeader.tsx | 173 +++--------------- src/components/AppHeader/AppBranding.tsx | 43 +++++ .../AppHeader/FestivalIndicator.tsx | 31 ++++ src/components/AppHeader/TitleSection.tsx | 38 ++++ src/components/AppHeader/TopBar.tsx | 48 +++++ src/components/AppHeader/UserActions.tsx | 64 +++++++ src/components/AppHeader/UserMenu.tsx | 6 +- src/hooks/useScrollVisibility.ts | 38 ++++ 8 files changed, 287 insertions(+), 154 deletions(-) create mode 100644 src/components/AppHeader/AppBranding.tsx create mode 100644 src/components/AppHeader/FestivalIndicator.tsx create mode 100644 src/components/AppHeader/TitleSection.tsx create mode 100644 src/components/AppHeader/TopBar.tsx create mode 100644 src/components/AppHeader/UserActions.tsx create mode 100644 src/hooks/useScrollVisibility.ts diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 00f74a5c..62c073d0 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,13 +1,9 @@ -import { ReactNode } from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { Button } from "@/components/ui/button"; +import { ReactNode, useRef } from "react"; +import { useNavigate } from "react-router-dom"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { Music, Heart, LogIn } from "lucide-react"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { Navigation } from "./AppHeader/Navigation"; -import { UserMenu } from "./AppHeader/UserMenu"; -import { AdminActions } from "./AppHeader/AdminActions"; -import { useAuth } from "@/contexts/AuthContext"; +import { useScrollVisibility } from "@/hooks/useScrollVisibility"; +import { TopBar } from "./AppHeader/TopBar"; +import { TitleSection } from "./AppHeader/TitleSection"; interface AppHeaderProps { // Navigation @@ -40,156 +36,31 @@ export function AppHeader({ // children, }: AppHeaderProps) { const navigate = useNavigate(); - const { user, profile, signOut, showAuthDialog } = useAuth(); - const isMobile = useIsMobile(); + + // Track visibility of title section for festival context in top bar + const titleRef = useRef(null); + const isTitleVisible = useScrollVisibility(titleRef); function handleBackClick() { navigate(-1); } - function getGreeting() { - const hour = new Date().getHours(); - if (hour < 12) return "Good morning"; - if (hour < 18) return "Good afternoon"; - return "Good evening"; - } - - const displayName = - profile?.username || user?.email?.split("@")[0] || "there"; - return ( -
- {/* Top Bar - App Branding, User Identity & Navigation */} -
- {/* Left Side - Branding */} -
- -
- -

- UpLine -

-
- - - {/* User Greeting - Desktop Only */} - {user && !isMobile && ( -
- - {getGreeting()}, {displayName}! 🎢 - -
- )} -
- - {/* Right Side - Navigation & User Actions */} -
- {/* Navigation Buttons */} - - - {/* Admin Actions */} - {user && ( -
- -
- )} - - {/* Authentication - User Menu or Sign In */} -
- {user ? ( - - ) : ( - - )} -
-
+
+ + +
+
- {title && ( -
- {title && ( -
- {logoUrl ? ( - {`${title} - ) : ( - <> - -

- {title} -

- - - )} -
- )} -
- )} - {/* Page Content Section - {(title || subtitle || description || children) && ( -
- {title && ( -
- {logoUrl ? ( - {`${title} - ) : ( - <> - -

- {title} -

- - - )} -
- )} - - {subtitle && ( -

- {subtitle} -

- )} - - {description && ( -

- {description} -

- )} - - {actions && ( -
{actions}
- )} - - {children} -
- )} */}
); diff --git a/src/components/AppHeader/AppBranding.tsx b/src/components/AppHeader/AppBranding.tsx new file mode 100644 index 00000000..f36925ab --- /dev/null +++ b/src/components/AppHeader/AppBranding.tsx @@ -0,0 +1,43 @@ +import { Link } from "react-router-dom"; +import { Music } from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; + +interface AppBrandingProps { + isMobile: boolean; +} + +export function AppBranding({ isMobile }: AppBrandingProps) { + const { user, profile } = useAuth(); + const { basePath } = useFestivalEdition(); + + function getGreeting() { + const hour = new Date().getHours(); + if (hour < 12) return "Good morning"; + if (hour < 18) return "Good afternoon"; + return "Good evening"; + } + + const displayName = + profile?.username || user?.email?.split("@")[0] || "there"; + + return ( +
+ +
+ +

UpLine

+
+ + + {/* User Greeting - Desktop Only */} + {user && !isMobile && ( +
+ + {getGreeting()}, {displayName}! 🎢 + +
+ )} +
+ ); +} diff --git a/src/components/AppHeader/FestivalIndicator.tsx b/src/components/AppHeader/FestivalIndicator.tsx new file mode 100644 index 00000000..72bc364f --- /dev/null +++ b/src/components/AppHeader/FestivalIndicator.tsx @@ -0,0 +1,31 @@ +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; + +interface FestivalIndicatorProps { + isTitleVisible: boolean; + logoUrl?: string | null; + title?: string; +} + +export function FestivalIndicator({ + isTitleVisible, + logoUrl, + title, +}: FestivalIndicatorProps) { + const { festival } = useFestivalEdition(); + + if (isTitleVisible || !logoUrl) { + return
; + } + + return ( +
+
+ {`${festival?.name +
+
+ ); +} diff --git a/src/components/AppHeader/TitleSection.tsx b/src/components/AppHeader/TitleSection.tsx new file mode 100644 index 00000000..2b489e14 --- /dev/null +++ b/src/components/AppHeader/TitleSection.tsx @@ -0,0 +1,38 @@ +import { forwardRef } from "react"; +import { Music, Heart } from "lucide-react"; + +interface TitleSectionProps { + title?: string; + logoUrl?: string | null; +} + +export const TitleSection = forwardRef( + ({ title, logoUrl }, ref) => { + if (!title) return null; + + return ( +
+
+ {logoUrl ? ( + {`${title} + ) : ( + <> + +

+ {title} +

+ + + )} +
+
+ ); + }, +); diff --git a/src/components/AppHeader/TopBar.tsx b/src/components/AppHeader/TopBar.tsx new file mode 100644 index 00000000..2cd90c75 --- /dev/null +++ b/src/components/AppHeader/TopBar.tsx @@ -0,0 +1,48 @@ +import { useIsMobile } from "@/hooks/use-mobile"; +import { AppBranding } from "./AppBranding"; +import { FestivalIndicator } from "./FestivalIndicator"; +import { UserActions } from "./UserActions"; + +interface TopBarProps { + showBackButton?: boolean; + backLabel?: string; + showGroupsButton?: boolean; + onBackClick: () => void; + + // Festival context + isTitleVisible: boolean; + logoUrl?: string | null; + title?: string; +} + +export function TopBar({ + showBackButton = false, + backLabel = "Back", + showGroupsButton = false, + onBackClick, + isTitleVisible, + logoUrl, + title, +}: TopBarProps) { + const isMobile = useIsMobile(); + + return ( +
+ + + + + +
+ ); +} diff --git a/src/components/AppHeader/UserActions.tsx b/src/components/AppHeader/UserActions.tsx new file mode 100644 index 00000000..03bd58e8 --- /dev/null +++ b/src/components/AppHeader/UserActions.tsx @@ -0,0 +1,64 @@ +import { Button } from "@/components/ui/button"; +import { LogIn } from "lucide-react"; +import { Navigation } from "./Navigation"; +import { UserMenu } from "./UserMenu"; +import { AdminActions } from "./AdminActions"; +import { useAuth } from "@/contexts/AuthContext"; + +interface UserActionsProps { + showBackButton?: boolean; + backLabel?: string; + showGroupsButton?: boolean; + onBackClick: () => void; + isMobile: boolean; +} + +export function UserActions({ + showBackButton = false, + backLabel = "Back", + showGroupsButton = false, + onBackClick, + isMobile, +}: UserActionsProps) { + const { user, profile, signOut, showAuthDialog } = useAuth(); + + return ( +
+ + + {user && ( +
+ +
+ )} + +
+ {user ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/AppHeader/UserMenu.tsx b/src/components/AppHeader/UserMenu.tsx index 50060515..0b9d2256 100644 --- a/src/components/AppHeader/UserMenu.tsx +++ b/src/components/AppHeader/UserMenu.tsx @@ -21,12 +21,12 @@ interface UserMenuProps { isMobile?: boolean; } -export const UserMenu = ({ +export function UserMenu({ user, profile, onSignOut, isMobile, -}: UserMenuProps) => { +}: UserMenuProps) { const displayName = profile?.username || user.email?.split("@")[0] || "User"; return ( @@ -92,4 +92,4 @@ export const UserMenu = ({ ); -}; +} diff --git a/src/hooks/useScrollVisibility.ts b/src/hooks/useScrollVisibility.ts new file mode 100644 index 00000000..f4425e9a --- /dev/null +++ b/src/hooks/useScrollVisibility.ts @@ -0,0 +1,38 @@ +import { useEffect, useState, RefObject } from "react"; + +interface UseScrollVisibilityOptions { + threshold?: number; + rootMargin?: string; +} + +export function useScrollVisibility( + elementRef: RefObject, + options: UseScrollVisibilityOptions = {}, +): boolean { + const [isVisible, setIsVisible] = useState(true); + const { threshold = 0, rootMargin = "0px" } = options; + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + setIsVisible(entry.isIntersecting); + }, + { + threshold, + rootMargin, + }, + ); + + observer.observe(element); + + return () => { + observer.unobserve(element); + observer.disconnect(); + }; + }, [elementRef, threshold, rootMargin]); + + return isVisible; +}