diff --git a/CLAUDE.md b/CLAUDE.md index 2efee5b8..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 @@ -126,3 +127,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/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/AppHeader.tsx b/src/components/AppHeader.tsx index 187664f2..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 @@ -17,12 +13,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,140 +28,39 @@ 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(); - 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 ? ( - - ) : ( - - )} -
-
+
+ + +
+
- - {/* 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/components/Index/filters/DesktopFilters.tsx b/src/components/Index/filters/DesktopFilters.tsx index 4b80b46e..e43fe5e3 100644 --- a/src/components/Index/filters/DesktopFilters.tsx +++ b/src/components/Index/filters/DesktopFilters.tsx @@ -1,14 +1,11 @@ import { Button } from "@/components/ui/button"; -import { X } from "lucide-react"; import type { FilterSortState } from "@/hooks/useUrlState"; import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; interface DesktopFiltersProps { 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; } @@ -16,7 +13,6 @@ export function DesktopFilters({ state, genres, onStateChange, - onClear, editionId, }: DesktopFiltersProps) { const { data: stages = [], isLoading: stagesLoading } = @@ -36,9 +32,6 @@ export function DesktopFilters({ onStateChange({ genres: newGenres }); } - const hasActiveFilters = - state.stages.length > 0 || state.genres.length > 0 || state.minRating > 0; - return (
{/* Stage Filter */} @@ -114,18 +107,6 @@ export function DesktopFilters({ ))}
- - {hasActiveFilters && ( - - )}
); } diff --git a/src/components/Index/filters/FilterHeader.tsx b/src/components/Index/filters/FilterHeader.tsx deleted file mode 100644 index 1dc0ae1e..00000000 --- a/src/components/Index/filters/FilterHeader.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Filter } from "lucide-react"; -import type { FilterSortState } from "@/hooks/useUrlState"; - -interface FilterHeaderProps { - state: FilterSortState; - isExpanded: boolean; - onToggleExpanded: () => void; - isMobile: boolean; -} - -export const FilterHeader = ({ - state, - isExpanded, - onToggleExpanded, - isMobile, -}: FilterHeaderProps) => { - const hasActiveFilters = - state.stages.length > 0 || state.genres.length > 0 || state.minRating > 0; - - return ( -
-
- - Filters & Sort - {hasActiveFilters && ( - - {state.stages.length + - state.genres.length + - (state.minRating > 0 ? 1 : 0)} - - )} -
- {!isMobile && ( - - )} -
- ); -}; diff --git a/src/components/Index/filters/FilterSortControls.tsx b/src/components/Index/filters/FilterSortControls.tsx index 49a356d0..b40ae8b0 100644 --- a/src/components/Index/filters/FilterSortControls.tsx +++ b/src/components/Index/filters/FilterSortControls.tsx @@ -1,27 +1,13 @@ import { useState, useEffect } from "react"; import type { SortOption, FilterSortState } from "@/hooks/useUrlState"; import { useGenres } from "@/hooks/queries/genres/useGenres"; -import { useAuth } from "@/contexts/AuthContext"; -import { useUserGroupsQuery } from "@/hooks/queries/groups/useUserGroups"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Filter, - RefreshCw, - Users, - Calendar, - List, - ChevronDown, -} from "lucide-react"; import { SortControls } from "./SortControls"; import { MobileFilters } from "./MobileFilters"; import { DesktopFilters } from "./DesktopFilters"; +import { GroupFilterDropdown } from "./GroupFilterDropdown"; +import { FilterToggle } from "@/components/common/filters/FilterToggle"; +import { FilterContainer } from "@/components/common/filters/FilterContainer"; +import { RefreshButton } from "./RefreshButton"; interface FilterSortControlsProps { state: FilterSortState; @@ -39,8 +25,6 @@ export function FilterSortControls({ const [isFiltersExpanded, setIsFiltersExpanded] = useState(false); const [isMobile, setIsMobile] = useState(false); const { genres } = useGenres(); - const { user } = useAuth(); - const { data: groups = [] } = useUserGroupsQuery(user?.id); useEffect(() => { function checkMobile() { @@ -63,159 +47,45 @@ export function FilterSortControls({ const hasActiveFilters = state.stages.length > 0 || state.genres.length > 0 || state.minRating > 0; - const hasActiveGroupFilter = state.groupId; + const activeFilterCount = + state.stages.length + state.genres.length + (state.minRating > 0 ? 1 : 0); - // Get the current group name for display - const currentGroup = groups.find((g) => g.id === state.groupId); - const groupDisplayText = currentGroup ? currentGroup.name : "All Votes"; + const Filters = isMobile ? MobileFilters : DesktopFilters; return (
- {/* Primary Controls Row */} -
-
- {/* View Toggle - Always prominent */} -
- - -
+ +
+ - {/* Context-Aware Controls */} -
- {/* Sort Controls - Only show in list view */} - {state.mainView === "list" && ( - - )} - - {/* Refresh Rankings - Only show when locked */} - {state.sortLocked && state.mainView === "list" && ( - - )} - - {/* 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} + onClearFilters={hasActiveFilters ? onClear : undefined} + />
-
+
- {/* Other Filters */} {isFiltersExpanded && ( -
- {isMobile ? ( - - ) : ( - - )} -
+ + + )}
); 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/MainViewToggle.tsx b/src/components/Index/filters/MainViewToggle.tsx deleted file mode 100644 index 037405f0..00000000 --- a/src/components/Index/filters/MainViewToggle.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { List, Calendar } from "lucide-react"; -import type { MainViewOption } from "@/hooks/useUrlState"; - -interface MainViewToggleProps { - mainView: MainViewOption; - onMainViewChange: (view: MainViewOption) => void; -} - -export const MainViewToggle = ({ - mainView, - onMainViewChange, -}: MainViewToggleProps) => { - return ( -
- - -
- ); -}; diff --git a/src/components/Index/filters/MobileFilters.tsx b/src/components/Index/filters/MobileFilters.tsx index 34724acb..67c50369 100644 --- a/src/components/Index/filters/MobileFilters.tsx +++ b/src/components/Index/filters/MobileFilters.tsx @@ -1,4 +1,3 @@ -import { Button } from "@/components/ui/button"; import { Select, SelectContent, @@ -6,16 +5,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { X } from "lucide-react"; import type { FilterSortState } from "@/hooks/useUrlState"; import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; 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; } @@ -23,7 +19,6 @@ export function MobileFilters({ state, genres, onStateChange, - onClear, editionId, }: MobileFiltersProps) { const { data: stages = [], isLoading: stagesLoading } = @@ -45,9 +40,6 @@ export function MobileFilters({ } } - const hasActiveFilters = - state.stages.length > 0 || state.genres.length > 0 || state.minRating > 0; - return (
{/* Stage Filter Select */} @@ -140,18 +132,6 @@ export function MobileFilters({
- - {hasActiveFilters && ( - - )}
); } 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/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 (
+ + + + + {dayOptions.map((option) => ( + + {option.label} + + ))} + + +
+ ); +} diff --git a/src/components/timeline/ListFilters.tsx b/src/components/timeline/ListFilters.tsx new file mode 100644 index 00000000..b3c27962 --- /dev/null +++ b/src/components/timeline/ListFilters.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +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 ListFilters() { + const [isExpanded, setIsExpanded] = useState(false); + const { state, updateState, clearFilters } = useTimelineUrlState(); + const { selectedDay, selectedTime, selectedStages } = state; + + function handleStageToggle(stageId: string) { + const newStages = selectedStages.includes(stageId) + ? selectedStages.filter((id) => id !== stageId) + : [...selectedStages, stageId]; + updateState({ selectedStages: newStages }); + } + + const activeFilterCount = + (selectedDay !== "all" ? 1 : 0) + + (selectedTime !== "all" ? 1 : 0) + + selectedStages.length; + const hasActiveFilters = activeFilterCount > 0; + const shouldShowFilters = isExpanded; + + return ( + +
+

Filters

+
+ + setIsExpanded(!isExpanded)} + hasActiveFilters={hasActiveFilters} + activeFilterCount={activeFilterCount} + label="Filters" + onClearFilters={hasActiveFilters ? clearFilters : undefined} + /> +
+ + {shouldShowFilters && ( +
+
+ updateState({ selectedDay: day })} + /> + updateState({ selectedTime: time })} + /> + +
+
+ )} + + ); +} 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/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/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/schedule/VoteButtons.tsx b/src/components/timeline/VoteButtons.tsx similarity index 100% rename from src/components/schedule/VoteButtons.tsx rename to src/components/timeline/VoteButtons.tsx diff --git a/src/components/schedule/ArtistScheduleBlock.tsx b/src/components/timeline/horizontal/SetBlock.tsx similarity index 82% rename from src/components/schedule/ArtistScheduleBlock.tsx rename to src/components/timeline/horizontal/SetBlock.tsx index 46119d84..3f52399f 100644 --- a/src/components/schedule/ArtistScheduleBlock.tsx +++ b/src/components/timeline/horizontal/SetBlock.tsx @@ -2,19 +2,15 @@ import { Card, CardContent } from "@/components/ui/card"; import type { ScheduleSet } from "@/hooks/useScheduleData"; import { SetHeader } from "./SetHeader"; import { TimeDisplay } from "./TimeDisplay"; -import { VoteButtons } from "./VoteButtons"; +import { VoteButtons } from "../VoteButtons"; -interface ArtistScheduleBlockProps { +interface SetBlockProps { set: ScheduleSet; userVote?: number; onVote?: (setId: string, voteType: number) => void; } -export function ArtistScheduleBlock({ - set, - userVote, - onVote, -}: ArtistScheduleBlockProps) { +export function SetBlock({ set, userVote, onVote }: SetBlockProps) { return ( diff --git a/src/components/schedule/SetHeader.tsx b/src/components/timeline/horizontal/SetHeader.tsx similarity index 100% rename from src/components/schedule/SetHeader.tsx rename to src/components/timeline/horizontal/SetHeader.tsx diff --git a/src/components/schedule/StageLabels.tsx b/src/components/timeline/horizontal/StageLabels.tsx similarity index 100% rename from src/components/schedule/StageLabels.tsx rename to src/components/timeline/horizontal/StageLabels.tsx diff --git a/src/components/schedule/StageRow.tsx b/src/components/timeline/horizontal/StageRow.tsx similarity index 92% rename from src/components/schedule/StageRow.tsx rename to src/components/timeline/horizontal/StageRow.tsx index b677f96f..c1ed9ea0 100644 --- a/src/components/schedule/StageRow.tsx +++ b/src/components/timeline/horizontal/StageRow.tsx @@ -1,4 +1,4 @@ -import { ArtistScheduleBlock } from "./ArtistScheduleBlock"; +import { SetBlock } from "./SetBlock"; import type { HorizontalTimelineSet } from "@/lib/timelineCalculator"; interface StageRowProps { @@ -37,7 +37,7 @@ export function StageRow({ }} >
- ; onVote: (artistId: string, voteType: number) => void; } -export function ScheduleHorizontalTimelineView({ - userVotes, - onVote, -}: ScheduleHorizontalTimelineViewProps) { +export function Timeline({ userVotes, onVote }: TimelineProps) { 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/TimelineContainer.tsx b/src/components/timeline/horizontal/TimelineContainer.tsx similarity index 100% rename from src/components/schedule/TimelineContainer.tsx rename to src/components/timeline/horizontal/TimelineContainer.tsx diff --git a/src/components/timeline/list/ListSchedule.tsx b/src/components/timeline/list/ListSchedule.tsx new file mode 100644 index 00000000..23090644 --- /dev/null +++ b/src/components/timeline/list/ListSchedule.tsx @@ -0,0 +1,168 @@ +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, 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 ListScheduleProps { + userVotes: Record; + onVote: (artistId: string, voteType: number) => void; +} + +interface TimeSlot { + time: Date; + sets: (ScheduleSet & { stageName: string })[]; +} + +export function ListSchedule({ userVotes, onVote }: ListScheduleProps) { + const { edition } = useFestivalEdition(); + const { data: editionSets = [], isLoading: setsLoading } = + useEditionSetsQuery(edition?.id); + 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 []; + + // 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 && matchesDay(set) && matchesTime(set)) { + allSets.push({ + ...set, + stageName: stage.name, + }); + } + }); + }); + }); + + // Group sets by start time + 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, []); + } + 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, + })) + .sort((a, b) => a.time.getTime() - b.time.getTime()); + + return slots; + }, [scheduleDays, selectedDay, selectedTime, selectedStages]); + + 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 ( + + ); + })} +
+ ); +} diff --git a/src/components/timeline/list/MobileSetCard.tsx b/src/components/timeline/list/MobileSetCard.tsx new file mode 100644 index 00000000..e43692a7 --- /dev/null +++ b/src/components/timeline/list/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/timeline/list/TimeSlotGroup.tsx b/src/components/timeline/list/TimeSlotGroup.tsx new file mode 100644 index 00000000..170c779f --- /dev/null +++ b/src/components/timeline/list/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/timeline/list/VerticalArtistScheduleBlock.tsx b/src/components/timeline/list/VerticalArtistScheduleBlock.tsx new file mode 100644 index 00000000..f2134d5a --- /dev/null +++ b/src/components/timeline/list/VerticalArtistScheduleBlock.tsx @@ -0,0 +1,73 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { Link } from "react-router-dom"; +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: VerticalTimelineSet; + 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} + +
+ )} + +
+ +
+
+
+ ); +} diff --git a/src/components/timeline/list/VerticalStageColumn.tsx b/src/components/timeline/list/VerticalStageColumn.tsx new file mode 100644 index 00000000..0997eed0 --- /dev/null +++ b/src/components/timeline/list/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 ( +
+
+ +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/timeline/list/VerticalStageLabels.tsx b/src/components/timeline/list/VerticalStageLabels.tsx new file mode 100644 index 00000000..42625021 --- /dev/null +++ b/src/components/timeline/list/VerticalStageLabels.tsx @@ -0,0 +1,17 @@ +interface VerticalStageLabelsProps { + stages: Array<{ name: string }>; +} + +export function VerticalStageLabels({ stages }: VerticalStageLabelsProps) { + return ( +
+ {stages.map((stage) => ( +
+
+ {stage.name} +
+
+ ))} +
+ ); +} diff --git a/src/components/timeline/list/VerticalTimeScale.tsx b/src/components/timeline/list/VerticalTimeScale.tsx new file mode 100644 index 00000000..ecdf5096 --- /dev/null +++ b/src/components/timeline/list/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")} +
+
+
+ ))} + +
+
+ ); +} diff --git a/src/components/timeline/list/VerticalTimelineContainer.tsx b/src/components/timeline/list/VerticalTimelineContainer.tsx new file mode 100644 index 00000000..ebd6fee3 --- /dev/null +++ b/src/components/timeline/list/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) => ( + + ))} +
+
+ ); +} diff --git a/src/contexts/FestivalEditionContext.tsx b/src/contexts/FestivalEditionContext.tsx index aa85f025..864f1f86 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< @@ -41,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, }; } @@ -68,6 +72,7 @@ function getSlugs(pathname: string) { editionSlug, }); return { + basePath: basePath + `/editions/${editionSlug}`, festivalSlug, editionSlug, }; @@ -86,6 +91,7 @@ function getSlugs(pathname: string) { pathname, }); return { + basePath: basePath + `/editions/${editionSlug}`, festivalSlug, editionSlug, }; @@ -101,7 +107,7 @@ function useParseSlugs() { export function FestivalEditionProvider({ children, }: PropsWithChildren) { - const { festivalSlug, editionSlug } = useParseSlugs(); + const { festivalSlug, editionSlug, basePath } = useParseSlugs(); const festivalQuery = useFestivalBySlugQuery(festivalSlug); @@ -128,6 +134,7 @@ export function FestivalEditionProvider({ festival: festival || null, edition: edition || null, isContextReady, + basePath, }; return ( 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/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; +} 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 636ba8c2..b52bb236 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, @@ -35,7 +36,7 @@ const defaultState: FilterSortState = { votePerspective: undefined, }; -export const useUrlState = () => { +export function useUrlState() { const [searchParams, setSearchParams] = useSearchParams(); const getStateFromUrl = useCallback((): FilterSortState => { @@ -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()); @@ -125,4 +126,4 @@ export const useUrlState = () => { updateUrlState, clearFilters, }; -}; +} 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/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..76a54939 100644 --- a/src/pages/EditionView.tsx +++ b/src/pages/EditionView.tsx @@ -1,36 +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 { ScheduleHorizontalTimelineView } from "@/components/schedule/ScheduleHorizontalTimelineView"; +import { MainTabNavigation } from "@/components/navigation/MainTabNavigation"; import ErrorBoundary from "@/components/ErrorBoundary"; -import { useSetsByEditionQuery } from "@/hooks/queries/sets/useSetsByEdition"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +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 ( @@ -40,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..38f96626 --- /dev/null +++ b/src/pages/tabs/ArtistsTab.tsx @@ -0,0 +1,52 @@ +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, + edition?.id, + ); + + 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..edb2c8d4 --- /dev/null +++ b/src/pages/tabs/InfoTab.tsx @@ -0,0 +1,18 @@ +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; + +export function InfoTab() { + const { edition } = useFestivalEdition(); + + return ( + <> +
+

+ {edition?.name} +

+
+
+

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/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/ScheduleTabTimeline.tsx b/src/pages/tabs/ScheduleTabTimeline.tsx new file mode 100644 index 00000000..127475c6 --- /dev/null +++ b/src/pages/tabs/ScheduleTabTimeline.tsx @@ -0,0 +1,17 @@ +import { useAuth } from "@/contexts/AuthContext"; +import { useOfflineVoting } from "@/hooks/useOfflineVoting"; +import { Timeline } from "@/components/timeline/horizontal/Timeline"; + +export function ScheduleTabTimeline() { + 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/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!

+
+ ); +}