feat: redesign band hub page and populate song stats
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
This commit is contained in:
parent
f10f8ad465
commit
18b102558d
5 changed files with 263 additions and 164 deletions
|
|
@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||
from sqlmodel import Session, select, col
|
||||
from sqlalchemy.orm import selectinload
|
||||
from database import get_session
|
||||
from models import Show, Song, Venue, Tour, Group, Performance, PerformanceNickname, Comment, Review, Vertical
|
||||
from models import Show, Song, Venue, Tour, Group, Performance, PerformanceNickname, Comment, Review, Vertical, SongCanon
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
|
|
@ -18,13 +18,32 @@ def global_search(
|
|||
|
||||
q_str = f"%{q}%"
|
||||
|
||||
# Search Songs
|
||||
songs = session.exec(
|
||||
# Search Canonical Songs (The Hub)
|
||||
canonical_songs = session.exec(
|
||||
select(SongCanon)
|
||||
.where(col(SongCanon.title).ilike(q_str))
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
# Search Songs (Artist Versions)
|
||||
songs_raw = session.exec(
|
||||
select(Song)
|
||||
.options(selectinload(Song.vertical))
|
||||
.where(col(Song.title).ilike(q_str))
|
||||
.limit(limit)
|
||||
).all()
|
||||
|
||||
# Serialize songs with vertical info
|
||||
songs = []
|
||||
for s in songs_raw:
|
||||
songs.append({
|
||||
"id": s.id,
|
||||
"title": s.title,
|
||||
"slug": s.slug,
|
||||
"original_artist": s.original_artist,
|
||||
"vertical": {"name": s.vertical.name, "slug": s.vertical.slug} if s.vertical else None
|
||||
})
|
||||
|
||||
# Search Venues
|
||||
venues = session.exec(
|
||||
select(Venue)
|
||||
|
|
@ -118,6 +137,7 @@ def global_search(
|
|||
).all()
|
||||
|
||||
return {
|
||||
"canonical_songs": canonical_songs,
|
||||
"songs": songs,
|
||||
"venues": venues,
|
||||
"tours": tours,
|
||||
|
|
|
|||
|
|
@ -37,15 +37,33 @@ def read_songs(
|
|||
return PaginatedResponse(data=[], meta=PaginationMeta(total=0, limit=limit, offset=offset))
|
||||
|
||||
if sort == "times_played":
|
||||
# Select both Song and count
|
||||
query = select(Song, func.count(Performance.id).label("times_played"))
|
||||
query = query.outerjoin(Performance).group_by(Song.id)
|
||||
|
||||
# Calculate total count before pagination
|
||||
total = session.exec(select(func.count()).select_from(query.subquery())).one()
|
||||
if vertical:
|
||||
query = query.where(Song.vertical_id == vertical_entity.id)
|
||||
|
||||
# Calculate total
|
||||
total_query = select(func.count()).select_from(select(Song.id).where(Song.vertical_id == vertical_entity.id) if vertical else select(Song.id))
|
||||
total = session.exec(total_query).one()
|
||||
|
||||
if sort == "times_played":
|
||||
query = query.order_by(func.count(Performance.id).desc())
|
||||
|
||||
songs = session.exec(query.offset(offset).limit(limit)).all()
|
||||
results = session.exec(query.offset(offset).limit(limit)).all()
|
||||
|
||||
# Map (Song, count) tuples to SongRead with times_played
|
||||
songs = []
|
||||
for song, count in results:
|
||||
song_read = SongRead.model_validate(song)
|
||||
song_read.times_played = count
|
||||
songs.append(song_read)
|
||||
|
||||
else:
|
||||
# Standard query
|
||||
# Calculate total count before pagination
|
||||
total = session.exec(select(func.count()).select_from(query.subquery())).one()
|
||||
songs = session.exec(query.offset(offset).limit(limit)).all()
|
||||
|
||||
return PaginatedResponse(
|
||||
data=songs,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ class SongRead(SongBase):
|
|||
tags: List["TagRead"] = []
|
||||
artist: Optional["ArtistRead"] = None
|
||||
vertical: Optional["VerticalSimple"] = None
|
||||
times_played: Optional[int] = 0
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,15 @@ import Link from "next/link"
|
|||
import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight } from "lucide-react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { VideoGallery } from "@/components/videos/video-gallery"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Show, Song, PaginatedResponse } from "@/types/models"
|
||||
|
||||
interface Props {
|
||||
|
|
@ -20,7 +28,8 @@ export function generateStaticParams() {
|
|||
|
||||
async function getRecentShows(verticalSlug: string): Promise<Show[]> {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/shows/?vertical_slugs=${verticalSlug}&limit=8&status=past`, {
|
||||
// Fetch 10 recent shows
|
||||
const res = await fetch(`${getApiUrl()}/shows/?vertical_slugs=${verticalSlug}&limit=10&status=past`, {
|
||||
next: { revalidate: 60 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
|
|
@ -33,7 +42,8 @@ async function getRecentShows(verticalSlug: string): Promise<Show[]> {
|
|||
|
||||
async function getTopSongs(verticalSlug: string): Promise<Song[]> {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/songs/?vertical=${verticalSlug}&limit=5&sort=times_played`, {
|
||||
// Fetch top 10 songs, assuming backend now populates times_played
|
||||
const res = await fetch(`${getApiUrl()}/songs/?vertical=${verticalSlug}&limit=10&sort=times_played`, {
|
||||
next: { revalidate: 60 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
|
|
@ -44,6 +54,18 @@ async function getTopSongs(verticalSlug: string): Promise<Song[]> {
|
|||
}
|
||||
}
|
||||
|
||||
async function getVerticalStats(verticalSlug: string) {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/bands/${verticalSlug}`, {
|
||||
next: { revalidate: 300 }
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default async function VerticalPage({ params }: Props) {
|
||||
const { vertical: verticalSlug } = await params
|
||||
const vertical = VERTICALS.find((v) => v.slug === verticalSlug)
|
||||
|
|
@ -52,166 +74,178 @@ export default async function VerticalPage({ params }: Props) {
|
|||
notFound()
|
||||
}
|
||||
|
||||
const [recentShows, topSongs] = await Promise.all([
|
||||
const [recentShows, topSongs, bandProfile] = await Promise.all([
|
||||
getRecentShows(verticalSlug),
|
||||
getTopSongs(verticalSlug)
|
||||
getTopSongs(verticalSlug),
|
||||
getVerticalStats(verticalSlug)
|
||||
])
|
||||
|
||||
const navCards = [
|
||||
{ href: `/${verticalSlug}/shows`, icon: Calendar, title: "Shows", desc: "Browse the complete archive" },
|
||||
{ href: `/${verticalSlug}/venues`, icon: Building, title: "Venues", desc: "Find your favorite spots" },
|
||||
{ href: `/${verticalSlug}/songs`, icon: Music, title: "Songs", desc: "Explore the catalog" },
|
||||
{ href: `/${verticalSlug}/performances`, icon: Trophy, title: "Top Performances", desc: "Highest rated jams" },
|
||||
{ href: `/leaderboards?band=${verticalSlug}`, icon: Trophy, title: "Leaderboards", desc: "Top rated everything" },
|
||||
{ href: `/${verticalSlug}/tours`, icon: Ticket, title: "Tours", desc: "Browse by tour" },
|
||||
{ href: `/videos?band=${verticalSlug}`, icon: Video, title: "Videos", desc: "Watch full shows and songs" },
|
||||
]
|
||||
const stats = bandProfile?.stats || {}
|
||||
|
||||
return (
|
||||
<div className="space-y-12 pb-12">
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-16 md:py-24 text-center space-y-6 bg-gradient-to-b from-primary/5 to-transparent">
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
{vertical.name}
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto px-4">
|
||||
A comprehensive community-driven archive for {vertical.name} history.
|
||||
<br className="hidden sm:block" />
|
||||
Discover shows, share ratings, and explore the music together.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4 pt-4">
|
||||
<Link href={`/${verticalSlug}/performances`}>
|
||||
<Button size="lg" className="gap-2">
|
||||
<Trophy className="h-4 w-4" />
|
||||
Top Performances
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/${verticalSlug}/shows`}>
|
||||
<Button size="lg" variant="outline" className="gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Browse Shows
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="space-y-8 pb-12">
|
||||
{/* Hero Section - Compact & Utilitarian */}
|
||||
<section className="bg-muted/30 border-b">
|
||||
<div className="container py-8 px-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold tracking-tight">{vertical.name}</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-lg">
|
||||
{vertical.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* High-Level Stats */}
|
||||
<div className="grid grid-cols-3 gap-8 text-center md:text-right">
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{stats.total_shows || 0}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Shows</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{stats.total_songs || 0}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Songs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{stats.total_venues || 0}</div>
|
||||
<div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Venues</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Bar */}
|
||||
<div className="flex flex-wrap gap-2 mt-8">
|
||||
<Link href={`/${verticalSlug}/shows`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Calendar className="h-4 w-4" /> Shows
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/${verticalSlug}/songs`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Music className="h-4 w-4" /> Songs
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/${verticalSlug}/venues`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<MapPin className="h-4 w-4" /> Venues
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/${verticalSlug}/performances`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Trophy className="h-4 w-4" /> Top Rated
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/videos?band=${verticalSlug}`}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Video className="h-4 w-4" /> Videos
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="container max-w-6xl space-y-12 px-4">
|
||||
{/* Recent Shows */}
|
||||
{recentShows.length > 0 && (
|
||||
<section className="space-y-4">
|
||||
<div className="container px-4 space-y-12">
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
{/* Recent Shows Table */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Recent Shows</h2>
|
||||
<Link href={`/${verticalSlug}/shows`} className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1">
|
||||
View all shows <ChevronRight className="h-4 w-4" />
|
||||
<Link href={`/${verticalSlug}/shows`}>
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
View All <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{recentShows.slice(0, 8).map((show) => (
|
||||
<Link
|
||||
key={show.id}
|
||||
href={`/${verticalSlug}/shows/${show.slug}`}
|
||||
className="group"
|
||||
>
|
||||
<Card className="h-full transition-all duration-200 hover:scale-[1.02] hover:shadow-lg hover:border-primary/50">
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="text-sm font-medium text-primary">
|
||||
{new Date(show.date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
<div className="font-semibold group-hover:text-primary transition-colors line-clamp-1">
|
||||
{show.venue?.name || "Unknown Venue"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{show.venue?.city}, {show.venue?.state || show.venue?.country}
|
||||
</div>
|
||||
{/* 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">
|
||||
{show.tour.name}
|
||||
</div>
|
||||
)} */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Most Played Songs */}
|
||||
{topSongs.length > 0 && (
|
||||
<section className="space-y-4">
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Venue</TableHead>
|
||||
<TableHead className="text-right">Location</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentShows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center h-24 text-muted-foreground">
|
||||
No shows found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
recentShows.map((show) => (
|
||||
<TableRow key={show.id}>
|
||||
<TableCell className="font-medium whitespace-nowrap">
|
||||
<Link href={`/${verticalSlug}/shows/${show.slug}`} className="hover:underline text-primary">
|
||||
{new Date(show.date).toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric'
|
||||
})}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="line-clamp-1">{show.venue?.name || "Unknown"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{show.venue?.city}, {show.venue?.state}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Most Played Songs Table */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Most Played Songs</h2>
|
||||
<Link href={`/${verticalSlug}/songs`} className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1">
|
||||
View all songs <ChevronRight className="h-4 w-4" />
|
||||
<Link href={`/${verticalSlug}/songs`}>
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
View Catalog <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{topSongs.slice(0, 5).map((song, index) => (
|
||||
<Link
|
||||
key={song.id}
|
||||
href={`/${verticalSlug}/songs/${song.slug}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="flex items-center gap-4 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-all duration-200">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-sm font-bold text-primary">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium group-hover:text-primary transition-colors truncate">
|
||||
{song.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{song.times_played || 0} performances
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Videos Section */}
|
||||
<section className="space-y-4">
|
||||
<VideoGallery
|
||||
bandSlug={verticalSlug}
|
||||
limit={8}
|
||||
title="Recent Videos"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Navigation Cards */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Explore</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{navCards.map((card) => (
|
||||
<Link key={card.href} href={card.href} className="group">
|
||||
<Card className="h-full transition-all duration-200 hover:scale-[1.02] hover:shadow-lg hover:border-primary/50">
|
||||
<CardContent className="p-6 flex items-start gap-4">
|
||||
<div className="p-3 rounded-lg bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
|
||||
<card.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold group-hover:text-primary transition-colors">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{card.desc}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="text-right">Plays</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topSongs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center h-24 text-muted-foreground">
|
||||
No songs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
topSongs.map((song) => (
|
||||
<TableRow key={song.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/${verticalSlug}/songs/${song.slug}`} className="hover:underline text-primary">
|
||||
{song.title}
|
||||
</Link>
|
||||
{song.original_artist && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
by {song.original_artist}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant="secondary">{song.times_played || 0}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -136,17 +136,43 @@ export function SearchDialog() {
|
|||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{results.songs?.length > 0 && (
|
||||
<CommandGroup heading="Songs">
|
||||
{/* Canonical Songs (Hub Results) */}
|
||||
{results.canonical_songs && results.canonical_songs.length > 0 && (
|
||||
<CommandGroup heading="Canonical Songs (Cross-Band Hub)">
|
||||
{results.canonical_songs.map((song: any) => (
|
||||
<CommandItem
|
||||
key={`canon-${song.id}`}
|
||||
value={`canon-${song.id}`}
|
||||
onSelect={() => handleSelect(`/songs/${song.slug}`)}
|
||||
>
|
||||
<Library className="mr-2 h-4 w-4 text-primary" />
|
||||
<span>{song.title}</span>
|
||||
{song.original_artist && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
(Orig. {song.original_artist})
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Artist Specific Songs */}
|
||||
{results.songs && results.songs.length > 0 && (
|
||||
<CommandGroup heading="Artist Versions">
|
||||
{results.songs.map((song: any) => (
|
||||
<CommandItem key={song.slug || song.id} onSelect={() => handleSelect(`/songs/${song.slug}`)}>
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span>{song.title}</span>
|
||||
{song.original_artist && (
|
||||
<span className="text-[10px] text-muted-foreground">Original by {song.original_artist}</span>
|
||||
)}
|
||||
</div>
|
||||
<CommandItem
|
||||
key={song.id}
|
||||
value={song.title}
|
||||
onSelect={() => handleSelect(`/${song.vertical?.slug || 'all'}/songs/${song.slug}`)}
|
||||
>
|
||||
<Music className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<span>{song.title}</span>
|
||||
{song.vertical && (
|
||||
<Badge variant="secondary" className="ml-2 text-[10px] h-5 px-1.5 font-normal">
|
||||
{song.vertical.name}
|
||||
</Badge>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue