veza/apps/web/src/components/views/ProfileView.tsx
senke d7ec8dd6e5 aesthetic-improvements: fix remaining spacing in pages and views (Action 11.5.1.5)
- 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)
2026-01-16 12:03:28 +01:00

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>
);
};