- DashboardPage: Updated gap (gap-6 → gap-8) for 8px grid alignment - FileDetailsView: Updated section spacing (space-y-6 → space-y-8) - GearView: Updated section spacing (space-y-6 → space-y-8, 2 instances) - ProfileView: Updated gap (gap-6 → gap-8) - DiscoverView: Updated card padding (p-6 → p-8) - PurchasesView: Updated gap (gap-6 → gap-8) - Preserved: Responsive padding (p-4 sm:p-6, px-6 md:px-12) and conditional padding (p-6 for non-primary cards) - Action 11.5.1.5: Apply direction to all pages - Batch 3 complete (spacing fixes)
602 lines
22 KiB
TypeScript
602 lines
22 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Card } from '../ui/card';
|
|
import { Button } from '../ui/button';
|
|
import { Badge } from '../ui/badge';
|
|
import { Avatar } from '../ui/avatar';
|
|
import { Tabs, TabsList, TabsTrigger } from '../ui/tabs';
|
|
import {
|
|
Instagram,
|
|
Twitter,
|
|
Globe,
|
|
MapPin,
|
|
Calendar,
|
|
LayoutGrid,
|
|
List,
|
|
Heart,
|
|
MoreHorizontal,
|
|
CheckCircle,
|
|
Play,
|
|
Share2,
|
|
MessageSquare,
|
|
UserPlus,
|
|
ListMusic,
|
|
Settings,
|
|
Loader2,
|
|
} from 'lucide-react';
|
|
import { User, Track, Playlist } from '@/types';
|
|
import { useToast } from '@/context/ToastContext';
|
|
import { useAudio } from '@/context/AudioContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { userService } from '@/services/userService';
|
|
import { trackService } from '@/services/trackService';
|
|
import { playlistService } from '@/services/playlistService';
|
|
import { logger } from '@/utils/logger';
|
|
|
|
// --- SUB-COMPONENTS ---
|
|
|
|
const TrackCard: React.FC<{ track: Track; mode: 'grid' | 'list' }> = ({
|
|
track,
|
|
mode,
|
|
}) => {
|
|
const { playTrack } = useAudio();
|
|
if (mode === 'grid') {
|
|
return (
|
|
<div
|
|
className="aspect-square relative group cursor-pointer overflow-hidden rounded-xl bg-kodo-graphite"
|
|
onClick={() => playTrack(track)}
|
|
>
|
|
<img
|
|
src={track.coverUrl || 'https://via.placeholder.com/400'}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center text-white p-4 text-center">
|
|
<div className="flex gap-4 mb-2">
|
|
<span className="flex items-center gap-1 font-bold">
|
|
<Play className="w-4 h-4 fill-current" />{' '}
|
|
{track.play_count > 1000
|
|
? `${(track.play_count / 1000).toFixed(1)}k`
|
|
: track.play_count}
|
|
</span>
|
|
</div>
|
|
<h4 className="font-bold truncate w-full">{track.title}</h4>
|
|
</div>
|
|
{track.isPremium && (
|
|
<div className="absolute top-2 right-2 bg-kodo-gold text-black text-[10px] font-bold px-2 py-0.5 rounded shadow-lg">
|
|
PRO
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<Card
|
|
variant="default"
|
|
className="flex gap-4 p-4 items-center group hover:border-kodo-steel/50 transition-all cursor-pointer"
|
|
onClick={() => playTrack(track)}
|
|
>
|
|
<div className="w-12 h-12 rounded overflow-hidden relative shrink-0">
|
|
<img
|
|
src={track.coverUrl || 'https://via.placeholder.com/400'}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Play className="w-6 h-6 text-white fill-current" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-bold text-white text-sm truncate group-hover:text-white">
|
|
{track.title}
|
|
</h4>
|
|
<p className="text-kodo-content-dim text-xs">{track.artist}</p>
|
|
</div>
|
|
<div className="hidden md:flex items-center gap-4 text-xs text-kodo-content-dim">
|
|
<span className="flex items-center gap-1">
|
|
<Play className="w-3 h-3" /> {track.play_count}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Heart className="w-3 h-3" /> {track.like_count}
|
|
</span>
|
|
<span className="font-mono">{track.duration}</span>
|
|
</div>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
const PlaylistCard: React.FC<{ playlist: Playlist }> = ({ playlist }) => (
|
|
<Card
|
|
variant="glass"
|
|
className="p-0 overflow-hidden group cursor-pointer hover:border-kodo-magenta/50"
|
|
>
|
|
<div className="aspect-video relative overflow-hidden">
|
|
<img
|
|
src={playlist.cover_url || 'https://via.placeholder.com/400'}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute bottom-2 right-2 bg-black/80 text-white text-[10px] px-2 py-1 rounded flex items-center gap-1">
|
|
<ListMusic className="w-3 h-3" /> {playlist.track_count}
|
|
</div>
|
|
</div>
|
|
<div className="p-4">
|
|
<h4 className="font-bold text-white text-sm mb-1 truncate">
|
|
{playlist.title}
|
|
</h4>
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{playlist.tags &&
|
|
playlist.tags.map((tag: string) => (
|
|
<Badge
|
|
key={tag}
|
|
label={tag}
|
|
variant="default"
|
|
className="text-[9px] px-1.5 py-0.5"
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
|
|
interface ProfileViewProps {
|
|
userId?: string | null;
|
|
}
|
|
|
|
export const ProfileView: React.FC<ProfileViewProps> = ({ userId }) => {
|
|
const { user: currentUser } = useAuth();
|
|
const { addToast } = useToast();
|
|
const { playTrack } = useAudio();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [profile, setProfile] = useState<User | null>(null);
|
|
const [activeTab, setActiveTab] = useState('overview');
|
|
const [isFollowing, setIsFollowing] = useState(false);
|
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
|
|
|
// Content State
|
|
const [tracks, setTracks] = useState<Track[]>([]);
|
|
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
|
|
|
useEffect(() => {
|
|
const fetchProfileData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
// 1. Fetch Profile
|
|
let userData: User;
|
|
if (userId) {
|
|
const res = await userService.getProfile(userId);
|
|
userData = res.profile;
|
|
} else if (currentUser) {
|
|
// Fallback to current user if no ID provided (My Profile)
|
|
const res = await userService.getProfile(currentUser.id);
|
|
userData = res.profile;
|
|
} else {
|
|
return; // Should redirect to login
|
|
}
|
|
setProfile(userData);
|
|
|
|
// 2. Fetch Tracks (using the filter by user_id)
|
|
const trackRes = await trackService.list({
|
|
user_id: userData.id,
|
|
limit: 10,
|
|
});
|
|
setTracks(trackRes.tracks);
|
|
|
|
// 3. Fetch Playlists
|
|
const playlistRes = await playlistService.list({
|
|
user_id: userData.id,
|
|
});
|
|
setPlaylists(playlistRes.playlists);
|
|
} catch (error) {
|
|
logger.error('Failed to load profile', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
addToast('Failed to load profile', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchProfileData();
|
|
setActiveTab('overview');
|
|
window.scrollTo(0, 0);
|
|
}, [userId, currentUser]);
|
|
|
|
const toggleFollow = () => {
|
|
setIsFollowing(!isFollowing);
|
|
// In real app: await socialService.followUser(profile.id);
|
|
addToast(
|
|
isFollowing
|
|
? `Unfollowed ${profile?.username}`
|
|
: `Following ${profile?.username}`,
|
|
isFollowing ? 'info' : 'success',
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-[50vh] items-center justify-center">
|
|
<Loader2 className="w-8 h-8 text-kodo-steel animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!profile) return <div>User not found</div>;
|
|
|
|
const isOwnProfile = currentUser?.id === profile.id;
|
|
|
|
// profileTabs removed as it is unused
|
|
|
|
return (
|
|
<div className="animate-fadeIn pb-20">
|
|
{/* --- HEADER --- */}
|
|
<div className="relative mb-6">
|
|
{/* Banner */}
|
|
<div className="h-64 md:h-80 rounded-2xl overflow-hidden relative group bg-kodo-ink">
|
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-kodo-void/90 z-10"></div>
|
|
{profile.banner ? (
|
|
<img
|
|
src={profile.banner}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full bg-gradient-gaming opacity-50"></div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Identity Section */}
|
|
<div className="px-6 md:px-12 relative z-20 -mt-20 flex flex-col md:flex-row items-end gap-8">
|
|
<div className="relative shrink-0 group">
|
|
<Avatar
|
|
src={profile.avatar}
|
|
alt={profile.username}
|
|
size="3xl"
|
|
className="border-[6px] border-kodo-void shadow-2xl"
|
|
status={profile.status}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 w-full md:w-auto mb-2 text-center md:text-left">
|
|
<div className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4 mb-2">
|
|
<h1 className="text-3xl md:text-4xl font-display font-bold text-white">
|
|
{profile.username}
|
|
</h1>
|
|
<div className="flex items-center justify-center md:justify-start gap-2">
|
|
{profile.roles?.map((role) => (
|
|
<Badge
|
|
key={role}
|
|
label={role}
|
|
variant={
|
|
role === 'Producer'
|
|
? 'gold'
|
|
: role === 'Admin'
|
|
? 'magenta'
|
|
: 'cyan'
|
|
}
|
|
className="shadow-lg"
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="text-kodo-content-dim font-mono text-sm flex items-center justify-center md:justify-start gap-4 mb-4">
|
|
<span>@{profile.username.toLowerCase().replace(/\s/g, '')}</span>
|
|
<span>•</span>
|
|
<span className="flex items-center gap-1">
|
|
<MapPin className="w-3 h-3" /> {profile.location || 'Unknown'}
|
|
</span>
|
|
<span>•</span>
|
|
<span className="flex items-center gap-1">
|
|
<Calendar className="w-3 h-3" /> Joined{' '}
|
|
{profile.created_at
|
|
? new Date(profile.created_at).toLocaleDateString()
|
|
: 'Unknown'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center md:justify-start gap-8 mb-4">
|
|
{profile.stats &&
|
|
Object.entries(profile.stats).map(([key, val]) => (
|
|
<div key={key} className="text-center md:text-left">
|
|
<span className="font-bold text-white text-lg block leading-none">
|
|
{typeof val === 'number' && val > 1000
|
|
? `${(val / 1000).toFixed(1)}k`
|
|
: val}
|
|
</span>
|
|
<span className="text-xs text-kodo-content-dim uppercase tracking-wider">
|
|
{key}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-4 mb-4 w-full md:w-auto">
|
|
{isOwnProfile ? (
|
|
<Button
|
|
variant="secondary"
|
|
icon={<Settings className="w-4 h-4" />}
|
|
onClick={() => addToast('Go to Settings > Profile')}
|
|
>
|
|
Edit Profile
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant={isFollowing ? 'ghost' : 'primary'}
|
|
className={`flex-1 md:flex-none ${isFollowing ? 'border border-kodo-steel text-kodo-text-main' : ''}`}
|
|
icon={
|
|
isFollowing ? (
|
|
<CheckCircle className="w-4 h-4" />
|
|
) : (
|
|
<UserPlus className="w-4 h-4" />
|
|
)
|
|
}
|
|
onClick={toggleFollow}
|
|
>
|
|
{isFollowing ? 'Following' : 'Follow'}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
className="flex-1 md:flex-none"
|
|
icon={<MessageSquare className="w-4 h-4" />}
|
|
onClick={() =>
|
|
addToast(`Opening chat with ${profile.username}...`)
|
|
}
|
|
>
|
|
Message
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="border border-kodo-steel"
|
|
>
|
|
<MoreHorizontal className="w-5 h-5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* --- MAIN LAYOUT --- */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 px-0 md:px-4">
|
|
{/* SIDEBAR (Quick Info) */}
|
|
<div className="lg:col-span-4 space-y-8">
|
|
<Card variant="default">
|
|
<h3 className="text-sm font-bold text-kodo-content-dim uppercase tracking-wider mb-4">
|
|
Connections
|
|
</h3>
|
|
<div className="space-y-4">
|
|
{profile.website && (
|
|
<div className="flex items-center gap-4 text-sm text-kodo-content-dim hover:text-white cursor-pointer transition-colors">
|
|
<Globe className="w-4 h-4 text-kodo-steel" />
|
|
<a
|
|
href={
|
|
profile.website.startsWith('http')
|
|
? profile.website
|
|
: `https://${profile.website}`
|
|
}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="hover:underline"
|
|
>
|
|
{profile.website}
|
|
</a>
|
|
</div>
|
|
)}
|
|
{profile.socials?.twitter && (
|
|
<div className="flex items-center gap-4 text-sm text-kodo-content-dim hover:text-white cursor-pointer transition-colors">
|
|
<Twitter className="w-4 h-4 text-kodo-steel" />
|
|
<span>{profile.socials.twitter}</span>
|
|
</div>
|
|
)}
|
|
{profile.socials?.instagram && (
|
|
<div className="flex items-center gap-4 text-sm text-kodo-content-dim hover:text-white cursor-pointer transition-colors">
|
|
<Instagram className="w-4 h-4 text-kodo-steel" />
|
|
<span>{profile.socials.instagram}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card variant="gaming">
|
|
<h3 className="text-sm font-bold text-kodo-gold uppercase tracking-wider mb-4 flex items-center gap-2">
|
|
<CheckCircle className="w-4 h-4" /> Achievements
|
|
</h3>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{[1, 2, 3].map((i) => (
|
|
<div
|
|
key={i}
|
|
className="aspect-square bg-kodo-ink rounded-lg flex items-center justify-center text-xl border border-kodo-gold/20 hover:border-kodo-gold hover:bg-kodo-gold/10 transition-all cursor-pointer tooltip group relative"
|
|
>
|
|
{['🏆', '⚡', '🎹'][i - 1]}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* MAIN CONTENT AREA */}
|
|
<div className="lg:col-span-8">
|
|
{/* Tab Navigation */}
|
|
<div className="flex items-center justify-between border-b border-kodo-steel/50 mb-6 sticky top-16 bg-kodo-void/95 backdrop-blur z-30 pt-4">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList>
|
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
<TabsTrigger value="tracks">Tracks</TabsTrigger>
|
|
<TabsTrigger value="playlists">Playlists</TabsTrigger>
|
|
<TabsTrigger value="about">About</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{['tracks', 'overview'].includes(activeTab) && (
|
|
<div className="flex gap-1 bg-kodo-ink p-1 rounded-lg border border-kodo-steel/30 shrink-0 ml-4">
|
|
<button
|
|
onClick={() => setViewMode('grid')}
|
|
className={`p-1.5 rounded transition-colors ${viewMode === 'grid' ? 'bg-kodo-slate text-white' : 'text-kodo-content-dim hover:text-kodo-text-main'}`}
|
|
>
|
|
<LayoutGrid className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('list')}
|
|
className={`p-1.5 rounded transition-colors ${viewMode === 'list' ? 'bg-kodo-slate text-white' : 'text-kodo-content-dim hover:text-kodo-text-main'}`}
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* TAB CONTENT */}
|
|
|
|
{activeTab === 'overview' && (
|
|
<div className="space-y-8 animate-fadeIn">
|
|
{/* Spotlight */}
|
|
{tracks.length > 0 ? (
|
|
<div className="relative rounded-2xl overflow-hidden bg-kodo-ink border border-kodo-steel">
|
|
<div className="p-8 md:p-8 flex flex-col md:flex-row gap-8 items-center">
|
|
<div className="w-32 h-32 md:w-48 md:h-48 shrink-0 shadow-2xl rounded-lg overflow-hidden">
|
|
<img
|
|
src={
|
|
tracks[0].coverUrl ||
|
|
'https://via.placeholder.com/400'
|
|
}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 text-center md:text-left">
|
|
<Badge
|
|
label="LATEST RELEASE"
|
|
variant="cyan"
|
|
className="mb-2"
|
|
/>
|
|
<h2 className="text-2xl md:text-4xl font-display font-bold text-white mb-2">
|
|
{tracks[0].title}
|
|
</h2>
|
|
<p className="text-kodo-content-dim mb-6">
|
|
Latest upload from {profile.username}.
|
|
</p>
|
|
<div className="flex justify-center md:justify-start gap-4">
|
|
<Button
|
|
variant="primary"
|
|
icon={<Play className="w-4 h-4" />}
|
|
onClick={() => playTrack(tracks[0])}
|
|
>
|
|
PLAY NOW
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
icon={<Share2 className="w-4 h-4" />}
|
|
>
|
|
SHARE
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="p-8 text-center text-kodo-content-dim bg-kodo-graphite rounded-2xl border border-kodo-steel border-dashed">
|
|
No tracks available.
|
|
</div>
|
|
)}
|
|
|
|
{/* Top Tracks */}
|
|
{tracks.length > 0 && (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="font-bold text-white">Popular Tracks</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setActiveTab('tracks')}
|
|
>
|
|
View All
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{tracks.slice(0, 5).map((track, i) => (
|
|
<div
|
|
key={track.id}
|
|
className="flex items-center gap-4 p-2 hover:bg-white/5 rounded transition-colors cursor-pointer group"
|
|
onClick={() => playTrack(track)}
|
|
>
|
|
<div className="text-kodo-content-dim w-4 text-center font-mono text-xs">
|
|
{i + 1}
|
|
</div>
|
|
<div className="w-10 h-10 rounded overflow-hidden">
|
|
<img
|
|
src={
|
|
track.coverUrl ||
|
|
'https://via.placeholder.com/400'
|
|
}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-bold text-white truncate">
|
|
{track.title}
|
|
</div>
|
|
<div className="text-xs text-kodo-content-dim">
|
|
{track.plays?.toLocaleString() || 0} plays
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="opacity-0 group-hover:opacity-100"
|
|
>
|
|
<Play className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'tracks' && (
|
|
<div
|
|
className={`animate-fadeIn ${viewMode === 'grid' ? 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-3 gap-4' : 'space-y-2'}`}
|
|
>
|
|
{tracks.length > 0 ? (
|
|
tracks.map((track) => (
|
|
<TrackCard key={track.id} track={track} mode={viewMode} />
|
|
))
|
|
) : (
|
|
<p className="col-span-full text-center text-kodo-content-dim">
|
|
No tracks found.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'playlists' && (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-8 animate-fadeIn">
|
|
{playlists.length > 0 ? (
|
|
playlists.map((playlist) => (
|
|
<PlaylistCard key={playlist.id} playlist={playlist} />
|
|
))
|
|
) : (
|
|
<p className="col-span-full text-center text-kodo-content-dim">
|
|
No playlists found.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'about' && (
|
|
<div className="space-y-8 animate-fadeIn">
|
|
<Card variant="default" className="p-8">
|
|
<h3 className="text-xl font-display font-bold text-white mb-6">
|
|
Biography
|
|
</h3>
|
|
<p className="text-kodo-text-main leading-relaxed mb-6 whitespace-pre-line">
|
|
{profile.bio || 'No biography provided.'}
|
|
</p>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|