From af9fcd4060c0517659581896711e27d0dd8ac778 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Mon, 29 Dec 2025 01:11:28 -0800 Subject: [PATCH] feat(frontend): add vertical-specific detail pages for songs and shows to fix 404 --- frontend/app/[vertical]/shows/[slug]/page.tsx | 400 ++++++++++++++++++ frontend/app/[vertical]/songs/[slug]/page.tsx | 311 ++++++++++++++ 2 files changed, 711 insertions(+) create mode 100644 frontend/app/[vertical]/shows/[slug]/page.tsx create mode 100644 frontend/app/[vertical]/songs/[slug]/page.tsx diff --git a/frontend/app/[vertical]/shows/[slug]/page.tsx b/frontend/app/[vertical]/shows/[slug]/page.tsx new file mode 100644 index 0000000..4d9cefe --- /dev/null +++ b/frontend/app/[vertical]/shows/[slug]/page.tsx @@ -0,0 +1,400 @@ + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ArrowLeft, MapPin, Music2, Disc, PlayCircle, Youtube } from "lucide-react" +import Link from "next/link" +import { CommentSection } from "@/components/social/comment-section" +import { EntityRating } from "@/components/social/entity-rating" +import { ShowAttendance } from "@/components/shows/show-attendance" +import { SocialWrapper } from "@/components/social/social-wrapper" +import { notFound } from "next/navigation" +import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialog" +import { EntityReviews } from "@/components/reviews/entity-reviews" +import { getApiUrl } from "@/lib/api-config" +import { YouTubeEmbed } from "@/components/ui/youtube-embed" +import { MarkCaughtButton } from "@/components/chase/mark-caught-button" +import { VERTICALS } from "@/config/verticals" + +// Helper to validate valid verticals for SSG +export function generateStaticParams() { + return VERTICALS.map((v) => ({ + vertical: v.slug, + })) +} + +async function getShow(id: string) { + try { + const res = await fetch(`${getApiUrl()}/shows/${id}`, { cache: 'no-store' }) + if (!res.ok) return null + return res.json() + } catch (e) { + console.error(e) + return null + } +} + + +export default async function VerticalShowDetailPage({ params }: { params: Promise<{ vertical: string, slug: string }> }) { + const { vertical, slug } = await params + + // Verify vertical exists + const validVertical = VERTICALS.find(v => v.slug === vertical) + if (!validVertical) notFound() + + const show = await getShow(slug) + + if (!show) { + notFound() + } + + // Group by set + const sets: Record = {}; + if (show.performances) { + show.performances.forEach((perf: any) => { + const setName = perf.set_name || "Set 1"; // Default to Set 1 if missing + if (!sets[setName]) sets[setName] = []; + sets[setName].push(perf); + }); + } + + // Sort keys: Set 1, Set 2, Set 3, Encore, Encore 2... + const sortedKeys = Object.keys(sets).sort((a, b) => { + const aLower = a.toLowerCase(); + const bLower = b.toLowerCase(); + + // Encore always last + if (aLower.includes("encore") && !bLower.includes("encore")) return 1; + if (!aLower.includes("encore") && bLower.includes("encore")) return -1; + + // If both have Set, compare numbers + if (aLower.includes("set") && bLower.includes("set")) { + const aNum = parseInt(a.replace(/\D/g, "") || "0"); + const bNum = parseInt(b.replace(/\D/g, "") || "0"); + return aNum - bNum; + } + + return a.localeCompare(b); + }); + + return ( +
+
+
+ + + +
+ {/* Band Name - Most Important */} + {show.vertical && ( + + + {show.vertical.name} + + )} +

+ {new Date(show.date).toLocaleDateString()} +

+ {show.venue && ( +

+ {show.venue.name}, {show.venue.city}, {show.venue.state} +

+ )} + {show.tags && show.tags.length > 0 && ( +
+ {show.tags.map((tag: any) => ( + + #{tag.name} + + ))} +
+ )} +
+ {show.tour && ( +

+ + + {show.tour.name} + +

+ )} +
+
+
+
+ + {show.notes && ( +
+ Note: {show.notes} +
+ )} + +
+
+ {/* Full Show Video */} + {show.youtube_link && ( + + + + + Full Show Video + + + + + + + )} + + + + Setlist + + + {show.performances && show.performances.length > 0 ? ( +
+ {sortedKeys.map((setName) => ( +
+

+ {setName} +

+
+ {sets[setName].map((perf: any) => ( +
+
+
+ {perf.position}. +
+ + {perf.song?.title || "Unknown Song"} + + {perf.track_url && ( + + + + )} + {perf.youtube_link && ( + + + + )} + {perf.bandcamp_link && ( + + + + )} + {perf.nugs_link && ( + + + + )} + {perf.segue && >} +
+ + {/* Nicknames */} + {perf.nicknames && perf.nicknames.length > 0 && ( +
+ {perf.nicknames.map((nick: any) => ( + + "{nick.nickname}" + + ))} +
+ )} + + {/* Suggest Nickname Button */} +
+ +
+
+ + {/* Rating Column */} + + + + + {/* Mark Caught (for chase songs) */} + +
+ {perf.notes && ( +
+ {perf.notes} +
+ )} +
+ ))} +
+
+ ))} +
+ ) : ( +
+ +

No Setlist Documented

+

+ This show's setlist hasn't been added yet. Early shows often weren't documented. +

+
+ )} +
+
+ + + + + + + + +
+ +
+ {/* Venue Info Card */} + + + Venue + + + {show.venue ? ( + <> +
+ + + {show.venue.name} + +
+

+ {show.venue.city}, {show.venue.state} {show.venue.country} +

+ + ) : ( +

Unknown Venue

+ )} +
+
+ + {/* Listen On Card */} + {(show.nugs_link || show.bandcamp_link) && ( + + + Listen On + + + {show.nugs_link && ( + + +
+

Nugs.net

+

Stream or download

+
+
+ )} + {show.bandcamp_link && ( + + +
+

Bandcamp

+

Official release

+
+
+ )} +
+
+ )} + + {/* Tour Info */} + {show.tour && ( + + + Tour + + +
+ + + {show.tour.name} + +
+
+
+ )} + + {/* Attendance */} + + + I Was There + + + + + + + {/* Rate This Show */} + + + Rate This Show + + + + + + + +
+
+
+ ) +} diff --git a/frontend/app/[vertical]/songs/[slug]/page.tsx b/frontend/app/[vertical]/songs/[slug]/page.tsx new file mode 100644 index 0000000..0324bc7 --- /dev/null +++ b/frontend/app/[vertical]/songs/[slug]/page.tsx @@ -0,0 +1,311 @@ + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ArrowLeft, PlayCircle, History, Calendar, Trophy, Youtube, Star } from "lucide-react" +import Link from "next/link" +import { notFound } from "next/navigation" +import { Badge } from "@/components/ui/badge" +import { getApiUrl } from "@/lib/api-config" +import { CommentSection } from "@/components/social/comment-section" +import { EntityRating } from "@/components/social/entity-rating" +import { EntityReviews } from "@/components/reviews/entity-reviews" +import { SocialWrapper } from "@/components/social/social-wrapper" +import { PerformanceList } from "@/components/songs/performance-list" +import { SongEvolutionChart } from "@/components/songs/song-evolution-chart" +import { YouTubeEmbed } from "@/components/ui/youtube-embed" +import { VERTICALS } from "@/config/verticals" + +// Helper to validate valid verticals for SSG +export function generateStaticParams() { + return VERTICALS.map((v) => ({ + vertical: v.slug, + })) +} + +async function getSong(id: string) { + try { + const res = await fetch(`${getApiUrl()}/songs/${id}`, { cache: 'no-store' }) + if (!res.ok) return null + return res.json() + } catch (e) { + console.error(e) + return null + } +} + +// Fetch cross-band versions of this song via SongCanon +async function getRelatedVersions(songId: number) { + try { + const res = await fetch(`${getApiUrl()}/canon/song/${songId}/related`, { + next: { revalidate: 60 } + }) + if (!res.ok) return [] + return res.json() + } catch { + return [] + } +} + +// Get top rated performances for "Heady Version" leaderboard +function getHeadyVersions(performances: any[]) { + if (!performances || performances.length === 0) return [] + return [...performances] + .filter(p => p.avg_rating && p.rating_count > 0) + .sort((a, b) => b.avg_rating - a.avg_rating) + .slice(0, 5) +} + +export default async function VerticalSongDetailPage({ params }: { params: Promise<{ vertical: string, slug: string }> }) { + const { vertical, slug } = await params + + // Verify vertical exists (optional, could just let 404 handle it if song logic doesn't care) + const validVertical = VERTICALS.find(v => v.slug === vertical) + if (!validVertical) notFound() + + const song = await getSong(slug) + + if (!song) { + notFound() + } + + const headyVersions = getHeadyVersions(song.performances || []) + const topPerformance = headyVersions[0] + + // Fetch cross-band versions + const relatedVersions = await getRelatedVersions(song.id) + + return ( +
+
+
+ + + +
+
+

{song.title}

+ {song.artist ? ( + + ({song.artist.name}) + + ) : song.original_artist ? ( + ({song.original_artist}) + ) : null} +
+ {song.tags && song.tags.length > 0 && ( +
+ {song.tags.map((tag: any) => ( + + #{tag.name} + + ))} +
+ )} +
+
+ + + +
+ +
+ + + Times Played + + +
+ + {song.times_played} +
+
+
+ + + Gap (Shows) + + +
+ + {song.gap} +
+
+
+ + + Last Played + + +
+ + {song.last_played ? new Date(song.last_played).toLocaleDateString() : "Never"} +
+
+
+
+ + {/* Set Breakdown */} + {song.set_breakdown && Object.keys(song.set_breakdown).length > 0 && ( + + + Set Distribution + + +
+ {Object.entries(song.set_breakdown).sort((a, b) => (b[1] as number) - (a[1] as number)).map(([set, count]) => ( +
+ {count as number} + {set} +
+ ))} +
+
+
+ )} + + {/* Heady Version Section */} + {headyVersions.length > 0 && ( + + + + + Heady Version Leaderboard + + + + {/* Top Performance with YouTube */} + {topPerformance && ( +
+ {topPerformance.youtube_link ? ( + + ) : song.youtube_link ? ( + + ) : ( +
+
+ +

No video available

+
+
+ )} +
+
+ 🏆 #1 Heady +
+

+ {topPerformance.show?.date ? new Date(topPerformance.show.date).toLocaleDateString() : "Unknown Date"} +

+

+ {topPerformance.show?.venue?.name || "Unknown Venue"} +

+
+ + {topPerformance.avg_rating?.toFixed(1)} + ({topPerformance.rating_count} ratings) +
+
+
+ )} + + {/* Leaderboard List */} +
+ {headyVersions.map((perf: any, index: number) => ( +
+
+ + {index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `${index + 1}.`} + +
+

+ {perf.show?.date ? new Date(perf.show.date).toLocaleDateString() : "Unknown"} +

+

+ {perf.show?.venue?.name || "Unknown Venue"} +

+
+
+
+ {perf.youtube_link && ( + + + + )} +
+ {perf.avg_rating?.toFixed(1)}★ + ({perf.rating_count}) +
+
+
+ ))} +
+
+
+ )} + + {/* Cross-Band Versions */} + {relatedVersions && relatedVersions.length > 0 && ( + + + + + + + + + Also Played By + +

+ This song is performed by {relatedVersions.length + 1} different bands +

+
+ +
+ {relatedVersions.map((version: any) => ( + +
+
+

+ {version.vertical_name} +

+

+ {version.title} +

+
+ + View → + +
+ + ))} +
+
+
+ )} + + + + {/* Performance List Component (Handles Client Sorting) */} + + +
+ + + + + + + +
+
+ ) +}