Ethos track library - Browse, filter, and search all tracks
cgen-e059fffdf88546e4902b3273507b65af
This commit is contained in:
parent
94cb755156
commit
5d519571ca
2 changed files with 562 additions and 0 deletions
263
client/pages/ethos/ArtistProfile.tsx
Normal file
263
client/pages/ethos/ArtistProfile.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
299
client/pages/ethos/TrackLibrary.tsx
Normal file
299
client/pages/ethos/TrackLibrary.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue