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:
fullsizemalt 2025-12-28 13:56:22 -08:00
parent b4cddf41ea
commit 29c5d30ebb
9 changed files with 475 additions and 16 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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>

View 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>
)
}

View file

@ -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>
))} ))}

View 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}`}`
}
}

View file

@ -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}`
}