feat: Add multi-vertical frontend infrastructure
Phase 3 - Frontend Multi-Vertical Support: - Add VerticalContext for band state management - Add BandSelector dropdown component - Create dynamic [vertical] routes for shows, songs, venues - Update navbar to use band selector and vertical-aware links - Update api-config.ts for Fediversion domain - Rebrand from Elmeg to Fediversion
This commit is contained in:
parent
b4cddf41ea
commit
29c5d30ebb
9 changed files with 475 additions and 16 deletions
60
frontend/app/[vertical]/page.tsx
Normal file
60
frontend/app/[vertical]/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { VERTICALS } from "@/contexts/vertical-context"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: { vertical: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return VERTICALS.map((v) => ({
|
||||||
|
vertical: v.slug,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerticalPage({ params }: Props) {
|
||||||
|
const vertical = VERTICALS.find((v) => v.slug === params.vertical)
|
||||||
|
|
||||||
|
if (!vertical) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h1 className="text-4xl font-bold flex items-center justify-center gap-4">
|
||||||
|
<span className="text-5xl">{vertical.emoji}</span>
|
||||||
|
<span style={{ color: vertical.color }}>{vertical.name}</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Explore setlists, rate performances, and connect with the {vertical.name} community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<a
|
||||||
|
href={`/${vertical.slug}/shows`}
|
||||||
|
className="block p-6 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Shows</h2>
|
||||||
|
<p className="text-muted-foreground">Browse all concerts and setlists</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`/${vertical.slug}/songs`}
|
||||||
|
className="block p-6 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Songs</h2>
|
||||||
|
<p className="text-muted-foreground">Explore the catalog and stats</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`/${vertical.slug}/venues`}
|
||||||
|
className="block p-6 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Venues</h2>
|
||||||
|
<p className="text-muted-foreground">See where they've played</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
frontend/app/[vertical]/shows/page.tsx
Normal file
76
frontend/app/[vertical]/shows/page.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { VERTICALS } from "@/contexts/vertical-context"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: { vertical: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return VERTICALS.map((v) => ({
|
||||||
|
vertical: v.slug,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getShows(verticalSlug: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiUrl()}/shows?vertical=${verticalSlug}`, {
|
||||||
|
next: { revalidate: 60 }
|
||||||
|
})
|
||||||
|
if (!res.ok) return []
|
||||||
|
return res.json()
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ShowsPage({ params }: Props) {
|
||||||
|
const vertical = VERTICALS.find((v) => v.slug === params.vertical)
|
||||||
|
|
||||||
|
if (!vertical) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const shows = await getShows(vertical.slug)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
<span className="mr-2">{vertical.emoji}</span>
|
||||||
|
{vertical.name} Shows
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shows.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<p>No shows imported yet for {vertical.name}.</p>
|
||||||
|
<p className="text-sm mt-2">Run the data importer to populate shows.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{shows.map((show: any) => (
|
||||||
|
<a
|
||||||
|
key={show.id}
|
||||||
|
href={`/${vertical.slug}/shows/${show.slug}`}
|
||||||
|
className="block p-4 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{show.venue?.name || "Unknown Venue"}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{show.venue?.city}, {show.venue?.state || show.venue?.country}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-mono">{new Date(show.date).toLocaleDateString()}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{show.tour?.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
frontend/app/[vertical]/songs/page.tsx
Normal file
75
frontend/app/[vertical]/songs/page.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { VERTICALS } from "@/contexts/vertical-context"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: { vertical: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return VERTICALS.map((v) => ({
|
||||||
|
vertical: v.slug,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSongs(verticalSlug: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiUrl()}/songs?vertical=${verticalSlug}`, {
|
||||||
|
next: { revalidate: 60 }
|
||||||
|
})
|
||||||
|
if (!res.ok) return []
|
||||||
|
return res.json()
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SongsPage({ params }: Props) {
|
||||||
|
const vertical = VERTICALS.find((v) => v.slug === params.vertical)
|
||||||
|
|
||||||
|
if (!vertical) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const songs = await getSongs(vertical.slug)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
<span className="mr-2">{vertical.emoji}</span>
|
||||||
|
{vertical.name} Songs
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{songs.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<p>No songs imported yet for {vertical.name}.</p>
|
||||||
|
<p className="text-sm mt-2">Run the data importer to populate songs.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{songs.map((song: any) => (
|
||||||
|
<a
|
||||||
|
key={song.id}
|
||||||
|
href={`/${vertical.slug}/songs/${song.slug}`}
|
||||||
|
className="flex justify-between items-center p-3 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{song.title}</div>
|
||||||
|
{song.original_artist && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Cover of {song.original_artist}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{song.times_played || 0} plays
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
frontend/app/[vertical]/venues/page.tsx
Normal file
75
frontend/app/[vertical]/venues/page.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { VERTICALS } from "@/contexts/vertical-context"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: { vertical: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return VERTICALS.map((v) => ({
|
||||||
|
vertical: v.slug,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVenues(verticalSlug: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiUrl()}/venues?vertical=${verticalSlug}`, {
|
||||||
|
next: { revalidate: 60 }
|
||||||
|
})
|
||||||
|
if (!res.ok) return []
|
||||||
|
return res.json()
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function VenuesPage({ params }: Props) {
|
||||||
|
const vertical = VERTICALS.find((v) => v.slug === params.vertical)
|
||||||
|
|
||||||
|
if (!vertical) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const venues = await getVenues(vertical.slug)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
<span className="mr-2">{vertical.emoji}</span>
|
||||||
|
{vertical.name} Venues
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{venues.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<p>No venues imported yet for {vertical.name}.</p>
|
||||||
|
<p className="text-sm mt-2">Run the data importer to populate venues.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{venues.map((venue: any) => (
|
||||||
|
<a
|
||||||
|
key={venue.id}
|
||||||
|
href={`/${vertical.slug}/venues/${venue.slug}`}
|
||||||
|
className="flex justify-between items-center p-4 rounded-lg border bg-card hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">{venue.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{venue.city}, {venue.state || venue.country}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{venue.capacity && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Capacity: {venue.capacity.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { Navbar } from "@/components/layout/navbar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PreferencesProvider } from "@/contexts/preferences-context";
|
import { PreferencesProvider } from "@/contexts/preferences-context";
|
||||||
import { AuthProvider } from "@/contexts/auth-context";
|
import { AuthProvider } from "@/contexts/auth-context";
|
||||||
|
import { VerticalProvider } from "@/contexts/vertical-context";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { Footer } from "@/components/layout/footer";
|
import { Footer } from "@/components/layout/footer";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
|
@ -20,8 +21,8 @@ const jetbrainsMono = JetBrains_Mono({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Elmeg",
|
title: "Fediversion",
|
||||||
description: "A Place to talk Goose",
|
description: "The ultimate HeadyVersion platform for all jam bands",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
@ -48,6 +49,7 @@ export default function RootLayout({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<VerticalProvider>
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<main className="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
|
@ -55,6 +57,7 @@ export default function RootLayout({
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
|
</VerticalProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
55
frontend/components/layout/band-selector.tsx
Normal file
55
frontend/components/layout/band-selector.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { useVertical, VERTICALS, VerticalSlug } from "@/contexts/vertical-context"
|
||||||
|
|
||||||
|
export function BandSelector() {
|
||||||
|
const { current, setCurrent } = useVertical()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleSelect = (slug: VerticalSlug) => {
|
||||||
|
setCurrent(slug)
|
||||||
|
router.push(`/${slug}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex items-center gap-2 font-bold text-lg"
|
||||||
|
style={{ color: current.color }}
|
||||||
|
>
|
||||||
|
<span>{current.emoji}</span>
|
||||||
|
<span>{current.name}</span>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
{VERTICALS.map((vertical) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={vertical.slug}
|
||||||
|
onClick={() => handleSelect(vertical.slug)}
|
||||||
|
className="flex items-center gap-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-lg">{vertical.emoji}</span>
|
||||||
|
<span
|
||||||
|
className={vertical.slug === current.slug ? "font-bold" : ""}
|
||||||
|
style={{ color: vertical.slug === current.slug ? vertical.color : undefined }}
|
||||||
|
>
|
||||||
|
{vertical.name}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||||
import { SearchDialog } from "@/components/ui/search-dialog"
|
import { SearchDialog } from "@/components/ui/search-dialog"
|
||||||
import { NotificationBell } from "@/components/notifications/notification-bell"
|
import { NotificationBell } from "@/components/notifications/notification-bell"
|
||||||
import { ThemeToggle } from "@/components/theme-toggle"
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
|
import { BandSelector } from "@/components/layout/band-selector"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -14,6 +15,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { useVertical } from "@/contexts/vertical-context"
|
||||||
|
|
||||||
const browseLinks = [
|
const browseLinks = [
|
||||||
{ href: "/shows", label: "Shows" },
|
{ href: "/shows", label: "Shows" },
|
||||||
|
|
@ -27,19 +29,21 @@ const browseLinks = [
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const { current } = useVertical()
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
// Build vertical-aware links
|
||||||
|
const getVerticalLink = (path: string) => `/${current.slug}${path}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex h-14 items-center justify-between">
|
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex h-14 items-center justify-between">
|
||||||
{/* Logo - always visible */}
|
{/* Band Selector - replaces logo */}
|
||||||
<Link href="/" className="flex items-center space-x-2">
|
<BandSelector />
|
||||||
<span className="font-bold text-lg">Elmeg</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium">
|
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium">
|
||||||
<Link href="/archive" className="transition-colors hover:text-foreground/80 text-foreground/60">
|
<Link href={getVerticalLink("/archive")} className="transition-colors hover:text-foreground/80 text-foreground/60">
|
||||||
Archive
|
Archive
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|
@ -50,7 +54,7 @@ export function Navbar() {
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
{browseLinks.map((link) => (
|
{browseLinks.map((link) => (
|
||||||
<Link key={link.href} href={link.href}>
|
<Link key={link.href} href={getVerticalLink(link.href)}>
|
||||||
<DropdownMenuItem>{link.label}</DropdownMenuItem>
|
<DropdownMenuItem>{link.label}</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
104
frontend/contexts/vertical-context.tsx
Normal file
104
frontend/contexts/vertical-context.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from "react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
|
// Supported verticals (bands)
|
||||||
|
export const VERTICALS = [
|
||||||
|
{ slug: "goose", name: "Goose", emoji: "🦆", color: "#F59E0B" },
|
||||||
|
{ slug: "phish", name: "Phish", emoji: "🐟", color: "#EF4444" },
|
||||||
|
{ slug: "grateful-dead", name: "Grateful Dead", emoji: "💀", color: "#8B5CF6" },
|
||||||
|
{ slug: "dead-and-company", name: "Dead & Company", emoji: "⚡", color: "#3B82F6" },
|
||||||
|
{ slug: "billy-strings", name: "Billy Strings", emoji: "🎸", color: "#10B981" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type VerticalSlug = typeof VERTICALS[number]["slug"]
|
||||||
|
|
||||||
|
export interface Vertical {
|
||||||
|
slug: VerticalSlug
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerticalContextType {
|
||||||
|
current: Vertical
|
||||||
|
setCurrent: (slug: VerticalSlug) => void
|
||||||
|
all: readonly Vertical[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const VerticalContext = createContext<VerticalContextType | null>(null)
|
||||||
|
|
||||||
|
export function VerticalProvider({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
// Detect vertical from URL path
|
||||||
|
const getVerticalFromPath = (): Vertical => {
|
||||||
|
const segments = pathname.split("/").filter(Boolean)
|
||||||
|
const firstSegment = segments[0]
|
||||||
|
const found = VERTICALS.find(v => v.slug === firstSegment)
|
||||||
|
return found || VERTICALS[0] // Default to Goose
|
||||||
|
}
|
||||||
|
|
||||||
|
const [current, setCurrentState] = useState<Vertical>(getVerticalFromPath)
|
||||||
|
|
||||||
|
// Update current when path changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentState(getVerticalFromPath())
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
|
const setCurrent = (slug: VerticalSlug) => {
|
||||||
|
const found = VERTICALS.find(v => v.slug === slug)
|
||||||
|
if (found) {
|
||||||
|
setCurrentState(found)
|
||||||
|
// Store preference
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("preferred-vertical", slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load preference on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const stored = localStorage.getItem("preferred-vertical") as VerticalSlug | null
|
||||||
|
if (stored && VERTICALS.find(v => v.slug === stored)) {
|
||||||
|
// Only set if not already in a vertical-specific route
|
||||||
|
const segments = pathname.split("/").filter(Boolean)
|
||||||
|
const isVerticalRoute = VERTICALS.some(v => v.slug === segments[0])
|
||||||
|
if (!isVerticalRoute) {
|
||||||
|
setCurrent(stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VerticalContext.Provider value={{ current, setCurrent, all: VERTICALS }}>
|
||||||
|
{children}
|
||||||
|
</VerticalContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVertical() {
|
||||||
|
const context = useContext(VerticalContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useVertical must be used within a VerticalProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to build vertical-aware paths
|
||||||
|
export function useVerticalPath() {
|
||||||
|
const { current } = useVertical()
|
||||||
|
|
||||||
|
return (path: string) => {
|
||||||
|
// If path already starts with a vertical, return as-is
|
||||||
|
const segments = path.split("/").filter(Boolean)
|
||||||
|
if (VERTICALS.some(v => v.slug === segments[0])) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
// Prepend current vertical
|
||||||
|
return `/${current.slug}${path.startsWith("/") ? path : `/${path}`}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,15 @@ export function getApiUrl() {
|
||||||
}
|
}
|
||||||
// Client-side
|
// Client-side
|
||||||
const hostname = window.location.hostname
|
const hostname = window.location.hostname
|
||||||
if (hostname === 'elmeg.xyz' || hostname === 'elmeg.runfoo.run') {
|
if (hostname === 'fediversion.xyz' || hostname === 'fediversion.runfoo.run' ||
|
||||||
|
hostname === 'elmeg.xyz' || hostname === 'elmeg.runfoo.run') {
|
||||||
return '/api'
|
return '/api'
|
||||||
}
|
}
|
||||||
return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get API URL with vertical scope
|
||||||
|
export function getVerticalApiUrl(verticalSlug: string) {
|
||||||
|
const base = getApiUrl()
|
||||||
|
return `${base}/v/${verticalSlug}`
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue