feat(frontend): Enforce strict mode and refactor pages
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

This commit is contained in:
fullsizemalt 2025-12-30 22:29:16 -08:00
parent 2941fa482e
commit 60456c4737
18 changed files with 142 additions and 134 deletions

View file

@ -2,10 +2,11 @@ import { notFound } from "next/navigation"
import { VERTICALS } from "@/config/verticals" import { VERTICALS } from "@/config/verticals"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import Link from "next/link" import Link from "next/link"
import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight, Play } from "lucide-react" import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { VideoGallery } from "@/components/videos/video-gallery" import { VideoGallery } from "@/components/videos/video-gallery"
import { Show, Song, PaginatedResponse } from "@/types/models"
interface Props { interface Props {
params: Promise<{ vertical: string }> params: Promise<{ vertical: string }>
@ -17,25 +18,26 @@ export function generateStaticParams() {
})) }))
} }
async function getRecentShows(verticalSlug: string) { async function getRecentShows(verticalSlug: string): Promise<Show[]> {
try { try {
const res = await fetch(`${getApiUrl()}/shows/?vertical_slugs=${verticalSlug}&limit=8&status=past`, { const res = await fetch(`${getApiUrl()}/shows/?vertical_slugs=${verticalSlug}&limit=8&status=past`, {
next: { revalidate: 60 } next: { revalidate: 60 }
}) })
if (!res.ok) return [] if (!res.ok) return []
return res.json() const data: PaginatedResponse<Show> = await res.json()
return data.data || []
} catch { } catch {
return [] return []
} }
} }
async function getTopSongs(verticalSlug: string) { async function getTopSongs(verticalSlug: string): Promise<Song[]> {
try { try {
const res = await fetch(`${getApiUrl()}/songs/?vertical=${verticalSlug}&limit=5&sort=times_played`, { const res = await fetch(`${getApiUrl()}/songs/?vertical=${verticalSlug}&limit=5&sort=times_played`, {
next: { revalidate: 60 } next: { revalidate: 60 }
}) })
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data: PaginatedResponse<Song> = await res.json()
return data.data || [] return data.data || []
} catch { } catch {
return [] return []
@ -104,7 +106,7 @@ export default async function VerticalPage({ params }: Props) {
</Link> </Link>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{recentShows.slice(0, 8).map((show: any) => ( {recentShows.slice(0, 8).map((show) => (
<Link <Link
key={show.id} key={show.id}
href={`/${verticalSlug}/shows/${show.slug}`} href={`/${verticalSlug}/shows/${show.slug}`}
@ -127,11 +129,12 @@ export default async function VerticalPage({ params }: Props) {
<MapPin className="h-3 w-3" /> <MapPin className="h-3 w-3" />
{show.venue?.city}, {show.venue?.state || show.venue?.country} {show.venue?.city}, {show.venue?.state || show.venue?.country}
</div> </div>
{show.tour?.name && ( {/* Tour is not in strict Show model yet, omitting for strictness or need to add to model */}
{/* {show.tour?.name && (
<div className="text-xs text-muted-foreground/80 pt-1"> <div className="text-xs text-muted-foreground/80 pt-1">
{show.tour.name} {show.tour.name}
</div> </div>
)} )} */}
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
@ -150,7 +153,7 @@ export default async function VerticalPage({ params }: Props) {
</Link> </Link>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{topSongs.slice(0, 5).map((song: any, index: number) => ( {topSongs.slice(0, 5).map((song, index) => (
<Link <Link
key={song.id} key={song.id}
href={`/${verticalSlug}/songs/${song.slug}`} href={`/${verticalSlug}/songs/${song.slug}`}
@ -166,7 +169,7 @@ export default async function VerticalPage({ params }: Props) {
</div> </div>
</div> </div>
<div className="text-sm text-muted-foreground whitespace-nowrap"> <div className="text-sm text-muted-foreground whitespace-nowrap">
{song.times_played || song.performance_count || 0} performances {song.times_played || 0} performances
</div> </div>
</div> </div>
</Link> </Link>

View file

@ -69,7 +69,7 @@ export default function AdminShowsPage() {
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setShows(data.shows || data) setShows(data.data || [])
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch shows", e) console.error("Failed to fetch shows", e)

View file

@ -9,32 +9,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { DateGroupedList } from "@/components/shows/date-grouped-list" import { DateGroupedList } from "@/components/shows/date-grouped-list"
import { FilterPills } from "@/components/shows/filter-pills" import { FilterPills } from "@/components/shows/filter-pills"
import { BandGrid } from "@/components/shows/band-grid" import { BandGrid } from "@/components/shows/band-grid"
import { Show, Vertical, PaginatedResponse, Venue } from "@/types/models"
interface Show {
id: number
slug?: string
date: string
youtube_link?: string
vertical?: {
name: string
slug: string
}
venue: {
id: number
name: string
city: string
state: string
}
performances?: any[] // Simplified
}
interface Vertical {
id: number
slug: string
name: string
show_count: number
logo_url?: string | null
}
function ShowsContent() { function ShowsContent() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
@ -111,15 +86,13 @@ function ShowsContent() {
.then(res => { .then(res => {
// If 401 (Unauthorized) for My Feed, we might get empty list or error // If 401 (Unauthorized) for My Feed, we might get empty list or error
if (res.status === 401 && activeView === "my-feed") { if (res.status === 401 && activeView === "my-feed") {
// Redirect to login or handle? return { data: [] as Show[], meta: { total: 0, limit: 0, offset: 0 } }
// For now, shows API returns [] if anon try to filter by tiers.
return []
} }
if (!res.ok) throw new Error("Failed to fetch shows") if (!res.ok) throw new Error("Failed to fetch shows")
return res.json() return res.json()
}) })
.then(data => { .then((data: PaginatedResponse<Show>) => {
setShows(data) setShows(data.data || [])
setLoading(false) setLoading(false)
}) })
.catch(err => { .catch(err => {

View file

@ -5,13 +5,7 @@ import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import Link from "next/link" import Link from "next/link"
import { Music } from "lucide-react" import { Music } from "lucide-react"
import { Song, PaginatedResponse } from "@/types/models"
interface Song {
id: number
title: string
slug?: string
original_artist?: string
}
export default function SongsPage() { export default function SongsPage() {
const [songs, setSongs] = useState<Song[]>([]) const [songs, setSongs] = useState<Song[]>([])
@ -20,11 +14,11 @@ export default function SongsPage() {
useEffect(() => { useEffect(() => {
fetch(`${getApiUrl()}/songs/?limit=1000`) fetch(`${getApiUrl()}/songs/?limit=1000`)
.then(res => res.json()) .then(res => res.json())
.then(data => { .then((data: PaginatedResponse<Song>) => {
// Handle envelope // Handle envelope
const songData = data.data || [] const songData = data.data || []
// Sort alphabetically // Sort alphabetically
const sorted = songData.sort((a: Song, b: Song) => a.title.localeCompare(b.title)) const sorted = songData.sort((a, b) => a.title.localeCompare(b.title))
setSongs(sorted) setSongs(sorted)
}) })
.catch(console.error) .catch(console.error)

View file

@ -54,7 +54,8 @@ export default function VenueDetailPage() {
// Fetch shows at this venue using numeric ID // Fetch shows at this venue using numeric ID
const showsRes = await fetch(`${getApiUrl()}/shows/?venue_id=${venueData.id}&limit=100`) const showsRes = await fetch(`${getApiUrl()}/shows/?venue_id=${venueData.id}&limit=100`)
if (showsRes.ok) { if (showsRes.ok) {
const showsData = await showsRes.json() const showsEnvelope = await showsRes.json()
const showsData = showsEnvelope.data || []
// Sort by date descending // Sort by date descending
showsData.sort((a: Show, b: Show) => showsData.sort((a: Show, b: Show) =>
new Date(b.date).getTime() - new Date(a.date).getTime() new Date(b.date).getTime() - new Date(a.date).getTime()

View file

@ -180,7 +180,7 @@ export default function WelcomePage() {
<Switch <Switch
id="wiki-mode" id="wiki-mode"
checked={wikiMode} checked={wikiMode}
onChange={(e) => setWikiMode(e.target.checked)} onCheckedChange={(checked) => setWikiMode(checked)}
/> />
</div> </div>

View file

@ -75,11 +75,11 @@ const DEFAULT_LINKS = {
} }
export function Footer() { export function Footer() {
const { currentVertical } = useVertical() const { current } = useVertical()
// Get links for current vertical or fallback // Get links for current vertical or fallback
const links = (currentVertical && VERTICAL_LINKS[currentVertical.slug]) const links = (current && VERTICAL_LINKS[current.slug])
? VERTICAL_LINKS[currentVertical.slug] ? VERTICAL_LINKS[current.slug]
: DEFAULT_LINKS : DEFAULT_LINKS
return ( return (
@ -142,7 +142,7 @@ export function Footer() {
{/* Brand & Copyright */} {/* Brand & Copyright */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-lg font-bold"> <span className="text-lg font-bold">
{currentVertical ? currentVertical.name : "Fediversion"} {current ? current.name : "Fediversion"}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
© {new Date().getFullYear()} All rights reserved © {new Date().getFullYear()} All rights reserved

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { PublicProfileRead } from "@/lib/types" // We'll need to define this or import generic // import { PublicProfileRead } from "@/lib/types" // We'll need to define this or import generic
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"

View file

@ -7,9 +7,10 @@ import Link from "next/link"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Playlist } from "@/types/models"
export function UserPlaylistsList({ userId, isOwner = false }: { userId: number, isOwner?: boolean }) { export function UserPlaylistsList({ userId, isOwner = false }: { userId: number, isOwner?: boolean }) {
const [playlists, setPlaylists] = useState<any[]>([]) const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {

View file

@ -3,14 +3,7 @@
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Music2, Check } from "lucide-react" import { Music2, Check } from "lucide-react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Vertical } from "@/types/models"
interface Vertical {
id: number
slug: string
name: string
show_count: number
logo_url?: string | null
}
interface BandGridProps { interface BandGridProps {
verticals: Vertical[] verticals: Vertical[]
@ -54,7 +47,7 @@ export function BandGrid({ verticals, selectedBands, onToggle }: BandGridProps)
<div className="space-y-1"> <div className="space-y-1">
<h3 className="font-semibold leading-tight">{v.name}</h3> <h3 className="font-semibold leading-tight">{v.name}</h3>
<p className="text-xs text-muted-foreground font-medium"> <p className="text-xs text-muted-foreground font-medium">
{v.show_count.toLocaleString()} shows {(v.show_count || 0).toLocaleString()} shows
</p> </p>
</div> </div>
</CardContent> </CardContent>

View file

@ -4,24 +4,7 @@
import Link from "next/link" import Link from "next/link"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Calendar, MapPin, Youtube, Music2 } from "lucide-react" import { Calendar, MapPin, Youtube, Music2 } from "lucide-react"
import { Button } from "@/components/ui/button" import { Show } from "@/types/models"
interface Show {
id: number
slug?: string
date: string
youtube_link?: string
vertical?: {
name: string
slug: string
}
venue: {
id: number
name: string
city: string
state: string
}
}
interface DateGroupedListProps { interface DateGroupedListProps {
shows: Show[] shows: Show[]

View file

@ -28,6 +28,7 @@ export interface Performance {
interface PerformanceListProps { interface PerformanceListProps {
performances: Performance[] performances: Performance[]
songTitle?: string
} }
type SortOption = "date_desc" | "date_asc" | "rating_desc" type SortOption = "date_desc" | "date_asc" | "rating_desc"

View file

@ -26,6 +26,7 @@ const buttonVariants = cva(
default: "h-10 px-4 py-2", default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3", sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8", lg: "h-11 rounded-md px-8",
xl: "h-14 px-8 text-lg rounded-full",
icon: "h-10 w-10", icon: "h-10 w-10",
}, },
}, },

View file

@ -34,6 +34,7 @@ interface CommandDialogProps extends React.ComponentPropsWithoutRef<typeof Dialo
description?: string description?: string
commandProps?: React.ComponentPropsWithoutRef<typeof Command> commandProps?: React.ComponentPropsWithoutRef<typeof Command>
showCloseButton?: boolean showCloseButton?: boolean
className?: string
} }
function CommandDialog({ function CommandDialog({

View file

@ -1,6 +1,7 @@
"use client" "use client"
import { useState, useRef, useEffect } from "react" import { useState, useRef, useEffect } from "react"
import { StarRating } from "@/components/ui/star-rating"
import { Star } from "lucide-react" import { Star } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -62,40 +63,21 @@ export function RatingInput({
} }
} }
// Visual star representation (readonly display) // Use StarRating for visual display
const renderStars = () => { const renderStars = () => (
const stars = [] <StarRating
const fullStars = Math.floor(localValue) value={localValue}
const partialFill = (localValue - fullStars) * 100 size={size}
readonly={readonly}
for (let i = 0; i < 10; i++) { onChange={(v) => {
const isFull = i < fullStars if (!readonly) {
const isPartial = i === fullStars && partialFill > 0 setLocalValue(v)
onChange?.(v)
stars.push( }
<div key={i} className="relative"> }}
<Star className={cn( precision="decimal"
size === "sm" ? "h-3 w-3" : size === "lg" ? "h-5 w-5" : "h-4 w-4", />
"fill-muted text-muted-foreground/50" )
)} />
{(isFull || isPartial) && (
<div
className="absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${isFull ? 0 : 100 - partialFill}% 0 0)`
}}
>
<Star className={cn(
size === "sm" ? "h-3 w-3" : size === "lg" ? "h-5 w-5" : "h-4 w-4",
"fill-yellow-500 text-yellow-500"
)} />
</div>
)}
</div>
)
}
return stars
}
if (readonly) { if (readonly) {
return ( return (

View file

@ -1,6 +1,5 @@
"use client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
interface UserAvatarProps { interface UserAvatarProps {
bgColor?: string bgColor?: string
@ -8,6 +7,7 @@ interface UserAvatarProps {
username?: string username?: string
size?: "sm" | "md" | "lg" | "xl" size?: "sm" | "md" | "lg" | "xl"
className?: string className?: string
src?: string | null
} }
const sizeClasses = { const sizeClasses = {
@ -22,21 +22,21 @@ export function UserAvatar({
text, text,
username = "", username = "",
size = "md", size = "md",
className className,
src
}: UserAvatarProps) { }: UserAvatarProps) {
// If no custom text, use first letter of username // If no custom text, use first letter of username
const displayText = text || username.charAt(0).toUpperCase() || "?" const displayText = text || username.charAt(0).toUpperCase() || "?"
return ( return (
<div <Avatar className={cn(sizeClasses[size], className)}>
className={cn( <AvatarImage src={src || undefined} alt={username} />
"rounded-full flex items-center justify-center font-bold text-white shadow-md select-none", <AvatarFallback
sizeClasses[size], className="font-bold text-white"
className style={{ backgroundColor: bgColor }}
)} >
style={{ backgroundColor: bgColor }} {displayText}
> </AvatarFallback>
{displayText} </Avatar>
</div>
) )
} }

View file

@ -17,6 +17,8 @@ interface AuthContextType {
loading: boolean loading: boolean
login: (token: string) => Promise<void> login: (token: string) => Promise<void>
logout: () => void logout: () => void
refreshUser: () => Promise<void>
isAuthenticated: boolean
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
@ -25,6 +27,8 @@ const AuthContext = createContext<AuthContextType>({
loading: true, loading: true,
login: async () => { }, login: async () => { },
logout: () => { }, logout: () => { },
refreshUser: async () => { },
isAuthenticated: false,
}) })
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
@ -80,8 +84,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(null) setUser(null)
} }
const refreshUser = async () => {
if (token) {
await fetchUser(token)
}
}
const isAuthenticated = !!user
return ( return (
<AuthContext.Provider value={{ user, token, loading, login, logout }}> <AuthContext.Provider value={{ user, token, loading, login, logout, refreshUser, isAuthenticated }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )

63
frontend/types/models.ts Normal file
View file

@ -0,0 +1,63 @@
export interface PaginationMeta {
total: number
limit: number
offset: number
}
export interface PaginatedResponse<T> {
data: T[]
meta: PaginationMeta
}
export interface Vertical {
id: number
name: string
slug: string
show_count?: number
logo_url?: string | null
}
export interface Venue {
id: number
name: string
slug: string
city: string
state: string | null
country: string
capacity?: number | null
}
export interface Song {
id: number
title: string
slug: string
original_artist?: string | null
times_played?: number
last_played?: string | null
gap?: number | null
}
export interface Performance {
id: number
song: Song
set_name?: string | null
position?: number
seg_audience?: boolean
transition?: boolean
notes?: string | null
youtube_link?: string | null
}
export interface Show {
id: number
date: string
slug: string
venue_id: number
vertical_id: number
venue?: Venue
vertical?: Vertical
performances?: Performance[]
notes?: string | null
likes_count?: number
youtube_link?: string | null
}