diff --git a/public/timeline-screenshot.png b/public/timeline-screenshot.png new file mode 100644 index 0000000..fbc1412 Binary files /dev/null and b/public/timeline-screenshot.png differ diff --git a/src/components/router/GlobalRoutes.tsx b/src/components/router/GlobalRoutes.tsx index 4ebf546..57c8cfb 100644 --- a/src/components/router/GlobalRoutes.tsx +++ b/src/components/router/GlobalRoutes.tsx @@ -21,7 +21,7 @@ export function GlobalRoutes() { {/* Global routes (not scoped to festival/edition) */} } /> - } /> + } /> {/* Admin routes */} }> @@ -30,8 +30,8 @@ export function GlobalRoutes() { } /> } /> }> - }> - }> + }> + }> } /> } /> } /> diff --git a/src/components/router/SubdomainRedirect.tsx b/src/components/router/SubdomainRedirect.tsx index 815b673..5aa77b7 100644 --- a/src/components/router/SubdomainRedirect.tsx +++ b/src/components/router/SubdomainRedirect.tsx @@ -16,10 +16,10 @@ interface SubdomainRedirectProps { export function SubdomainRedirect({ component: Component, }: SubdomainRedirectProps) { - const { festivalSlug, editionSlug, setId } = useParams<{ + const { festivalSlug, editionSlug, setSlug } = useParams<{ festivalSlug?: string; editionSlug?: string; - setId?: string; + setSlug?: string; }>(); const shouldNotRedirect = !isMainGetuplineDomain(); @@ -32,8 +32,8 @@ export function SubdomainRedirect({ // Build the target path based on current route let targetPath = "/"; - if (editionSlug && setId) { - targetPath = `/editions/${editionSlug}/sets/${setId}`; + if (editionSlug && setSlug) { + targetPath = `/editions/${editionSlug}/sets/${setSlug}`; } else if (editionSlug && window.location.pathname.includes("schedule")) { targetPath = `/editions/${editionSlug}/schedule`; } else if (editionSlug) { @@ -43,7 +43,7 @@ export function SubdomainRedirect({ // Redirect to subdomain const subdomainUrl = createFestivalSubdomainUrl(festivalSlug, targetPath); window.location.href = subdomainUrl; - }, [festivalSlug, editionSlug, setId, shouldNotRedirect]); + }, [festivalSlug, editionSlug, setSlug, shouldNotRedirect]); if (shouldNotRedirect) { return ; diff --git a/src/hooks/queries/groups/useCreateGroup.ts b/src/hooks/queries/groups/useCreateGroup.ts index 7655b8e..8a6aa20 100644 --- a/src/hooks/queries/groups/useCreateGroup.ts +++ b/src/hooks/queries/groups/useCreateGroup.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import { userGroupsKeys } from "./useUserGroups"; +import { generateSlug } from "@/lib/slug"; // Mutation function async function createGroup(variables: { @@ -15,6 +16,7 @@ async function createGroup(variables: { .from("groups") .insert({ name, + slug: generateSlug(name), description, created_by: userId, }) diff --git a/src/hooks/queries/groups/useGroupBySlug.ts b/src/hooks/queries/groups/useGroupBySlug.ts new file mode 100644 index 0000000..4c67e0d --- /dev/null +++ b/src/hooks/queries/groups/useGroupBySlug.ts @@ -0,0 +1,68 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import type { Group } from "@/types/groups"; + +// Query key factory +export const groupBySlugKeys = { + all: ["groups"] as const, + bySlug: () => [...groupBySlugKeys.all, "by-slug"] as const, + detail: (slug: string, userId: string) => + [...groupBySlugKeys.bySlug(), slug, userId] as const, +}; + +interface UseGroupBySlugParams { + slug?: string; + userId?: string; +} + +async function fetchGroupBySlug(slug: string, userId: string): Promise { + // First, try to find the group where user is a member + const { data: membership, error: membershipError } = await supabase + .from("group_members") + .select( + ` + group_id, + groups!inner ( + id, + name, + slug, + description, + created_by, + archived, + created_at, + updated_at + ) + `, + ) + .eq("user_id", userId) + .eq("groups.slug", slug) + .eq("groups.archived", false) + .single(); + + if (!membershipError && membership) { + return membership.groups as Group; + } + + // If not found as a member, check if user is the creator + const { data, error } = await supabase + .from("groups") + .select("*") + .eq("slug", slug) + .eq("created_by", userId) + .eq("archived", false) + .single(); + + if (error) { + throw new Error("Group not found or you don't have access"); + } + + return data; +} + +export function useGroupBySlugQuery({ slug, userId }: UseGroupBySlugParams) { + return useQuery({ + queryKey: groupBySlugKeys.detail(slug!, userId!), + queryFn: () => fetchGroupBySlug(slug!, userId!), + enabled: !!slug && !!userId, + }); +} diff --git a/src/hooks/queries/stages/useCreateStage.ts b/src/hooks/queries/stages/useCreateStage.ts index c8b8875..b2a4f9c 100644 --- a/src/hooks/queries/stages/useCreateStage.ts +++ b/src/hooks/queries/stages/useCreateStage.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import { stagesKeys } from "./types"; +import { generateSlug } from "@/lib/slug"; async function createStage(stageData: { name: string; @@ -9,7 +10,10 @@ async function createStage(stageData: { }) { const { data, error } = await supabase .from("stages") - .insert(stageData) + .insert({ + ...stageData, + slug: generateSlug(stageData.name), + }) .select() .single(); diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 7b36b1a..b281e43 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -392,6 +392,7 @@ export type Database = { description: string | null; id: string; name: string; + slug: string; updated_at: string; }; Insert: { @@ -401,6 +402,7 @@ export type Database = { description?: string | null; id?: string; name: string; + slug: string; updated_at?: string; }; Update: { @@ -410,6 +412,7 @@ export type Database = { description?: string | null; id?: string; name?: string; + slug?: string; updated_at?: string; }; Relationships: []; @@ -565,6 +568,7 @@ export type Database = { festival_edition_id: string; id: string; name: string; + slug: string; updated_at: string; }; Insert: { @@ -573,6 +577,7 @@ export type Database = { festival_edition_id: string; id?: string; name: string; + slug: string; updated_at?: string; }; Update: { @@ -581,6 +586,7 @@ export type Database = { festival_edition_id?: string; id?: string; name?: string; + slug?: string; updated_at?: string; }; Relationships: []; diff --git a/src/pages/admin/festivals/AdminFestivals.tsx b/src/pages/admin/festivals/AdminFestivals.tsx index 6f797ce..98d7f23 100644 --- a/src/pages/admin/festivals/AdminFestivals.tsx +++ b/src/pages/admin/festivals/AdminFestivals.tsx @@ -12,15 +12,15 @@ export default function AdminFestivals() { const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const navigate = useNavigate(); - const { festivalId = "" } = useParams<{ - festivalId?: string; + const { festivalSlug = "" } = useParams<{ + festivalSlug?: string; }>(); - function handleFestivalChange(festivalId: string) { - if (festivalId === "none") { + function handleFestivalChange(festivalSlug: string) { + if (festivalSlug === "none") { navigate("/admin/festivals"); } else { - navigate(`/admin/festivals/${festivalId}`); + navigate(`/admin/festivals/${festivalSlug}`); } } @@ -50,9 +50,9 @@ export default function AdminFestivals() { setIsEditDialogOpen(true); }} onSelect={(festival) => { - handleFestivalChange(festival.id); + handleFestivalChange(festival.slug); }} - selected={festivalId} + selected={festivalSlug} /> diff --git a/src/pages/admin/festivals/FestivalDetail.tsx b/src/pages/admin/festivals/FestivalDetail.tsx index e77d2a0..0a6b09f 100644 --- a/src/pages/admin/festivals/FestivalDetail.tsx +++ b/src/pages/admin/festivals/FestivalDetail.tsx @@ -1,21 +1,21 @@ import { useParams, useNavigate, Outlet } from "react-router-dom"; import { FestivalEditionManagement } from "./FestivalEditionManagement"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { useFestivalQuery } from "@/hooks/queries/festivals/useFestival"; +import { useFestivalBySlugQuery } from "@/hooks/queries/festivals/useFestivalBySlug"; import { Loader2 } from "lucide-react"; export default function FestivalDetail() { - const { festivalId, editionId = "" } = useParams<{ - festivalId: string; - editionId?: string; + const { festivalSlug, editionSlug = "" } = useParams<{ + festivalSlug: string; + editionSlug?: string; }>(); // const [selectedEditionId, setSelectedEditionId] = useState(""); const navigate = useNavigate(); - const festivalQuery = useFestivalQuery(festivalId); + const festivalQuery = useFestivalBySlugQuery(festivalSlug); - if (!festivalId) { - return
festivalId param is missing
; + if (!festivalSlug) { + return
festivalSlug param is missing
; } if (festivalQuery.isLoading) { @@ -39,8 +39,8 @@ export default function FestivalDetail() { ); } - function handleEditionSelect(editionId: string) { - navigate(`/admin/festivals/${festivalId}/editions/${editionId}/stages`); + function handleEditionSelect(editionSlug: string) { + navigate(`/admin/festivals/${festivalSlug}/editions/${editionSlug}/stages`); } const festival = festivalQuery.data; @@ -58,11 +58,11 @@ export default function FestivalDetail() {
{ - handleEditionSelect(editionId); + festivalSlug={festivalSlug} + onSelect={(editionSlug) => { + handleEditionSelect(editionSlug); }} - selected={editionId} + selected={editionSlug} />
diff --git a/src/pages/admin/festivals/FestivalEdition.tsx b/src/pages/admin/festivals/FestivalEdition.tsx index 091a069..1decf6e 100644 --- a/src/pages/admin/festivals/FestivalEdition.tsx +++ b/src/pages/admin/festivals/FestivalEdition.tsx @@ -3,12 +3,12 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Loader2, MapPin, Music } from "lucide-react"; import { useEffect } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useFestivalEditionsForFestivalQuery } from "@/hooks/queries/festivals/editions/useFestivalEditionsForFestival"; +import { useFestivalEditionBySlugQuery } from "@/hooks/queries/festivals/editions/useFestivalEditionBySlug"; export default function FestivalEdition() { - const { festivalId, editionId } = useParams<{ - festivalId: string; - editionId: string; + const { festivalSlug, editionSlug } = useParams<{ + festivalSlug: string; + editionSlug: string; }>(); const location = useLocation(); const navigate = useNavigate(); @@ -16,26 +16,27 @@ export default function FestivalEdition() { // Redirect to stages if we're at the index route useEffect(() => { if ( - festivalId && - editionId && + festivalSlug && + editionSlug && location.pathname === - `/admin/festivals/${festivalId}/editions/${editionId}` + `/admin/festivals/${festivalSlug}/editions/${editionSlug}` ) { - navigate(`/admin/festivals/${festivalId}/editions/${editionId}/stages`); + navigate( + `/admin/festivals/${festivalSlug}/editions/${editionSlug}/stages`, + ); } - }, [location.pathname, festivalId, editionId, navigate]); + }, [location.pathname, festivalSlug, editionSlug, navigate]); - const editionsQuery = useFestivalEditionsForFestivalQuery(festivalId); + const editionQuery = useFestivalEditionBySlugQuery({ + festivalSlug, + editionSlug, + }); - if (!festivalId || !editionId) { + if (!festivalSlug || !editionSlug) { return
Festival or edition not found
; } - if (editionsQuery.isLoading) { - return Loading edition data; - } - - if (editionsQuery.isLoading) { + if (editionQuery.isLoading) { return ( @@ -46,7 +47,7 @@ export default function FestivalEdition() { ); } - const currentEdition = editionsQuery.data?.find((f) => f.id === editionId); + const currentEdition = editionQuery.data; if (!currentEdition) { return ( @@ -68,7 +69,9 @@ export default function FestivalEdition() { const currentSubTab = getCurrentSubTab(); function handleSubTabChange(value: string) { - navigate(`/admin/festivals/${festivalId}/editions/${editionId}/${value}`); + navigate( + `/admin/festivals/${festivalSlug}/editions/${editionSlug}/${value}`, + ); } return ( @@ -105,7 +108,7 @@ export default function FestivalEdition() {
- +
diff --git a/src/pages/admin/festivals/FestivalEditionManagement.tsx b/src/pages/admin/festivals/FestivalEditionManagement.tsx index cbe142c..8d58e5d 100644 --- a/src/pages/admin/festivals/FestivalEditionManagement.tsx +++ b/src/pages/admin/festivals/FestivalEditionManagement.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useFestivalEditionsForFestivalQuery } from "@/hooks/queries/festivals/editions/useFestivalEditionsForFestival"; +import { useFestivalBySlugQuery } from "@/hooks/queries/festivals/useFestivalBySlug"; import { useCreateFestivalEditionMutation } from "@/hooks/queries/festivals/editions/useCreateFestivalEdition"; import { useUpdateFestivalEditionMutation } from "@/hooks/queries/festivals/editions/useUpdateFestivalEdition"; import { useDeleteFestivalEditionMutation } from "@/hooks/queries/festivals/editions/useDeleteFestivalEdition"; @@ -40,16 +41,18 @@ interface EditionFormData { } export function FestivalEditionManagement({ - festivalId, + festivalSlug, onSelect, selected, }: { - festivalId: string; - onSelect: (editionId: string) => void; + festivalSlug: string; + onSelect: (editionSlug: string) => void; selected: string; }) { + // All hooks must be at the top level + const festivalQuery = useFestivalBySlugQuery(festivalSlug); const { data: editions = [], isLoading } = - useFestivalEditionsForFestivalQuery(festivalId, { all: true }); + useFestivalEditionsForFestivalQuery(festivalQuery.data?.id, { all: true }); const createEditionMutation = useCreateFestivalEditionMutation(); const updateEditionMutation = useUpdateFestivalEditionMutation(); const deleteEditionMutation = useDeleteFestivalEditionMutation(); @@ -70,6 +73,27 @@ export function FestivalEditionManagement({ const [isSubmitting, setIsSubmitting] = useState(false); const [slugError, setSlugError] = useState(""); + if (festivalQuery.isLoading) { + return ( + + + + Loading festival... + + + ); + } + + if (!festivalQuery.data) { + return ( + + + Festival not found + + + ); + } + function resetForm() { setFormData({ name: "", @@ -164,7 +188,7 @@ export function FestivalEditionManagement({ ...formData, start_date: formData.start_date || null, end_date: formData.end_date || null, - festival_id: festivalId, + festival_id: festivalQuery.data!.id, }; if (editingEdition) { @@ -365,9 +389,9 @@ export function FestivalEditionManagement({ {editions.map((edition) => ( onSelect(edition.id)} + onClick={() => onSelect(edition.slug)} className={cn( - selected === edition.id ? "bg-slate-200 selected" : "", + selected === edition.slug ? "bg-slate-200 selected" : "", )} > {edition.name} diff --git a/src/pages/admin/festivals/FestivalSets.tsx b/src/pages/admin/festivals/FestivalSets.tsx index 8303f84..d273b0a 100644 --- a/src/pages/admin/festivals/FestivalSets.tsx +++ b/src/pages/admin/festivals/FestivalSets.tsx @@ -1,12 +1,5 @@ -import { useParams } from "react-router-dom"; import { SetManagement } from "./SetsManagement/SetManagement"; export default function FestivalSets() { - const { editionId } = useParams<{ editionId: string }>(); - - if (!editionId) { - return
Edition not found
; - } - - return ; + return ; } diff --git a/src/pages/admin/festivals/FestivalStages.tsx b/src/pages/admin/festivals/FestivalStages.tsx index a6715d2..a6984cb 100644 --- a/src/pages/admin/festivals/FestivalStages.tsx +++ b/src/pages/admin/festivals/FestivalStages.tsx @@ -1,12 +1,5 @@ -import { useParams } from "react-router-dom"; import { StageManagement } from "./StageManagement"; export default function FestivalStages() { - const { editionId } = useParams<{ editionId: string }>(); - - if (!editionId) { - return
Edition not found
; - } - - return ; + return ; } diff --git a/src/pages/admin/festivals/SetsManagement/SetManagement.tsx b/src/pages/admin/festivals/SetsManagement/SetManagement.tsx index 24ce508..aed703e 100644 --- a/src/pages/admin/festivals/SetsManagement/SetManagement.tsx +++ b/src/pages/admin/festivals/SetsManagement/SetManagement.tsx @@ -1,24 +1,24 @@ import { useState } from "react"; +import { useOutletContext } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Loader2, Plus, Music, Upload } from "lucide-react"; import { CSVImportDialog } from "../CSVImportDialog"; import { FestivalSet } from "@/hooks/queries/sets/useSets"; import { useSetsQuery } from "@/hooks/queries/sets/useSets"; +import { FestivalEdition } from "@/hooks/queries/festivals/editions/types"; import { useDeleteSetMutation } from "@/hooks/queries/sets/useDeleteSet"; import { SetFormDialog } from "../SetFormDialog"; import { SetsTable } from "../SetsTable"; -interface SetManagementProps { - editionId: string; -} +interface SetManagementProps {} -export function SetManagement({ editionId }: SetManagementProps) { +export function SetManagement(_props: SetManagementProps) { + // All hooks must be at the top level + const { edition } = useOutletContext<{ edition: FestivalEdition }>(); const { data: sets = [], isLoading } = useSetsQuery(); - const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingSet, setEditingSet] = useState(null); - const deleteSetMutation = useDeleteSetMutation(); function handleCreate() { @@ -50,7 +50,7 @@ export function SetManagement({ editionId }: SetManagementProps) { // Filter sets by selected edition const filteredSets = sets.filter( - (set) => set.festival_edition_id === editionId, + (set) => set.festival_edition_id === edition.id, ); if (isLoading) { @@ -73,7 +73,7 @@ export function SetManagement({ editionId }: SetManagementProps) { Set Management
- +