Ethos track library - Browse, filter, and search all tracks

cgen-e059fffdf88546e4902b3273507b65af
This commit is contained in:
Builder.io 2025-11-11 23:10:14 +00:00
parent 94cb755156
commit 5d519571ca
2 changed files with 562 additions and 0 deletions

View file

@ -0,0 +1,263 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Star, Mail, Music, Zap, Clock } from "lucide-react";
interface Artist {
user_id: string;
skills: string[];
for_hire: boolean;
bio?: string;
portfolio_url?: string;
sample_price_track?: number;
sample_price_sfx?: number;
sample_price_score?: number;
turnaround_days?: number;
verified: boolean;
total_downloads: number;
created_at: string;
user_profiles: {
id: string;
full_name: string;
avatar_url?: string;
email?: string;
};
tracks: Array<{
id: string;
title: string;
genre: string[];
download_count: number;
}>;
}
export default function ArtistProfile() {
const { userId } = useParams<{ userId: string }>();
const [artist, setArtist] = useState<Artist | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchArtist = async () => {
if (!userId) return;
try {
const res = await fetch(`/api/ethos/artists?id=${userId}`);
const data = await res.json();
setArtist(data);
} catch (error) {
console.error("Failed to fetch artist:", error);
} finally {
setLoading(false);
}
};
fetchArtist();
}, [userId]);
if (loading) {
return <Layout><div className="py-20 text-center">Loading artist profile...</div></Layout>;
}
if (!artist) {
return <Layout><div className="py-20 text-center">Artist not found</div></Layout>;
}
const memberSince = new Date(artist.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
});
return (
<>
<SEO
pageTitle={`${artist.user_profiles.full_name} - Ethos Guild Artist`}
description={artist.bio || "Ethos Guild artist profile"}
/>
<Layout>
<div className="bg-slate-950 text-foreground min-h-screen">
{/* Profile Header */}
<section className="border-b border-slate-800 bg-gradient-to-b from-slate-900 to-slate-950 py-12">
<div className="container mx-auto px-4 max-w-4xl">
<div className="flex flex-col md:flex-row gap-8 items-start">
<Avatar className="h-24 w-24 rounded-lg">
<AvatarImage src={artist.user_profiles.avatar_url} />
<AvatarFallback className="bg-slate-800 text-xl">
{artist.user_profiles.full_name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">
{artist.user_profiles.full_name}
</h1>
{artist.verified && (
<Badge className="bg-gradient-to-r from-blue-600 to-cyan-600">
Verified Artist
</Badge>
)}
</div>
{artist.bio && (
<p className="text-slate-300 mb-4">{artist.bio}</p>
)}
<div className="flex flex-wrap gap-6 mb-6 text-sm">
<div>
<p className="text-slate-500">Total Downloads</p>
<p className="text-xl font-bold text-white">
{artist.total_downloads}
</p>
</div>
<div>
<p className="text-slate-500">Tracks Published</p>
<p className="text-xl font-bold text-white">
{artist.tracks.length}
</p>
</div>
<div>
<p className="text-slate-500">Member Since</p>
<p className="text-xl font-bold text-white">{memberSince}</p>
</div>
</div>
{artist.for_hire && (
<Button className="bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-700 hover:to-purple-700">
<Mail className="h-4 w-4 mr-2" />
Contact for Commission
</Button>
)}
</div>
</div>
{/* Skills & Services */}
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
{artist.skills.length > 0 && (
<Card className="bg-slate-900/50 border-slate-800">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Zap className="h-5 w-5" />
Skills
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{artist.skills.map((skill) => (
<Badge
key={skill}
variant="secondary"
className="bg-slate-800"
>
{skill}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{artist.for_hire && (
<Card className="bg-slate-900/50 border-slate-800">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Music className="h-5 w-5" />
Services
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{artist.sample_price_track && (
<div className="flex justify-between">
<span className="text-slate-300">Custom Track</span>
<span className="text-white font-semibold">
${artist.sample_price_track}
</span>
</div>
)}
{artist.sample_price_sfx && (
<div className="flex justify-between">
<span className="text-slate-300">SFX Pack</span>
<span className="text-white font-semibold">
${artist.sample_price_sfx}
</span>
</div>
)}
{artist.sample_price_score && (
<div className="flex justify-between">
<span className="text-slate-300">Full Score</span>
<span className="text-white font-semibold">
${artist.sample_price_score}
</span>
</div>
)}
{artist.turnaround_days && (
<div className="pt-2 border-t border-slate-800 flex items-center gap-2 text-sm text-slate-400">
<Clock className="h-4 w-4" />
{artist.turnaround_days} day turnaround
</div>
)}
</CardContent>
</Card>
)}
</div>
</div>
</section>
{/* Portfolio */}
<section className="py-12">
<div className="container mx-auto px-4 max-w-4xl">
<h2 className="text-2xl font-bold text-white mb-6">Portfolio</h2>
{artist.tracks.length === 0 ? (
<Card className="bg-slate-900/50 border-slate-800">
<CardContent className="py-12 text-center text-slate-400">
No tracks published yet
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{artist.tracks.map((track) => (
<Card
key={track.id}
className="bg-slate-900/50 border-slate-800 hover:border-slate-700 transition"
>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex-1">
<h3 className="text-white font-semibold">{track.title}</h3>
<div className="flex gap-2 mt-1">
{track.genre.map((g) => (
<Badge
key={g}
variant="secondary"
className="bg-slate-800 text-xs"
>
{g}
</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2 text-slate-400 text-sm">
<Music className="h-4 w-4" />
{track.download_count} downloads
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</section>
</div>
</Layout>
</>
);
}

View file

@ -0,0 +1,299 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Music, Download, Radio, Search, Filter } from "lucide-react";
interface Track {
id: string;
user_id: string;
title: string;
description?: string;
genre: string[];
license_type: string;
duration_seconds?: number;
download_count: number;
created_at: string;
user_profiles?: {
id: string;
full_name: string;
avatar_url?: string;
};
}
const GENRES = [
"All Genres",
"Synthwave",
"Orchestral",
"SFX",
"Ambient",
"Electronic",
"Cinematic",
"Jazz",
"Hip-Hop",
"Folk",
];
export default function TrackLibrary() {
const [tracks, setTracks] = useState<Track[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [selectedGenre, setSelectedGenre] = useState("All Genres");
const [licenseFilter, setLicenseFilter] = useState("all");
const [sortBy, setSortBy] = useState("newest");
useEffect(() => {
const fetchTracks = async () => {
try {
const params = new URLSearchParams();
params.append("limit", "100");
if (searchQuery) params.append("search", searchQuery);
if (selectedGenre !== "All Genres") params.append("genre", selectedGenre);
if (licenseFilter !== "all") params.append("licenseType", licenseFilter);
const res = await fetch(`/api/ethos/tracks?${params}`);
const { data } = await res.json();
let sorted = [...data];
if (sortBy === "newest") {
sorted.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
} else if (sortBy === "popular") {
sorted.sort((a, b) => b.download_count - a.download_count);
}
setTracks(sorted);
} catch (error) {
console.error("Failed to fetch tracks:", error);
} finally {
setLoading(false);
}
};
const timer = setTimeout(fetchTracks, 300);
return () => clearTimeout(timer);
}, [searchQuery, selectedGenre, licenseFilter, sortBy]);
const formatDuration = (seconds?: number) => {
if (!seconds) return "—";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
return (
<>
<SEO
pageTitle="Ethos Track Library"
description="Browse music and sound effects from Ethos Guild artists"
/>
<Layout>
<div className="bg-slate-950 text-foreground min-h-screen">
{/* Hero Section */}
<section className="relative border-b border-slate-800 bg-gradient-to-b from-slate-900 to-slate-950 py-16">
<div className="container mx-auto px-4 max-w-6xl">
<div className="space-y-6">
<div className="space-y-3">
<Badge className="inline-flex items-center gap-2 bg-gradient-to-r from-pink-600 to-purple-600">
<Music className="h-3 w-3" />
Ethos Track Library
</Badge>
<h1 className="text-4xl font-bold text-white">
Discover Ethos Music & SFX
</h1>
<p className="text-lg text-slate-400 max-w-2xl">
Browse original music and sound effects created by Ethos Guild artists.
Use freely in your projects or license commercially.
</p>
</div>
{/* Search & Filters */}
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-4 top-3 h-5 w-5 text-slate-500" />
<Input
placeholder="Search tracks by title or artist..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 bg-slate-800 border-slate-700 h-11"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-xs uppercase text-slate-500 mb-2 block">
Genre
</label>
<Select value={selectedGenre} onValueChange={setSelectedGenre}>
<SelectTrigger className="bg-slate-800 border-slate-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{GENRES.map((genre) => (
<SelectItem key={genre} value={genre}>
{genre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs uppercase text-slate-500 mb-2 block">
License Type
</label>
<Select value={licenseFilter} onValueChange={setLicenseFilter}>
<SelectTrigger className="bg-slate-800 border-slate-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">All Licenses</SelectItem>
<SelectItem value="ecosystem">Ecosystem Free</SelectItem>
<SelectItem value="commercial_sample">Commercial Demo</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs uppercase text-slate-500 mb-2 block">
Sort By
</label>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="bg-slate-800 border-slate-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="popular">Most Popular</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Tracks Grid */}
<section className="py-12">
<div className="container mx-auto px-4 max-w-6xl">
{loading ? (
<div className="text-center py-12">
<div className="inline-block animate-spin">
<Music className="h-8 w-8 text-slate-500" />
</div>
<p className="text-slate-400 mt-4">Loading tracks...</p>
</div>
) : tracks.length === 0 ? (
<div className="text-center py-12">
<Music className="h-12 w-12 text-slate-600 mx-auto mb-4" />
<p className="text-slate-400">No tracks found. Try adjusting your filters.</p>
</div>
) : (
<div className="grid gap-4">
{tracks.map((track) => (
<Card
key={track.id}
className="bg-slate-900/50 border-slate-800 hover:border-slate-700 transition"
>
<CardContent className="p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold truncate">
{track.title}
</h3>
{track.user_profiles && (
<Link
to={`/ethos/artists/${track.user_id}`}
className="text-sm text-slate-400 hover:text-slate-300 transition"
>
{track.user_profiles.full_name}
</Link>
)}
</div>
<Badge
variant="outline"
className={
track.license_type === "ecosystem"
? "bg-green-500/10 border-green-500/30"
: "bg-blue-500/10 border-blue-500/30"
}
>
{track.license_type === "ecosystem" ? "Free" : "Commercial"}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2 mb-3">
{track.genre.map((g) => (
<Badge
key={g}
variant="secondary"
className="bg-slate-800 text-xs"
>
{g}
</Badge>
))}
</div>
{track.description && (
<p className="text-xs text-slate-400 line-clamp-1">
{track.description}
</p>
)}
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
<div className="flex items-center gap-1">
<Download className="h-4 w-4" />
{track.download_count}
</div>
{track.duration_seconds && (
<div className="flex items-center gap-1">
<Radio className="h-4 w-4" />
{formatDuration(track.duration_seconds)}
</div>
)}
</div>
<Button
asChild
variant="outline"
size="sm"
className="border-slate-700 hover:bg-slate-800"
>
<Link to={`/ethos/tracks/${track.id}`}>
Listen & Details
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</section>
</div>
</Layout>
</>
);
}