chore: remove production logs in components

This commit is contained in:
senke 2026-01-07 10:31:02 +01:00
parent 81d08a4680
commit 83f12a6e42
264 changed files with 33688 additions and 1129 deletions

View file

@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { StatCard } from '../dashboard/StatCard';
import { Users, DollarSign, Activity, AlertTriangle, HardDrive, ShoppingBag, ShieldAlert, CheckCircle, Loader2 } from 'lucide-react';
import { adminService } from '../../services/adminService';
import { Report } from '../../types';
import { useToast } from '../../context/ToastContext';
import { logger } from '@/utils/logger';
export const AdminDashboardView: React.FC = () => {
const { addToast } = useToast();
const [stats, setStats] = useState<any>({});
const [reports, setReports] = useState<Report[]>([]);
const [uploads, setUploads] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [statsData, reportsData, uploadsData] = await Promise.all([
adminService.getDashboardStats(),
adminService.getModerationQueue('pending'),
adminService.getRecentUploads()
]);
setStats(statsData);
setReports(reportsData);
setUploads(uploadsData);
} catch (e) {
logger.error('Error loading admin dashboard data', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleAction = async (id: string, action: string) => {
await adminService.resolveReport(id, action);
setReports(reports.filter(r => r.id !== id));
addToast(`Report ${action}`, 'success');
};
if (loading) return <div className="flex justify-center py-20"><Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" /></div>;
return (
<div className="space-y-8 animate-fadeIn pb-20">
<h2 className="text-3xl font-display font-bold text-white mb-6">SYSTEM OVERVIEW</h2>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard label="Total Users" value={stats.totalUsers?.toLocaleString()} icon={<Users className="w-5 h-5" />} trend={stats.trends?.users} color="cyan" />
<StatCard label="Monthly Revenue" value={`$${stats.monthlyRevenue?.toLocaleString()}`} icon={<DollarSign className="w-5 h-5" />} trend={stats.trends?.revenue} color="gold" />
<StatCard label="Active Sessions" value={stats.activeSessions?.toLocaleString()} icon={<Activity className="w-5 h-5" />} trend={stats.trends?.sessions} color="lime" />
<StatCard label="Pending Reports" value={stats.pendingReports} icon={<ShieldAlert className="w-5 h-5" />} trend={stats.trends?.reports} color="red" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Chart Area (Mock) */}
<Card variant="default" className="lg:col-span-2">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-white">Traffic & Server Load</h3>
<div className="flex gap-2">
<span className="text-xs text-gray-400 flex items-center gap-1"><div className="w-2 h-2 bg-kodo-cyan rounded-full"></div> Traffic</span>
<span className="text-xs text-gray-400 flex items-center gap-1"><div className="w-2 h-2 bg-kodo-magenta rounded-full"></div> CPU</span>
</div>
</div>
<div className="h-64 flex items-end gap-1">
{Array.from({length: 40}).map((_, i) => (
<div key={i} className="flex-1 flex flex-col justify-end gap-1 h-full group">
<div className="w-full bg-kodo-magenta/30 rounded-t" style={{height: `${Math.random() * 30 + 10}%`}}></div>
<div className="w-full bg-kodo-cyan/30 rounded-t" style={{height: `${Math.random() * 50 + 20}%`}}></div>
</div>
))}
</div>
</Card>
{/* Quick Actions */}
<div className="space-y-6">
<Card variant="default">
<h3 className="font-bold text-white mb-4 text-sm uppercase tracking-wider">Quick Actions</h3>
<div className="grid grid-cols-2 gap-3">
<Button variant="secondary" size="sm" icon={<AlertTriangle className="w-4 h-4" />}>Lockdown</Button>
<Button variant="secondary" size="sm" icon={<HardDrive className="w-4 h-4" />}>Clear Cache</Button>
<Button variant="secondary" size="sm" icon={<ShoppingBag className="w-4 h-4" />}>Sales Rep</Button>
<Button variant="secondary" size="sm" icon={<ShieldAlert className="w-4 h-4" />}>Audit Log</Button>
</div>
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-4 text-sm uppercase tracking-wider">System Health</h3>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Database</span>
<span className="text-kodo-lime font-bold">Healthy</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Storage</span>
<span className="text-kodo-lime font-bold">65% Used</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">API Latency</span>
<span className="text-white font-mono">45ms</span>
</div>
</div>
</Card>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Recent Reports */}
<Card variant="default">
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-white flex items-center gap-2"><AlertTriangle className="w-4 h-4 text-kodo-red" /> Recent Reports</h3>
<Button variant="ghost" size="sm">View All</Button>
</div>
<div className="space-y-1">
{reports.map(report => (
<div key={report.id} className="flex items-center justify-between p-3 bg-kodo-ink rounded border border-kodo-steel/30">
<div>
<div className="text-sm font-bold text-white">{report.targetName}</div>
<div className="text-xs text-gray-400">{report.targetType} {report.reason}</div>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-kodo-lime" onClick={() => handleAction(report.id, 'resolved')}><CheckCircle className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-kodo-red" onClick={() => handleAction(report.id, 'banned')}><AlertTriangle className="w-4 h-4" /></Button>
</div>
</div>
))}
{reports.length === 0 && <div className="text-center text-gray-500 py-4">No pending reports.</div>}
</div>
</Card>
{/* Recent Uploads */}
<Card variant="default">
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-white flex items-center gap-2"><HardDrive className="w-4 h-4 text-kodo-cyan" /> Moderation Queue</h3>
<Button variant="ghost" size="sm">View All</Button>
</div>
<div className="space-y-1">
{uploads.map(upload => (
<div key={upload.id} className="flex items-center justify-between p-3 bg-kodo-ink rounded border border-kodo-steel/30">
<div>
<div className="text-sm font-bold text-white">{upload.name}</div>
<div className="text-xs text-gray-400">{upload.user} {upload.size}</div>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-kodo-lime"><CheckCircle className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-kodo-red"><AlertTriangle className="w-4 h-4" /></Button>
</div>
</div>
))}
</div>
</Card>
</div>
</div>
);
};

View file

@ -0,0 +1,116 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Report } from '../../types';
import { ShieldAlert, CheckCircle, Ban, MessageSquare, Clock, Loader2 } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
import { adminService } from '../../services/adminService';
import { logger } from '@/utils/logger';
export const AdminModerationView: React.FC = () => {
const { addToast } = useToast();
const [queue, setQueue] = useState<Report[]>([]);
const [activeTab, setActiveTab] = useState<'pending' | 'reviewed' | 'resolved'>('pending');
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadQueue = async () => {
setLoading(true);
try {
const data = await adminService.getModerationQueue('all');
setQueue(data);
} catch (e) {
logger.error('Error loading moderation queue', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
loadQueue();
}, []);
const filteredQueue = queue.filter(r =>
activeTab === 'pending' ? r.status === 'pending' :
activeTab === 'reviewed' ? r.status === 'reviewed' :
r.status === 'resolved' || r.status === 'dismissed'
);
const handleAction = async (id: string, action: string) => {
try {
await adminService.resolveReport(id, action);
addToast(`Report ${action}`, 'success');
setQueue(queue.map(r => r.id === id ? { ...r, status: action === 'dismissed' ? 'dismissed' : 'resolved' } as any : r));
} catch (e) {
addToast("Action failed", "error");
}
};
return (
<div className="space-y-6 animate-fadeIn pb-20">
<h2 className="text-3xl font-display font-bold text-white mb-6">MODERATION QUEUE</h2>
<div className="border-b border-kodo-steel flex gap-6 mb-6">
{['pending', 'reviewed', 'resolved'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab as any)}
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${activeTab === tab ? 'border-kodo-red text-white' : 'border-transparent text-gray-500 hover:text-gray-300'}`}
>
{tab} ({queue.filter(r => tab === 'pending' ? r.status === 'pending' : tab === 'reviewed' ? r.status === 'reviewed' : (r.status === 'resolved' || r.status === 'dismissed')).length})
</button>
))}
</div>
<div className="space-y-4">
{loading && <div className="flex justify-center py-20"><Loader2 className="w-8 h-8 text-kodo-cyan animate-spin" /></div>}
{!loading && filteredQueue.length === 0 && (
<div className="text-center py-20 text-gray-500">
<ShieldAlert className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p>All caught up! No reports in this queue.</p>
</div>
)}
{!loading && filteredQueue.map(report => (
<Card key={report.id} variant="default" className="border-l-4 border-l-kodo-red">
<div className="flex flex-col md:flex-row justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<Badge label={report.targetType} variant="terminal" />
<span className="font-bold text-white text-lg">{report.targetName}</span>
<span className="text-xs text-gray-500 font-mono flex items-center gap-1">
<Clock className="w-3 h-3" /> {report.timestamp}
</span>
</div>
<div className="bg-kodo-ink p-3 rounded border border-kodo-steel/50 mb-3">
<div className="text-xs font-bold text-kodo-red uppercase mb-1">Reason: {report.reason}</div>
<p className="text-sm text-gray-300">{report.description}</p>
</div>
<div className="text-xs text-gray-500">Reported by: <span className="text-white">{report.reportedBy}</span></div>
</div>
<div className="flex flex-col gap-2 justify-center min-w-[140px]">
<Button variant="primary" size="sm" className="bg-red-600 hover:bg-red-700 border-red-500 text-white" icon={<Ban className="w-4 h-4" />} onClick={() => handleAction(report.id, 'banned')}>
Ban User
</Button>
<Button variant="secondary" size="sm" icon={<CheckCircle className="w-4 h-4" />} onClick={() => handleAction(report.id, 'resolved')}>
Resolve
</Button>
<Button variant="ghost" size="sm" icon={<MessageSquare className="w-4 h-4" />} onClick={() => addToast("Warning sent")}>
Send Warning
</Button>
<Button variant="ghost" size="sm" className="text-gray-500 hover:text-white" onClick={() => handleAction(report.id, 'dismissed')}>
Dismiss
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Save, AlertTriangle, Server, Activity } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
export const AdminSettingsView: React.FC = () => {
const { addToast } = useToast();
const [maintenance, setMaintenance] = useState(false);
const [uploadLimit, setUploadLimit] = useState(500); // MB
const [announcement, setAnnouncement] = useState('');
const handleSave = () => {
addToast("System settings updated", "success");
};
return (
<div className="space-y-8 animate-fadeIn max-w-4xl pb-20">
<div className="flex justify-between items-center border-b border-kodo-steel/50 pb-6">
<h2 className="text-3xl font-display font-bold text-white">SYSTEM SETTINGS</h2>
<Button variant="primary" icon={<Save className="w-4 h-4" />} onClick={handleSave}>
SAVE CHANGES
</Button>
</div>
{/* General Config */}
<Card variant="default">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<Server className="w-5 h-5 text-kodo-cyan" /> General Configuration
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-bold text-gray-400 mb-2">Upload Limit (MB)</label>
<input
type="number"
className="w-full bg-kodo-ink border border-kodo-steel rounded p-2 text-white outline-none focus:border-kodo-cyan"
value={uploadLimit}
onChange={(e) => setUploadLimit(Number(e.target.value))}
/>
<p className="text-xs text-gray-500 mt-1">Maximum file size for standard users.</p>
</div>
<div>
<label className="block text-sm font-bold text-gray-400 mb-2">Default Storage Region</label>
<select className="w-full bg-kodo-ink border border-kodo-steel rounded p-2 text-white outline-none focus:border-kodo-cyan">
<option>us-east-1 (N. Virginia)</option>
<option>eu-west-1 (Ireland)</option>
<option>ap-northeast-1 (Tokyo)</option>
</select>
</div>
</div>
</Card>
{/* Feature Flags */}
<Card variant="default">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<Activity className="w-5 h-5 text-kodo-magenta" /> Feature Flags
</h3>
<div className="space-y-4">
{['Live Streaming', 'Marketplace Transactions', 'AI Mastering', 'Public Registrations'].map(feature => (
<div key={feature} className="flex items-center justify-between p-3 bg-kodo-ink rounded border border-kodo-steel">
<span className="font-bold text-white">{feature}</span>
<div className="w-10 h-5 bg-kodo-lime rounded-full relative cursor-pointer">
<div className="absolute top-0.5 right-0.5 w-4 h-4 bg-white rounded-full shadow-md"></div>
</div>
</div>
))}
</div>
</Card>
{/* Maintenance */}
<Card variant="default" className="border-kodo-red/30">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-kodo-red" /> Emergency & Maintenance
</h3>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="text-white font-bold">Maintenance Mode</div>
<div className="text-xs text-gray-400">Disable access for non-admin users</div>
</div>
<div
onClick={() => setMaintenance(!maintenance)}
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${maintenance ? 'bg-kodo-red' : 'bg-gray-600'}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all ${maintenance ? 'left-7' : 'left-1'}`}></div>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-400 mb-2">Global Announcement</label>
<textarea
className="w-full bg-kodo-ink border border-kodo-steel rounded p-3 text-white outline-none focus:border-kodo-cyan h-24 resize-none"
placeholder="Message to display on all pages..."
value={announcement}
onChange={(e) => setAnnouncement(e.target.value)}
/>
</div>
</div>
</Card>
</div>
);
};

View file

@ -0,0 +1,137 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { SearchInput } from '../ui/input';
import { UserTableRow } from './UserTableRow';
import { BanUserModal } from './modals/BanUserModal';
import { User } from '../../types';
import { Filter, Download, UserPlus, Loader2 } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
import { userService } from '../../services/userService';
import { logger } from '@/utils/logger';
export const AdminUsersView: React.FC = () => {
const { addToast } = useToast();
const [search, setSearch] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
useEffect(() => {
const loadUsers = async () => {
setLoading(true);
try {
const res = await userService.list();
setUsers(res.users);
} catch (e) {
logger.error('Failed to load users', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
addToast("Failed to load users", "error");
} finally {
setLoading(false);
}
};
loadUsers();
}, []);
const handleBan = (reason: string, _details: string, duration: string) => {
if (!selectedUser) return;
addToast(`Banned ${selectedUser.username} for ${duration}. Reason: ${reason}`, 'success');
setUsers(users.filter(u => u.id !== selectedUser.id)); // Mock remove
setSelectedUser(null);
};
const handleDelete = (user: User) => {
if (confirm(`Are you sure you want to delete ${user.username}? This cannot be undone.`)) {
setUsers(users.filter(u => u.id !== user.id));
addToast(`Deleted user ${user.username}`, 'info');
}
};
const filteredUsers = users.filter(u =>
u.username.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-6 animate-fadeIn pb-20">
<div className="flex flex-col md:flex-row justify-between items-end gap-4">
<div>
<h2 className="text-3xl font-display font-bold text-white mb-2">USER MANAGEMENT</h2>
<p className="text-gray-400 font-mono text-sm">Manage accounts, roles, and permissions.</p>
</div>
<div className="flex gap-3">
<Button variant="ghost" icon={<Download className="w-4 h-4" />} onClick={() => addToast("Exporting CSV...")}>Export</Button>
<Button variant="primary" icon={<UserPlus className="w-4 h-4" />} onClick={() => addToast("Create User Modal")}>Create User</Button>
</div>
</div>
<Card variant="default" className="p-0 overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink/50 flex flex-col md:flex-row gap-4 justify-between items-center">
<div className="w-full md:w-96">
<SearchInput placeholder="Search users by name or email..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" icon={<Filter className="w-3 h-3" />}>Filter Role</Button>
<Button variant="ghost" size="sm" icon={<Filter className="w-3 h-3" />}>Filter Status</Button>
</div>
</div>
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-8 h-8 text-kodo-cyan animate-spin" /></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-kodo-steel bg-kodo-graphite text-xs font-bold text-gray-500 uppercase tracking-wider">
<th className="p-4">User</th>
<th className="p-4">Email</th>
<th className="p-4">Roles</th>
<th className="p-4">Plan</th>
<th className="p-4">Joined</th>
<th className="p-4">Last Login</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-kodo-steel/30">
{filteredUsers.map(user => (
<UserTableRow
key={user.id}
user={user}
onBan={() => setSelectedUser(user)}
onDelete={() => handleDelete(user)}
onEditRole={() => addToast(`Editing role for ${user.username}`)}
/>
))}
{filteredUsers.length === 0 && (
<tr>
<td colSpan={7} className="p-8 text-center text-gray-500">No users found.</td>
</tr>
)}
</tbody>
</table>
</div>
)}
<div className="p-4 border-t border-kodo-steel bg-kodo-ink/30 text-xs text-gray-500 flex justify-between items-center">
<span>Showing {filteredUsers.length} of {users.length} users</span>
<div className="flex gap-2">
<button className="hover:text-white disabled:opacity-50" disabled>Previous</button>
<button className="hover:text-white">Next</button>
</div>
</div>
</Card>
{selectedUser && (
<BanUserModal
username={selectedUser.username}
onClose={() => setSelectedUser(null)}
onConfirm={handleBan}
/>
)}
</div>
);
};

View file

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { User } from '../../types';
import { Badge } from '../ui/badge';
import { MoreVertical, Shield, Ban, Mail, Trash2 } from 'lucide-react';
interface UserTableRowProps {
user: User;
onBan: (user: User) => void;
onDelete: (user: User) => void;
onEditRole: (user: User) => void;
}
export const UserTableRow: React.FC<UserTableRowProps> = ({ user, onBan, onDelete, onEditRole }) => {
const [showMenu, setShowMenu] = useState(false);
const statusColor = {
'online': 'bg-kodo-lime',
'offline': 'bg-gray-500',
'dnd': 'bg-kodo-red',
'idle': 'bg-kodo-gold'
};
return (
<tr className="hover:bg-white/5 transition-colors group relative">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="relative">
<img src={user.avatar} className="w-8 h-8 rounded-full object-cover" />
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 border-2 border-kodo-ink rounded-full ${user.status ? statusColor[user.status] : statusColor.offline}`}></div>
</div>
<div>
<div className="font-bold text-white text-sm">{user.username}</div>
<div className="text-xs text-gray-500 font-mono">{user.id}</div>
</div>
</div>
</td>
<td className="p-4 text-sm text-gray-400">{user.email}</td>
<td className="p-4">
<div className="flex gap-1">
{(user.roles || [user.role]).map((role: string) => (
<Badge key={role} label={role} variant={role === 'Admin' || role === 'admin' ? 'magenta' : 'cyan'} className="scale-90" />
))}
</div>
</td>
<td className="p-4 text-sm text-gray-400">
{user.tier || 'Free'}
</td>
<td className="p-4 text-sm text-gray-400 font-mono">
{user.joinDate || user.created_at}
</td>
<td className="p-4 text-sm text-gray-400 font-mono">
{user.lastLogin || user.last_login_at || 'Never'}
</td>
<td className="p-4 text-right">
<div className="relative">
<button
className="p-1.5 hover:bg-white/10 rounded text-gray-400 hover:text-white"
onClick={(e) => { e.stopPropagation(); setShowMenu(!showMenu); }}
>
<MoreVertical className="w-4 h-4" />
</button>
{showMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)}></div>
<div className="absolute right-0 top-full mt-2 w-48 bg-kodo-graphite border border-kodo-steel rounded-lg shadow-xl z-20 overflow-hidden">
<button className="w-full text-left px-4 py-2.5 text-xs text-gray-300 hover:bg-white/10 flex items-center gap-2" onClick={() => { onEditRole(user); setShowMenu(false); }}>
<Shield className="w-3 h-3" /> Change Role
</button>
<button className="w-full text-left px-4 py-2.5 text-xs text-gray-300 hover:bg-white/10 flex items-center gap-2">
<Mail className="w-3 h-3" /> Send Email
</button>
<div className="h-px bg-kodo-steel/50 my-1"></div>
<button className="w-full text-left px-4 py-2.5 text-xs text-kodo-gold hover:bg-white/10 flex items-center gap-2" onClick={() => { onBan(user); setShowMenu(false); }}>
<Ban className="w-3 h-3" /> Suspend User
</button>
<button className="w-full text-left px-4 py-2.5 text-xs text-kodo-red hover:bg-white/10 flex items-center gap-2" onClick={() => { onDelete(user); setShowMenu(false); }}>
<Trash2 className="w-3 h-3" /> Delete Account
</button>
</div>
</>
)}
</div>
</td>
</tr>
);
};

View file

@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { X, Calendar, ShieldBan } from 'lucide-react';
interface BanUserModalProps {
username: string;
onClose: () => void;
onConfirm: (reason: string, details: string, duration: string) => void;
}
export const BanUserModal: React.FC<BanUserModalProps> = ({ username, onClose, onConfirm }) => {
const [reason, setReason] = useState('Terms of Service Violation');
const [details, setDetails] = useState('');
const [isPermanent, setIsPermanent] = useState(false);
const [duration, setDuration] = useState('7'); // days
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-red rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-red/30 bg-kodo-red/10 flex justify-between items-center">
<h3 className="font-bold text-kodo-red flex items-center gap-2">
<ShieldBan className="w-5 h-5 fill-current" /> Suspend User
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 space-y-4">
<p className="text-gray-300 text-sm">
You are about to suspend <span className="font-bold text-white">{username}</span>. This will restrict their access to the platform.
</p>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Reason</label>
<select
className="w-full bg-kodo-void border border-kodo-steel rounded p-2 text-white focus:border-kodo-red outline-none text-sm"
value={reason}
onChange={(e) => setReason(e.target.value)}
>
<option>Terms of Service Violation</option>
<option>Spam or Bot Activity</option>
<option>Harassment / Hate Speech</option>
<option>Copyright Infringement</option>
<option>Fraudulent Activity</option>
<option>Other</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Details (Internal Note)</label>
<textarea
className="w-full bg-kodo-void border border-kodo-steel rounded p-2 text-white focus:border-kodo-red outline-none text-sm resize-none h-24"
placeholder="Provide context for this ban..."
value={details}
onChange={(e) => setDetails(e.target.value)}
/>
</div>
<div className="flex items-center justify-between p-3 bg-kodo-ink rounded border border-kodo-steel">
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-gray-400" />
<div>
<div className="text-sm font-bold text-white">Ban Duration</div>
<div className="text-xs text-gray-400">{isPermanent ? 'Permanent Ban' : `${duration} Days`}</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs ${!isPermanent ? 'text-white' : 'text-gray-500'}`}>Temp</span>
<div
onClick={() => setIsPermanent(!isPermanent)}
className={`w-10 h-5 rounded-full relative cursor-pointer transition-colors ${isPermanent ? 'bg-kodo-red' : 'bg-gray-600'}`}
>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${isPermanent ? 'left-6' : 'left-1'}`}></div>
</div>
<span className={`text-xs ${isPermanent ? 'text-kodo-red font-bold' : 'text-gray-500'}`}>Perm</span>
</div>
</div>
{!isPermanent && (
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Days</label>
<input
type="number"
min="1"
className="w-full bg-kodo-void border border-kodo-steel rounded p-2 text-white focus:border-kodo-red outline-none text-sm"
value={duration}
onChange={(e) => setDuration(e.target.value)}
/>
</div>
)}
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" className="bg-red-600 hover:bg-red-700 border-red-500 text-white" onClick={() => onConfirm(reason, details, isPermanent ? 'Permanent' : `${duration} days`)}>
Suspend User
</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,136 @@
import React from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { StatCard } from '../dashboard/StatCard';
import { Play, SkipForward, Clock, Users, Map, ArrowLeft, Download } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
interface TrackAnalyticsViewProps {
trackId: string;
onBack: () => void;
}
export const TrackAnalyticsView: React.FC<TrackAnalyticsViewProps> = ({ trackId: _trackId, onBack }) => {
const { addToast } = useToast();
// Mock Track Data
const trackData = {
title: 'Neon Nights',
artist: 'Cyber_Producer',
plays: 15420,
skips: 320,
avgListen: '2:45', // vs 3:45 total
completion: 78,
demographics: { '18-24': 45, '25-34': 30, '35+': 25 },
geo: [
{ country: 'USA', percent: 40 },
{ country: 'Japan', percent: 25 },
{ country: 'Germany', percent: 15 },
{ country: 'UK', percent: 10 },
]
};
return (
<div className="space-y-8 animate-fadeIn pb-20">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={onBack}><ArrowLeft className="w-5 h-5" /></Button>
<div>
<h2 className="text-2xl font-bold text-white">{trackData.title}</h2>
<p className="text-gray-400 text-sm">Analytics Report</p>
</div>
</div>
<Button variant="secondary" icon={<Download className="w-4 h-4" />} onClick={() => addToast("Report downloaded")}>Export CSV</Button>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
label="Total Plays"
value={trackData.plays.toLocaleString()}
icon={<Play className="w-5 h-5" />}
color="cyan"
trend={12}
sparklineData={[10, 15, 12, 20, 25, 30, 28, 35, 40]}
/>
<StatCard
label="Completion Rate"
value={`${trackData.completion}%`}
icon={<Clock className="w-5 h-5" />}
color="lime"
trend={2.5}
/>
<StatCard
label="Skip Rate"
value={`${((trackData.skips / trackData.plays) * 100).toFixed(1)}%`}
icon={<SkipForward className="w-5 h-5" />}
color="red"
trend={-0.5} // Negative is good for skip rate, logic in StatCard handles green/red based on +/-. might need tweak for inverse metrics
/>
<StatCard
label="Avg Listen Time"
value={trackData.avgListen}
icon={<Users className="w-5 h-5" />}
color="gold"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Plays Over Time Graph Placeholder */}
<Card variant="default">
<h3 className="font-bold text-white mb-6">Plays Over Time (30 Days)</h3>
<div className="h-64 flex items-end gap-2 px-4 pb-4">
{Array.from({length: 30}).map((_, i) => {
const h = Math.random() * 100;
return (
<div key={i} className="flex-1 bg-kodo-cyan/20 hover:bg-kodo-cyan/50 transition-colors rounded-t relative group" style={{height: `${h}%`}}>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 bg-black text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 pointer-events-none">
{Math.floor(h * 10)} plays
</div>
</div>
);
})}
</div>
</Card>
{/* Demographics & Geo */}
<div className="space-y-6">
<Card variant="default">
<h3 className="font-bold text-white mb-4 flex items-center gap-2"><Map className="w-4 h-4 text-kodo-magenta" /> Top Locations</h3>
<div className="space-y-3">
{trackData.geo.map(g => (
<div key={g.country} className="flex items-center gap-4">
<div className="w-16 text-sm text-gray-400">{g.country}</div>
<div className="flex-1 h-2 bg-kodo-steel rounded-full overflow-hidden">
<div className="h-full bg-kodo-magenta" style={{width: `${g.percent}%`}}></div>
</div>
<div className="w-12 text-right text-sm font-bold text-white">{g.percent}%</div>
</div>
))}
</div>
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-4 flex items-center gap-2"><Users className="w-4 h-4 text-kodo-gold" /> Listeners Age</h3>
<div className="flex gap-2 h-8">
{Object.entries(trackData.demographics).map(([range, val]) => (
<div key={range} className="h-full first:rounded-l last:rounded-r relative group" style={{width: `${val}%`, backgroundColor: range === '18-24' ? '#66FCF1' : range === '25-34' ? '#36E5D1' : '#1F2833'}}>
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-black opacity-0 group-hover:opacity-100 transition-opacity">
{range}
</div>
</div>
))}
</div>
<div className="flex justify-between text-xs text-gray-500 mt-2">
{Object.entries(trackData.demographics).map(([range, val]) => (
<span key={range}>{range}: {val}%</span>
))}
</div>
</Card>
</div>
</div>
</div>
);
};

View file

@ -1,5 +1,6 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/features/auth/hooks/useAuth';
import { useAuthStore } from '@/features/auth/store/authStore';
import { TokenStorage } from '@/services/tokenStorage';
import { useEffect, useState } from 'react';
@ -16,22 +17,24 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, user } = useAuth();
const [isChecking, setIsChecking] = useState(true);
const hasToken = !!TokenStorage.getAccessToken();
const { isLoading } = useAuthStore();
useEffect(() => {
// Donner un peu de temps pour que le store se hydrate
const timer = setTimeout(() => {
setIsChecking(false);
}, 100);
}, 200);
return () => clearTimeout(timer);
}, []);
// Si on vérifie encore, attendre un peu
if (isChecking) {
// Si on vérifie encore ou si le store charge, attendre un peu
if (isChecking || isLoading) {
return null; // ou un spinner
}
// Vérifier à la fois le store et le token
// Si le token existe mais le store n'est pas encore hydraté, on attend
// Priorité: isAuthenticated du store > (hasToken && user)
const isAuth = isAuthenticated || (hasToken && user);
if (!isAuth) {

View file

@ -0,0 +1,54 @@
import React from 'react';
import { CartItem as CartItemType } from '../../types';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Trash2, Tag } from 'lucide-react';
interface CartItemProps {
item: CartItemType;
onRemove: (id: string) => void;
}
export const CartItem: React.FC<CartItemProps> = ({ item, onRemove }) => {
const price = item.selectedLicense ? item.selectedLicense.price : item.price;
const licenseName = item.selectedLicense ? item.selectedLicense.name : 'Standard';
return (
<Card variant="default" className="flex flex-col md:flex-row items-center gap-4 p-4 group hover:border-kodo-cyan/30 transition-all">
{/* Thumbnail */}
<div className="w-full md:w-24 h-24 rounded-lg overflow-hidden flex-shrink-0 bg-gray-800">
<img src={item.coverUrl} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" alt={item.title} />
</div>
{/* Info */}
<div className="flex-1 w-full text-center md:text-left">
<h4 className="font-bold text-white text-lg">{item.title}</h4>
<p className="text-gray-400 text-sm mb-2">{item.author}</p>
<div className="flex items-center justify-center md:justify-start gap-2 text-xs">
<span className="px-2 py-1 bg-kodo-ink border border-kodo-steel rounded flex items-center gap-1">
<Tag className="w-3 h-3 text-kodo-cyan" /> {licenseName} License
</span>
<span className="px-2 py-1 bg-kodo-ink border border-kodo-steel rounded uppercase font-bold text-gray-500">
{item.type}
</span>
</div>
</div>
{/* Price & Actions */}
<div className="flex flex-row md:flex-col items-center gap-4 md:gap-2 w-full md:w-auto justify-between">
<div className="text-xl font-mono font-bold text-white">
${price.toFixed(2)}
</div>
<Button
variant="ghost"
size="sm"
className="text-gray-500 hover:text-kodo-red hover:bg-kodo-red/10"
onClick={() => onRemove(item.cartId)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
);
};

View file

@ -0,0 +1,91 @@
import React from 'react';
import { Card } from '../ui/card';
import { Tag, ShieldCheck } from 'lucide-react';
interface OrderSummaryProps {
subtotal: number;
taxRate?: number;
discount?: { code: string; amount: number; type: 'percent' | 'fixed' };
currency?: string;
onCheckout?: () => void;
loading?: boolean;
}
export const OrderSummary: React.FC<OrderSummaryProps> = ({
subtotal,
taxRate = 0.08,
discount,
currency = 'USD',
onCheckout,
loading = false
}) => {
const discountAmount = discount
? (discount.type === 'percent' ? subtotal * (discount.amount / 100) : discount.amount)
: 0;
const taxableAmount = Math.max(0, subtotal - discountAmount);
const taxAmount = taxableAmount * taxRate;
const total = taxableAmount + taxAmount;
const formatPrice = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
};
return (
<div className="space-y-6">
<Card variant="gaming" className="p-6">
<h3 className="font-bold text-white mb-6 uppercase tracking-wider text-sm border-b border-gray-700 pb-2">Order Summary</h3>
<div className="space-y-3 text-sm mb-6">
<div className="flex justify-between text-gray-400">
<span>Subtotal</span>
<span className="text-white font-mono">{formatPrice(subtotal)}</span>
</div>
{discount && (
<div className="flex justify-between text-kodo-lime">
<span className="flex items-center gap-2"><Tag className="w-3 h-3" /> Discount ({discount.code})</span>
<span className="font-mono">-{formatPrice(discountAmount)}</span>
</div>
)}
<div className="flex justify-between text-gray-400">
<span>Estimated Tax ({(taxRate * 100).toFixed(0)}%)</span>
<span className="text-white font-mono">{formatPrice(taxAmount)}</span>
</div>
</div>
<div className="border-t border-gray-700 pt-4 mb-6">
<div className="flex justify-between items-end">
<span className="font-bold text-white">Total</span>
<span className="text-2xl font-mono font-bold text-kodo-cyan">{formatPrice(total)}</span>
</div>
</div>
{onCheckout && (
<button
className="w-full h-12 bg-gradient-to-r from-kodo-cyan-dim to-kodo-cyan text-kodo-void hover:shadow-lg hover:shadow-kodo-cyan/20 border border-transparent font-bold tracking-wide rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
onClick={onCheckout}
disabled={loading}
>
{loading ? 'PROCESSING...' : 'PROCEED TO CHECKOUT'}
</button>
)}
</Card>
<div className="bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50 flex items-start gap-3">
<ShieldCheck className="w-5 h-5 text-kodo-gold flex-shrink-0" />
<div>
<h4 className="font-bold text-white text-sm mb-1">Secure Checkout</h4>
<p className="text-xs text-gray-400 leading-relaxed">
Transactions are encrypted. Downloads are available immediately after payment.
</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Product } from '../../types';
import { useCart } from '../../context/CartContext';
import { Heart, ShoppingCart, Trash2, Play, Pause, Zap } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
// Mock Wishlist Data
const MOCK_WISHLIST: Product[] = [
{ id: 'w1', title: 'Analog Dreams Vol. 2', type: 'sample_pack', price: 24.99, currency: 'USD', rating: 4.8, coverUrl: 'https://picsum.photos/id/40/300/300', author: 'Vintage Synths', description: 'Warm analog pads and leads.', features: [], licenses: [] },
{ id: 'w2', title: 'Tech House Essentials', type: 'preset', price: 19.99, currency: 'USD', rating: 4.5, coverUrl: 'https://picsum.photos/id/45/300/300', author: 'Club Ready', description: 'Floor filling serum presets.', features: [], licenses: [] },
{ id: 'w3', title: 'Cinematic FX', type: 'sample_pack', price: 34.50, currency: 'USD', rating: 5.0, coverUrl: 'https://picsum.photos/id/50/300/300', author: 'Sound Design Co', isHot: true, description: 'Impacts, risers, and drops.', features: [], licenses: [] },
];
export const WishlistView: React.FC = () => {
const { addToCart } = useCart();
const { addToast } = useToast();
const [wishlist, setWishlist] = useState<Product[]>(MOCK_WISHLIST);
const [playingPreview, setPlayingPreview] = useState<string | null>(null);
const handleRemove = (id: string) => {
setWishlist(prev => prev.filter(p => p.id !== id));
addToast("Removed from wishlist", "info");
};
const handleAddToCart = (product: Product) => {
addToCart(product);
handleRemove(product.id);
};
const handleAddAll = () => {
wishlist.forEach(p => addToCart(p));
setWishlist([]);
addToast("All items moved to cart", "success");
};
if (wishlist.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center animate-fadeIn">
<div className="w-24 h-24 bg-kodo-ink rounded-full flex items-center justify-center mb-6 border-2 border-dashed border-kodo-steel">
<Heart className="w-10 h-10 text-gray-600" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Your wishlist is empty</h2>
<p className="text-gray-400 max-w-sm">Save items you want to listen to later or purchase in the future.</p>
</div>
);
}
return (
<div className="animate-fadeIn max-w-6xl mx-auto pb-20">
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4 mb-8">
<div>
<h1 className="text-3xl font-display font-bold text-white mb-2">WISHLIST</h1>
<p className="text-gray-400 font-mono text-sm">{wishlist.length} saved items</p>
</div>
<Button variant="primary" icon={<ShoppingCart className="w-4 h-4" />} onClick={handleAddAll}>
ADD ALL TO CART
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{wishlist.map(product => (
<Card key={product.id} variant="default" className="p-4 group hover:border-kodo-cyan/50 transition-all">
<div className="flex gap-4">
<div className="relative w-24 h-24 bg-gray-800 rounded-lg overflow-hidden flex-shrink-0">
<img src={product.coverUrl} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
<div
className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => setPlayingPreview(playingPreview === product.id ? null : product.id)}
>
{playingPreview === product.id ? <Pause className="w-8 h-8 text-white" /> : <Play className="w-8 h-8 text-white fill-current" />}
</div>
{product.isHot && <div className="absolute top-1 left-1 bg-kodo-gold text-black text-[9px] font-bold px-1.5 py-0.5 rounded"><Zap className="w-2 h-2 inline" /> HOT</div>}
</div>
<div className="flex-1 min-w-0 flex flex-col justify-between">
<div>
<h3 className="font-bold text-white truncate">{product.title}</h3>
<p className="text-xs text-gray-400 truncate">{product.author}</p>
<p className="text-xs text-gray-500 mt-1 capitalize">{product.type}</p>
</div>
<div className="text-lg font-mono font-bold text-kodo-cyan">
${product.price}
</div>
</div>
</div>
<div className="flex gap-2 mt-4 pt-4 border-t border-kodo-steel/30">
<Button variant="secondary" size="sm" className="flex-1" onClick={() => handleAddToCart(product)}>
Add to Cart
</Button>
<Button variant="ghost" size="icon" className="text-gray-500 hover:text-kodo-red" onClick={() => handleRemove(product.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,68 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { X, Tag, Check, AlertCircle } from 'lucide-react';
interface PromoCodeModalProps {
onClose: () => void;
onApply: (discountPercent: number, code: string) => void;
}
export const PromoCodeModal: React.FC<PromoCodeModalProps> = ({ onClose, onApply }) => {
const [code, setCode] = useState('');
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
const handleApply = () => {
// Mock validation
if (code.toUpperCase() === 'VEZA20') {
setStatus('success');
setTimeout(() => {
onApply(20, 'VEZA20');
onClose();
}, 1000);
} else {
setStatus('error');
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-sm bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<Tag className="w-4 h-4 text-kodo-cyan" /> Add Promo Code
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 space-y-4">
<Input
placeholder="Enter code (e.g. VEZA20)"
value={code}
onChange={(e) => { setCode(e.target.value); setStatus('idle'); }}
className={status === 'error' ? 'border-kodo-red focus:border-kodo-red' : ''}
/>
{status === 'error' && (
<div className="flex items-center gap-2 text-xs text-kodo-red animate-shake">
<AlertCircle className="w-3 h-3" /> Invalid promo code
</div>
)}
{status === 'success' && (
<div className="flex items-center gap-2 text-xs text-kodo-lime animate-fadeIn">
<Check className="w-3 h-3" /> Code applied! 20% Off
</div>
)}
<Button variant="primary" className="w-full" onClick={handleApply} disabled={!code}>
Apply Discount
</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,76 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { X, RefreshCcw, UploadCloud } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface RefundRequestModalProps {
orderId: string;
onClose: () => void;
}
export const RefundRequestModal: React.FC<RefundRequestModalProps> = ({ orderId, onClose }) => {
const { addToast } = useToast();
const [reason, setReason] = useState('Duplicate Purchase');
const [details, setDetails] = useState('');
const handleSubmit = () => {
addToast(`Refund request submitted for Order #${orderId}`, "success");
onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-lg bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<RefreshCcw className="w-4 h-4 text-kodo-orange" /> Request Refund
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-gray-400">
Refund requests are subject to approval. Please provide details below for Order <span className="font-mono text-white">#{orderId}</span>.
</p>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Reason</label>
<select
className="w-full bg-kodo-void border border-kodo-steel rounded-lg px-4 py-2.5 text-white focus:border-kodo-cyan outline-none"
value={reason}
onChange={(e) => setReason(e.target.value)}
>
<option>Duplicate Purchase</option>
<option>Accidental Purchase</option>
<option>Quality Issue / Corrupted File</option>
<option>Other</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Details</label>
<textarea
className="w-full bg-kodo-void border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-cyan outline-none text-sm resize-none h-24"
placeholder="Please explain why you are requesting a refund..."
value={details}
onChange={(e) => setDetails(e.target.value)}
/>
</div>
<div className="border-2 border-dashed border-kodo-steel rounded-lg p-6 flex flex-col items-center justify-center text-gray-500 hover:text-white hover:border-gray-500 cursor-pointer transition-colors">
<UploadCloud className="w-8 h-8 mb-2" />
<span className="text-xs font-bold uppercase">Upload Evidence (Optional)</span>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit}>Submit Request</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,90 @@
import React from 'react';
import { Card } from '../ui/card';
import { ArrowUp, ArrowDown } from 'lucide-react';
interface StatCardProps {
label: string;
value: string | number;
icon: React.ReactNode;
trend?: string | number; // String like "+12%" or raw number
color?: 'cyan' | 'magenta' | 'lime' | 'gold' | 'red';
sparklineData?: number[];
}
export const StatCard: React.FC<StatCardProps> = ({ label, value, icon, trend, color = 'cyan', sparklineData }) => {
const colorMap = {
cyan: 'text-kodo-cyan',
magenta: 'text-kodo-magenta',
lime: 'text-kodo-lime',
gold: 'text-kodo-gold',
red: 'text-kodo-red'
};
const bgMap = {
cyan: 'bg-kodo-cyan/10',
magenta: 'bg-kodo-magenta/10',
lime: 'bg-kodo-lime/10',
gold: 'bg-kodo-gold/10',
red: 'bg-kodo-red/10'
};
const renderSparkline = (data: number[]) => {
if (!data || data.length < 2) return null;
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const width = 100;
const height = 40;
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - ((d - min) / range) * height;
return `${x},${y}`;
}).join(' ');
return (
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} className="opacity-50 overflow-visible">
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
};
const isPositive = typeof trend === 'string' ? !trend.startsWith('-') : (trend || 0) >= 0;
const trendValue = typeof trend === 'number' ? `${Math.abs(trend)}%` : trend;
return (
<Card variant="default" className="flex flex-col justify-between h-full p-5 hover:border-opacity-100 transition-all relative overflow-hidden">
<div className="flex justify-between items-start mb-2 relative z-10">
<div>
<p className="text-xs font-mono text-gray-400 uppercase tracking-widest mb-1">{label}</p>
<h3 className="text-2xl font-display font-bold text-white">{value}</h3>
</div>
<div className={`p-2 rounded-lg ${bgMap[color]} ${colorMap[color]}`}>
{icon}
</div>
</div>
<div className="relative z-10 flex items-end justify-between mt-2">
{trend && (
<div className={`flex items-center gap-1 text-xs font-bold ${isPositive ? 'text-kodo-lime' : 'text-kodo-red'}`}>
{isPositive ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
{trendValue} <span className="text-gray-500 font-normal">vs last period</span>
</div>
)}
</div>
{sparklineData && (
<div className={`absolute bottom-0 left-0 right-0 h-12 ${colorMap[color]} opacity-20 pointer-events-none`}>
{renderSparkline(sparklineData)}
</div>
)}
</Card>
);
};

View file

@ -0,0 +1,147 @@
import React, { useState, useEffect } from 'react';
import { Play, Heart, MoreHorizontal, AlertCircle, BarChart3 } from 'lucide-react';
import { Button } from '../ui/button';
import { Track } from '../../types';
import { useAudio } from '../../context/AudioContext';
import { useToast } from '../../context/ToastContext';
import { trackService } from '../../services/trackService';
import { logger } from '@/utils/logger';
export const TrackList: React.FC = () => {
const { playTrack, currentTrack, isPlaying, togglePlay } = useAudio();
const { addToast } = useToast();
const [tracks, setTracks] = useState<Track[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
const loadTracks = async () => {
try {
setLoading(true);
// Fetch trending/top tracks for the dashboard
const response = await trackService.list({ limit: 5, sort_by: 'play_count' });
setTracks(response.tracks);
} catch (err) {
logger.error('Failed to load tracks', {
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
setError(true);
} finally {
setLoading(false);
}
};
loadTracks();
}, []);
const handlePlay = (track: Track) => {
if (currentTrack?.id === track.id) {
togglePlay();
} else {
playTrack(track, tracks);
}
};
const handleLike = async (e: React.MouseEvent, track: Track) => {
e.stopPropagation();
try {
await trackService.like(track.id);
addToast(`Liked ${track.title}`, 'success');
} catch (e) {
addToast("Action failed", "error");
}
};
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-16 bg-kodo-ink/50 animate-pulse rounded-xl border border-kodo-steel/30"></div>
))}
</div>
);
}
if (error) {
return (
<div className="p-6 text-center border border-kodo-red/30 bg-kodo-red/10 rounded-xl text-kodo-red">
<AlertCircle className="w-6 h-6 mx-auto mb-2" />
<p className="text-sm">Unable to load trending audio.</p>
<Button variant="ghost" size="sm" className="mt-2" onClick={() => window.location.reload()}>Retry</Button>
</div>
);
}
if (tracks.length === 0) {
return (
<div className="text-gray-500 text-center py-10 bg-kodo-ink/30 rounded-xl border border-dashed border-kodo-steel">
<BarChart3 className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No tracks trending right now.</p>
</div>
);
}
return (
<div className="space-y-2">
{tracks.map((track, i) => {
const isCurrent = currentTrack?.id === track.id;
return (
<div
key={track.id}
className={`
group flex items-center gap-4 p-3 rounded-xl transition-all border cursor-pointer relative overflow-hidden
${isCurrent ? 'bg-kodo-cyan/10 border-kodo-cyan/30' : 'bg-kodo-ink border-transparent hover:border-kodo-steel/50 hover:bg-kodo-ink/80'}
`}
onClick={() => handlePlay(track)}
>
{/* Active Indicator Bar */}
{isCurrent && <div className="absolute left-0 top-0 bottom-0 w-1 bg-kodo-cyan"></div>}
<div className="w-8 text-center text-gray-500 font-mono text-xs font-bold pl-2">
{isCurrent && isPlaying ? (
<div className="flex gap-0.5 justify-center items-end h-3">
<div className="w-0.5 bg-kodo-cyan h-full animate-[bounce_1s_infinite]"></div>
<div className="w-0.5 bg-kodo-cyan h-2/3 animate-[bounce_1.2s_infinite]"></div>
<div className="w-0.5 bg-kodo-cyan h-full animate-[bounce_0.8s_infinite]"></div>
</div>
) : (
<span className="group-hover:hidden text-gray-600">{i + 1}</span>
)}
<Play className={`w-4 h-4 mx-auto fill-current hidden group-hover:block ${isCurrent ? 'text-kodo-cyan' : 'text-white'}`} />
</div>
<div className="relative w-12 h-12 rounded-lg overflow-hidden flex-shrink-0 shadow-lg">
<img src={track.coverUrl || track.cover_art_path || ''} className="w-full h-full object-cover" alt={track.title} />
{isCurrent && <div className="absolute inset-0 bg-kodo-cyan/20 ring-1 ring-inset ring-kodo-cyan"></div>}
</div>
<div className="flex-1 min-w-0">
<h4 className={`font-bold text-sm truncate ${isCurrent ? 'text-kodo-cyan' : 'text-white'}`}>{track.title}</h4>
<p className="text-xs text-gray-400 truncate hover:underline">{track.artist}</p>
</div>
<div className="hidden md:flex items-center gap-6 text-gray-500 text-xs font-medium">
<span className="flex items-center gap-1.5 w-16 justify-end">
<Play className="w-3 h-3" /> {(track.plays || track.play_count) > 1000 ? `${((track.plays || track.play_count)/1000).toFixed(1) }k` : (track.plays || track.play_count)}
</span>
<span className="flex items-center gap-1.5 w-12 justify-end font-mono">
{track.duration}
</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8 hover:text-kodo-magenta" onClick={(e) => handleLike(e, track)}>
<Heart className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="w-4 h-4" />
</Button>
</div>
</div>
);
})}
</div>
);
};

View file

@ -26,6 +26,9 @@ export interface TableProps<T> {
emptyMessage?: string;
className?: string;
rowClassName?: (row: T, index: number) => string;
// CRITIQUE FIX #40: Ajouter aria-label pour l'accessibilité
'aria-label'?: string;
'aria-labelledby'?: string;
}
/**
@ -44,6 +47,8 @@ export function Table<T extends Record<string, any>>({
emptyMessage = 'Aucune donnée disponible',
className,
rowClassName,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
}: TableProps<T>) {
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [sortColumn, setSortColumn] = useState<string | null>(null);
@ -188,7 +193,12 @@ export function Table<T extends Record<string, any>>({
<div className={cn('w-full', className)}>
<div className="rounded-md border">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
{/* CRITIQUE FIX #40: Ajouter aria-label pour l'accessibilité */}
<table
className="w-full border-collapse"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
>
<thead>
<tr className="border-b bg-muted/50">
{selectable && (

View file

@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Play, RotateCcw, Copy } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
const ENDPOINTS = [
{ method: 'GET', path: '/v1/user/profile', desc: 'Get current user profile' },
{ method: 'GET', path: '/v1/tracks', desc: 'List tracks' },
{ method: 'POST', path: '/v1/tracks/upload', desc: 'Upload a new track' },
{ method: 'GET', path: '/v1/sales/history', desc: 'Get sales history' },
];
export const APIPlaygroundView: React.FC = () => {
const { addToast } = useToast();
const [selectedEndpoint, setSelectedEndpoint] = useState(ENDPOINTS[0]);
const [params, setParams] = useState('{\n "limit": 10,\n "offset": 0\n}');
const [response, setResponse] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSend = () => {
setLoading(true);
setResponse(null);
// Simulate network request
setTimeout(() => {
setLoading(false);
setResponse(JSON.stringify({
status: 200,
data: {
message: "Success",
timestamp: new Date().toISOString(),
result: [
{ id: 1, name: "Sample Item" },
{ id: 2, name: "Another Item" }
]
}
}, null, 2));
addToast("Request successful", "success");
}, 800);
};
return (
<div className="space-y-6 animate-fadeIn pb-20">
<h2 className="text-2xl font-bold text-white mb-6">API PLAYGROUND</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Request Builder */}
<div className="space-y-4">
<Card variant="default">
<h3 className="font-bold text-white mb-4">Request</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Endpoint</label>
<select
className="w-full bg-kodo-ink border border-kodo-steel rounded p-3 text-white focus:border-kodo-cyan outline-none font-mono text-sm"
value={selectedEndpoint.path}
onChange={(e) => {
const ep = ENDPOINTS.find(p => p.path === e.target.value);
if(ep) setSelectedEndpoint(ep);
}}
>
{ENDPOINTS.map(ep => (
<option key={ep.path} value={ep.path}>{ep.method} {ep.path}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">{selectedEndpoint.desc}</p>
</div>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Body (JSON)</label>
<textarea
className="w-full bg-kodo-ink border border-kodo-steel rounded p-3 text-white focus:border-kodo-cyan outline-none font-mono text-xs h-48 resize-none"
value={params}
onChange={(e) => setParams(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setParams('{}')} icon={<RotateCcw className="w-4 h-4" />}>Reset</Button>
<Button variant="primary" onClick={handleSend} disabled={loading} icon={<Play className="w-4 h-4 fill-current" />}>
{loading ? 'Sending...' : 'Send Request'}
</Button>
</div>
</div>
</Card>
</div>
{/* Response Viewer */}
<div className="space-y-4 h-full">
<Card variant="gaming" className="h-full flex flex-col">
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-white">Response</h3>
{response && (
<div className="flex gap-2">
<span className="text-xs font-bold text-kodo-lime bg-kodo-lime/10 px-2 py-1 rounded">200 OK</span>
<span className="text-xs font-bold text-gray-400 bg-white/10 px-2 py-1 rounded">45ms</span>
</div>
)}
</div>
<div className="flex-1 bg-black/30 rounded border border-kodo-steel/50 p-4 relative group">
{response ? (
<>
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap overflow-auto h-full max-h-[500px]">
{response}
</pre>
<button
className="absolute top-2 right-2 p-2 bg-gray-800 rounded text-gray-400 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => { navigator.clipboard.writeText(response); addToast("Copied JSON"); }}
>
<Copy className="w-4 h-4" />
</button>
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<p className="text-sm">Waiting for request...</p>
</div>
)}
</div>
</Card>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,133 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { StatCard } from '../dashboard/StatCard';
import { CreateAPIKeyModal } from './modals/CreateAPIKeyModal';
import { Key, Activity, Globe, Plus, Trash2, Eye, ExternalLink, Loader2 } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
import { developerService } from '../../services/developerService';
import { logger } from '@/utils/logger';
interface ApiKey {
id: string;
name: string;
prefix: string;
created: string;
lastUsed: string;
status: 'active' | 'revoked';
}
export const DeveloperDashboardView: React.FC = () => {
const { addToast } = useToast();
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<any>({});
const [showCreateModal, setShowCreateModal] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [keysData, statsData] = await Promise.all([
developerService.listKeys(),
developerService.getStats()
]);
setKeys(keysData);
setStats(statsData);
} catch (e) {
logger.error('Error loading developer dashboard data', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleCreateKey = async (data: { name: string, scopes: string[] }) => {
try {
const newKey = await developerService.createKey(data);
setKeys([newKey, ...keys]);
addToast("API Key created successfully", "success");
} catch (e) {
addToast("Failed to create API key", "error");
}
};
const handleRevoke = async (id: string) => {
if (confirm('Are you sure you want to revoke this key?')) {
await developerService.revokeKey(id);
setKeys(keys.filter(k => k.id !== id));
addToast("API Key revoked", "info");
}
};
if (loading) return <div className="flex justify-center py-20"><Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" /></div>;
return (
<div className="space-y-8 animate-fadeIn pb-20">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-end gap-4 border-b border-kodo-steel/50 pb-6">
<div>
<h1 className="text-3xl font-display font-bold text-white mb-2">DEVELOPER PORTAL</h1>
<p className="text-gray-400 font-mono text-sm">Build on top of the Veza Platform.</p>
</div>
<div className="flex gap-3">
<Button variant="secondary" icon={<ExternalLink className="w-4 h-4" />} onClick={() => window.open('https://docs.veza.io', '_blank')}>Documentation</Button>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => setShowCreateModal(true)}>Create API Key</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard label="API Requests (24h)" value={stats.requests_24h?.toLocaleString() || 0} icon={<Activity className="w-5 h-5" />} trend={5.2} color="cyan" />
<StatCard label="Avg Latency" value={`${stats.avg_latency || 0}ms`} icon={<Globe className="w-5 h-5" />} trend={-12} color="lime" />
<StatCard label="Active Keys" value={keys.length} icon={<Key className="w-5 h-5" />} color="gold" />
</div>
{/* API Keys List */}
<Card variant="default">
<h3 className="font-bold text-white mb-6">Active API Keys</h3>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="text-xs text-gray-500 uppercase border-b border-kodo-steel/50">
<th className="pb-3 pl-4">Name</th>
<th className="pb-3">Key Prefix</th>
<th className="pb-3">Created</th>
<th className="pb-3">Last Used</th>
<th className="pb-3 text-right pr-4">Actions</th>
</tr>
</thead>
<tbody className="text-sm">
{keys.map(key => (
<tr key={key.id} className="border-b border-kodo-steel/20 hover:bg-white/5 transition-colors">
<td className="py-4 pl-4 font-bold text-white">{key.name}</td>
<td className="py-4 font-mono text-kodo-gold">{key.prefix}</td>
<td className="py-4 text-gray-400">{key.created}</td>
<td className="py-4 text-gray-300">{key.lastUsed}</td>
<td className="py-4 text-right pr-4 flex justify-end gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-400 hover:text-white" onClick={() => addToast("Full key hidden for security")}>
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-kodo-red hover:bg-kodo-red/10" onClick={() => handleRevoke(key.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</td>
</tr>
))}
{keys.length === 0 && (
<tr><td colSpan={5} className="py-8 text-center text-gray-500">No active API keys. Create one to get started.</td></tr>
)}
</tbody>
</table>
</div>
</Card>
{showCreateModal && <CreateAPIKeyModal onClose={() => setShowCreateModal(false)} onCreate={handleCreateKey} />}
</div>
);
};

View file

@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Radio, Plus, Trash2, Zap, AlertTriangle } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
interface Webhook {
id: string;
url: string;
events: string[];
status: 'active' | 'failed';
lastTriggered: string;
}
const MOCK_WEBHOOKS: Webhook[] = [
{ id: 'w1', url: 'https://api.myapp.com/veza-hook', events: ['track.upload', 'user.update'], status: 'active', lastTriggered: '2 hours ago' },
{ id: 'w2', url: 'https://hooks.slack.com/services/T000...', events: ['sales.new'], status: 'failed', lastTriggered: '1 day ago' },
];
export const WebhooksView: React.FC = () => {
const { addToast } = useToast();
const [webhooks, setWebhooks] = useState<Webhook[]>(MOCK_WEBHOOKS);
const [newUrl, setNewUrl] = useState('');
const handleCreate = () => {
if (!newUrl) return;
const newHook: Webhook = {
id: `w-${Date.now()}`,
url: newUrl,
events: ['all'],
status: 'active',
lastTriggered: 'Never'
};
setWebhooks([...webhooks, newHook]);
setNewUrl('');
addToast("Webhook created", "success");
};
const handleTest = (_id: string) => {
addToast(`Sending test payload to webhook...`, "info");
};
const handleDelete = (id: string) => {
setWebhooks(webhooks.filter(w => w.id !== id));
addToast("Webhook deleted", "info");
};
return (
<div className="space-y-8 animate-fadeIn pb-20">
<div>
<h2 className="text-2xl font-bold text-white mb-2">WEBHOOKS</h2>
<p className="text-gray-400 font-mono text-sm">Subscribe to real-time events.</p>
</div>
<Card variant="default">
<h3 className="font-bold text-white mb-4 flex items-center gap-2">
<Plus className="w-4 h-4 text-kodo-cyan" /> Add Endpoint
</h3>
<div className="flex gap-4">
<Input placeholder="https://your-domain.com/webhook" value={newUrl} onChange={(e) => setNewUrl(e.target.value)} className="flex-1" />
<Button variant="primary" onClick={handleCreate} disabled={!newUrl}>Create</Button>
</div>
</Card>
<div className="space-y-4">
{webhooks.map(hook => (
<Card key={hook.id} variant="gaming" className="flex flex-col md:flex-row items-start md:items-center justify-between p-4 gap-4">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-full ${hook.status === 'active' ? 'bg-kodo-lime/10 text-kodo-lime' : 'bg-kodo-red/10 text-kodo-red'}`}>
{hook.status === 'active' ? <Radio className="w-5 h-5" /> : <AlertTriangle className="w-5 h-5" />}
</div>
<div>
<div className="font-bold text-white font-mono text-sm break-all">{hook.url}</div>
<div className="text-xs text-gray-400 mt-1 flex flex-wrap gap-2">
<span className="text-gray-500">Events:</span>
{hook.events.map(e => <span key={e} className="bg-white/5 px-1.5 rounded text-gray-300">{e}</span>)}
<span className="text-gray-500 ml-2">Last Triggered: {hook.lastTriggered}</span>
</div>
</div>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button variant="ghost" size="sm" className="flex-1 md:flex-none border border-kodo-steel" onClick={() => handleTest(hook.id)}>
<Zap className="w-4 h-4 mr-2" /> Test
</Button>
<Button variant="ghost" size="sm" className="flex-1 md:flex-none text-kodo-red hover:bg-kodo-red/10" onClick={() => handleDelete(hook.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { X, Key, Copy, Check } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface CreateAPIKeyModalProps {
onClose: () => void;
onCreate: (keyData: { name: string, scopes: string[] }) => void;
}
const SCOPES = [
{ id: 'user.read', label: 'Read User Data' },
{ id: 'user.write', label: 'Update User Profile' },
{ id: 'tracks.read', label: 'Read Tracks' },
{ id: 'tracks.upload', label: 'Upload Tracks' },
{ id: 'sales.read', label: 'Read Sales Data' },
];
export const CreateAPIKeyModal: React.FC<CreateAPIKeyModalProps> = ({ onClose, onCreate }) => {
const { addToast } = useToast();
const [step, setStep] = useState(1);
const [name, setName] = useState('');
const [selectedScopes, setSelectedScopes] = useState<string[]>(['user.read']);
const [generatedKey, setGeneratedKey] = useState('');
const toggleScope = (id: string) => {
setSelectedScopes(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
};
const handleGenerate = () => {
if (!name) {
addToast("Please name your key", "error");
return;
}
// Mock Key Generation
const mockKey = `vz_${Math.random().toString(36).substr(2, 8)}_${Math.random().toString(36).substr(2, 16)}`;
setGeneratedKey(mockKey);
onCreate({ name, scopes: selectedScopes }); // Notify parent
setStep(2);
};
const copyKey = () => {
navigator.clipboard.writeText(generatedKey);
addToast("API Key copied to clipboard", "success");
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-lg bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<Key className="w-4 h-4 text-kodo-gold" /> {step === 1 ? 'Create API Key' : 'API Key Generated'}
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6">
{step === 1 ? (
<div className="space-y-6">
<Input
label="Key Name"
placeholder="e.g. Production Server, Mobile App"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-3">Permissions (Scopes)</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{SCOPES.map(scope => (
<label key={scope.id} className="flex items-center gap-3 p-3 bg-kodo-ink rounded border border-kodo-steel cursor-pointer hover:border-gray-500 transition-colors">
<input
type="checkbox"
className="rounded border-gray-600 bg-transparent text-kodo-gold focus:ring-0"
checked={selectedScopes.includes(scope.id)}
onChange={() => toggleScope(scope.id)}
/>
<span className="text-sm text-gray-300">{scope.label}</span>
</label>
))}
</div>
</div>
</div>
) : (
<div className="text-center space-y-6">
<div className="w-16 h-16 bg-kodo-lime/20 rounded-full flex items-center justify-center mx-auto text-kodo-lime">
<Check className="w-8 h-8" />
</div>
<div>
<h4 className="text-xl font-bold text-white mb-2">Key Created Successfully</h4>
<p className="text-sm text-gray-400 max-w-xs mx-auto">
Please copy your API key now. For security reasons, you won't be able to see it again.
</p>
</div>
<div className="bg-black border border-kodo-steel rounded-lg p-4 flex items-center gap-2 relative group">
<code className="text-kodo-gold font-mono text-sm flex-1 break-all">{generatedKey}</code>
<Button variant="ghost" size="icon" onClick={copyKey} className="hover:text-white">
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
{step === 1 ? (
<>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleGenerate}>Generate Key</Button>
</>
) : (
<Button variant="primary" onClick={onClose}>Done</Button>
)}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,85 @@
import React from 'react';
import { Card } from '../ui/card';
import { ProgressBar } from '../ui/progress';
import { Course } from '../../types';
import { PlayCircle, Clock, Star, Users, CheckCircle } from 'lucide-react';
interface CourseCardProps {
course: Course;
onClick: (course: Course) => void;
showProgress?: boolean;
}
export const CourseCard: React.FC<CourseCardProps> = ({ course, onClick, showProgress = false }) => {
return (
<Card
variant="default"
className="group p-0 overflow-hidden cursor-pointer hover:border-kodo-cyan/50 transition-all flex flex-col h-full"
onClick={() => onClick(course)}
>
{/* Cover */}
<div className="relative aspect-video bg-gray-900 overflow-hidden">
<img src={course.thumbnailUrl} className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110 opacity-90 group-hover:opacity-100" alt={course.title} />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
<PlayCircle className="w-12 h-12 text-white fill-current opacity-80" />
</div>
{course.certificateAvailable && (
<div className="absolute top-2 right-2 bg-kodo-gold/90 text-black text-[10px] font-bold px-2 py-0.5 rounded shadow-lg flex items-center gap-1">
<Star className="w-3 h-3 fill-current" /> CERTIFIED
</div>
)}
<div className="absolute bottom-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded font-mono flex items-center gap-1">
<Clock className="w-3 h-3" /> {course.duration}
</div>
</div>
{/* Content */}
<div className="p-4 flex flex-col flex-1">
<div className="flex justify-between items-start mb-2">
<span className={`text-[10px] px-2 py-0.5 rounded uppercase font-bold ${
course.level === 'Advanced' ? 'bg-kodo-red/20 text-kodo-red' :
course.level === 'Intermediate' ? 'bg-kodo-gold/20 text-kodo-gold' :
'bg-kodo-lime/20 text-kodo-lime'
}`}>
{course.level}
</span>
{course.rating && (
<div className="flex items-center gap-1 text-xs text-kodo-gold font-bold">
<Star className="w-3 h-3 fill-current" /> {course.rating}
</div>
)}
</div>
<h3 className="font-bold text-white text-base mb-1 line-clamp-2 group-hover:text-kodo-cyan transition-colors">{course.title}</h3>
<p className="text-gray-400 text-xs mb-3">by {course.instructor}</p>
<div className="mt-auto pt-2">
{showProgress && course.progress !== undefined ? (
<div className="space-y-2">
<div className="flex justify-between text-xs text-gray-400">
<span>Progress</span>
<span className={course.progress === 100 ? 'text-kodo-lime' : 'text-white'}>{course.progress}%</span>
</div>
<ProgressBar value={course.progress} color={course.progress === 100 ? 'lime' : 'cyan'} />
{course.progress === 100 && (
<div className="flex items-center gap-1 text-xs text-kodo-lime mt-1 font-bold">
<CheckCircle className="w-3 h-3" /> Completed
</div>
)}
</div>
) : (
<div className="flex justify-between items-center border-t border-white/5 pt-3">
<div className="flex items-center gap-1 text-xs text-gray-500">
<Users className="w-3 h-3" /> {(course.studentCount || 0).toLocaleString()} students
</div>
<span className="font-mono font-bold text-white">
{course.price && course.price > 0 ? `$${course.price}` : 'Free'}
</span>
</div>
)}
</div>
</div>
</Card>
);
};

View file

@ -0,0 +1,226 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Course } from '../../types';
import { PlayCircle, Star, Users, CheckCircle, Clock, Globe, ShieldCheck, Lock, ChevronDown, ChevronUp } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
interface CourseDetailViewProps {
course: Course;
onBack: () => void;
onEnroll: () => void;
isEnrolled?: boolean;
}
export const CourseDetailView: React.FC<CourseDetailViewProps> = ({ course, onBack, onEnroll, isEnrolled }) => {
const { addToast: _addToast } = useToast();
const [activeTab, setActiveTab] = useState<'overview' | 'curriculum' | 'reviews'>('overview');
const [expandedModule, setExpandedModule] = useState<string | null>(course.modules?.[0].id || null);
const toggleModule = (id: string) => {
setExpandedModule(expandedModule === id ? null : id);
};
return (
<div className="animate-fadeIn pb-20 max-w-7xl mx-auto">
{/* Breadcrumb */}
<div className="mb-6">
<Button variant="ghost" onClick={onBack} className="pl-0 text-gray-400 hover:text-white"> Back to Courses</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Content */}
<div className="lg:col-span-2 space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl md:text-4xl font-display font-bold text-white mb-4">{course.title}</h1>
<p className="text-xl text-gray-300 mb-6 font-light">{course.description}</p>
<div className="flex flex-wrap items-center gap-6 text-sm text-gray-400 mb-6">
{course.rating && (
<span className="flex items-center gap-1 text-kodo-gold font-bold">
<Star className="w-4 h-4 fill-current" /> {course.rating}
</span>
)}
<span className="flex items-center gap-1">
<Users className="w-4 h-4" /> {(course.studentCount || 0).toLocaleString()} students
</span>
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" /> {course.duration} total
</span>
<span className="flex items-center gap-1">
<Globe className="w-4 h-4" /> English
</span>
</div>
<div className="flex items-center gap-3">
<img src={`https://ui-avatars.com/api/?name=${course.instructor}&background=random`} className="w-10 h-10 rounded-full" />
<div>
<div className="text-xs text-gray-500 uppercase">Created by</div>
<div className="text-sm font-bold text-white text-kodo-cyan cursor-pointer hover:underline">{course.instructor}</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-kodo-steel flex gap-6">
{['overview', 'curriculum', 'reviews'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab as any)}
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${activeTab === tab ? 'border-kodo-cyan text-white' : 'border-transparent text-gray-500 hover:text-gray-300'}`}
>
{tab}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="space-y-8 animate-fadeIn">
<Card variant="default">
<h3 className="font-bold text-white text-lg mb-4">What you'll learn</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{course.whatYouWillLearn?.map((item, i) => (
<div key={i} className="flex gap-3 text-sm text-gray-300">
<CheckCircle className="w-4 h-4 text-kodo-lime flex-shrink-0 mt-0.5" />
<span>{item}</span>
</div>
))}
</div>
</Card>
<div>
<h3 className="font-bold text-white text-lg mb-4">Requirements</h3>
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-400">
{course.requirements?.map((req, i) => (
<li key={i}>{req}</li>
))}
</ul>
</div>
</div>
)}
{activeTab === 'curriculum' && (
<div className="space-y-4 animate-fadeIn">
<div className="flex justify-between items-center text-sm text-gray-400 mb-2">
<span>{course.modules?.length} Modules {course.modules?.reduce((acc, m) => acc + m.lessons.length, 0)} Lessons</span>
<button className="text-kodo-cyan hover:underline" onClick={() => setExpandedModule(expandedModule ? null : 'all')}>
{expandedModule === 'all' ? 'Collapse All' : 'Expand All'}
</button>
</div>
{course.modules?.map((module) => (
<div key={module.id} className="border border-kodo-steel rounded-lg overflow-hidden bg-kodo-ink/30">
<div
className="p-4 flex justify-between items-center cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => toggleModule(module.id)}
>
<h4 className="font-bold text-white flex items-center gap-3">
{expandedModule === module.id || expandedModule === 'all' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{module.title}
</h4>
<span className="text-xs text-gray-500">{module.lessons.length} lectures</span>
</div>
{(expandedModule === module.id || expandedModule === 'all') && (
<div className="border-t border-kodo-steel">
{module.lessons.map((lesson) => (
<div key={lesson.id} className="p-3 pl-8 flex justify-between items-center hover:bg-white/5 border-b border-kodo-steel/30 last:border-0">
<div className="flex items-center gap-3 text-sm text-gray-300">
{lesson.type === 'video' ? <PlayCircle className="w-4 h-4" /> : <ShieldCheck className="w-4 h-4" />}
{lesson.title}
</div>
<div className="flex items-center gap-3">
{lesson.isLocked && !isEnrolled && <Lock className="w-3 h-3 text-gray-500" />}
<span className="text-xs text-gray-500">{lesson.duration}</span>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{activeTab === 'reviews' && (
<div className="space-y-6 animate-fadeIn">
{course.reviews?.map(review => (
<div key={review.id} className="border-b border-kodo-steel/50 pb-6">
<div className="flex items-center gap-3 mb-2">
<img src={review.avatar} className="w-10 h-10 rounded-full" />
<div>
<div className="font-bold text-white text-sm">{review.username}</div>
<div className="flex text-kodo-gold text-xs">
{[...Array(5)].map((_, i) => <Star key={i} className={`w-3 h-3 ${i < review.rating ? 'fill-current' : 'text-gray-700'}`} />)}
</div>
</div>
<span className="ml-auto text-xs text-gray-500">{review.date}</span>
</div>
<p className="text-sm text-gray-300">{review.comment}</p>
</div>
))}
</div>
)}
</div>
{/* Right Sidebar */}
<div className="relative">
<div className="sticky top-24 space-y-6">
<Card variant="default" className="p-0 overflow-hidden border-kodo-cyan/30 shadow-neon-cyan/10">
{/* Preview Video Placeholder */}
<div className="relative aspect-video bg-black group cursor-pointer">
<img src={course.thumbnailUrl} className="w-full h-full object-cover opacity-80" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
<PlayCircle className="w-8 h-8 text-black fill-current" />
</div>
</div>
<div className="absolute bottom-4 text-center w-full text-white font-bold text-sm drop-shadow-md">Preview Course</div>
</div>
<div className="p-6">
<div className="text-3xl font-display font-bold text-white mb-2">
{isEnrolled ? 'Enrolled' : course.price && course.price > 0 ? `$${course.price}` : 'Free'}
</div>
{course.price && course.price > 0 && !isEnrolled && (
<p className="text-gray-400 text-xs mb-6 line-through">$199.99 (85% off)</p>
)}
{isEnrolled ? (
<Button variant="primary" className="w-full h-12 text-lg" onClick={onEnroll}>
CONTINUE LEARNING
</Button>
) : (
<div className="space-y-3">
<Button variant="primary" className="w-full h-12 text-lg" onClick={onEnroll}>
ENROLL NOW
</Button>
<p className="text-center text-xs text-gray-500">30-Day Money-Back Guarantee</p>
</div>
)}
<div className="mt-6 space-y-3">
<h4 className="font-bold text-white text-sm">This course includes:</h4>
<ul className="text-sm text-gray-400 space-y-2">
<li className="flex items-center gap-3"><PlayCircle className="w-4 h-4" /> {course.duration} on-demand video</li>
<li className="flex items-center gap-3"><ShieldCheck className="w-4 h-4" /> Full lifetime access</li>
<li className="flex items-center gap-3"><Globe className="w-4 h-4" /> Access on mobile and TV</li>
{course.certificateAvailable && (
<li className="flex items-center gap-3"><Star className="w-4 h-4" /> Certificate of completion</li>
)}
</ul>
</div>
</div>
</Card>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,262 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { ProgressBar } from '../ui/progress';
import { Course } from '../../types';
import { ChevronLeft, ChevronRight, CheckCircle, PlayCircle, FileText, HelpCircle, Menu, X } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
import { QuizModal } from './modals/QuizModal';
import { CertificateModal } from './modals/CertificateModal';
interface CourseLearningViewProps {
course: Course;
onBack: () => void;
}
export const CourseLearningView: React.FC<CourseLearningViewProps> = ({ course, onBack }) => {
const { addToast } = useToast();
const [activeLessonId, setActiveLessonId] = useState<string>(course.modules?.[0]?.lessons[0]?.id || '');
const [completedLessons, setCompletedLessons] = useState<string[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [activeTab, setActiveTab] = useState<'overview' | 'notes' | 'resources'>('overview');
// Quiz State
const [showQuiz, setShowQuiz] = useState(false);
const [activeQuiz, setActiveQuiz] = useState<any>(null);
// Certificate State
const [showCertificate, setShowCertificate] = useState(false);
// Flattened lessons for navigation
const allLessons = course.modules?.flatMap(m => m.lessons) || [];
const currentLessonIndex = allLessons.findIndex(l => l.id === activeLessonId);
const currentLesson = allLessons[currentLessonIndex];
const handleNext = () => {
if (currentLessonIndex < allLessons.length - 1) {
const nextLesson = allLessons[currentLessonIndex + 1];
setActiveLessonId(nextLesson.id);
markComplete(currentLesson.id);
} else {
// Course finished
markComplete(currentLesson.id);
addToast("Course Completed! 🎉", "success");
if (course.certificateAvailable) {
setShowCertificate(true);
}
}
};
const handlePrev = () => {
if (currentLessonIndex > 0) {
setActiveLessonId(allLessons[currentLessonIndex - 1].id);
}
};
const markComplete = (id: string) => {
if (!completedLessons.includes(id)) {
setCompletedLessons([...completedLessons, id]);
}
};
const startQuiz = (quizId: string) => {
// Mock Quiz Data
setActiveQuiz({
id: quizId,
title: 'Module Assessment',
passingScore: 70,
questions: [
{ id: 'q1', question: 'What is the frequency range of a sub-bass?', options: ['20-60Hz', '200-500Hz', '1-2kHz'], correctIndex: 0 },
{ id: 'q2', question: 'Which plugin is best for sidechaining?', options: ['Reverb', 'Compressor', 'Delay'], correctIndex: 1 },
]
});
setShowQuiz(true);
};
const progress = Math.round((completedLessons.length / allLessons.length) * 100);
return (
<div className="flex flex-col h-[calc(100vh-6rem)] -m-6 md:-m-10 bg-kodo-void">
{/* Header Bar */}
<div className="h-16 border-b border-kodo-steel bg-kodo-ink px-4 flex items-center justify-between shrink-0 z-20">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={onBack}><ChevronLeft className="w-4 h-4 mr-1" /> Back</Button>
<div className="h-6 w-px bg-kodo-steel"></div>
<h2 className="font-bold text-white text-sm md:text-base truncate max-w-md">{course.title}</h2>
</div>
<div className="flex items-center gap-4">
<div className="hidden md:block w-32">
<ProgressBar value={progress} color="lime" />
</div>
<div className="text-xs text-gray-400 font-mono hidden md:block">{progress}% Complete</div>
<Button variant="ghost" size="icon" onClick={() => setSidebarOpen(!sidebarOpen)}>
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0 overflow-y-auto custom-scrollbar">
{/* Player Stage */}
<div className="bg-black aspect-video w-full flex items-center justify-center relative">
{currentLesson?.type === 'video' ? (
<div className="text-center">
<PlayCircle className="w-16 h-16 text-white opacity-50 mx-auto mb-4" />
<p className="text-gray-500">Video Player Placeholder</p>
<p className="text-xs text-gray-600 mt-2">{currentLesson.title}</p>
</div>
) : currentLesson?.type === 'quiz' ? (
<div className="text-center">
<HelpCircle className="w-16 h-16 text-kodo-gold mx-auto mb-4" />
<h3 className="text-white text-xl font-bold mb-4">Quiz: {currentLesson.title}</h3>
<Button variant="primary" onClick={() => currentLesson.quizId && startQuiz(currentLesson.quizId)}>Start Quiz</Button>
</div>
) : (
<div className="p-8 max-w-2xl mx-auto text-left w-full h-full overflow-y-auto bg-kodo-graphite">
<h2 className="text-2xl font-bold text-white mb-4">{currentLesson?.title}</h2>
<p className="text-gray-300 leading-relaxed">
{currentLesson?.content || "This is a text-based lesson. Content would be rendered here in Markdown."}
</p>
</div>
)}
</div>
{/* Tabs & Meta */}
<div className="p-6 md:p-8 max-w-5xl mx-auto w-full">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-white">{currentLesson?.title}</h1>
<div className="flex gap-2">
<Button variant="secondary" onClick={handlePrev} disabled={currentLessonIndex === 0} icon={<ChevronLeft className="w-4 h-4" />}>Prev</Button>
<Button variant="primary" onClick={handleNext}>
{currentLessonIndex === allLessons.length - 1 ? 'Finish Course' : 'Next'} <ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
<div className="border-b border-kodo-steel flex gap-6 mb-6">
{['overview', 'notes', 'resources'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab as any)}
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${activeTab === tab ? 'border-kodo-cyan text-white' : 'border-transparent text-gray-500 hover:text-gray-300'}`}
>
{tab}
</button>
))}
</div>
{activeTab === 'overview' && (
<div className="text-gray-300 space-y-4">
<p>In this lesson, we cover the fundamentals of the topic. Make sure to take notes.</p>
<div className="p-4 bg-kodo-ink rounded border border-kodo-steel/50">
<h4 className="font-bold text-white mb-2">Key Takeaways</h4>
<ul className="list-disc pl-5 text-sm space-y-1">
<li>Understanding the core concept</li>
<li>Applying technique A to situation B</li>
<li>Common pitfalls to avoid</li>
</ul>
</div>
</div>
)}
{activeTab === 'notes' && (
<div>
<textarea className="w-full h-40 bg-kodo-ink border border-kodo-steel rounded p-4 text-white resize-none focus:border-kodo-cyan outline-none" placeholder="Type your personal notes here..." />
<Button variant="secondary" size="sm" className="mt-2">Save Note</Button>
</div>
)}
{activeTab === 'resources' && (
<div className="space-y-2">
<div className="flex items-center justify-between p-3 bg-kodo-ink rounded border border-kodo-steel">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-kodo-cyan" />
<span className="text-sm text-white">Lesson Slides.pdf</span>
</div>
<Button variant="ghost" size="sm">Download</Button>
</div>
<div className="flex items-center justify-between p-3 bg-kodo-ink rounded border border-kodo-steel">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-kodo-magenta" />
<span className="text-sm text-white">Project Files.zip</span>
</div>
<Button variant="ghost" size="sm">Download</Button>
</div>
</div>
)}
</div>
</div>
{/* Sidebar (Curriculum) */}
{sidebarOpen && (
<div className="w-80 bg-kodo-graphite border-l border-kodo-steel flex flex-col flex-shrink-0 animate-slideInRight">
<div className="p-4 border-b border-kodo-steel font-bold text-white text-sm bg-kodo-ink">
Course Content
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{course.modules?.map((module, i) => (
<div key={module.id} className="border-b border-kodo-steel/30">
<div className="p-4 bg-kodo-ink/50 text-xs font-bold text-gray-400 uppercase tracking-wider sticky top-0 backdrop-blur-sm z-10">
Section {i + 1}: {module.title}
</div>
<div>
{module.lessons.map(lesson => {
const isActive = lesson.id === activeLessonId;
const isCompleted = completedLessons.includes(lesson.id);
return (
<div
key={lesson.id}
onClick={() => setActiveLessonId(lesson.id)}
className={`flex items-start gap-3 p-3 cursor-pointer border-l-2 transition-all hover:bg-white/5 ${isActive ? 'bg-kodo-cyan/10 border-kodo-cyan' : 'border-transparent'}`}
>
<div className="mt-0.5">
{isCompleted ? (
<CheckCircle className="w-4 h-4 text-kodo-lime" />
) : lesson.type === 'video' ? (
<PlayCircle className={`w-4 h-4 ${isActive ? 'text-kodo-cyan' : 'text-gray-500'}`} />
) : (
<HelpCircle className="w-4 h-4 text-gray-500" />
)}
</div>
<div className="flex-1">
<div className={`text-sm font-medium leading-snug ${isActive ? 'text-white' : 'text-gray-300'}`}>{lesson.title}</div>
<div className="text-[10px] text-gray-500 mt-1 flex items-center gap-2">
<span>{lesson.duration}</span>
{lesson.type === 'quiz' && <span className="text-kodo-gold">Quiz</span>}
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Modals */}
{showQuiz && activeQuiz && (
<QuizModal
quiz={activeQuiz}
onClose={() => setShowQuiz(false)}
onComplete={(score) => {
addToast(`Quiz Completed. Score: ${score}%`, 'info');
markComplete(currentLesson.id);
}}
/>
)}
{showCertificate && (
<CertificateModal
studentName="Cyber Producer"
courseName={course.title}
completionDate={new Date().toLocaleDateString()}
onClose={() => setShowCertificate(false)}
/>
)}
</div>
);
};

View file

@ -0,0 +1,100 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { Course } from '../../types';
import { CourseCard } from './CourseCard';
import { GraduationCap, PlayCircle, Clock } from 'lucide-react';
// Mock Enrolled Courses
const MY_COURSES: Course[] = [
{
id: 'c1', title: 'Mastering with Ozone 10', level: 'Advanced', duration: '3h 45m', progress: 75,
thumbnailUrl: 'https://picsum.photos/id/200/400/250', instructor: 'Luca Pretellesi', lastAccessed: '2 days ago'
},
{
id: 'c2', title: 'Music Theory for Producers', level: 'Beginner', duration: '5h 10m', progress: 10,
thumbnailUrl: 'https://picsum.photos/id/201/400/250', instructor: 'Sarah Devine', lastAccessed: '1 week ago'
},
{
id: 'c3', title: 'Ableton Live 11 Fundamentals', level: 'Beginner', duration: '8h 20m', progress: 100,
thumbnailUrl: 'https://picsum.photos/id/203/400/250', instructor: 'Ableton Certified', lastAccessed: '1 month ago', certificateAvailable: true
},
];
interface MyCoursesViewProps {
onContinue: (course: Course) => void;
}
export const MyCoursesView: React.FC<MyCoursesViewProps> = ({ onContinue }) => {
const [activeTab, setActiveTab] = useState<'in_progress' | 'completed'>('in_progress');
const filteredCourses = MY_COURSES.filter(c =>
activeTab === 'in_progress' ? (c.progress < 100) : (c.progress === 100)
);
const lastActiveCourse = MY_COURSES.find(c => c.progress > 0 && c.progress < 100);
return (
<div className="animate-fadeIn space-y-8 pb-20">
<div className="flex items-center gap-3 mb-6">
<GraduationCap className="w-8 h-8 text-kodo-cyan" />
<h1 className="text-3xl font-display font-bold text-white">My Learning</h1>
</div>
{/* Continue Learning Banner */}
{lastActiveCourse && activeTab === 'in_progress' && (
<div className="bg-gradient-to-r from-kodo-ink to-kodo-graphite p-6 rounded-2xl border border-kodo-steel flex flex-col md:flex-row gap-6 items-center shadow-2xl">
<div className="relative w-full md:w-64 aspect-video rounded-lg overflow-hidden group cursor-pointer" onClick={() => onContinue(lastActiveCourse)}>
<img src={lastActiveCourse.thumbnailUrl} className="w-full h-full object-cover opacity-80 group-hover:scale-105 transition-transform" />
<div className="absolute inset-0 flex items-center justify-center">
<PlayCircle className="w-12 h-12 text-white fill-current drop-shadow-lg" />
</div>
</div>
<div className="flex-1 text-center md:text-left">
<div className="text-xs text-kodo-gold font-bold uppercase mb-2 flex items-center justify-center md:justify-start gap-2">
<Clock className="w-3 h-3" /> Last accessed {lastActiveCourse.lastAccessed}
</div>
<h2 className="text-2xl font-bold text-white mb-2">{lastActiveCourse.title}</h2>
<div className="w-full bg-gray-700 h-2 rounded-full mb-4 max-w-md mx-auto md:mx-0">
<div className="bg-kodo-cyan h-full rounded-full" style={{width: `${lastActiveCourse.progress}%`}}></div>
</div>
<Button variant="primary" onClick={() => onContinue(lastActiveCourse)}>Continue Lesson</Button>
</div>
</div>
)}
{/* Tabs */}
<div className="border-b border-kodo-steel flex gap-6">
<button
onClick={() => setActiveTab('in_progress')}
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${activeTab === 'in_progress' ? 'border-kodo-cyan text-white' : 'border-transparent text-gray-500 hover:text-gray-300'}`}
>
In Progress
</button>
<button
onClick={() => setActiveTab('completed')}
className={`pb-3 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${activeTab === 'completed' ? 'border-kodo-lime text-white' : 'border-transparent text-gray-500 hover:text-gray-300'}`}
>
Completed
</button>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredCourses.map(course => (
<CourseCard
key={course.id}
course={course}
onClick={onContinue}
showProgress={true}
/>
))}
{filteredCourses.length === 0 && (
<div className="col-span-full text-center py-20 text-gray-500">
<p>No courses found in this category.</p>
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,78 @@
import React from 'react';
import { Button } from '../../ui/button';
import { X, Download, Share2, Award } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface CertificateModalProps {
studentName: string;
courseName: string;
completionDate: string;
onClose: () => void;
}
export const CertificateModal: React.FC<CertificateModalProps> = ({ studentName, courseName, completionDate, onClose }) => {
const { addToast } = useToast();
const handleDownload = () => {
addToast("Downloading certificate PDF...", "info");
};
const handleShare = () => {
addToast("Shared to LinkedIn", "success");
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-3xl bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden flex flex-col">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<Award className="w-5 h-5 text-kodo-gold" /> Completion Certificate
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-8 bg-gray-900 flex justify-center">
{/* Certificate Preview */}
<div className="w-full aspect-[1.414] bg-white text-black p-8 relative shadow-2xl max-w-2xl border-8 border-double border-gray-300">
<div className="h-full border-4 border-kodo-gold/20 flex flex-col items-center justify-center text-center p-8 bg-[url('https://www.transparenttextures.com/patterns/cream-paper.png')]">
<div className="mb-8">
<h1 className="text-4xl font-display font-bold text-gray-900 mb-2 uppercase tracking-widest">Certificate</h1>
<p className="text-sm font-serif italic text-gray-500">of Completion</p>
</div>
<p className="text-sm text-gray-600 mb-2">This is to certify that</p>
<h2 className="text-3xl font-script font-bold text-kodo-cyan-dim mb-6 border-b-2 border-gray-200 pb-2 px-8">{studentName}</h2>
<p className="text-sm text-gray-600 mb-2">has successfully completed the course</p>
<h3 className="text-xl font-bold text-gray-800 mb-8 max-w-md leading-tight">{courseName}</h3>
<div className="flex justify-between w-full mt-auto pt-8 border-t border-gray-300">
<div className="text-left">
<p className="text-xs font-bold text-gray-900">{completionDate}</p>
<p className="text-[10px] text-gray-500 uppercase">Date</p>
</div>
<div className="text-right">
<div className="h-8 w-24 bg-gray-200 mb-1 opacity-50"></div> {/* Signature line */}
<p className="text-[10px] text-gray-500 uppercase">Veza Academy</p>
</div>
</div>
<div className="absolute top-8 right-8 opacity-20">
<Award className="w-24 h-24 text-kodo-gold" />
</div>
</div>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Close</Button>
<Button variant="secondary" icon={<Share2 className="w-4 h-4" />} onClick={handleShare}>Share on LinkedIn</Button>
<Button variant="primary" icon={<Download className="w-4 h-4" />} onClick={handleDownload}>Download PDF</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Quiz } from '../../../types';
import { X, CheckCircle, AlertCircle, HelpCircle } from 'lucide-react';
interface QuizModalProps {
quiz: Quiz;
onClose: () => void;
onComplete: (score: number) => void;
}
export const QuizModal: React.FC<QuizModalProps> = ({ quiz, onClose, onComplete }) => {
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [selectedAnswers, setSelectedAnswers] = useState<number[]>(new Array(quiz.questions.length).fill(-1));
const [showResults, setShowResults] = useState(false);
const currentQuestion = quiz.questions[currentQuestionIndex];
const isLastQuestion = currentQuestionIndex === quiz.questions.length - 1;
const handleAnswerSelect = (index: number) => {
const newAnswers = [...selectedAnswers];
newAnswers[currentQuestionIndex] = index;
setSelectedAnswers(newAnswers);
};
const handleNext = () => {
if (currentQuestionIndex < quiz.questions.length - 1) {
setCurrentQuestionIndex(currentQuestionIndex + 1);
} else {
setShowResults(true);
}
};
const calculateScore = () => {
let correct = 0;
quiz.questions.forEach((q, i) => {
if (selectedAnswers[i] === q.correctIndex) correct++;
});
return Math.round((correct / quiz.questions.length) * 100);
};
const score = calculateScore();
const passed = score >= quiz.passingScore;
const handleFinish = () => {
onComplete(score);
onClose();
};
if (showResults) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={handleFinish}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden p-8 text-center">
<div className="mb-6 flex justify-center">
{passed ? (
<div className="w-20 h-20 bg-kodo-lime/20 rounded-full flex items-center justify-center text-kodo-lime border-2 border-kodo-lime animate-pulse">
<CheckCircle className="w-10 h-10" />
</div>
) : (
<div className="w-20 h-20 bg-kodo-red/20 rounded-full flex items-center justify-center text-kodo-red border-2 border-kodo-red">
<AlertCircle className="w-10 h-10" />
</div>
)}
</div>
<h2 className="text-2xl font-bold text-white mb-2">{passed ? 'Assessment Passed!' : 'Try Again'}</h2>
<p className="text-gray-400 mb-6">
You scored <span className={`font-bold ${passed ? 'text-kodo-lime' : 'text-kodo-red'}`}>{score}%</span>.
{passed ? " Great job!" : ` You need ${quiz.passingScore}% to pass.`}
</p>
<Button variant={passed ? 'primary' : 'secondary'} className="w-full" onClick={handleFinish}>
{passed ? 'Continue Learning' : 'Review Material'}
</Button>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm"></div>
<div className="relative w-full max-w-2xl bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden flex flex-col max-h-[80vh]">
{/* Header */}
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<HelpCircle className="w-5 h-5 text-kodo-cyan" /> {quiz.title}
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
{/* Progress */}
<div className="h-1 bg-kodo-steel w-full">
<div className="h-full bg-kodo-cyan transition-all duration-300" style={{ width: `${((currentQuestionIndex + 1) / quiz.questions.length) * 100}%` }}></div>
</div>
{/* Question Area */}
<div className="p-8 flex-1 overflow-y-auto">
<span className="text-xs font-bold text-gray-500 uppercase mb-2 block">Question {currentQuestionIndex + 1} of {quiz.questions.length}</span>
<h2 className="text-xl font-bold text-white mb-6 leading-relaxed">{currentQuestion.question}</h2>
<div className="space-y-3">
{currentQuestion.options.map((option, idx) => (
<button
key={idx}
onClick={() => handleAnswerSelect(idx)}
className={`w-full text-left p-4 rounded-lg border transition-all ${
selectedAnswers[currentQuestionIndex] === idx
? 'bg-kodo-cyan/10 border-kodo-cyan text-white'
: 'bg-kodo-ink border-kodo-steel text-gray-300 hover:bg-white/5 hover:border-gray-500'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-6 h-6 rounded-full border flex items-center justify-center text-xs font-bold ${selectedAnswers[currentQuestionIndex] === idx ? 'bg-kodo-cyan border-kodo-cyan text-black' : 'border-gray-500 text-gray-500'}`}>
{String.fromCharCode(65 + idx)}
</div>
{option}
</div>
</button>
))}
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-between items-center">
<span className="text-xs text-gray-500">Passing Score: {quiz.passingScore}%</span>
<Button
variant="primary"
onClick={handleNext}
disabled={selectedAnswers[currentQuestionIndex] === -1}
>
{isLastQuestion ? 'Submit Quiz' : 'Next Question'}
</Button>
</div>
</div>
</div>
);
};

View file

@ -3,6 +3,7 @@ import { Select } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { logger } from '@/utils/logger';
/**
* FE-COMP-007: Reusable Sort component for consistent sorting UI across all pages
@ -54,7 +55,10 @@ export function Sort({
onSortChange(parsed.sortBy, parsed.sortOrder);
}
} catch (error) {
console.error('Error parsing stored sort options:', error);
logger.error('Error parsing stored sort options', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
}
}
}

View file

@ -0,0 +1,62 @@
import React from 'react';
import { Card } from '../ui/card';
import { Achievement } from '../../types';
import { Lock, CheckCircle } from 'lucide-react';
interface AchievementCardProps {
achievement: Achievement;
compact?: boolean;
}
export const AchievementCard: React.FC<AchievementCardProps> = ({ achievement, compact = false }) => {
const isUnlocked = achievement.progress >= achievement.maxProgress;
const percentage = Math.min(100, (achievement.progress / achievement.maxProgress) * 100);
return (
<Card
variant={isUnlocked ? 'gaming' : 'default'}
className={`relative overflow-hidden transition-all group ${isUnlocked ? 'border-kodo-gold/30 bg-kodo-gold/5' : 'opacity-80 grayscale hover:grayscale-0 hover:opacity-100'}`}
>
{isUnlocked && (
<div className="absolute top-2 right-2 text-kodo-gold animate-pulse">
<CheckCircle className="w-5 h-5" />
</div>
)}
{!isUnlocked && (
<div className="absolute top-2 right-2 text-gray-500">
<Lock className="w-4 h-4" />
</div>
)}
<div className={`flex ${compact ? 'flex-row items-center gap-4' : 'flex-col items-center text-center gap-3'}`}>
<div className={`rounded-full bg-gradient-to-br from-gray-800 to-black flex items-center justify-center border-2 ${isUnlocked ? 'border-kodo-gold w-16 h-16 text-3xl shadow-[0_0_15px_rgba(234,179,8,0.3)]' : 'border-gray-700 w-12 h-12 text-xl text-gray-500'}`}>
{achievement.icon}
</div>
<div className="flex-1 min-w-0">
<h4 className={`font-bold truncate ${isUnlocked ? 'text-white' : 'text-gray-400'}`}>
{achievement.name}
</h4>
<p className="text-xs text-gray-500 line-clamp-2 mb-2">
{achievement.description}
</p>
{/* Progress */}
<div className="w-full bg-gray-800 h-1.5 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${isUnlocked ? 'bg-kodo-gold' : 'bg-gray-600'}`}
style={{ width: `${percentage}%` }}
></div>
</div>
<div className="flex justify-between text-[10px] mt-1 font-mono">
<span className={isUnlocked ? 'text-kodo-gold' : 'text-gray-500'}>
{achievement.progress} / {achievement.maxProgress}
</span>
<span className="text-kodo-cyan">+{achievement.xpReward} XP</span>
</div>
</div>
</div>
</Card>
);
};

View file

@ -0,0 +1,102 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../ui/button';
import { SearchInput } from '../ui/input';
import { AchievementCard } from './AchievementCard';
import { Achievement } from '../../types';
import { Trophy, Lock, CheckCircle, Loader2 } from 'lucide-react';
import { gamificationService } from '../../services/gamificationService';
import { logger } from '@/utils/logger';
export const AchievementsView: React.FC = () => {
const [filter, setFilter] = useState<'all' | 'earned' | 'locked'>('all');
const [search, setSearch] = useState('');
const [achievements, setAchievements] = useState<Achievement[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const data = await gamificationService.getAchievements('me');
setAchievements(data);
} catch (e) {
logger.error('Error loading achievements', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const filtered = achievements.filter(ach => {
const matchSearch = ach.name.toLowerCase().includes(search.toLowerCase());
const isEarned = ach.progress >= ach.maxProgress;
if (filter === 'earned') return matchSearch && isEarned;
if (filter === 'locked') return matchSearch && !isEarned;
return matchSearch;
});
const earnedCount = achievements.filter(a => a.progress >= a.maxProgress).length;
if (loading) return <div className="flex justify-center py-20"><Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" /></div>;
return (
<div className="space-y-6 animate-fadeIn pb-20">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
<div>
<h2 className="text-3xl font-display font-bold text-white mb-2">ACHIEVEMENTS</h2>
<p className="text-gray-400 font-mono text-sm">Track your milestones and earn rewards.</p>
</div>
<div className="bg-kodo-ink px-4 py-2 rounded-lg border border-kodo-steel flex items-center gap-3">
<Trophy className="w-5 h-5 text-kodo-gold" />
<span className="text-sm font-bold text-white">{earnedCount} / {achievements.length} Unlocked</span>
</div>
</div>
{/* Controls */}
<div className="flex flex-col md:flex-row gap-4 items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50">
<div className="flex gap-2 w-full md:w-auto">
<Button
variant={filter === 'all' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setFilter('all')}
>
All
</Button>
<Button
variant={filter === 'earned' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setFilter('earned')}
icon={<CheckCircle className="w-3 h-3" />}
>
Earned
</Button>
<Button
variant={filter === 'locked' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setFilter('locked')}
icon={<Lock className="w-3 h-3" />}
>
Locked
</Button>
</div>
<div className="w-full md:w-96">
<SearchInput placeholder="Search achievements..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filtered.map(ach => (
<AchievementCard key={ach.id} achievement={ach} />
))}
</div>
</div>
);
};

View file

@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { LeaderboardEntry } from '../../types';
import { ChevronUp, ChevronDown, Minus, Crown, Loader2 } from 'lucide-react';
import { gamificationService } from '../../services/gamificationService';
import { logger } from '@/utils/logger';
export const LeaderboardView: React.FC = () => {
const [period, setPeriod] = useState<'weekly' | 'monthly' | 'all'>('weekly');
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadLeaderboard = async () => {
setLoading(true);
try {
const data = await gamificationService.getLeaderboard(period);
setLeaderboard(data);
} catch (e) {
logger.error('Error loading leaderboard', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
period,
});
} finally {
setLoading(false);
}
};
loadLeaderboard();
}, [period]);
return (
<div className="space-y-8 animate-fadeIn pb-20 max-w-5xl mx-auto">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
<div>
<h2 className="text-3xl font-display font-bold text-white mb-2">LEADERBOARD</h2>
<p className="text-gray-400 font-mono text-sm">Top producers dominating the network.</p>
</div>
<div className="flex bg-kodo-ink p-1 rounded-lg border border-kodo-steel">
{['weekly', 'monthly', 'all'].map(p => (
<button
key={p}
onClick={() => setPeriod(p as any)}
className={`px-4 py-2 rounded text-xs font-bold uppercase transition-all ${period === p ? 'bg-kodo-gold text-black shadow-lg' : 'text-gray-400 hover:text-white'}`}
>
{p === 'all' ? 'All Time' : p}
</button>
))}
</div>
</div>
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" /></div>
) : (
<>
{/* Top 3 Podium (Visual) */}
{leaderboard.length >= 3 && (
<div className="grid grid-cols-3 gap-4 items-end mb-8 md:px-20">
{[leaderboard[1], leaderboard[0], leaderboard[2]].map((entry, i) => (
<div key={entry.userId} className={`flex flex-col items-center ${i === 1 ? '-mt-12 order-2' : i === 0 ? 'order-1' : 'order-3'}`}>
<div className="relative mb-4">
<div className={`w-20 h-20 md:w-24 md:h-24 rounded-full overflow-hidden border-4 ${i === 1 ? 'border-kodo-gold' : i === 0 ? 'border-gray-300' : 'border-orange-400'}`}>
<img src={entry.avatar} className="w-full h-full object-cover" />
</div>
{i === 1 && <Crown className="absolute -top-8 left-1/2 -translate-x-1/2 w-10 h-10 text-kodo-gold fill-current animate-bounce" />}
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-black px-2 py-0.5 rounded-full text-xs font-bold border border-white/20">
{entry.rank}
</div>
</div>
<div className="text-center">
<div className="font-bold text-white text-lg">{entry.username}</div>
<div className="text-xs text-kodo-cyan">{entry.xp.toLocaleString()} XP</div>
</div>
</div>
))}
</div>
)}
{/* Table */}
<Card variant="default" className="p-0 overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="border-b border-kodo-steel bg-kodo-ink text-xs font-bold text-gray-500 uppercase tracking-wider">
<th className="p-4 w-16 text-center">Rank</th>
<th className="p-4">Producer</th>
<th className="p-4">Level</th>
<th className="p-4 text-right">XP</th>
<th className="p-4 text-center">Trend</th>
</tr>
</thead>
<tbody className="divide-y divide-kodo-steel/30 text-sm">
{leaderboard.map(entry => (
<tr key={entry.userId} className="hover:bg-white/5 transition-colors group">
<td className="p-4 text-center font-bold font-mono text-gray-400">
#{entry.rank}
</td>
<td className="p-4">
<div className="flex items-center gap-3">
<img src={entry.avatar} className="w-8 h-8 rounded-full" />
<span className="font-bold text-white group-hover:text-kodo-cyan transition-colors">{entry.username}</span>
</div>
</td>
<td className="p-4">
<span className="bg-kodo-slate px-2 py-1 rounded text-xs font-mono text-gray-300">LVL {entry.level}</span>
</td>
<td className="p-4 text-right font-mono font-bold text-white">
{entry.xp.toLocaleString()}
</td>
<td className="p-4 text-center">
{entry.trend > 0 ? (
<span className="text-kodo-lime flex items-center justify-center gap-1"><ChevronUp className="w-4 h-4" /> {entry.trend}</span>
) : entry.trend < 0 ? (
<span className="text-kodo-red flex items-center justify-center gap-1"><ChevronDown className="w-4 h-4" /> {Math.abs(entry.trend)}</span>
) : (
<span className="text-gray-500 flex items-center justify-center"><Minus className="w-4 h-4" /></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
</>
)}
</div>
);
};

View file

@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { XPBar } from './XPBar';
import { AchievementCard } from './AchievementCard';
import { TrendingUp, Target, Crown, Zap, Loader2 } from 'lucide-react';
import { Achievement } from '../../types';
import { gamificationService } from '../../services/gamificationService';
import { logger } from '@/utils/logger';
interface ProfileXPViewProps {
username: string;
}
export const ProfileXPView: React.FC<ProfileXPViewProps> = ({ username }) => {
const [xpData, setXpData] = useState<any>(null);
const [recentAchievements, setRecentAchievements] = useState<Achievement[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [xp, achievements] = await Promise.all([
gamificationService.getUserXP('me'),
gamificationService.getAchievements('me')
]);
setXpData(xp);
setRecentAchievements(achievements.slice(0, 3));
} catch (e) {
logger.error('Error loading profile XP data', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
username,
});
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) return <div className="flex justify-center py-20"><Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" /></div>;
return (
<div className="space-y-8 animate-fadeIn pb-20">
<h2 className="text-3xl font-display font-bold text-white mb-6">LEVEL & PROGRESS</h2>
{/* Main XP Card */}
<Card variant="gaming" className="p-8 relative overflow-hidden border-kodo-gold/30">
<div className="relative z-10 flex flex-col md:flex-row items-center gap-8">
{/* Level Badge */}
<div className="flex flex-col items-center justify-center">
<div className="w-24 h-24 bg-gradient-to-b from-kodo-gold to-orange-600 rounded-full flex items-center justify-center shadow-[0_0_30px_rgba(234,179,8,0.4)] border-4 border-black">
<div className="text-4xl font-black text-black">{xpData.level}</div>
</div>
<div className="mt-2 text-kodo-gold font-bold uppercase tracking-widest text-sm">Level</div>
</div>
{/* Progress */}
<div className="flex-1 w-full space-y-4">
<div className="flex justify-between items-end">
<div>
<h3 className="text-2xl font-bold text-white">{username}</h3>
<p className="text-gray-400 text-sm">Producer Rank #{xpData.rank}</p>
</div>
<div className="text-right">
<div className="text-2xl font-mono font-bold text-kodo-gold">{xpData.current} XP</div>
<div className="text-xs text-gray-500">Next Level: {xpData.next} XP</div>
</div>
</div>
<XPBar currentXP={xpData.current} nextLevelXP={xpData.next} level={xpData.level} size="lg" showLabels={false} />
<div className="flex gap-4 pt-2">
<div className="bg-black/30 px-3 py-1 rounded text-xs text-gray-400">
<span className="text-white font-bold">{xpData.totalEarned.toLocaleString()}</span> Total Lifetime XP
</div>
<div className="bg-black/30 px-3 py-1 rounded text-xs text-gray-400">
<span className="text-kodo-lime font-bold">+12%</span> vs Last Week
</div>
</div>
</div>
</div>
</Card>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card variant="default" className="flex items-center gap-4 p-4">
<div className="w-12 h-12 bg-kodo-ink rounded-lg flex items-center justify-center text-kodo-gold">
<Crown className="w-6 h-6" />
</div>
<div>
<div className="text-xs text-gray-500 uppercase font-bold">Global Rank</div>
<div className="text-xl font-bold text-white">#{xpData.rank}</div>
</div>
</Card>
<Card variant="default" className="flex items-center gap-4 p-4">
<div className="w-12 h-12 bg-kodo-ink rounded-lg flex items-center justify-center text-kodo-cyan">
<Zap className="w-6 h-6" />
</div>
<div>
<div className="text-xs text-gray-500 uppercase font-bold">Daily Streak</div>
<div className="text-xl font-bold text-white">12 Days</div>
</div>
</Card>
<Card variant="default" className="flex items-center gap-4 p-4">
<div className="w-12 h-12 bg-kodo-ink rounded-lg flex items-center justify-center text-kodo-magenta">
<Target className="w-6 h-6" />
</div>
<div>
<div className="text-xs text-gray-500 uppercase font-bold">Quests Complete</div>
<div className="text-xl font-bold text-white">8/10</div>
</div>
</Card>
</div>
{/* Recent Achievements */}
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-white">Recent Achievements</h3>
<Button variant="ghost" size="sm">View All</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{recentAchievements.map(ach => (
<AchievementCard key={ach.id} achievement={ach} />
))}
</div>
</div>
{/* XP History Graph (Mock) */}
<Card variant="default">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-kodo-cyan" /> XP History
</h3>
<div className="h-48 flex items-end gap-2 px-2">
{Array.from({length: 14}).map((_, i) => (
<div key={i} className="flex-1 flex flex-col justify-end gap-1 h-full group relative cursor-pointer">
<div className="w-full bg-kodo-gold rounded-t opacity-50 group-hover:opacity-100 transition-opacity" style={{height: `${Math.random() * 60 + 10}%`}}></div>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-black text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 pointer-events-none whitespace-nowrap">
+{Math.floor(Math.random() * 500)} XP
</div>
</div>
))}
</div>
<div className="flex justify-between text-xs text-gray-500 mt-2">
<span>14 Days Ago</span>
<span>Today</span>
</div>
</Card>
</div>
);
};

View file

@ -0,0 +1,67 @@
import React from 'react';
interface XPBarProps {
currentXP: number;
nextLevelXP: number;
level: number;
size?: 'sm' | 'md' | 'lg';
showLabels?: boolean;
className?: string;
}
export const XPBar: React.FC<XPBarProps> = ({
currentXP,
nextLevelXP,
level,
size = 'md',
showLabels = true,
className = ''
}) => {
const percentage = Math.min(100, Math.max(0, (currentXP / nextLevelXP) * 100));
const heightClasses = {
sm: 'h-2',
md: 'h-4',
lg: 'h-6'
};
const textClasses = {
sm: 'text-[10px]',
md: 'text-xs',
lg: 'text-sm'
};
return (
<div className={`w-full ${className}`}>
{showLabels && (
<div className={`flex justify-between items-end mb-1 font-mono font-bold ${textClasses[size]}`}>
<span className="text-kodo-gold">LVL {level}</span>
<span className="text-gray-400">
<span className="text-white">{currentXP}</span> / {nextLevelXP} XP
</span>
</div>
)}
<div className={`w-full bg-kodo-void rounded-full overflow-hidden border border-kodo-gold/30 ${heightClasses[size]} relative`}>
{/* Background Pattern */}
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] opacity-20"></div>
{/* Progress Fill */}
<div
className="h-full bg-gradient-to-r from-kodo-gold/80 to-kodo-gold transition-all duration-500 shadow-[0_0_15px_rgba(234,179,8,0.4)] relative"
style={{ width: `${percentage}%` }}
>
{/* Shimmer Effect */}
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-r from-transparent via-white/20 to-transparent -skew-x-12 translate-x-[-100%] animate-shimmer"></div>
</div>
</div>
{showLabels && size === 'lg' && (
<div className="text-right text-[10px] text-gray-500 mt-1 font-mono">
{Math.round(nextLevelXP - currentXP)} XP to next level
</div>
)}
</div>
);
};

View file

@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Input, FileUpload } from '../ui/input';
import { Save, Camera, FileText } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
export const AddEquipmentView: React.FC = () => {
const { addToast } = useToast();
const [formData, setFormData] = useState({
category: 'Synth',
brand: '',
model: '',
serial: '',
purchaseDate: '',
price: '',
status: 'Active',
location: 'Main Studio',
warrantyEnd: '',
notes: ''
});
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSave = () => {
if (!formData.brand || !formData.model) {
addToast("Please fill in basic information", "error");
return;
}
addToast("Equipment added to inventory", "success");
// Redirect logic would go here
};
return (
<div className="animate-fadeIn max-w-4xl mx-auto pb-20">
<div className="flex justify-between items-center mb-8">
<h2 className="text-3xl font-display font-bold text-white">REGISTER EQUIPMENT</h2>
<Button variant="primary" icon={<Save className="w-4 h-4" />} onClick={handleSave}>
Save Item
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left: Media */}
<div className="space-y-6">
<Card variant="default">
<h3 className="font-bold text-white mb-4 text-sm uppercase tracking-wider flex items-center gap-2">
<Camera className="w-4 h-4 text-kodo-cyan" /> Photos
</h3>
<div className="aspect-square bg-kodo-ink border-2 border-dashed border-kodo-steel rounded-xl flex flex-col items-center justify-center text-gray-500 hover:text-white hover:border-kodo-cyan cursor-pointer transition-colors group mb-4">
<Camera className="w-8 h-8 mb-2 group-hover:scale-110 transition-transform" />
<span className="text-xs font-bold uppercase">Upload Photos</span>
</div>
<div className="grid grid-cols-3 gap-2">
{[1,2,3].map(i => (
<div key={i} className="aspect-square bg-kodo-ink rounded border border-kodo-steel/50"></div>
))}
</div>
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-4 text-sm uppercase tracking-wider flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-400" /> Documents
</h3>
<FileUpload />
<p className="text-xs text-gray-500 mt-2 text-center">Receipts, Manuals, Warranty Cards</p>
</Card>
</div>
{/* Right: Form */}
<div className="lg:col-span-2 space-y-6">
<Card variant="default">
<h3 className="font-bold text-white mb-6 border-b border-kodo-steel pb-2">Basic Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">Category</label>
<select
className="w-full bg-kodo-graphite border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-cyan outline-none"
value={formData.category}
onChange={(e) => handleChange('category', e.target.value)}
>
<option>Synth</option>
<option>Interface</option>
<option>Microphone</option>
<option>Computer</option>
<option>Effect Pedal</option>
<option>Monitor</option>
</select>
</div>
<Input label="Status" placeholder="Active" value={formData.status} onChange={(e) => handleChange('status', e.target.value)} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<Input label="Brand" placeholder="e.g. Roland" value={formData.brand} onChange={(e) => handleChange('brand', e.target.value)} />
<Input label="Model" placeholder="e.g. TR-8S" value={formData.model} onChange={(e) => handleChange('model', e.target.value)} />
</div>
<Input label="Serial Number" placeholder="S/N..." value={formData.serial} onChange={(e) => handleChange('serial', e.target.value)} />
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-6 border-b border-kodo-steel pb-2">Purchase & Warranty</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<Input label="Purchase Date" type="date" value={formData.purchaseDate} onChange={(e) => handleChange('purchaseDate', e.target.value)} />
<Input label="Price Paid" placeholder="0.00" value={formData.price} onChange={(e) => handleChange('price', e.target.value)} />
<Input label="Warranty Ends" type="date" value={formData.warrantyEnd} onChange={(e) => handleChange('warrantyEnd', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">Location / Tags</label>
<Input placeholder="Main Studio, Rack A..." value={formData.location} onChange={(e) => handleChange('location', e.target.value)} />
</div>
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-4 border-b border-kodo-steel pb-2">Notes</h3>
<textarea
className="w-full bg-kodo-graphite border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-cyan outline-none min-h-[100px]"
placeholder="Condition notes, modifications, etc..."
value={formData.notes}
onChange={(e) => handleChange('notes', e.target.value)}
/>
</Card>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,55 @@
import React from 'react';
import { Card } from '../ui/card';
import { Badge } from '../ui/badge';
import { GearItem } from '../../types';
import { Tag, DollarSign } from 'lucide-react';
interface EquipmentCardProps {
item: GearItem;
onClick: (item: GearItem) => void;
}
export const EquipmentCard: React.FC<EquipmentCardProps> = ({ item, onClick }) => {
const statusColor = {
'Active': 'text-kodo-lime bg-kodo-lime/10',
'Maintenance': 'text-kodo-orange bg-kodo-orange/10',
'Sold': 'text-gray-400 bg-gray-500/10',
'Wishlist': 'text-kodo-magenta bg-kodo-magenta/10',
};
return (
<Card
variant="default"
className="group p-0 overflow-hidden cursor-pointer hover:border-kodo-cyan/50 transition-all hover:shadow-lg flex flex-col h-full"
onClick={() => onClick(item)}
>
<div className="relative aspect-square bg-gray-900 overflow-hidden">
<img src={item.image || 'https://via.placeholder.com/400'} className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110 opacity-90 group-hover:opacity-100" />
<div className="absolute top-2 right-2">
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase backdrop-blur-md ${statusColor[item.status]}`}>
{item.status}
</span>
</div>
</div>
<div className="p-4 flex flex-col flex-1">
<div className="flex justify-between items-start mb-1">
<Badge label={item.category} variant="terminal" className="mb-2" />
</div>
<h3 className="font-bold text-white text-base truncate mb-1">{item.name}</h3>
<p className="text-kodo-gold text-xs font-mono uppercase mb-4">{item.brand} {item.model}</p>
<div className="mt-auto pt-3 border-t border-white/5 flex justify-between items-center text-xs">
<div className="flex items-center gap-1 text-gray-400">
<Tag className="w-3 h-3" /> {item.condition}
</div>
<div className="flex items-center gap-1 font-mono text-white font-bold">
<DollarSign className="w-3 h-3 text-kodo-cyan" /> {item.purchasePrice}
</div>
</div>
</div>
</Card>
);
};

View file

@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Badge } from '../ui/badge';
import { GearItem } from '../../types';
import {
ArrowLeft, Edit3, Trash2, Tag,
ShieldCheck, FileText, Wrench, Download, ChevronLeft, ChevronRight, Loader2
} from 'lucide-react';
import { useToast } from '../../context/ToastContext';
import { gearService } from '../../services/gearService';
import { logger } from '@/utils/logger';
interface EquipmentDetailViewProps {
itemId: string;
onBack: () => void;
}
export const EquipmentDetailView: React.FC<EquipmentDetailViewProps> = ({ itemId, onBack }) => {
const { addToast } = useToast();
const [item, setItem] = useState<GearItem | null>(null);
const [loading, setLoading] = useState(true);
const [activeImgIndex, setActiveImgIndex] = useState(0);
useEffect(() => {
const fetchItem = async () => {
try {
setLoading(true);
const data = await gearService.get(itemId);
setItem(data);
} catch (e) {
logger.error('Failed to load equipment details', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
itemId,
});
addToast("Failed to load equipment details", "error");
} finally {
setLoading(false);
}
};
fetchItem();
}, [itemId]);
if (loading) return <div className="flex h-[50vh] items-center justify-center"><Loader2 className="w-8 h-8 text-kodo-cyan animate-spin" /></div>;
if (!item) return <div className="text-center py-20 text-gray-500">Item not found</div>;
const images = item.images && item.images.length > 0 ? item.images : [item.image || ''];
const nextImage = () => setActiveImgIndex((prev) => (prev + 1) % images.length);
const prevImage = () => setActiveImgIndex((prev) => (prev - 1 + images.length) % images.length);
return (
<div className="animate-fadeIn max-w-6xl mx-auto pb-20">
{/* Nav */}
<div className="flex justify-between items-center mb-6">
<Button variant="ghost" onClick={onBack} className="pl-0 text-gray-400 hover:text-white">
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Inventory
</Button>
<div className="flex gap-2">
<Button variant="secondary" icon={<Edit3 className="w-4 h-4" />}>Edit</Button>
<Button variant="ghost" className="text-kodo-red hover:bg-kodo-red/10" icon={<Trash2 className="w-4 h-4" />}>Delete</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left: Photos & Key Info */}
<div className="space-y-6">
<div className="relative aspect-video bg-black rounded-xl overflow-hidden border border-kodo-steel group">
<img src={images[activeImgIndex]} className="w-full h-full object-contain" />
{images.length > 1 && (
<>
<button onClick={prevImage} className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-black/50 hover:bg-black/80 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronLeft className="w-6 h-6" />
</button>
<button onClick={nextImage} className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-black/50 hover:bg-black/80 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronRight className="w-6 h-6" />
</button>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
{images.map((_, i) => (
<div key={i} className={`w-2 h-2 rounded-full ${i === activeImgIndex ? 'bg-kodo-cyan' : 'bg-gray-600'}`}></div>
))}
</div>
</>
)}
</div>
<Card variant="default">
<h3 className="font-bold text-white mb-4 border-b border-kodo-steel pb-2 flex items-center gap-2">
<Tag className="w-4 h-4 text-kodo-cyan" /> Core Specifications
</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
{item.specs ? Object.entries(item.specs).map(([key, val]) => (
<div key={key}>
<span className="text-gray-500 block text-xs uppercase">{key}</span>
<span className="text-white font-medium">{val}</span>
</div>
)) : <p className="text-gray-500">No specs defined.</p>}
</div>
</Card>
</div>
{/* Right: Details & History */}
<div className="space-y-6">
<div>
<div className="flex items-center gap-3 mb-2">
<Badge label={item.category} variant="terminal" />
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase ${item.status === 'Active' ? 'bg-kodo-lime/10 text-kodo-lime' : 'bg-gray-700 text-gray-300'}`}>
{item.status}
</span>
</div>
<h1 className="text-4xl font-display font-bold text-white mb-1">{item.name}</h1>
<p className="text-xl text-kodo-gold font-mono mb-4">{item.brand} {item.model}</p>
<div className="flex gap-6 text-sm text-gray-400 mb-6 font-mono bg-kodo-ink p-4 rounded-lg border border-kodo-steel/50">
<div className="flex flex-col">
<span className="text-[10px] uppercase font-bold text-gray-500">Serial</span>
<span className="text-white">{item.serialNumber}</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] uppercase font-bold text-gray-500">Purchased</span>
<span className="text-white">{item.purchaseDate}</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] uppercase font-bold text-gray-500">Value</span>
<span className="text-kodo-cyan font-bold">${item.purchasePrice}</span>
</div>
</div>
</div>
<Card variant="gaming">
<h3 className="font-bold text-white mb-4 border-b border-gray-700 pb-2 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-kodo-lime" /> Warranty & Support
</h3>
<div className="flex justify-between items-center mb-4 p-3 bg-kodo-ink rounded border border-kodo-steel/30">
<div>
<span className="block text-xs text-gray-500 uppercase">Expires</span>
<span className="font-bold text-white">{item.warrantyExpire || 'N/A'}</span>
</div>
<Badge label={item.warrantyType || 'Standard'} variant="cyan" />
</div>
{item.supportContact && (
<div className="text-sm">
<span className="text-gray-500">Support: </span>
<a href={`mailto:${item.supportContact}`} className="text-kodo-cyan hover:underline">{item.supportContact}</a>
</div>
)}
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-4 border-b border-kodo-steel pb-2 flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-400" /> Documentation
</h3>
<div className="space-y-2">
{item.documents?.map((doc, i) => (
<div key={i} className="flex items-center justify-between p-2 hover:bg-white/5 rounded cursor-pointer group transition-colors">
<div className="flex items-center gap-3">
<FileText className="w-4 h-4 text-kodo-cyan" />
<span className="text-sm text-gray-300">{doc.name}</span>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-500 hover:text-white">
<Download className="w-4 h-4" />
</Button>
</div>
))}
</div>
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-4 border-b border-kodo-steel pb-2 flex items-center gap-2">
<Wrench className="w-4 h-4 text-kodo-orange" /> Service History
</h3>
<div className="space-y-4 relative">
<div className="absolute left-2 top-2 bottom-2 w-px bg-kodo-steel"></div>
{item.maintenanceHistory?.map((log) => (
<div key={log.id} className="relative pl-6">
<div className="absolute left-0 top-1.5 w-4 h-4 bg-kodo-graphite border border-kodo-orange rounded-full flex items-center justify-center">
<div className="w-1.5 h-1.5 bg-kodo-orange rounded-full"></div>
</div>
<div className="flex justify-between items-start">
<span className="font-bold text-white text-sm">{log.type}</span>
<span className="text-xs text-gray-500">{log.date}</span>
</div>
<p className="text-xs text-gray-400 mt-1">{log.notes}</p>
</div>
))}
</div>
</Card>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../ui/button';
import { SearchInput } from '../ui/input';
import { EquipmentCard } from './EquipmentCard';
import { GearItem } from '../../types';
import { Plus, Filter, Download, Box, Loader2 } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
import { gearService } from '../../services/gearService';
import { logger } from '@/utils/logger';
interface InventoryViewProps {
onNavigate: (view: string, id?: string) => void;
}
export const InventoryView: React.FC<InventoryViewProps> = ({ onNavigate }) => {
const { addToast } = useToast();
const [search, setSearch] = useState('');
const [filterCat, setFilterCat] = useState('All');
const [filterStatus, setFilterStatus] = useState('All');
const [inventory, setInventory] = useState<GearItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadInventory();
}, []);
const loadInventory = async () => {
try {
setLoading(true);
const data = await gearService.list();
setInventory(data);
} catch (e) {
logger.error('Failed to load gear', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
const filteredItems = inventory.filter(item => {
const matchSearch = item.name.toLowerCase().includes(search.toLowerCase()) ||
item.brand.toLowerCase().includes(search.toLowerCase());
const matchCat = filterCat === 'All' || item.category === filterCat;
const matchStatus = filterStatus === 'All' || item.status === filterStatus;
return matchSearch && matchCat && matchStatus;
});
return (
<div className="space-y-6 animate-fadeIn pb-20">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
<div>
<h2 className="text-3xl font-display font-bold text-white mb-2">GEAR INVENTORY</h2>
<p className="text-gray-400 font-mono text-sm">Track hardware, warranties, and maintenance.</p>
</div>
<div className="flex gap-3">
<Button variant="ghost" icon={<Download className="w-4 h-4" />} onClick={() => addToast("Exporting CSV...")}>Export</Button>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => onNavigate('inventory/add')}>Add Equipment</Button>
</div>
</div>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4 items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50">
<div className="w-full md:w-96">
<SearchInput placeholder="Search gear..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<div className="flex items-center gap-2 bg-kodo-void rounded-lg p-1 border border-kodo-steel">
<Filter className="w-4 h-4 text-gray-500 ml-2" />
<select
className="bg-transparent text-sm text-gray-300 focus:outline-none p-1 cursor-pointer"
value={filterCat}
onChange={(e) => setFilterCat(e.target.value)}
>
<option value="All">All Categories</option>
<option value="Synth">Synths</option>
<option value="Interface">Interfaces</option>
<option value="Microphone">Microphones</option>
<option value="Computer">Computers</option>
</select>
</div>
<div className="flex items-center gap-2 bg-kodo-void rounded-lg p-1 border border-kodo-steel">
<Box className="w-4 h-4 text-gray-500 ml-2" />
<select
className="bg-transparent text-sm text-gray-300 focus:outline-none p-1 cursor-pointer"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
>
<option value="All">All Status</option>
<option value="Active">Active</option>
<option value="Maintenance">Maintenance</option>
<option value="Sold">Sold</option>
<option value="Wishlist">Wishlist</option>
</select>
</div>
</div>
</div>
{/* Grid */}
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-8 h-8 text-kodo-cyan animate-spin" /></div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredItems.map(item => (
<EquipmentCard
key={item.id}
item={item}
onClick={() => onNavigate('inventory/detail', item.id)}
/>
))}
{filteredItems.length === 0 && (
<div className="col-span-full text-center py-20 text-gray-500">
<Box className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No equipment found.</p>
</div>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,188 @@
import React, { useState } from 'react';
import { useAudio } from '../../context/AudioContext';
import { MiniPlayer } from '../player/MiniPlayer';
import { FullPlayer } from '../player/FullPlayer';
import { X, ListMusic, Play, GripVertical, Trash2, ArrowUpToLine, ListPlus, Clock, Heart } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
import { Button } from '../ui/button';
export const AudioPlayer: React.FC = () => {
const { currentTrack, queue, history, reorderQueue, playTrack, playNext, removeFromQueue, addToQueue, clearQueue } = useAudio();
const { addToast } = useToast();
const [isImmersive, setIsImmersive] = useState(false);
const [showQueue, setShowQueue] = useState(false);
const [queueTab, setQueueTab] = useState<'up-next' | 'history'>('up-next');
const [draggedItemIndex, setDraggedItemIndex] = useState<number | null>(null);
if (!currentTrack) return null;
// Queue Drag Handlers
const onDragStart = (e: React.DragEvent, index: number) => {
setDraggedItemIndex(index);
e.dataTransfer.effectAllowed = "move";
const ghost = document.createElement("div");
ghost.style.opacity = "0";
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
};
const onDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedItemIndex === null || draggedItemIndex === index) return;
reorderQueue(draggedItemIndex, index);
setDraggedItemIndex(index);
};
const onDragEnd = () => setDraggedItemIndex(null);
return (
<>
{/* IMMERSIVE PLAYER OVERLAY */}
{isImmersive && <FullPlayer onClose={() => setIsImmersive(false)} />}
{/* QUEUE DRAWER */}
{showQueue && !isImmersive && (
<div className="fixed bottom-24 right-4 w-full md:w-[400px] bg-kodo-graphite/95 backdrop-blur-xl border border-kodo-steel/50 rounded-2xl shadow-2xl z-40 overflow-hidden animate-slideUp max-h-[70vh] flex flex-col ring-1 ring-white/10">
<div className="flex items-center justify-between p-4 border-b border-kodo-steel bg-kodo-ink/80">
<h3 className="font-bold text-white text-sm tracking-wide flex items-center gap-2">
<ListMusic className="w-4 h-4 text-kodo-cyan" /> PLAY QUEUE
</h3>
<X className="w-5 h-5 text-gray-400 cursor-pointer hover:text-white" onClick={() => setShowQueue(false)} />
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="flex border-b border-kodo-steel bg-kodo-slate/30">
<button
className={`flex-1 py-3 text-xs font-bold uppercase tracking-wider transition-colors ${queueTab === 'up-next' ? 'text-kodo-cyan border-b-2 border-kodo-cyan bg-white/5' : 'text-gray-500 hover:text-white'}`}
onClick={() => setQueueTab('up-next')}
>
Up Next ({queue.length})
</button>
<button
className={`flex-1 py-3 text-xs font-bold uppercase tracking-wider transition-colors ${queueTab === 'history' ? 'text-kodo-magenta border-b-2 border-kodo-magenta bg-white/5' : 'text-gray-500 hover:text-white'}`}
onClick={() => setQueueTab('history')}
>
History
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-0">
{queueTab === 'up-next' && (
<>
<div className="p-4 bg-gradient-to-b from-kodo-cyan/5 to-transparent border-b border-kodo-steel/30">
<div className="text-[10px] font-bold text-kodo-cyan uppercase tracking-wider mb-2">Now Playing</div>
<div className="flex items-center gap-3 group relative">
<img
src={currentTrack.coverUrl}
alt={`Cover art for ${currentTrack.title} by ${currentTrack.artist}`}
className="w-12 h-12 rounded shadow-lg"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-white truncate">{currentTrack.title}</div>
<div className="text-xs text-gray-400 truncate">{currentTrack.artist}</div>
</div>
<button className="p-2 hover:bg-white/10 rounded-full text-gray-400 hover:text-kodo-magenta" onClick={() => addToast("Saved to Library", "success")}><Heart className="w-4 h-4" /></button>
</div>
</div>
<div className="p-2 space-y-1">
{queue.length === 0 && (
<div className="text-center text-gray-500 py-12 flex flex-col items-center">
<ListMusic className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm italic">Queue is empty</p>
</div>
)}
{queue.map((track, i) => (
<div
key={track.id}
draggable
onDragStart={(e) => onDragStart(e, i)}
onDragOver={(e) => onDragOver(e, i)}
onDragEnd={onDragEnd}
className={`flex items-center gap-3 p-2 rounded-lg group transition-colors border border-transparent ${draggedItemIndex === i ? 'bg-kodo-cyan/10 border-kodo-cyan/50' : 'hover:bg-white/5 hover:border-white/5'}`}
>
<div className="cursor-grab active:cursor-grabbing text-gray-600 hover:text-white p-1">
<GripVertical className="w-4 h-4" />
</div>
<div className="relative w-8 h-8 rounded overflow-hidden flex-shrink-0">
<img
src={track.coverUrl}
alt={`Cover art for ${track.title} by ${track.artist}`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 hidden group-hover:flex items-center justify-center cursor-pointer" onClick={() => playTrack(track)}>
<Play className="w-3 h-3 text-white fill-current" />
</div>
</div>
<div className="flex-1 min-w-0 select-none">
<div className="text-sm font-medium text-gray-300 group-hover:text-white truncate">{track.title}</div>
<div className="text-xs text-gray-500 truncate">{track.artist}</div>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity gap-1">
<button className="p-1.5 hover:text-kodo-cyan" title="Play Next" onClick={(e) => { e.stopPropagation(); playNext(track); removeFromQueue(track.id); }}>
<ArrowUpToLine className="w-3.5 h-3.5" />
</button>
<button className="p-1.5 hover:text-red-500" title="Remove" onClick={(e) => { e.stopPropagation(); removeFromQueue(track.id); }}>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</>
)}
{queueTab === 'history' && (
<div className="p-2 space-y-1">
{history.length === 0 && (
<div className="text-center text-gray-500 py-12 flex flex-col items-center">
<Clock className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm italic">No history yet</p>
</div>
)}
{[...history].reverse().map((track, i) => (
<div key={`${track.id}-${i}`} className="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 group opacity-70 hover:opacity-100 transition-opacity">
<div className="w-8 h-8 rounded overflow-hidden flex-shrink-0 grayscale group-hover:grayscale-0 transition-all">
<img
src={track.coverUrl}
alt={`Cover art for ${track.title} by ${track.artist}`}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-300 group-hover:text-white truncate">{track.title}</div>
<div className="text-xs text-gray-500 truncate">{track.artist}</div>
</div>
<button className="p-1.5 text-gray-400 hover:text-kodo-cyan opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => { addToQueue(track); addToast("Added back to Queue"); }}>
<ListPlus className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
{queueTab === 'up-next' && queue.length > 0 && (
<div className="p-3 border-t border-kodo-steel bg-kodo-ink">
<Button variant="ghost" size="sm" className="w-full text-xs text-gray-400 hover:text-red-400" onClick={() => { clearQueue(); addToast("Queue Cleared"); }}>
Clear Queue
</Button>
</div>
)}
</div>
</div>
)}
{/* MINI PLAYER BAR */}
<MiniPlayer
onExpand={() => setIsImmersive(true)}
onToggleQueue={() => setShowQueue(!showQueue)}
isQueueOpen={showQueue}
/>
</>
);
};

View file

@ -153,6 +153,7 @@ export function Header({ className: _className }: HeaderProps = {}) {
aria-label={t('common.userMenu')}
aria-expanded={isUserMenuOpen}
aria-haspopup="menu"
aria-controls="user-menu" // CRITIQUE FIX #67: Associer le bouton au menu
>
<User className="h-5 w-5" />
</Button>
@ -164,6 +165,7 @@ export function Header({ className: _className }: HeaderProps = {}) {
onEscape={() => setIsUserMenuOpen(false)}
>
<div
id="user-menu" // CRITIQUE FIX #67: ID pour aria-controls
className="absolute right-0 mt-2 w-48 bg-popover border rounded-md shadow-lg z-50"
role="menu"
aria-orientation="vertical"

View file

@ -0,0 +1,176 @@
import React, { useState } from 'react';
import { Menu, Palette, Zap, ChevronDown, LogOut, Settings, User, ShoppingCart } from 'lucide-react';
import { Button } from '../ui/button';
import { useTheme } from '../../context/ThemeContext';
import { SearchInput } from '../ui/input';
import { Notification } from '../../types';
import { useCart } from '../../context/CartContext';
import { NotificationBell } from '../notifications/NotificationBell';
interface NavbarProps {
onNavigate: (viewId: string) => void;
onLogout: () => void;
}
const mockNotifications: Notification[] = [
{ id: '1', type: 'system', text: 'System Update v2.0 Live', time: '2m', read: false },
{ id: '2', type: 'like', text: 'Neon_Dev liked your track', time: '15m', read: false, actionUrl: '/track/1' },
{ id: '3', type: 'follow', text: 'Skrillex started following you', time: '1h', read: true, actionUrl: '/u/skrillex' },
];
export const Navbar: React.FC<NavbarProps> = ({ onNavigate, onLogout }) => {
const { toggleTheme, theme } = useTheme();
const { itemCount } = useCart();
const [showUserMenu, setShowUserMenu] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>(mockNotifications);
const handleMarkAllRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
};
const handleRead = (id: string) => {
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
};
return (
<>
{/* Backdrop for closing menus - Lower z-index than Navbar (z-40) but higher than content */}
{showUserMenu && (
<div
className="fixed inset-0 z-[35] bg-transparent cursor-default"
onClick={() => setShowUserMenu(false)}
/>
)}
<nav className="fixed top-0 left-0 right-0 h-16 bg-kodo-void/80 backdrop-blur-md border-b border-kodo-steel/40 z-40 flex items-center justify-between px-6 lg:px-8">
{/* Brand & Mobile Menu */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" className="lg:hidden p-1">
<Menu className="w-5 h-5" />
</Button>
<div className="flex items-center gap-3 cursor-pointer" onClick={() => onNavigate('dashboard')}>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-kodo-cyan-dim to-kodo-cyan flex items-center justify-center shadow-lg shadow-kodo-cyan/20">
<span className="font-display font-bold text-kodo-void text-lg">V</span>
</div>
<div className="hidden sm:flex flex-col justify-center">
<span className="font-display font-bold text-base tracking-wide text-kodo-primary leading-none">
VEZA
</span>
<span className="text-[10px] text-kodo-secondary font-medium tracking-widest uppercase leading-none mt-1">
Spectre Astral
</span>
</div>
</div>
</div>
{/* Center Search (Hidden on Mobile) */}
<div className="hidden md:flex flex-1 max-w-md mx-8 relative">
<SearchInput placeholder="Search platform..." />
<div className="absolute right-0 top-1/2 -translate-y-1/2 pr-3 flex gap-2">
<span className="px-1.5 py-0.5 bg-kodo-steel/50 rounded text-[10px] text-kodo-secondary font-mono border border-white/5">CMD+K</span>
</div>
</div>
{/* Right Actions */}
<div className="flex items-center gap-3 md:gap-6">
{/* Pro Badge */}
<div className="hidden xl:flex items-center gap-3 border-r border-kodo-steel/50 pr-6">
<div className="text-right">
<div className="text-xs text-kodo-primary font-medium">Pro Plan</div>
<div className="text-[10px] text-kodo-secondary">Valid until Dec 31</div>
</div>
<div className="w-8 h-8 rounded-full bg-kodo-slate flex items-center justify-center">
<Zap className="w-4 h-4 text-kodo-gold fill-current" />
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
title={`Theme: ${theme}`}
className="text-kodo-secondary hover:text-kodo-primary hidden sm:flex"
>
<Palette className="w-5 h-5" />
</Button>
{/* Cart Trigger */}
<Button
variant="ghost"
size="sm"
className="relative text-kodo-secondary hover:text-kodo-primary hidden sm:flex"
onClick={() => onNavigate('cart')}
>
<ShoppingCart className="w-5 h-5" />
{itemCount > 0 && <span className="absolute top-1.5 right-1.5 w-2 h-2 bg-kodo-cyan rounded-full border border-kodo-void"></span>}
</Button>
{/* Notification Center */}
<NotificationBell
notifications={notifications}
onMarkAllRead={handleMarkAllRead}
onRead={handleRead}
onViewAll={() => onNavigate('notifications')}
/>
{/* User Menu */}
<div className="relative z-50">
<div
className="flex items-center gap-2 cursor-pointer group select-none"
onClick={() => setShowUserMenu(!showUserMenu)}
>
<div className="w-9 h-9 rounded-full bg-gradient-to-tr from-gray-700 to-gray-600 p-[1px] hover:ring-2 hover:ring-kodo-cyan transition-all">
<div className="w-full h-full rounded-full overflow-hidden">
<img src="https://picsum.photos/100/100" alt="Avatar" className="w-full h-full object-cover" />
</div>
</div>
<ChevronDown className={`w-4 h-4 text-kodo-secondary group-hover:text-kodo-primary transition-transform duration-200 ${showUserMenu ? 'rotate-180' : ''}`} />
</div>
{showUserMenu && (
<div className="absolute top-full right-0 mt-4 w-56 bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl overflow-hidden animate-fadeIn origin-top-right ring-1 ring-white/5 flex flex-col">
<div className="px-4 py-3 border-b border-kodo-steel/30 mb-1 bg-kodo-ink/50">
<p className="text-sm font-bold text-white">Cyber_Producer</p>
<p className="text-xs text-gray-500">Pro Plan</p>
</div>
<button
onClick={() => { onNavigate('profile'); setShowUserMenu(false); }}
className="w-full text-left px-4 py-2.5 text-sm text-gray-300 hover:bg-white/5 hover:text-white flex items-center gap-3 transition-colors"
>
<User className="w-4 h-4" /> My Profile
</button>
<button
onClick={() => { onNavigate('studio/go-live'); setShowUserMenu(false); }}
className="w-full text-left px-4 py-2.5 text-sm text-gray-300 hover:bg-white/5 hover:text-white flex items-center gap-3 transition-colors"
>
<Zap className="w-4 h-4 text-kodo-red" /> Go Live
</button>
<button
onClick={() => { onNavigate('purchases'); setShowUserMenu(false); }}
className="w-full text-left px-4 py-2.5 text-sm text-gray-300 hover:bg-white/5 hover:text-white flex items-center gap-3 transition-colors"
>
<ShoppingCart className="w-4 h-4" /> Purchases
</button>
<button
onClick={() => { onNavigate('settings'); setShowUserMenu(false); }}
className="w-full text-left px-4 py-2.5 text-sm text-gray-300 hover:bg-white/5 hover:text-white flex items-center gap-3 transition-colors"
>
<Settings className="w-4 h-4" /> Settings
</button>
<div className="h-px bg-kodo-steel/30 my-1 mx-2"></div>
<button
onClick={() => { onLogout(); setShowUserMenu(false); }}
className="w-full text-left px-4 py-2.5 text-sm text-kodo-red hover:bg-kodo-red/10 rounded-lg flex items-center gap-3 transition-colors"
>
<LogOut className="w-4 h-4" /> Sign Out
</button>
</div>
)}
</div>
</div>
</nav>
</>
);
};

View file

@ -1,98 +1,189 @@
import { Link, useLocation } from 'react-router-dom';
import { useUIStore } from '@/stores/ui';
import React from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { Home, Library, Users, Disc, Radio, Settings, LogOut, ShoppingBag, GraduationCap, BarChart2, Shield, Box, MessageSquare, Cloud, Layers, Globe, Cpu, Heart, ListMusic, CreditCard, DollarSign, Terminal } from 'lucide-react';
import { NavItem } from '../../types';
import { useAuthStore } from '@/features/auth/store/authStore';
import { useTranslation } from '@/hooks/useTranslation';
import { cn } from '@/lib/utils';
import {
Home,
MessageSquare,
Library,
Users,
Settings,
Shield,
} from 'lucide-react';
export function Sidebar() {
const { sidebarOpen } = useUIStore();
const { user } = useAuthStore();
const { t } = useTranslation();
const location = useLocation();
interface SidebarProps {
currentView?: string;
onNavigate?: (viewId: string) => void;
onLogout?: () => void;
}
// Vérifier si l'utilisateur est admin
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
const navigation = [
{ name: t('navigation.dashboard'), href: '/dashboard', icon: Home },
{ name: t('navigation.chat'), href: '/chat', icon: MessageSquare },
{ name: t('navigation.library'), href: '/library', icon: Library },
{ name: t('navigation.profile'), href: '/profile', icon: Users },
{ name: t('navigation.settings'), href: '/settings', icon: Settings },
];
// Ajouter les liens admin si l'utilisateur est admin
if (isAdmin) {
navigation.push({
name: 'Roles',
href: '/admin/roles',
icon: Shield,
});
const navItems: { section: string; items: NavItem[] }[] = [
{
section: 'My Studio',
items: [
{ id: 'dashboard', label: 'Command Center', icon: <Home className="w-4 h-4" /> },
{ id: 'studio', label: 'Cloud Files', icon: <Cloud className="w-4 h-4" /> },
{ id: 'tracks', label: 'Projects', icon: <Layers className="w-4 h-4" /> },
{ id: 'gear', label: 'Gear Locker', icon: <Box className="w-4 h-4" /> },
{ id: 'analytics', label: 'Performance', icon: <BarChart2 className="w-4 h-4" /> },
]
},
{
section: 'Veza Network',
items: [
{ id: 'social', label: 'Community Feed', icon: <Users className="w-4 h-4" /> },
{ id: 'marketplace', label: 'Marketplace', icon: <ShoppingBag className="w-4 h-4" /> },
{ id: 'live', label: 'Live Sessions', icon: <Radio className="w-4 h-4 text-kodo-red" />, badge: 3 },
{ id: 'chat', label: 'Channels', icon: <MessageSquare className="w-4 h-4" />, badge: 12 },
{ id: 'education', label: 'Academy', icon: <GraduationCap className="w-4 h-4" /> },
]
},
{
section: 'Commerce',
items: [
{ id: 'sell', label: 'Seller Dashboard', icon: <DollarSign className="w-4 h-4" /> },
{ id: 'wishlist', label: 'Wishlist', icon: <Heart className="w-4 h-4" /> },
{ id: 'purchases', label: 'Purchases', icon: <CreditCard className="w-4 h-4" /> },
]
},
{
section: 'Library',
items: [
{ id: 'playlists', label: 'Playlists', icon: <ListMusic className="w-4 h-4" /> },
{ id: 'queue', label: 'Play Queue', icon: <Disc className="w-4 h-4" /> },
]
},
{
section: 'System',
items: [
{ id: 'developer', label: 'Developer API', icon: <Terminal className="w-4 h-4" /> },
{ id: 'admin', label: 'Admin Panel', icon: <Shield className="w-4 h-4" /> },
]
}
];
// Mapping des IDs de navigation vers les routes React Router
const routeMap: Record<string, string> = {
dashboard: '/dashboard',
studio: '/dashboard',
tracks: '/library',
gear: '/dashboard',
analytics: '/analytics',
social: '/dashboard',
marketplace: '/marketplace',
live: '/dashboard',
chat: '/chat',
education: '/dashboard',
sell: '/marketplace',
wishlist: '/marketplace',
purchases: '/marketplace',
playlists: '/playlists',
queue: '/dashboard',
developer: '/dashboard',
admin: '/admin',
settings: '/settings',
};
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onNavigate, onLogout }) => {
const navigate = useNavigate();
const location = useLocation();
const { logout } = useAuthStore();
// Déterminer la vue actuelle depuis l'URL
const activeView = currentView || Object.keys(routeMap).find(
key => routeMap[key] === location.pathname
) || 'dashboard';
const handleNavigate = (viewId: string) => {
const route = routeMap[viewId] || '/dashboard';
navigate(route);
// Appeler onNavigate si fourni (pour compatibilité)
if (onNavigate) {
onNavigate(viewId);
}
};
const handleLogout = () => {
logout();
navigate('/login');
// Appeler onLogout si fourni (pour compatibilité)
if (onLogout) {
onLogout();
}
};
return (
<aside
className={cn(
'fixed inset-y-0 left-0 z-40 w-64 bg-background border-r transform transition-transform duration-200 ease-in-out',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
'md:translate-x-0',
)}
role="navigation"
aria-label={t('navigation.menu')}
>
<div className="flex flex-col h-full">
{/* Logo */}
<div className="flex items-center h-16 px-6 border-b">
<div
className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center"
aria-hidden="true"
>
<span className="text-primary-foreground font-bold text-lg">V</span>
<aside className="fixed left-0 top-16 bottom-0 w-64 bg-kodo-void/95 backdrop-blur-2xl border-r border-kodo-steel/40 hidden lg:flex flex-col z-30">
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
{navItems.map((group, idx) => (
<div key={idx} className="mb-8">
<h3 className="text-[10px] font-bold text-kodo-secondary uppercase tracking-widest mb-3 px-3 font-display flex items-center gap-2">
{group.section === 'My Studio' && <Cpu className="w-3 h-3 text-kodo-cyan" />}
{group.section === 'Veza Network' && <Globe className="w-3 h-3 text-kodo-magenta" />}
{group.section === 'Commerce' && <DollarSign className="w-3 h-3 text-kodo-gold" />}
{group.section === 'Library' && <Library className="w-3 h-3 text-white" />}
{group.section}
</h3>
<div className="space-y-1">
{group.items.map((item) => {
const route = routeMap[item.id] || '/dashboard';
const isActive = activeView === item.id || location.pathname === route;
return (
<Link
key={item.id}
to={route}
onClick={() => {
// CRITIQUE FIX #36: Ne pas utiliser preventDefault() sur les liens React Router
// Laisser React Router gérer la navigation naturellement
// Appeler onNavigate si fourni pour compatibilité, mais sans bloquer la navigation
if (onNavigate) {
onNavigate(item.id);
}
}}
className={`
w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group relative overflow-hidden
${isActive
? 'bg-white/5 text-kodo-primary shadow-[inset_0_0_20px_rgba(102,252,241,0.05)] border-l-2 border-kodo-cyan'
: 'text-kodo-secondary hover:text-kodo-primary hover:bg-white/5 border-l-2 border-transparent'}
`}
>
<div className="flex items-center gap-3 relative z-10">
<span className={`transition-colors duration-300 ${isActive ? 'text-kodo-cyan' : 'text-kodo-secondary group-hover:text-kodo-primary'}`}>
{item.icon}
</span>
{item.label}
</div>
{item.badge && (
<span className="bg-kodo-magenta/20 text-kodo-magenta text-[9px] px-1.5 py-0.5 rounded font-mono font-bold shadow-neon-magenta">
{item.badge}
</span>
)}
</Link>
);
})}
</div>
</div>
<span className="ml-2 font-bold text-xl">Veza</span>
</div>
))}
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2" role="menubar">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
role="menuitem"
tabIndex={0}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent',
)}
>
<item.icon className="mr-3 h-5 w-5" aria-hidden="true" />
{item.name}
</Link>
);
})}
</nav>
{/* Footer */}
<footer className="p-4 border-t">
<div className="text-xs text-muted-foreground">
<p>Veza v1.0.0</p>
<p>© 2024 Veza Team</p>
</div>
</footer>
<div className="p-4 border-t border-kodo-steel/30 bg-kodo-graphite/20">
<Link
to="/settings"
onClick={() => {
// CRITIQUE FIX #36: Ne pas utiliser preventDefault() sur les liens React Router
// Laisser React Router gérer la navigation naturellement
if (onNavigate) {
onNavigate('settings');
}
}}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-sm mb-1 rounded-lg transition-colors ${activeView === 'settings' || location.pathname === '/settings' ? 'bg-white/5 text-kodo-primary' : 'text-kodo-secondary hover:text-kodo-primary hover:bg-white/5'}`}
>
<Settings className="w-4 h-4" />
Settings
</Link>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors text-sm"
>
<LogOut className="w-4 h-4" />
Sign Out
</button>
</div>
</aside>
);
}
};

View file

@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../ui/button';
import { X, Wand2, Check, Music2 } from 'lucide-react';
interface DetectedData {
bpm: number;
key: string;
genre: string;
energy: string;
}
interface AutoMetadataDetectionModalProps {
onClose: () => void;
onApply: (data: DetectedData) => void;
fileName: string;
}
export const AutoMetadataDetectionModal: React.FC<AutoMetadataDetectionModalProps> = ({ onClose, onApply, fileName }) => {
const { addToast: _addToast } = useToast();
const [loading, setLoading] = useState(true);
const [result, setResult] = useState<DetectedData | null>(null);
useEffect(() => {
// Simulate AI detection
const timer = setTimeout(() => {
setResult({
bpm: 128,
key: 'F# Minor',
genre: 'Synthwave',
energy: 'High'
});
setLoading(false);
}, 2500);
return () => clearTimeout(timer);
}, []);
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-cyan/30 rounded-xl shadow-neon-cyan/20 overflow-hidden animate-scaleIn">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<Wand2 className="w-4 h-4 text-kodo-cyan" /> AI Metadata Detection
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-8 flex flex-col items-center text-center">
{loading ? (
<div className="space-y-6">
<div className="relative">
<div className="w-20 h-20 rounded-full border-4 border-kodo-steel border-t-kodo-cyan animate-spin mx-auto"></div>
<div className="absolute inset-0 flex items-center justify-center">
<Music2 className="w-8 h-8 text-kodo-cyan/50" />
</div>
</div>
<div>
<h4 className="text-lg font-bold text-white animate-pulse">Analyzing Audio...</h4>
<p className="text-sm text-gray-400 mt-2">Detecting BPM, Key, and Genre for <br/><span className="text-kodo-cyan">{fileName}</span></p>
</div>
</div>
) : (
<div className="w-full space-y-6 animate-fadeIn">
<div className="bg-kodo-ink border border-kodo-cyan/20 rounded-lg p-6 w-full">
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-2">
<div className="text-xs text-gray-500 uppercase font-bold mb-1">Detected BPM</div>
<div className="text-2xl font-bold text-white">{result?.bpm}</div>
</div>
<div className="text-center p-2">
<div className="text-xs text-gray-500 uppercase font-bold mb-1">Detected Key</div>
<div className="text-2xl font-bold text-kodo-cyan">{result?.key}</div>
</div>
<div className="text-center p-2">
<div className="text-xs text-gray-500 uppercase font-bold mb-1">Genre</div>
<div className="text-lg font-medium text-white">{result?.genre}</div>
</div>
<div className="text-center p-2">
<div className="text-xs text-gray-500 uppercase font-bold mb-1">Energy Level</div>
<div className="text-lg font-medium text-kodo-gold">{result?.energy}</div>
</div>
</div>
</div>
<div className="flex gap-3 w-full">
<Button variant="ghost" onClick={onClose} className="flex-1">Discard</Button>
<Button variant="primary" className="flex-1" icon={<Check className="w-4 h-4"/>} onClick={() => result && onApply(result)}>
Apply Tags
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,128 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Stamp, Eye } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
interface WatermarkSettingsModalProps {
onClose: () => void;
onSave: () => void;
}
export const WatermarkSettingsModal: React.FC<WatermarkSettingsModalProps> = ({ onClose, onSave }) => {
const { addToast: _addToast } = useToast();
const [enabled, setEnabled] = useState(true);
const [text, setText] = useState('PREVIEW - DO NOT USE');
const [opacity, setOpacity] = useState(30);
const [position, setPosition] = useState(4); // 0-8 grid
const positions = [
'Top Left', 'Top Center', 'Top Right',
'Mid Left', 'Center', 'Mid Right',
'Bot Left', 'Bot Center', 'Bot Right'
];
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-2xl bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl overflow-hidden animate-scaleIn flex flex-col md:flex-row">
{/* Left: Controls */}
<div className="w-full md:w-1/2 p-6 border-r border-kodo-steel bg-kodo-ink">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-white flex items-center gap-2">
<Stamp className="w-4 h-4 text-kodo-magenta" /> Watermark
</h3>
</div>
<div className="space-y-6">
<label className="flex items-center justify-between cursor-pointer">
<span className="text-sm font-medium text-white">Enable Watermarking</span>
<div
onClick={() => setEnabled(!enabled)}
className={`w-10 h-5 rounded-full relative transition-colors ${enabled ? 'bg-kodo-magenta' : 'bg-gray-600'}`}
>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${enabled ? 'left-6' : 'left-1'}`}></div>
</div>
</label>
<div className={!enabled ? 'opacity-50 pointer-events-none' : ''}>
<Input label="Watermark Text" value={text} onChange={(e) => setText(e.target.value)} />
<div className="mt-4">
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Position</label>
<div className="grid grid-cols-3 gap-2">
{positions.map((_pos, i) => (
<button
key={i}
onClick={() => setPosition(i)}
className={`h-10 rounded border text-[10px] uppercase font-bold transition-all ${position === i ? 'bg-kodo-magenta border-kodo-magenta text-white' : 'bg-kodo-void border-kodo-steel text-gray-500 hover:border-gray-400'}`}
>
{/* Icon representation usually better, but text works for demo */}
<div className={`w-2 h-2 rounded-full mx-auto ${position === i ? 'bg-white' : 'bg-gray-600'}`}></div>
</button>
))}
</div>
</div>
<div className="mt-4">
<div className="flex justify-between text-xs text-gray-400 mb-2">
<span className="font-bold uppercase">Opacity</span>
<span>{opacity}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full h-1 bg-kodo-steel rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-kodo-magenta [&::-webkit-slider-thumb]:rounded-full"
/>
</div>
</div>
</div>
<div className="mt-8 pt-4 border-t border-kodo-steel flex gap-3">
<Button variant="ghost" onClick={onClose} className="flex-1">Cancel</Button>
<Button variant="primary" onClick={() => { onSave(); onClose(); }} className="flex-1">Save Settings</Button>
</div>
</div>
{/* Right: Preview */}
<div className="w-full md:w-1/2 bg-black relative flex items-center justify-center overflow-hidden">
<div className="absolute top-4 right-4 z-10 bg-black/50 px-2 py-1 rounded text-xs text-white font-mono flex items-center gap-2">
<Eye className="w-3 h-3" /> PREVIEW
</div>
{/* Dummy Content */}
<div className="w-3/4 aspect-square bg-gray-800 rounded-lg relative overflow-hidden shadow-2xl">
<img src="https://picsum.photos/id/237/600/600" className="w-full h-full object-cover opacity-80" />
{/* Watermark Overlay */}
{enabled && (
<div className={`absolute inset-0 flex p-4 ${
position === 0 ? 'items-start justify-start' :
position === 1 ? 'items-start justify-center' :
position === 2 ? 'items-start justify-end' :
position === 3 ? 'items-center justify-start' :
position === 4 ? 'items-center justify-center' :
position === 5 ? 'items-center justify-end' :
position === 6 ? 'items-end justify-start' :
position === 7 ? 'items-end justify-center' :
'items-end justify-end'
}`}>
<span
className="text-white font-bold text-xl uppercase whitespace-nowrap transform -rotate-12 border-4 border-white/50 px-4 py-1"
style={{ opacity: opacity / 100 }}
>
{text}
</span>
</div>
)}
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,94 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { X, Search, Plus, Check } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
import { Playlist } from '../../../types';
interface AddToPlaylistModalProps {
onClose: () => void;
onAdd: (playlistIds: string[]) => void;
}
// Mock user playlists
const MOCK_USER_PLAYLISTS: Playlist[] = [
{ id: 'p1', title: 'Cyberpunk Essentials', creator: 'Cyber_Producer', userId: 'u1', isPublic: true, coverUrl: 'https://picsum.photos/100', trackCount: 45, likes: 120, tags: [] },
{ id: 'p2', title: 'Late Night Coding', creator: 'Cyber_Producer', userId: 'u1', isPublic: false, coverUrl: 'https://picsum.photos/101', trackCount: 22, likes: 15, tags: [] },
{ id: 'p3', title: 'Gym Phonk', creator: 'Cyber_Producer', userId: 'u1', isPublic: true, coverUrl: 'https://picsum.photos/102', trackCount: 105, likes: 300, tags: [] },
];
export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ onClose, onAdd }) => {
const { addToast } = useToast();
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const filteredPlaylists = MOCK_USER_PLAYLISTS.filter(p => p.title.toLowerCase().includes(search.toLowerCase()));
const toggleSelection = (id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(pid => pid !== id) : [...prev, id]);
};
const handleConfirm = () => {
if (selectedIds.length === 0) return;
onAdd(selectedIds);
onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden flex flex-col max-h-[70vh]">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white">Add to Playlist</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-4">
<div className="relative mb-4">
<input
className="w-full bg-kodo-void border border-kodo-steel rounded-lg py-2 pl-9 pr-4 text-white text-sm focus:border-kodo-cyan outline-none"
placeholder="Find playlist"
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
</div>
<Button variant="ghost" className="w-full justify-start border border-dashed border-kodo-steel mb-4 hover:border-kodo-cyan hover:text-kodo-cyan group" onClick={() => addToast("New Playlist Flow")}>
<div className="w-8 h-8 bg-kodo-steel rounded flex items-center justify-center mr-3 group-hover:bg-kodo-cyan/20">
<Plus className="w-4 h-4" />
</div>
<span className="text-sm font-bold">New Playlist</span>
</Button>
<div className="space-y-1 overflow-y-auto max-h-64 custom-scrollbar">
{filteredPlaylists.map(playlist => (
<div
key={playlist.id}
onClick={() => toggleSelection(playlist.id)}
className={`flex items-center p-2 rounded cursor-pointer group transition-colors ${selectedIds.includes(playlist.id) ? 'bg-kodo-cyan/10' : 'hover:bg-white/5'}`}
>
<img src={playlist.coverUrl} className="w-10 h-10 rounded object-cover mr-3" />
<div className="flex-1 min-w-0">
<div className={`text-sm font-bold truncate ${selectedIds.includes(playlist.id) ? 'text-kodo-cyan' : 'text-white'}`}>{playlist.title}</div>
<div className="text-xs text-gray-500">{playlist.trackCount} tracks</div>
</div>
<div className={`w-5 h-5 rounded-full border flex items-center justify-center ${selectedIds.includes(playlist.id) ? 'bg-kodo-cyan border-kodo-cyan' : 'border-gray-600'}`}>
{selectedIds.includes(playlist.id) && <Check className="w-3 h-3 text-black" />}
</div>
</div>
))}
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end">
<Button variant="primary" onClick={handleConfirm} disabled={selectedIds.length === 0}>
Done
</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { X, Lock, Globe, Users, Image as ImageIcon } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface CreatePlaylistModalProps {
onClose: () => void;
onCreate: (data: any) => void;
}
export const CreatePlaylistModal: React.FC<CreatePlaylistModalProps> = ({ onClose, onCreate }) => {
const { addToast } = useToast();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [isCollaborative, setIsCollaborative] = useState(false);
const handleSubmit = () => {
if (!name) {
addToast("Please enter a playlist name", "error");
return;
}
onCreate({ name, description, isPublic, isCollaborative });
onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-lg bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white">Create Playlist</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 flex flex-col md:flex-row gap-6">
<div className="w-40 h-40 bg-kodo-ink border-2 border-dashed border-kodo-steel rounded-lg flex flex-col items-center justify-center text-gray-500 hover:text-white hover:border-kodo-cyan cursor-pointer transition-colors flex-shrink-0">
<ImageIcon className="w-8 h-8 mb-2" />
<span className="text-xs font-bold uppercase">Cover</span>
</div>
<div className="flex-1 space-y-4">
<Input placeholder="Playlist Name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
<textarea
className="w-full bg-kodo-graphite border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-cyan outline-none text-sm resize-none h-24"
placeholder="Description (Optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between p-2 rounded hover:bg-white/5 cursor-pointer" onClick={() => setIsPublic(!isPublic)}>
<div className="flex items-center gap-3">
{isPublic ? <Globe className="w-4 h-4 text-kodo-cyan" /> : <Lock className="w-4 h-4 text-kodo-gold" />}
<div className="text-sm">
<div className="text-white font-bold">{isPublic ? 'Public' : 'Private'}</div>
<div className="text-xs text-gray-400">{isPublic ? 'Visible to everyone' : 'Only you can see this'}</div>
</div>
</div>
<div className={`w-8 h-4 rounded-full relative transition-colors ${isPublic ? 'bg-kodo-cyan' : 'bg-gray-600'}`}>
<div className={`absolute top-0.5 w-3 h-3 bg-white rounded-full transition-all ${isPublic ? 'left-4.5' : 'left-0.5'}`}></div>
</div>
</div>
<div className="flex items-center justify-between p-2 rounded hover:bg-white/5 cursor-pointer" onClick={() => setIsCollaborative(!isCollaborative)}>
<div className="flex items-center gap-3">
<Users className={`w-4 h-4 ${isCollaborative ? 'text-kodo-lime' : 'text-gray-400'}`} />
<div className="text-sm">
<div className="text-white font-bold">Collaborative</div>
<div className="text-xs text-gray-400">Friends can add tracks</div>
</div>
</div>
<div className={`w-8 h-4 rounded-full relative transition-colors ${isCollaborative ? 'bg-kodo-lime' : 'bg-gray-600'}`}>
<div className={`absolute top-0.5 w-3 h-3 bg-white rounded-full transition-all ${isCollaborative ? 'left-4.5' : 'left-0.5'}`}></div>
</div>
</div>
</div>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit}>Create</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { X, Lock, Globe, Users, Image as ImageIcon } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
import { Playlist } from '../../../types';
interface EditPlaylistModalProps {
playlist: Playlist;
onClose: () => void;
onSave: (data: Partial<Playlist>) => void;
onDelete: () => void;
}
export const EditPlaylistModal: React.FC<EditPlaylistModalProps> = ({ playlist, onClose, onSave, onDelete }) => {
const { addToast } = useToast();
const [name, setName] = useState(playlist.title);
const [description, setDescription] = useState(playlist.description || '');
const [isPublic, setIsPublic] = useState(playlist.isPublic ?? true);
const [isCollaborative, setIsCollaborative] = useState(playlist.isCollaborative ?? false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleSubmit = () => {
if (!name) {
addToast("Playlist name cannot be empty", "error");
return;
}
onSave({ title: name, description, isPublic, isCollaborative });
onClose();
};
const handleDelete = () => {
onDelete();
onClose();
};
if (showDeleteConfirm) {
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={() => setShowDeleteConfirm(false)}></div>
<div className="relative w-full max-w-sm bg-kodo-graphite border border-kodo-red rounded-xl shadow-2xl animate-scaleIn p-6 text-center">
<h3 className="text-xl font-bold text-white mb-2">Delete "{playlist.title}"?</h3>
<p className="text-sm text-gray-400 mb-6">This action cannot be undone.</p>
<div className="flex gap-3 justify-center">
<Button variant="ghost" onClick={() => setShowDeleteConfirm(false)}>Cancel</Button>
<Button variant="primary" className="bg-red-600 hover:bg-red-700 border-red-500" onClick={handleDelete}>Delete</Button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-lg bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white">Edit Details</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 flex flex-col md:flex-row gap-6">
<div className="w-40 h-40 bg-kodo-ink border border-kodo-steel rounded-lg flex flex-col items-center justify-center relative group overflow-hidden flex-shrink-0">
<img src={playlist.coverUrl} className="w-full h-full object-cover opacity-60 group-hover:opacity-40 transition-opacity" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100">
<ImageIcon className="w-8 h-8 text-white" />
</div>
</div>
<div className="flex-1 space-y-4">
<Input placeholder="Playlist Name" value={name} onChange={(e) => setName(e.target.value)} />
<textarea
className="w-full bg-kodo-graphite border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-cyan outline-none text-sm resize-none h-24"
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between p-2 rounded hover:bg-white/5 cursor-pointer" onClick={() => setIsPublic(!isPublic)}>
<div className="flex items-center gap-3">
{isPublic ? <Globe className="w-4 h-4 text-kodo-cyan" /> : <Lock className="w-4 h-4 text-kodo-gold" />}
<div className="text-sm">
<div className="text-white font-bold">{isPublic ? 'Public' : 'Private'}</div>
</div>
</div>
<div className={`w-8 h-4 rounded-full relative transition-colors ${isPublic ? 'bg-kodo-cyan' : 'bg-gray-600'}`}>
<div className={`absolute top-0.5 w-3 h-3 bg-white rounded-full transition-all ${isPublic ? 'left-4.5' : 'left-0.5'}`}></div>
</div>
</div>
<div className="flex items-center justify-between p-2 rounded hover:bg-white/5 cursor-pointer" onClick={() => setIsCollaborative(!isCollaborative)}>
<div className="flex items-center gap-3">
<Users className={`w-4 h-4 ${isCollaborative ? 'text-kodo-lime' : 'text-gray-400'}`} />
<div className="text-sm">
<div className="text-white font-bold">Collaborative</div>
</div>
</div>
<div className={`w-8 h-4 rounded-full relative transition-colors ${isCollaborative ? 'bg-kodo-lime' : 'bg-gray-600'}`}>
<div className={`absolute top-0.5 w-3 h-3 bg-white rounded-full transition-all ${isCollaborative ? 'left-4.5' : 'left-0.5'}`}></div>
</div>
</div>
</div>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-between items-center">
<Button variant="ghost" className="text-kodo-red hover:bg-kodo-red/10" onClick={() => setShowDeleteConfirm(true)}>Delete Playlist</Button>
<div className="flex gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit}>Save</Button>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Play, Shuffle, Heart, MoreHorizontal, Clock, Edit3 } from 'lucide-react';
import { Playlist, Track } from '../../../types';
import { useToast } from '../../../context/ToastContext';
import { useAudio } from '../../../context/AudioContext';
import { EditPlaylistModal } from './EditPlaylistModal';
interface PlaylistDetailViewProps {
playlistId: string;
onBack: () => void;
}
// Mock Data Fetcher
const getPlaylistById = (id: string): Playlist => ({
id,
title: 'Cyberpunk 2077 Vibes',
creator: 'Cyber_Producer',
userId: 'u1',
trackCount: 12,
likes: 1240,
coverUrl: 'https://picsum.photos/id/55/600/600',
tags: ['Synthwave', 'Dark'],
description: 'High octane sounds for the street samurai. A mix of heavy bass, retro synths, and futuristic atmosphere.',
isPublic: true,
isCollaborative: false,
duration: '45 min',
followers: 850,
tracks: Array.from({length: 12}).map((_, i) => ({
id: `t${i}`,
title: `Neon Track ${i+1}`,
artist: 'Various Artists',
album: 'Compilation',
coverUrl: `https://picsum.photos/id/${60+i}/200/200`,
duration: '3:45',
durationSec: 225,
plays: 1000 + i*100,
likes: 50 + i,
}))
});
export const PlaylistDetailView: React.FC<PlaylistDetailViewProps> = ({ playlistId, onBack }) => {
const { addToast } = useToast();
const { playTrack } = useAudio();
const [playlist, setPlaylist] = useState<Playlist>(getPlaylistById(playlistId));
const [isEditing, setIsEditing] = useState(false);
const [tracks, setTracks] = useState<Track[]>(playlist.tracks || []);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const handleUpdate = (data: Partial<Playlist>) => {
setPlaylist(prev => ({ ...prev, ...data }));
addToast("Playlist updated", "success");
};
const handleDelete = () => {
addToast("Playlist deleted", "info");
onBack();
};
// Drag and Drop Logic
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = "move";
const ghost = document.createElement("div");
ghost.style.opacity = "0";
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
const newTracks = [...tracks];
const [removed] = newTracks.splice(draggedIndex, 1);
newTracks.splice(index, 0, removed);
setTracks(newTracks);
setDraggedIndex(index);
};
return (
<div className="animate-fadeIn pb-20">
{/* Header Section */}
<div className="flex flex-col md:flex-row gap-8 items-end mb-8 p-6 bg-gradient-to-b from-kodo-ink/80 to-transparent rounded-2xl border-t border-white/5">
<div className="w-52 h-52 shadow-2xl shadow-kodo-cyan/10 rounded-lg overflow-hidden flex-shrink-0 group relative">
<img src={playlist.coverUrl} 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 cursor-pointer" onClick={() => setIsEditing(true)}>
<Edit3 className="w-8 h-8 text-white" />
</div>
</div>
<div className="flex-1 w-full">
<div className="flex items-center gap-2 mb-2 text-xs font-bold text-white uppercase tracking-widest">
<span>{playlist.isPublic ? 'Public Playlist' : 'Private Playlist'}</span>
{playlist.isCollaborative && <span className="bg-kodo-lime/20 text-kodo-lime px-2 py-0.5 rounded">Collaborative</span>}
</div>
<h1 className="text-4xl md:text-6xl font-display font-bold text-white mb-4 leading-tight">{playlist.title}</h1>
<p className="text-gray-400 text-sm mb-6 max-w-2xl">{playlist.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-300 font-medium mb-6">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-gray-700"></div>
<span className="text-white hover:underline cursor-pointer">{playlist.creator}</span>
</div>
<span className="w-1 h-1 bg-gray-500 rounded-full"></span>
<span>{playlist.likes} likes</span>
<span className="w-1 h-1 bg-gray-500 rounded-full"></span>
<span>{tracks.length} songs, {playlist.duration}</span>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="primary" size="lg" icon={<Play className="w-5 h-5 fill-current" />} onClick={() => playTrack(tracks[0], tracks)}>
PLAY
</Button>
<Button variant="secondary" size="lg" icon={<Shuffle className="w-5 h-5" />} onClick={() => addToast("Shuffle play started")}>
SHUFFLE
</Button>
<Button variant="ghost" size="icon" className="border border-white/10 hover:border-white text-gray-300 hover:text-white" onClick={() => addToast("Saved to Library")} aria-label="Ajouter à la bibliothèque"><Heart className="w-5 h-5" /></Button>
<Button variant="ghost" size="icon" className="border border-white/10 hover:border-white text-gray-300 hover:text-white" onClick={() => setIsEditing(true)} aria-label="Plus d'options"><MoreHorizontal className="w-5 h-5" /></Button>
</div>
</div>
</div>
{/* Tracks List */}
<div className="px-2">
<div className="grid grid-cols-[auto_1fr_auto_auto_auto] gap-4 text-xs font-bold text-gray-500 uppercase px-4 pb-2 border-b border-white/10 mb-2">
<div className="w-8 text-center">#</div>
<div>Title</div>
<div className="hidden md:block">Album</div>
<div className="hidden sm:block">Date Added</div>
<div className="text-right pr-4"><Clock className="w-4 h-4 ml-auto" /></div>
</div>
<div className="space-y-1">
{tracks.map((track, i) => (
<div
key={track.id}
draggable
onDragStart={(e) => handleDragStart(e, i)}
onDragOver={(e) => handleDragOver(e, i)}
onDragEnd={() => setDraggedIndex(null)}
className={`grid grid-cols-[auto_1fr_auto_auto_auto] gap-4 items-center p-2 rounded-lg hover:bg-white/5 group transition-colors ${draggedIndex === i ? 'bg-kodo-cyan/10' : ''}`}
>
<div className="w-8 text-center flex justify-center text-gray-500 group-hover:text-white cursor-grab active:cursor-grabbing">
<span className="group-hover:hidden">{i + 1}</span>
<Play className="w-4 h-4 fill-current hidden group-hover:block cursor-pointer" onClick={() => playTrack(track, tracks)} />
</div>
<div className="flex items-center gap-3 min-w-0">
<img src={track.coverUrl} className="w-10 h-10 rounded object-cover" />
<div className="min-w-0">
<div className="text-white font-bold text-sm truncate">{track.title}</div>
<div className="text-gray-400 text-xs truncate hover:underline cursor-pointer">{track.artist}</div>
</div>
</div>
<div className="hidden md:block text-gray-400 text-sm truncate">{track.album}</div>
<div className="hidden sm:block text-gray-500 text-xs">2 days ago</div>
<div className="text-right pr-2 flex items-center justify-end gap-4 text-sm text-gray-400 font-mono">
<Heart className="w-4 h-4 opacity-0 group-hover:opacity-100 hover:text-kodo-magenta cursor-pointer transition-all" />
<span>{track.duration}</span>
<MoreHorizontal className="w-4 h-4 opacity-0 group-hover:opacity-100 hover:text-white cursor-pointer" />
</div>
</div>
))}
</div>
</div>
{isEditing && (
<EditPlaylistModal
playlist={playlist}
onClose={() => setIsEditing(false)}
onSave={handleUpdate}
onDelete={handleDelete}
/>
)}
</div>
);
};

View file

@ -0,0 +1,119 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { SearchInput } from '../../ui/input';
import { Plus, PlayCircle, Lock, Globe, Loader2, ListMusic } from 'lucide-react';
import { Playlist } from '../../../types';
import { useToast } from '../../../context/ToastContext';
import { CreatePlaylistModal } from './CreatePlaylistModal';
import { playlistService } from '../../../services/playlistService';
import { logger } from '@/utils/logger';
export const PlaylistsView: React.FC<{ onNavigate: (playlistId: string) => void }> = ({ onNavigate }) => {
const { addToast } = useToast();
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
useEffect(() => {
loadPlaylists();
}, []);
const loadPlaylists = async () => {
try {
setLoading(true);
const response = await playlistService.list();
setPlaylists(response.playlists || []);
} catch (error) {
logger.error('Error loading playlists', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
// Fallback mock
setPlaylists([
{ id: '1', title: 'Cyberpunk 2077 Vibes', creator: 'Cyber_Producer', userId: 'u1', trackCount: 45, likes: 1200, coverUrl: 'https://picsum.photos/id/55/400/400', tags: ['Synthwave'], isPublic: true },
{ id: '2', title: 'Deep Focus Coding', creator: 'Cyber_Producer', userId: 'u1', trackCount: 120, likes: 540, coverUrl: 'https://picsum.photos/id/60/400/400', tags: ['Ambient'], isPublic: true },
]);
} finally {
setLoading(false);
}
};
const handleCreate = async (data: any) => {
try {
const newPlaylist = await playlistService.create(data);
setPlaylists([newPlaylist, ...playlists]);
addToast("Playlist created successfully", "success");
} catch (e) {
addToast("Failed to create playlist", "error");
}
};
const filtered = playlists.filter(p => p.title.toLowerCase().includes(search.toLowerCase()));
return (
<div className="space-y-6 animate-fadeIn pb-20">
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
<div>
<h1 className="text-3xl font-display font-bold text-white mb-2">MY PLAYLISTS</h1>
<p className="text-gray-400 font-mono text-sm">Curate your sonic collection.</p>
</div>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => setShowCreateModal(true)}>
NEW PLAYLIST
</Button>
</div>
<div className="relative max-w-md">
<SearchInput placeholder="Filter playlists..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-8 h-8 text-kodo-cyan animate-spin" /></div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{filtered.map(playlist => (
<Card
key={playlist.id}
variant="default"
className="p-0 overflow-hidden group cursor-pointer hover:border-kodo-cyan/50 transition-all hover:-translate-y-1"
onClick={() => onNavigate(playlist.id)}
>
<div className="aspect-square relative bg-gray-900">
{playlist.coverUrl ? (
<img src={playlist.coverUrl} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-600">
<ListMusic className="w-12 h-12 opacity-50" />
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<PlayCircle className="w-12 h-12 text-white fill-current opacity-80 hover:opacity-100 hover:scale-110 transition-all" />
</div>
<div className="absolute top-2 right-2">
{!playlist.isPublic && <div className="bg-black/60 p-1.5 rounded-full backdrop-blur"><Lock className="w-3 h-3 text-white" /></div>}
</div>
</div>
<div className="p-4">
<h3 className="font-bold text-white truncate mb-1">{playlist.title}</h3>
<p className="text-xs text-gray-400 mb-3 line-clamp-1">{playlist.description || `By ${playlist.creator}`}</p>
<div className="flex justify-between items-center text-[10px] font-bold text-gray-500 uppercase">
<span>{playlist.trackCount} Tracks</span>
{playlist.isPublic ? <Globe className="w-3 h-3 text-gray-600" /> : <Lock className="w-3 h-3 text-gray-600" />}
</div>
</div>
</Card>
))}
</div>
)}
{showCreateModal && (
<CreatePlaylistModal
onClose={() => setShowCreateModal(false)}
onCreate={handleCreate}
/>
)}
</div>
);
};

View file

@ -0,0 +1,149 @@
import React, { useState } from 'react';
import { useAudio } from '../../../context/AudioContext';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { SaveQueueAsPlaylistModal } from './SaveQueueAsPlaylistModal';
import { Play, Pause, X, GripVertical, Trash2, Save, ListMusic } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
export const QueueView: React.FC = () => {
const { queue, currentTrack, reorderQueue, removeFromQueue, clearQueue, playTrack, isPlaying, togglePlay, autoplay, toggleAutoplay } = useAudio();
const { addToast } = useToast();
const [showSaveModal, setShowSaveModal] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedIndex(index);
e.dataTransfer.effectAllowed = "move";
// Transparent ghost image
const ghost = document.createElement("div");
ghost.style.opacity = "0";
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
reorderQueue(draggedIndex, index);
setDraggedIndex(index);
};
const handleSavePlaylist = (name: string, _isPublic: boolean) => {
addToast(`Queue saved as "${name}"`, "success");
// Logic to actually save would connect to backend/context here
};
return (
<div className="max-w-4xl mx-auto space-y-6 animate-fadeIn pb-20">
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
<div>
<h1 className="text-3xl font-display font-bold text-white mb-2">PLAY QUEUE</h1>
<p className="text-gray-400 font-mono text-sm">{queue.length} tracks upcoming</p>
</div>
<div className="flex gap-3">
<Button variant="ghost" onClick={() => setShowSaveModal(true)} icon={<Save className="w-4 h-4" />}>Save Queue</Button>
<Button variant="ghost" className="text-kodo-red hover:bg-kodo-red/10" onClick={clearQueue} icon={<Trash2 className="w-4 h-4" />}>Clear</Button>
</div>
</div>
{/* Current Track */}
{currentTrack && (
<div>
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest mb-3">Now Playing</h3>
<Card variant="gaming" className="flex items-center gap-4 p-4 border-l-4 border-l-kodo-cyan">
<div className="relative w-16 h-16 rounded overflow-hidden flex-shrink-0 group cursor-pointer" onClick={togglePlay}>
<img src={currentTrack.coverUrl} 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">
{isPlaying ? <Pause className="w-6 h-6 text-white" /> : <Play className="w-6 h-6 text-white fill-current ml-1" />}
</div>
{isPlaying && (
<div className="absolute bottom-1 right-1 flex gap-0.5 items-end h-3">
<div className="w-1 bg-kodo-cyan animate-[bounce_1s_infinite] h-full"></div>
<div className="w-1 bg-kodo-cyan animate-[bounce_1.2s_infinite] h-2/3"></div>
<div className="w-1 bg-kodo-cyan animate-[bounce_0.8s_infinite] h-full"></div>
</div>
)}
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-white">{currentTrack.title}</h2>
<p className="text-kodo-cyan">{currentTrack.artist}</p>
</div>
<div className="text-gray-500 font-mono text-sm hidden md:block">
{currentTrack.duration}
</div>
</Card>
</div>
)}
{/* Up Next */}
<div>
<div className="flex justify-between items-center mb-3">
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest">Up Next</h3>
<div
className="flex items-center gap-2 cursor-pointer group"
onClick={toggleAutoplay}
>
<span className={`text-xs font-bold ${autoplay ? 'text-kodo-lime' : 'text-gray-500'}`}>Autoplay</span>
<div className={`w-8 h-4 rounded-full relative transition-colors ${autoplay ? 'bg-kodo-lime' : 'bg-gray-700'}`}>
<div className={`absolute top-0.5 w-3 h-3 bg-white rounded-full transition-all ${autoplay ? 'left-4.5' : 'left-0.5'}`}></div>
</div>
</div>
</div>
<div className="space-y-2">
{queue.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed border-kodo-steel rounded-xl text-gray-500">
<ListMusic className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">Queue is empty. Add tracks to keep the vibe going.</p>
{autoplay && <p className="text-xs text-kodo-lime mt-2">Autoplay is on. We'll pick a song for you.</p>}
</div>
) : (
queue.map((track, i) => (
<div
key={`${track.id}-${i}`}
draggable
onDragStart={(e) => handleDragStart(e, i)}
onDragOver={(e) => handleDragOver(e, i)}
onDragEnd={() => setDraggedIndex(null)}
className={`flex items-center gap-4 p-3 bg-kodo-ink rounded-lg border border-transparent hover:border-kodo-steel transition-all group ${draggedIndex === i ? 'opacity-50 border-kodo-cyan' : ''}`}
>
<div className="text-gray-600 cursor-grab active:cursor-grabbing hover:text-white p-1">
<GripVertical className="w-5 h-5" />
</div>
<div className="w-10 h-10 rounded overflow-hidden flex-shrink-0 relative">
<img src={track.coverUrl} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/50 hidden group-hover:flex items-center justify-center cursor-pointer" onClick={() => playTrack(track)}>
<Play className="w-4 h-4 text-white fill-current" />
</div>
</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-gray-400 truncate">{track.artist}</div>
</div>
<div className="text-gray-500 font-mono text-xs hidden sm:block">
{track.duration}
</div>
<button
className="p-2 text-gray-500 hover:text-kodo-red opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeFromQueue(track.id)}
>
<X className="w-4 h-4" />
</button>
</div>
))
)}
</div>
</div>
{showSaveModal && (
<SaveQueueAsPlaylistModal
onClose={() => setShowSaveModal(false)}
onSave={handleSavePlaylist}
/>
)}
</div>
);
};

View file

@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { X, Lock, Globe } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface SaveQueueAsPlaylistModalProps {
onClose: () => void;
onSave: (name: string, isPublic: boolean) => void;
}
export const SaveQueueAsPlaylistModal: React.FC<SaveQueueAsPlaylistModalProps> = ({ onClose, onSave }) => {
const { addToast } = useToast();
const [name, setName] = useState('');
const [isPublic, setIsPublic] = useState(false);
const handleSubmit = () => {
if (!name) {
addToast("Please name your playlist", "error");
return;
}
onSave(name, isPublic);
onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white">Save Queue as Playlist</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 space-y-4">
<Input label="Playlist Name" value={name} onChange={(e) => setName(e.target.value)} autoFocus placeholder="My Queue Session" />
<div className="flex items-center justify-between p-3 bg-kodo-ink rounded border border-kodo-steel cursor-pointer hover:border-gray-500" onClick={() => setIsPublic(!isPublic)}>
<div className="flex items-center gap-3">
{isPublic ? <Globe className="w-5 h-5 text-kodo-cyan" /> : <Lock className="w-5 h-5 text-kodo-gold" />}
<div>
<div className="text-sm font-bold text-white">{isPublic ? 'Public Playlist' : 'Private Playlist'}</div>
<div className="text-xs text-gray-400">{isPublic ? 'Visible on your profile' : 'Only visible to you'}</div>
</div>
</div>
<div className={`w-10 h-5 rounded-full relative transition-colors ${isPublic ? 'bg-kodo-cyan' : 'bg-gray-600'}`}>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${isPublic ? 'left-6' : 'left-1'}`}></div>
</div>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit}>Save Playlist</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,147 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { LiveStream } from '../../types';
import {
Users, Heart, Share2, DollarSign, Send, Radio, Settings, ArrowLeft
} from 'lucide-react';
import { useToast } from '../../context/ToastContext';
import { TipStreamerModal } from './modals/TipStreamerModal';
interface LiveStreamDetailViewProps {
streamId: string;
onBack: () => void;
}
// Mock Stream Data
const MOCK_STREAM: LiveStream = {
id: 's1',
title: 'Late Night DnB Production 🎧 | Feedback Session',
streamer: 'Neuro_Glitch',
viewers: 1240,
thumbnailUrl: 'https://picsum.photos/id/140/1200/800',
tags: ['Production', 'Ableton', 'DnB'],
isLive: true,
category: 'Production'
};
export const LiveStreamDetailView: React.FC<LiveStreamDetailViewProps> = ({ streamId: _streamId, onBack }) => {
const { addToast } = useToast();
const [chatInput, setChatInput] = useState('');
const [showTipModal, setShowTipModal] = useState(false);
const [messages, setMessages] = useState([
{ id: 1, user: 'BassHead99', text: 'That Reese bass is filthy! 🤮🔥', color: 'text-kodo-cyan' },
{ id: 2, user: 'Studio_Rat', text: 'What VST is that?', color: 'text-gray-400' },
{ id: 3, user: 'Neuro_Glitch', text: 'It\'s Phase Plant, just initializing now.', color: 'text-kodo-gold font-bold' },
]);
const handleSendChat = () => {
if (!chatInput.trim()) return;
setMessages([...messages, { id: Date.now(), user: 'You', text: chatInput, color: 'text-white' }]);
setChatInput('');
};
const handleTip = (amount: number, message: string) => {
addToast(`Sent $${amount} to ${MOCK_STREAM.streamer}`, 'success');
setMessages([...messages, { id: Date.now(), user: 'System', text: `You tipped $${amount}: ${message}`, color: 'text-kodo-lime font-bold italic' }]);
};
return (
<div className="flex flex-col h-[calc(100vh-6rem)] -m-6 md:-m-10 bg-black animate-fadeIn overflow-hidden">
{/* Header Overlay (Fade in/out logic usually here) */}
<div className="absolute top-0 left-0 right-0 p-4 z-20 flex justify-between items-start pointer-events-none">
<Button variant="ghost" size="sm" className="bg-black/50 backdrop-blur pointer-events-auto text-white hover:bg-black/70" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" /> Back
</Button>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Video Player Area */}
<div className="flex-1 bg-black relative flex items-center justify-center">
{/* Simulated Video */}
<div className="absolute inset-0">
<img src={MOCK_STREAM.thumbnailUrl} className="w-full h-full object-cover opacity-80" />
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-black/30"></div>
</div>
{/* Stream Info Overlay */}
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black to-transparent pt-24">
<div className="flex items-end justify-between">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full border-2 border-kodo-red p-0.5">
<img src="https://picsum.photos/id/100/100" className="w-full h-full rounded-full object-cover" />
</div>
<div>
<h1 className="text-2xl font-bold text-white mb-1">{MOCK_STREAM.title}</h1>
<div className="flex items-center gap-3 text-sm">
<span className="text-kodo-cyan font-bold">{MOCK_STREAM.streamer}</span>
<span className="flex items-center gap-1 text-kodo-red font-bold animate-pulse"><Radio className="w-3 h-3" /> LIVE</span>
<span className="flex items-center gap-1 text-white"><Users className="w-3 h-3" /> {MOCK_STREAM.viewers.toLocaleString()}</span>
</div>
</div>
</div>
<div className="flex gap-3">
<Button variant="secondary" className="bg-black/50 backdrop-blur border-white/20 text-white" icon={<Heart className="w-4 h-4" />}>Follow</Button>
<Button variant="primary" className="shadow-neon-cyan" icon={<DollarSign className="w-4 h-4" />} onClick={() => setShowTipModal(true)}>Tip</Button>
<Button variant="ghost" size="icon" className="bg-black/50 backdrop-blur text-white"><Share2 className="w-4 h-4" /></Button>
</div>
</div>
</div>
</div>
{/* Chat Sidebar */}
<div className="w-80 md:w-96 bg-kodo-graphite border-l border-kodo-steel flex flex-col z-20 shadow-2xl">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white text-sm">LIVE CHAT</h3>
<Settings className="w-4 h-4 text-gray-400 cursor-pointer hover:text-white" />
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 font-mono text-sm custom-scrollbar">
{messages.map((msg) => (
<div key={msg.id} className="break-words animate-slideInRight">
<span className={`font-bold ${msg.color} mr-2 cursor-pointer hover:underline opacity-90`}>{msg.user}:</span>
<span className="text-gray-300">{msg.text}</span>
</div>
))}
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink">
<div className="relative">
<input
className="w-full bg-kodo-void border border-kodo-steel rounded-full py-2.5 pl-4 pr-10 text-white text-sm focus:border-kodo-cyan outline-none"
placeholder="Send a message..."
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSendChat()}
/>
<button
className="absolute right-1.5 top-1.5 p-1.5 bg-kodo-cyan text-black rounded-full hover:bg-white transition-colors"
onClick={handleSendChat}
>
<Send className="w-3 h-3 fill-current" />
</button>
</div>
<div className="flex justify-between items-center mt-2 px-2">
<span className="text-[10px] text-gray-500">Slow Mode: Off</span>
<div className="flex gap-2">
<button className="text-kodo-gold hover:text-white" title="Send Tip" onClick={() => setShowTipModal(true)}>
<DollarSign className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
{showTipModal && (
<TipStreamerModal
streamerName={MOCK_STREAM.streamer}
onClose={() => setShowTipModal(false)}
onSend={handleTip}
/>
)}
</div>
);
};

View file

@ -0,0 +1,113 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { X, DollarSign, CreditCard, Heart } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface TipStreamerModalProps {
streamerName: string;
onClose: () => void;
onSend: (amount: number, message: string) => void;
}
export const TipStreamerModal: React.FC<TipStreamerModalProps> = ({ streamerName, onClose, onSend }) => {
const { addToast } = useToast();
const [amount, setAmount] = useState('5');
const [message, setMessage] = useState('');
const [paymentMethod, setPaymentMethod] = useState('card');
const presetAmounts = ['2', '5', '10', '20', '50'];
const handleSubmit = () => {
const val = parseFloat(amount);
if (isNaN(val) || val <= 0) {
addToast("Please enter a valid amount", "error");
return;
}
onSend(val, message);
onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-gold rounded-xl shadow-2xl animate-scaleIn overflow-hidden flex flex-col">
<div className="p-4 border-b border-kodo-gold/30 bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-kodo-gold flex items-center gap-2">
<DollarSign className="w-5 h-5" /> Support {streamerName}
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 space-y-6">
{/* Amount Selection */}
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Select Amount</label>
<div className="flex gap-2 mb-3">
{presetAmounts.map(val => (
<button
key={val}
onClick={() => setAmount(val)}
className={`flex-1 py-2 rounded font-bold border transition-colors ${amount === val ? 'bg-kodo-gold text-black border-kodo-gold' : 'bg-kodo-void border-kodo-steel text-gray-400 hover:text-white'}`}
>
${val}
</button>
))}
</div>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 font-bold">$</span>
<input
type="number"
className="w-full bg-kodo-ink border border-kodo-steel rounded pl-8 pr-4 py-2 text-white font-mono focus:border-kodo-gold outline-none"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Custom Amount"
/>
</div>
</div>
{/* Message */}
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Message (Optional)</label>
<textarea
className="w-full bg-kodo-ink border border-kodo-steel rounded p-3 text-white focus:border-kodo-gold outline-none text-sm resize-none h-24"
placeholder={`Say something nice to ${streamerName}...`}
value={message}
onChange={(e) => setMessage(e.target.value)}
maxLength={200}
/>
<p className="text-right text-xs text-gray-500 mt-1">{message.length}/200</p>
</div>
{/* Payment Method (Mock) */}
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Payment Method</label>
<div className="flex gap-2">
<button
onClick={() => setPaymentMethod('card')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded border ${paymentMethod === 'card' ? 'bg-kodo-cyan/10 border-kodo-cyan text-kodo-cyan' : 'bg-kodo-void border-kodo-steel text-gray-400'}`}
>
<CreditCard className="w-4 h-4" /> Card
</button>
<button
onClick={() => setPaymentMethod('crypto')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded border ${paymentMethod === 'crypto' ? 'bg-kodo-magenta/10 border-kodo-magenta text-kodo-magenta' : 'bg-kodo-void border-kodo-steel text-gray-400'}`}
>
<span className="font-bold">Ξ</span> Crypto
</button>
</div>
</div>
</div>
<div className="p-4 border-t border-kodo-gold/30 bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="gaming" className="border-kodo-gold text-kodo-gold hover:bg-kodo-gold/10" onClick={handleSubmit}>
<Heart className="w-4 h-4 mr-2" /> Send Tip
</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,52 @@
import React from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Check, Info } from 'lucide-react';
import { ProductLicense } from '../../types';
interface LicenceCardProps {
license: ProductLicense;
onSelect: (license: ProductLicense) => void;
onInfo: (license: ProductLicense) => void;
selected?: boolean;
}
export const LicenceCard: React.FC<LicenceCardProps> = ({ license, onSelect, onInfo, selected }) => {
return (
<Card
variant={selected ? 'gaming' : 'default'}
className={`p-4 transition-all cursor-pointer h-full flex flex-col ${selected ? 'border-kodo-cyan shadow-neon-cyan/20 bg-kodo-cyan/5' : 'hover:border-kodo-steel'}`}
onClick={() => onSelect(license)}
>
<div className="flex justify-between items-start mb-4">
<div>
<h4 className="font-bold text-white text-lg">{license.name}</h4>
{license.isPopular && <span className="text-[10px] text-black bg-kodo-gold px-2 py-0.5 rounded font-bold uppercase">Popular</span>}
</div>
<div className="text-xl font-mono font-bold text-kodo-cyan">${license.price}</div>
</div>
<ul className="space-y-2 mb-6 flex-1">
{license.features.slice(0, 3).map((feat, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-gray-300">
<Check className="w-4 h-4 text-kodo-lime flex-shrink-0 mt-0.5" />
<span className="leading-snug">{feat}</span>
</li>
))}
{license.features.length > 3 && (
<li className="text-xs text-gray-500 pl-6">+ {license.features.length - 3} more features</li>
)}
</ul>
<div className="flex gap-2 mt-auto">
<Button variant={selected ? 'primary' : 'secondary'} className="flex-1" size="sm">
{selected ? 'SELECTED' : 'SELECT'}
</Button>
<Button variant="ghost" size="icon" className="border border-kodo-steel" onClick={(e) => { e.stopPropagation(); onInfo(license); }}>
<Info className="w-4 h-4" />
</Button>
</div>
</Card>
);
};

View file

@ -0,0 +1,90 @@
import React from 'react';
import { Card } from '../ui/card';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Product } from '../../types';
import { Play, Pause, ShoppingCart, Star, Eye, Zap } from 'lucide-react';
interface ProductCardProps {
product: Product;
onAddToCart: (product: Product) => void;
onClick: (product: Product) => void;
onPreview: (productId: string) => void;
isPlayingPreview: boolean;
}
export const ProductCard: React.FC<ProductCardProps> = ({
product,
onAddToCart,
onClick,
onPreview,
isPlayingPreview
}) => {
return (
<Card
variant="default"
className="group p-0 overflow-hidden border-transparent hover:border-kodo-cyan/50 transition-all duration-300 hover:shadow-neon-cyan/20 bg-kodo-graphite cursor-pointer"
onClick={() => onClick(product)}
>
{/* Image & Overlay */}
<div className="relative aspect-square overflow-hidden bg-black">
<img src={product.coverUrl} className={`w-full h-full object-cover transition-transform duration-700 group-hover:scale-110 ${isPlayingPreview ? 'scale-110 blur-sm opacity-50' : ''}`} alt={product.title} />
{product.isHot && (
<div className="absolute top-2 left-2">
<Badge label="TRENDING" variant="gold" icon={<Zap className="w-3 h-3" />} />
</div>
)}
{/* Play Button Overlay */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button
onClick={(e) => { e.stopPropagation(); onPreview(product.id); }}
className="w-16 h-16 rounded-full bg-kodo-cyan text-black flex items-center justify-center hover:scale-110 transition-transform shadow-lg"
>
{isPlayingPreview ? <Pause className="w-8 h-8 fill-current" /> : <Play className="w-8 h-8 fill-current ml-1" />}
</button>
</div>
{/* Audio Viz Simulation */}
{isPlayingPreview && (
<div className="absolute bottom-0 left-0 right-0 h-1/2 flex items-end justify-center gap-1 px-8 pb-8">
{[...Array(12)].map((_, i) => (
<div key={i} className="w-2 bg-white rounded-t animate-pulse" style={{ height: `${Math.random() * 80 + 20}%`, animationDuration: '0.6s' }}></div>
))}
</div>
)}
</div>
{/* Details */}
<div className="p-5">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold text-white text-base truncate pr-2 group-hover:text-kodo-cyan transition-colors">{product.title}</h3>
<div className="flex flex-col items-end">
<span className="font-mono font-bold text-white">{product.currency === 'USD' ? '$' : 'Ξ'}{product.price}</span>
</div>
</div>
<p className="text-gray-400 text-xs mb-4 flex items-center gap-1">
by <span className="text-gray-300 hover:underline">{product.author}</span>
</p>
<div className="flex items-center gap-2 mb-4 text-xs text-kodo-gold">
<Star className="w-3 h-3 fill-current" />
<span className="font-bold">{product.rating}</span>
<span className="text-gray-500">({product.reviewCount || 0})</span>
</div>
<div className="flex gap-2">
<Button variant="primary" size="sm" className="flex-1" onClick={(e) => { e.stopPropagation(); onAddToCart(product); }}>
<ShoppingCart className="w-4 h-4 mr-2" /> ADD
</Button>
<Button variant="ghost" size="icon" className="border border-kodo-steel hover:bg-white/10" onClick={(e) => { e.stopPropagation(); onClick(product); }}>
<Eye className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
};

View file

@ -0,0 +1,261 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Card } from '../ui/card';
import { Product, ProductLicense } from '../../types';
import {
ArrowLeft, ShoppingCart, Heart, Share2, Play, Pause,
Star, Layers
} from 'lucide-react';
import { LicenceCard } from './LicenceCard';
import { LicenceDetailsModal } from './modals/LicenceDetailsModal';
import { ReviewProductModal } from './modals/ReviewProductModal';
import { useToast } from '../../context/ToastContext';
interface ProductDetailViewProps {
product: Product;
onBack: () => void;
onAddToCart: (product: Product, license?: ProductLicense) => void;
similarProducts: Product[];
}
export const ProductDetailView: React.FC<ProductDetailViewProps> = ({
product,
onBack,
onAddToCart,
similarProducts
}) => {
const { addToast } = useToast();
const [activeImage, setActiveImage] = useState(product.coverUrl);
const [isPlaying, setIsPlaying] = useState(false);
const [selectedLicenseId, setSelectedLicenseId] = useState<string>(product.licenses?.[0]?.id || '');
const [showLicenseInfo, setShowLicenseInfo] = useState<ProductLicense | null>(null);
const [showReviewModal, setShowReviewModal] = useState(false);
const selectedLicense = product.licenses?.find((l: ProductLicense) => l.id === selectedLicenseId);
const handleReviewSubmit = (_rating: number, _comment: string) => {
addToast("Review submitted for moderation", "success");
};
return (
<div className="animate-fadeIn pb-20">
{/* Header / Breadcrumb */}
<div className="mb-6 flex items-center gap-4">
<Button variant="ghost" onClick={onBack} icon={<ArrowLeft className="w-4 h-4" />}>Back to Market</Button>
<span className="text-gray-500 text-sm">/ {product.type} / {product.title}</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-12">
{/* Left Column: Visuals */}
<div className="lg:col-span-5 space-y-4">
<div className="relative aspect-square rounded-2xl overflow-hidden bg-black border border-kodo-steel shadow-2xl group">
<img src={activeImage} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/20 group-hover:bg-transparent transition-colors"></div>
{/* Audio Preview Overlay */}
<div className="absolute bottom-6 left-6 right-6 bg-black/60 backdrop-blur-md rounded-xl p-3 flex items-center gap-4 border border-white/10">
<button
onClick={() => setIsPlaying(!isPlaying)}
className="w-10 h-10 rounded-full bg-kodo-cyan text-black flex items-center justify-center hover:scale-110 transition-transform"
>
{isPlaying ? <Pause className="w-5 h-5 fill-current" /> : <Play className="w-5 h-5 fill-current ml-1" />}
</button>
<div className="flex-1">
<div className="text-xs font-bold text-white mb-1">Audio Preview</div>
<div className="h-1 bg-gray-600 rounded-full overflow-hidden">
<div className="h-full bg-kodo-cyan w-1/3 animate-pulse"></div>
</div>
</div>
</div>
</div>
{/* Thumbnails */}
{product.images && product.images.length > 1 && (
<div className="flex gap-4 overflow-x-auto pb-2">
{product.images.map((img: string, i: number) => (
<div
key={i}
onClick={() => setActiveImage(img)}
className={`w-20 h-20 rounded-lg overflow-hidden cursor-pointer border-2 transition-all ${activeImage === img ? 'border-kodo-cyan' : 'border-transparent opacity-60 hover:opacity-100'}`}
>
<img src={img} className="w-full h-full object-cover" />
</div>
))}
</div>
)}
</div>
{/* Right Column: Info & Purchase */}
<div className="lg:col-span-7 flex flex-col">
<div className="mb-6">
<div className="flex justify-between items-start mb-2">
<Badge label={product.type} variant="terminal" className="mb-2" />
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="border border-kodo-steel text-gray-400 hover:text-kodo-magenta"><Heart className="w-5 h-5" /></Button>
<Button variant="ghost" size="icon" className="border border-kodo-steel text-gray-400 hover:text-white"><Share2 className="w-5 h-5" /></Button>
</div>
</div>
<h1 className="text-4xl md:text-5xl font-display font-bold text-white mb-2 leading-tight">{product.title}</h1>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1 text-kodo-gold font-bold"><Star className="w-4 h-4 fill-current" /> {product.rating}</span>
<span></span>
<span>{product.reviewCount || 0} reviews</span>
<span></span>
<span className="text-kodo-cyan">{product.author}</span>
</div>
</div>
{/* Metadata Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-kodo-ink p-3 rounded border border-kodo-steel/50">
<div className="text-[10px] text-gray-500 uppercase font-bold">BPM</div>
<div className="text-white font-mono">{product.bpm || '-'}</div>
</div>
<div className="bg-kodo-ink p-3 rounded border border-kodo-steel/50">
<div className="text-[10px] text-gray-500 uppercase font-bold">Key</div>
<div className="text-white font-mono">{product.key || '-'}</div>
</div>
<div className="bg-kodo-ink p-3 rounded border border-kodo-steel/50">
<div className="text-[10px] text-gray-500 uppercase font-bold">Genre</div>
<div className="text-white truncate">{product.genre || '-'}</div>
</div>
<div className="bg-kodo-ink p-3 rounded border border-kodo-steel/50">
<div className="text-[10px] text-gray-500 uppercase font-bold">Size</div>
<div className="text-white">{product.size || '-'}</div>
</div>
</div>
{/* Licenses */}
<div className="mb-8">
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<Layers className="w-4 h-4" /> Select License
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{product.licenses?.map((license: ProductLicense) => (
<LicenceCard
key={license.id}
license={license}
selected={selectedLicenseId === license.id}
onSelect={(l) => setSelectedLicenseId(l.id)}
onInfo={(l) => setShowLicenseInfo(l)}
/>
))}
</div>
</div>
{/* Sticky Action Bar (Mobile optimized) */}
<div className="mt-auto bg-kodo-graphite border border-kodo-steel p-4 rounded-xl shadow-2xl flex flex-col md:flex-row gap-4 items-center">
<div className="flex-1">
<div className="text-xs text-gray-400 uppercase font-bold">Total Price</div>
<div className="text-3xl font-mono font-bold text-white">
${selectedLicense?.price || product.price}
</div>
</div>
<Button
variant="primary"
size="lg"
className="w-full md:w-auto px-8"
icon={<ShoppingCart className="w-5 h-5" />}
onClick={() => onAddToCart(product, selectedLicense)}
>
ADD TO CART
</Button>
</div>
</div>
</div>
{/* Bottom Content: Desc & Reviews */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-8">
<Card variant="default">
<h3 className="font-bold text-white text-xl mb-4 border-b border-kodo-steel pb-2">Description</h3>
<div className="prose prose-invert max-w-none text-gray-300">
<p>{product.description}</p>
<ul>
{product.features?.map((f: string, i: number) => <li key={i}>{f}</li>)}
</ul>
</div>
</Card>
<Card variant="default">
<div className="flex justify-between items-center mb-6 border-b border-kodo-steel pb-2">
<h3 className="font-bold text-white text-xl">Reviews</h3>
<Button variant="ghost" size="sm" onClick={() => setShowReviewModal(true)}>Write a Review</Button>
</div>
<div className="space-y-6">
{product.reviews?.map((review: any) => (
<div key={review.id} className="flex gap-4">
<img src={review.avatar} className="w-10 h-10 rounded-full bg-gray-700" />
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-bold text-white">{review.username}</span>
<div className="flex text-kodo-gold text-xs">
{[...Array(5)].map((_, i) => <Star key={i} className={`w-3 h-3 ${i < review.rating ? 'fill-current' : 'text-gray-600'}`} />)}
</div>
<span className="text-xs text-gray-500">{review.date}</span>
</div>
<p className="text-sm text-gray-300">{review.comment}</p>
</div>
</div>
))}
{(!product.reviews || product.reviews.length === 0) && (
<p className="text-gray-500 italic text-center py-4">No reviews yet. Be the first!</p>
)}
</div>
</Card>
</div>
<div className="space-y-8">
{/* Seller Info */}
<UserCard
user={{
username: product.author,
fullName: product.author, // Mock
avatar: 'https://picsum.photos/id/100/200/200',
stats: { followers: 1200, tracks: 45, following: 0, plays: 0 }
}}
onView={() => addToast("Viewing Seller Profile")}
/>
{/* More from Seller */}
<div>
<h3 className="font-bold text-white text-sm uppercase tracking-wider mb-4">More from {product.author}</h3>
<div className="space-y-4">
{similarProducts.slice(0, 3).map(p => (
<div key={p.id} className="flex gap-3 cursor-pointer group" onClick={() => addToast("Navigating to product...")}>
<img src={p.coverUrl} className="w-16 h-16 rounded bg-gray-800 object-cover" />
<div>
<h4 className="font-bold text-white text-sm group-hover:text-kodo-cyan">{p.title}</h4>
<p className="text-xs text-gray-500">{p.type}</p>
<p className="text-xs font-mono text-white mt-1">${p.price}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Modals */}
{showLicenseInfo && (
<LicenceDetailsModal
license={showLicenseInfo}
onClose={() => setShowLicenseInfo(null)}
onAddToCart={() => { setSelectedLicenseId(showLicenseInfo.id); onAddToCart(product, showLicenseInfo); }}
/>
)}
{showReviewModal && (
<ReviewProductModal
productTitle={product.title}
onClose={() => setShowReviewModal(false)}
onSubmit={handleReviewSubmit}
/>
)}
</div>
);
};

View file

@ -0,0 +1,75 @@
import React from 'react';
import { Button } from '../../ui/button';
import { X, ShieldCheck, Check, XCircle } from 'lucide-react';
import { ProductLicense } from '../../../types';
interface LicenceDetailsModalProps {
license: ProductLicense;
onClose: () => void;
onAddToCart: () => void;
}
export const LicenceDetailsModal: React.FC<LicenceDetailsModalProps> = ({ license, onClose, onAddToCart }) => {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-lg bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden flex flex-col max-h-[80vh]">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-kodo-cyan" /> License Agreement
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
<div className="flex justify-between items-end mb-6">
<div>
<h2 className="text-2xl font-bold text-white">{license.name} License</h2>
<p className="text-gray-400 text-sm">Review usage rights and restrictions.</p>
</div>
<div className="text-3xl font-mono font-bold text-kodo-cyan">${license.price}</div>
</div>
<div className="space-y-6">
<div>
<h4 className="text-sm font-bold text-white uppercase tracking-wider mb-3 border-b border-kodo-steel pb-1">What You Get</h4>
<ul className="space-y-2">
{license.features.map((feat, i) => (
<li key={i} className="flex items-start gap-3 text-sm text-gray-300">
<div className="mt-0.5 bg-kodo-lime/10 p-0.5 rounded-full"><Check className="w-3 h-3 text-kodo-lime" /></div>
{feat}
</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-bold text-white uppercase tracking-wider mb-3 border-b border-kodo-steel pb-1">Restrictions</h4>
<ul className="space-y-2">
<li className="flex items-start gap-3 text-sm text-gray-400">
<div className="mt-0.5 bg-kodo-red/10 p-0.5 rounded-full"><XCircle className="w-3 h-3 text-kodo-red" /></div>
Do not resell or redistribute as a sample pack.
</li>
<li className="flex items-start gap-3 text-sm text-gray-400">
<div className="mt-0.5 bg-kodo-red/10 p-0.5 rounded-full"><XCircle className="w-3 h-3 text-kodo-red" /></div>
Content ID registration is prohibited.
</li>
</ul>
</div>
<p className="text-xs text-gray-500 italic">
This is a simplified summary. Please read the full <a href="#" className="text-kodo-cyan hover:underline">legal contract</a> before purchasing.
</p>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Close</Button>
<Button variant="primary" onClick={() => { onAddToCart(); onClose(); }}>Add to Cart</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,84 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { X, Star, MessageSquare } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface ReviewProductModalProps {
productTitle: string;
onClose: () => void;
onSubmit: (rating: number, comment: string) => void;
}
export const ReviewProductModal: React.FC<ReviewProductModalProps> = ({ productTitle, onClose, onSubmit }) => {
const { addToast } = useToast();
const [rating, setRating] = useState(0);
const [comment, setComment] = useState('');
const [hoverRating, setHoverRating] = useState(0);
const handleSubmit = () => {
if (rating === 0) {
addToast("Please select a rating", "error");
return;
}
onSubmit(rating, comment);
onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-kodo-cyan" /> Write Review
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 space-y-6">
<div className="text-center">
<p className="text-gray-400 text-sm mb-2">How was your experience with</p>
<h4 className="font-bold text-white text-lg">{productTitle}?</h4>
</div>
<div className="flex justify-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
className="p-1 transition-transform hover:scale-110 focus:outline-none"
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
onClick={() => setRating(star)}
>
<Star
className={`w-8 h-8 transition-colors ${
star <= (hoverRating || rating)
? 'fill-kodo-gold text-kodo-gold'
: 'text-gray-600'
}`}
/>
</button>
))}
</div>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Your Review</label>
<textarea
className="w-full bg-kodo-void border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-cyan outline-none text-sm resize-none h-32"
placeholder="Share your thoughts on the quality, usability, and value..."
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit} disabled={rating === 0}>Submit Review</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,128 @@
import React, { useState } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Input, FileUpload } from '../ui/input';
import { X, Music, Package, BookOpen, CheckCircle, Tag, Lock, DollarSign } from 'lucide-react';
interface CreatorModalProps {
isOpen: boolean;
onClose: () => void;
}
export const CreatorModal: React.FC<CreatorModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState<'track' | 'product' | 'course'>('track');
const [step, setStep] = useState(1);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-4xl bg-kodo-graphite border border-kodo-steel rounded-2xl shadow-2xl animate-scaleIn overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex justify-between items-center p-6 border-b border-kodo-steel bg-kodo-ink">
<div className="flex items-center gap-4">
<h2 className="text-2xl font-display font-bold text-white">CREATOR STUDIO</h2>
<div className="flex bg-kodo-slate rounded-lg p-1">
<button
onClick={() => setActiveTab('track')}
className={`px-4 py-1.5 rounded text-sm font-bold transition-all flex items-center gap-2 ${activeTab === 'track' ? 'bg-kodo-cyan text-black' : 'text-gray-400 hover:text-white'}`}
>
<Music className="w-4 h-4" /> Track
</button>
<button
onClick={() => setActiveTab('product')}
className={`px-4 py-1.5 rounded text-sm font-bold transition-all flex items-center gap-2 ${activeTab === 'product' ? 'bg-kodo-magenta text-white' : 'text-gray-400 hover:text-white'}`}
>
<Package className="w-4 h-4" /> Product
</button>
<button
onClick={() => setActiveTab('course')}
className={`px-4 py-1.5 rounded text-sm font-bold transition-all flex items-center gap-2 ${activeTab === 'course' ? 'bg-kodo-gold text-black' : 'text-gray-400 hover:text-white'}`}
>
<BookOpen className="w-4 h-4" /> Course
</button>
</div>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-white"><X className="w-6 h-6" /></button>
</div>
{/* Content */}
<div className="p-8 flex-1 overflow-y-auto">
{/* Progress Stepper */}
<div className="flex justify-between max-w-lg mx-auto mb-10 relative">
<div className="absolute top-1/2 left-0 w-full h-1 bg-kodo-steel -z-10"></div>
{[1, 2, 3].map(i => (
<div key={i} className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm transition-colors ${step >= i ? 'bg-kodo-cyan text-black' : 'bg-kodo-slate text-gray-500'}`}>
{step > i ? <CheckCircle className="w-5 h-5" /> : i}
</div>
))}
</div>
{activeTab === 'track' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-6">
<FileUpload />
<div className="bg-kodo-slate p-4 rounded-lg border border-kodo-steel">
<h4 className="font-bold text-gray-400 text-sm mb-2 uppercase">File Requirements</h4>
<ul className="text-xs text-gray-500 space-y-1">
<li> WAV, FLAC, AIFF (Lossless preferred)</li>
<li> Max size 500MB</li>
<li> 44.1kHz / 24-bit minimum</li>
</ul>
</div>
</div>
<div className="space-y-4">
<Input label="Track Title" placeholder="e.g. Neon Nights" />
<div className="grid grid-cols-2 gap-4">
<Input label="BPM" placeholder="128" />
<Input label="Key" placeholder="F# Minor" />
</div>
<Input label="Genre" placeholder="Cyberpunk / Synthwave" />
<div className="pt-4">
<label className="block text-sm font-medium text-gray-400 mb-2 font-body">Visibility</label>
<div className="grid grid-cols-3 gap-2">
<button className="p-3 rounded border border-kodo-cyan bg-kodo-cyan/10 text-kodo-cyan flex flex-col items-center justify-center gap-2">
<Tag className="w-5 h-5" /> <span className="text-xs font-bold">Public</span>
</button>
<button className="p-3 rounded border border-kodo-steel bg-kodo-slate text-gray-400 flex flex-col items-center justify-center gap-2 hover:border-white hover:text-white">
<Lock className="w-5 h-5" /> <span className="text-xs font-bold">Private</span>
</button>
<button className="p-3 rounded border border-kodo-steel bg-kodo-slate text-gray-400 flex flex-col items-center justify-center gap-2 hover:border-white hover:text-white">
<DollarSign className="w-5 h-5" /> <span className="text-xs font-bold">Premium</span>
</button>
</div>
</div>
</div>
</div>
)}
{activeTab === 'product' && (
<div className="text-center py-10">
<h3 className="text-xl font-bold text-white mb-2">Sell Your Sounds</h3>
<p className="text-gray-400 mb-6">Create Sample Packs, Presets, or DAW Templates.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{['Sample Pack', 'Serum Presets', 'Ableton Template'].map(type => (
<Card key={type} variant="default" className="hover:border-kodo-magenta cursor-pointer group">
<Package className="w-8 h-8 text-kodo-magenta mb-4 group-hover:scale-110 transition-transform" />
<h4 className="font-bold">{type}</h4>
</Card>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-4">
<Button variant="ghost" onClick={onClose}>CANCEL</Button>
<Button variant="primary" onClick={() => setStep(s => Math.min(3, s + 1))}>
{step === 3 ? 'PUBLISH RELEASE' : 'NEXT STEP'}
</Button>
</div>
</div>
</div>
);
};

View file

@ -96,6 +96,43 @@ export function Pagination({
onPageChange(totalPages);
};
// CRITIQUE FIX #44: Gestion complète du clavier pour l'accessibilité
// Gérer les touches de navigation (flèches, Home, End) pour une meilleure accessibilité
const handleKeyDown = (e: React.KeyboardEvent, action: () => void, alternativeAction?: () => void) => {
// Les boutons HTML natifs gèrent déjà Enter et Space automatiquement
// On ne doit pas utiliser preventDefault() pour ces touches car cela peut interférer
// avec le comportement natif des boutons
// Gérer les flèches pour navigation
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
handlePrevious();
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
handleNext();
return;
}
// Gérer Home/End pour aller à la première/dernière page
if (e.key === 'Home') {
e.preventDefault();
handleFirst();
return;
}
if (e.key === 'End') {
e.preventDefault();
handleLast();
return;
}
// Pour Enter et Space, laisser le comportement natif du bouton
// Ne pas utiliser preventDefault() car les boutons HTML gèrent déjà ces touches
};
// FE-COMP-006: Calculate item range for display
const startItem = totalItems && itemsPerPage
? (currentPage - 1) * itemsPerPage + 1
@ -126,17 +163,13 @@ export function Pagination({
>
{showFirstLast && (
<Button
type="button"
variant="outline"
size="icon"
onClick={handleFirst}
disabled={currentPage === 1}
aria-label="Première page"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleFirst();
}
}}
onKeyDown={(e) => handleKeyDown(e, handleFirst)}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
<ChevronLeft className="h-4 w-4 -ml-2" aria-hidden="true" />
@ -145,17 +178,13 @@ export function Pagination({
)}
<Button
type="button"
variant="outline"
size="icon"
onClick={handlePrevious}
disabled={currentPage === 1}
aria-label="Page précédente"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePrevious();
}
}}
onKeyDown={(e) => handleKeyDown(e, handlePrevious)}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Page précédente</span>
@ -176,17 +205,13 @@ export function Pagination({
return (
<Button
key={page}
type="button"
variant={currentPage === page ? 'default' : 'outline'}
size="icon"
onClick={() => onPageChange(page)}
aria-label={`Aller à la page ${page}`}
aria-current={currentPage === page ? 'page' : undefined}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onPageChange(page);
}
}}
onKeyDown={(e) => handleKeyDown(e, () => onPageChange(page))}
className={cn(
'h-9 w-9',
currentPage === page && 'bg-primary text-primary-foreground',
@ -198,17 +223,13 @@ export function Pagination({
})}
<Button
type="button"
variant="outline"
size="icon"
onClick={handleNext}
disabled={currentPage === totalPages}
aria-label="Page suivante"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleNext();
}
}}
onKeyDown={(e) => handleKeyDown(e, handleNext)}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Page suivante</span>
@ -216,17 +237,13 @@ export function Pagination({
{showFirstLast && (
<Button
type="button"
variant="outline"
size="icon"
onClick={handleLast}
disabled={currentPage === totalPages}
aria-label="Dernière page"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleLast();
}
}}
onKeyDown={(e) => handleKeyDown(e, handleLast)}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
<ChevronRight className="h-4 w-4 -ml-2" aria-hidden="true" />

View file

@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { Tabs } from './Tabs';
@ -70,13 +70,12 @@ describe('Tabs Component', () => {
});
it('navigates to next tab with ArrowRight key', async () => {
const user = userEvent.setup();
render(<Tabs items={mockItems} defaultActiveId="tab1" />);
const tab1 = screen.getByText('Tab 1');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowRight}');
fireEvent.keyDown(tab1, { key: 'ArrowRight', code: 'ArrowRight' });
await waitFor(() => {
expect(screen.getByText('Content 2')).toBeInTheDocument();
@ -84,13 +83,12 @@ describe('Tabs Component', () => {
});
it('navigates to previous tab with ArrowLeft key', async () => {
const user = userEvent.setup();
render(<Tabs items={mockItems} defaultActiveId="tab2" />);
const tab2 = screen.getByText('Tab 2');
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
tab2.focus();
await user.keyboard('{ArrowLeft}');
fireEvent.keyDown(tab2, { key: 'ArrowLeft', code: 'ArrowLeft' });
await waitFor(() => {
expect(screen.getByText('Content 1')).toBeInTheDocument();
@ -98,13 +96,12 @@ describe('Tabs Component', () => {
});
it('navigates to first tab with Home key', async () => {
const user = userEvent.setup();
render(<Tabs items={mockItems} defaultActiveId="tab3" />);
const tab3 = screen.getByText('Tab 3');
const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
tab3.focus();
await user.keyboard('{Home}');
fireEvent.keyDown(tab3, { key: 'Home', code: 'Home' });
await waitFor(() => {
expect(screen.getByText('Content 1')).toBeInTheDocument();
@ -112,13 +109,12 @@ describe('Tabs Component', () => {
});
it('navigates to last tab with End key', async () => {
const user = userEvent.setup();
render(<Tabs items={mockItems} defaultActiveId="tab1" />);
const tab1 = screen.getByText('Tab 1');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{End}');
fireEvent.keyDown(tab1, { key: 'End', code: 'End' });
await waitFor(() => {
expect(screen.getByText('Content 3')).toBeInTheDocument();
@ -126,13 +122,12 @@ describe('Tabs Component', () => {
});
it('wraps around when navigating with ArrowRight at last tab', async () => {
const user = userEvent.setup();
render(<Tabs items={mockItems} defaultActiveId="tab3" />);
const tab3 = screen.getByText('Tab 3');
const tab3 = screen.getByRole('tab', { name: 'Tab 3' });
tab3.focus();
await user.keyboard('{ArrowRight}');
fireEvent.keyDown(tab3, { key: 'ArrowRight', code: 'ArrowRight' });
await waitFor(() => {
expect(screen.getByText('Content 1')).toBeInTheDocument();
@ -140,13 +135,12 @@ describe('Tabs Component', () => {
});
it('wraps around when navigating with ArrowLeft at first tab', async () => {
const user = userEvent.setup();
render(<Tabs items={mockItems} defaultActiveId="tab1" />);
const tab1 = screen.getByText('Tab 1');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowLeft}');
fireEvent.keyDown(tab1, { key: 'ArrowLeft', code: 'ArrowLeft' });
await waitFor(() => {
expect(screen.getByText('Content 3')).toBeInTheDocument();
@ -166,12 +160,11 @@ describe('Tabs Component', () => {
render(<Tabs items={itemsWithDisabled} />);
const tab2 = screen.getByText('Tab 2');
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
expect(tab2).toBeDisabled();
});
it('skips disabled tabs when navigating with keyboard', async () => {
const user = userEvent.setup();
const itemsWithDisabled = [
{ id: 'tab1', label: 'Tab 1', content: <div>Content 1</div> },
{
@ -185,10 +178,10 @@ describe('Tabs Component', () => {
render(<Tabs items={itemsWithDisabled} defaultActiveId="tab1" />);
const tab1 = screen.getByText('Tab 1');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
tab1.focus();
await user.keyboard('{ArrowRight}');
fireEvent.keyDown(tab1, { key: 'ArrowRight', code: 'ArrowRight' });
// Devrait aller à tab3, en sautant tab2 désactivé
await waitFor(() => {
@ -235,8 +228,8 @@ describe('Tabs Component', () => {
it('applies default variant styles', () => {
render(<Tabs items={mockItems} variant="default" />);
const tab1 = screen.getByText('Tab 1');
expect(tab1.closest('div')).toHaveClass('border-b');
const tabList = screen.getByRole('tablist');
expect(tabList).toHaveClass('border-b');
});
it('applies pills variant styles', () => {
@ -256,12 +249,12 @@ describe('Tabs Component', () => {
it('has correct ARIA attributes', () => {
render(<Tabs items={mockItems} defaultActiveId="tab1" />);
const tab1 = screen.getByText('Tab 1');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
expect(tab1).toHaveAttribute('role', 'tab');
expect(tab1).toHaveAttribute('aria-selected', 'true');
expect(tab1).toHaveAttribute('aria-controls', 'tabpanel-tab1');
const tabpanel = screen.getByText('Content 1').closest('[role="tabpanel"]');
const tabpanel = screen.getByRole('tabpanel');
expect(tabpanel).toHaveAttribute('id', 'tabpanel-tab1');
expect(tabpanel).toHaveAttribute('aria-labelledby', 'tab-tab1');
});
@ -269,11 +262,11 @@ describe('Tabs Component', () => {
it('sets tabIndex correctly', () => {
render(<Tabs items={mockItems} defaultActiveId="tab1" />);
const tab1 = screen.getByText('Tab 1');
expect(tab1).toHaveAttribute('tabIndex', '0');
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
expect(tab1).toHaveAttribute('tabindex', '0');
const tab2 = screen.getByText('Tab 2');
expect(tab2).toHaveAttribute('tabIndex', '-1');
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
expect(tab2).toHaveAttribute('tabindex', '-1');
});
it('applies custom className', () => {

View file

@ -29,9 +29,17 @@ export function Tabs({
variant = 'default',
className,
}: TabsProps) {
const [internalActiveId, setInternalActiveId] = useState(
defaultActiveId || items.find((item) => !item.disabled)?.id || items[0]?.id,
);
const getInitialActiveId = () => {
if (defaultActiveId) {
const defaultItem = items.find((item) => item.id === defaultActiveId);
if (defaultItem && !defaultItem.disabled) {
return defaultActiveId;
}
}
return items.find((item) => !item.disabled)?.id || items[0]?.id;
};
const [internalActiveId, setInternalActiveId] = useState(getInitialActiveId());
const tabRefs = useRef<Record<string, HTMLButtonElement>>({});
const isControlled = controlledActiveId !== undefined;
const activeId = isControlled ? controlledActiveId : internalActiveId;

View file

@ -0,0 +1,86 @@
import React, { useState, useRef, useEffect } from 'react';
import { Bell, Check } from 'lucide-react';
import { Button } from '../ui/button';
import { Notification } from '../../types';
import { NotificationItem } from './NotificationItem';
interface NotificationBellProps {
notifications: Notification[];
onMarkAllRead: () => void;
onRead: (id: string) => void;
onViewAll: () => void;
}
export const NotificationBell: React.FC<NotificationBellProps> = ({ notifications, onMarkAllRead, onRead, onViewAll }) => {
const [isOpen, setIsOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const unreadCount = notifications.filter(n => !n.read).length;
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div ref={wrapperRef} className="relative z-50">
<Button
variant="ghost"
size="sm"
className={`relative text-kodo-secondary hover:text-kodo-primary ${isOpen ? 'text-kodo-primary bg-white/5' : ''}`}
onClick={() => setIsOpen(!isOpen)}
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute top-1.5 right-2 w-2 h-2 bg-kodo-red rounded-full border-2 border-kodo-void animate-pulse"></span>
)}
</Button>
{isOpen && (
<div className="absolute top-full right-0 mt-4 w-96 bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl overflow-hidden animate-fadeIn origin-top-right ring-1 ring-white/5 flex flex-col max-h-[80vh]">
<div className="p-4 border-b border-kodo-steel/50 flex justify-between items-center bg-kodo-ink">
<div>
<h3 className="font-bold text-white">Notifications</h3>
<p className="text-xs text-gray-400">{unreadCount} unread</p>
</div>
{unreadCount > 0 && (
<button onClick={onMarkAllRead} className="text-xs text-kodo-cyan hover:underline flex items-center gap-1">
<Check className="w-3 h-3" /> Mark all read
</button>
)}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
{notifications.length === 0 ? (
<div className="text-center py-10 text-gray-500">
<Bell className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No new notifications</p>
</div>
) : (
notifications.slice(0, 5).map(n => (
<NotificationItem
key={n.id}
notification={n}
onRead={onRead}
onAction={() => { setIsOpen(false); onViewAll(); }}
/>
))
)}
</div>
<div className="p-2 border-t border-kodo-steel/30 bg-kodo-ink/50">
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={() => { setIsOpen(false); onViewAll(); }}>
View All Notifications
</Button>
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,65 @@
import React from 'react';
import { Heart, UserPlus, MessageSquare, DollarSign, Info, ShieldAlert, Circle } from 'lucide-react';
import { Notification } from '../../types';
import { Button } from '../ui/button';
interface NotificationItemProps {
notification: Notification;
onRead: (id: string) => void;
onAction?: (notification: Notification) => void;
}
export const NotificationItem: React.FC<NotificationItemProps> = ({ notification, onRead, onAction }) => {
const getIcon = () => {
switch (notification.type) {
case 'like': return <Heart className="w-4 h-4 text-kodo-magenta fill-current" />;
case 'follow': return <UserPlus className="w-4 h-4 text-kodo-cyan" />;
case 'mention': return <MessageSquare className="w-4 h-4 text-kodo-gold" />;
case 'sale': return <DollarSign className="w-4 h-4 text-kodo-lime" />;
case 'security': return <ShieldAlert className="w-4 h-4 text-kodo-red" />;
default: return <Info className="w-4 h-4 text-gray-400" />;
}
};
const getBgColor = () => {
if (notification.read) return 'bg-transparent';
switch (notification.type) {
case 'sale': return 'bg-kodo-lime/5 border-l-2 border-l-kodo-lime';
case 'security': return 'bg-kodo-red/5 border-l-2 border-l-kodo-red';
default: return 'bg-kodo-cyan/5 border-l-2 border-l-kodo-cyan';
}
};
return (
<div className={`p-4 rounded-lg flex items-start gap-4 transition-all hover:bg-white/5 group border border-transparent ${getBgColor()}`}>
<div className="flex-shrink-0 mt-1 p-2 bg-kodo-graphite rounded-full border border-kodo-steel shadow-sm">
{getIcon()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-200 leading-relaxed">
{notification.text}
</p>
<span className="text-xs text-gray-500 font-mono mt-1 block">{notification.time}</span>
</div>
<div className="flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{!notification.read && (
<button
onClick={() => onRead(notification.id)}
className="p-1.5 hover:bg-white/10 rounded-full text-kodo-cyan"
title="Mark as read"
>
<Circle className="w-3 h-3 fill-current" />
</button>
)}
{notification.actionUrl && (
<Button variant="ghost" size="sm" className="text-xs" onClick={() => onAction && onAction(notification)}>
View
</Button>
)}
</div>
</div>
);
};

View file

@ -18,6 +18,7 @@ import {
import { useToast } from '@/hooks/useToast';
import { QueuePanel } from './QueuePanel';
import { useState } from 'react';
import { logger } from '@/utils/logger';
import type { BaseComponentProps } from '../types';
/**
@ -112,7 +113,11 @@ export function AudioPlayer({ className: _className }: AudioPlayerProps = {}) {
if (isPlaying) {
audio.play().catch((err) => {
console.error('Playback error:', err);
logger.error('Playback error', {
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
trackId: currentTrack?.id,
});
pause();
});
} else {

View file

@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { Minimize2 } from 'lucide-react';
import { Button } from '../ui/button';
import { useAudio } from '../../context/AudioContext';
import { PlayerControls } from './PlayerControls';
import { LyricsPanel } from './LyricsPanel';
import { WaveformVisualizer } from '../ui/WaveformVisualizer';
interface FullPlayerProps {
onClose: () => void;
}
export const FullPlayer: React.FC<FullPlayerProps> = ({ onClose }) => {
const { currentTrack, currentTime, duration, progress, seek, visualizerSettings } = useAudio();
const [showLyrics, setShowLyrics] = useState(false);
if (!currentTrack) return null;
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const p = Math.max(0, Math.min(100, (x / rect.width) * 100));
seek(p);
};
return (
<div className="fixed inset-0 z-[60] bg-kodo-void flex flex-col animate-fadeIn">
{/* Ambient Backdrop */}
<div className="absolute inset-0 z-0 overflow-hidden">
<div className="absolute inset-0 bg-black/60 z-10 backdrop-blur-3xl"></div>
<img src={currentTrack.coverUrl} className="w-full h-full object-cover opacity-50 scale-125 blur-3xl" />
</div>
{/* Header */}
<div className="relative z-20 flex justify-between items-center p-8">
<Button variant="ghost" size="sm" onClick={onClose} className="text-white/50 hover:text-white">
<Minimize2 className="w-6 h-6" />
</Button>
<div className="flex gap-2">
<span className="px-3 py-1 bg-white/10 rounded-full text-xs font-bold text-white tracking-widest border border-white/20 backdrop-blur">
LOSSLESS
</span>
</div>
</div>
{/* Main Content */}
<div className="relative z-20 flex-1 flex flex-col md:flex-row items-center justify-center gap-12 px-8 pb-8 max-w-7xl mx-auto w-full">
{/* Left: Artwork & Metadata */}
<div className={`flex flex-col items-center md:items-start text-center md:text-left transition-all duration-500 ${showLyrics ? 'md:w-1/3' : 'md:w-1/2'}`}>
<div
className="aspect-square w-full max-w-[400px] rounded-2xl overflow-hidden shadow-2xl mb-8 border border-white/10 relative group cursor-pointer"
onClick={() => setShowLyrics(!showLyrics)}
>
<img src={currentTrack.coverUrl} className="w-full h-full object-cover" />
{/* Visualizer Overlay if enabled */}
{visualizerSettings.mode !== 'off' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-white font-bold uppercase tracking-widest text-sm border border-white px-4 py-2 rounded-full">
{showLyrics ? 'Hide Lyrics' : 'Show Lyrics'}
</span>
</div>
)}
</div>
<h1 className="text-4xl md:text-6xl font-display font-bold text-white mb-2 leading-tight">{currentTrack.title}</h1>
<h2 className="text-xl md:text-2xl text-kodo-cyan font-medium mb-1">{currentTrack.artist}</h2>
<p className="text-white/50 font-mono text-sm">{currentTrack.album} {currentTrack.genre}</p>
</div>
{/* Right: Lyrics View */}
{showLyrics && (
<div className="flex-1 h-[60vh] w-full md:w-auto animate-slideInRight">
<LyricsPanel />
</div>
)}
</div>
{/* Controls & Waveform */}
<div className="relative z-20 p-8 pb-12 max-w-4xl mx-auto w-full">
<div className="flex items-center justify-between text-xs font-mono text-white/50 mb-2">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
{/* Seek Bar / Waveform */}
<div className="h-16 mb-8 relative group" onClick={handleSeek}>
{visualizerSettings.mode === 'waveform' ? (
<WaveformVisualizer
progress={progress}
onSeek={seek}
height={64}
color="rgba(255,255,255,0.2)"
playedColor={visualizerSettings.color}
/>
) : (
<div className="h-2 bg-white/20 rounded-full cursor-pointer mt-7 relative">
<div className="absolute top-0 left-0 h-full bg-white rounded-full transition-all" style={{width: `${progress}%`}}>
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
</div>
)}
</div>
<PlayerControls layout="full" />
</div>
</div>
);
};

View file

@ -0,0 +1,63 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAudio } from '../../context/AudioContext';
import { Mic2, AlignLeft } from 'lucide-react';
export const LyricsPanel: React.FC = () => {
const { currentTrack, currentTime, seek, duration } = useAudio();
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
// Auto-scroll logic
useEffect(() => {
if (autoScroll && scrollRef.current && currentTrack?.lyrics) {
const activeIndex = currentTrack.lyrics.findIndex((line: { time: number; text: string }, i: number) => {
return currentTime >= line.time && (i === currentTrack.lyrics!.length - 1 || currentTime < currentTrack.lyrics![i+1].time);
});
if (activeIndex !== -1) {
const element = scrollRef.current.children[activeIndex] as HTMLElement;
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
}, [currentTime, currentTrack, autoScroll]);
if (!currentTrack?.lyrics) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-500 opacity-50">
<Mic2 className="w-16 h-16 mb-4" />
<p>No lyrics available</p>
</div>
);
}
return (
<div className="h-full flex flex-col relative group" onMouseEnter={() => setAutoScroll(false)} onMouseLeave={() => setAutoScroll(true)}>
<div className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setAutoScroll(!autoScroll)}
className={`p-2 rounded-full backdrop-blur-md ${autoScroll ? 'bg-kodo-cyan/20 text-kodo-cyan' : 'bg-black/30 text-gray-400'}`}
title="Auto-scroll"
>
<AlignLeft className="w-4 h-4" />
</button>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar px-4 space-y-6 text-center mask-image-linear-to-b py-20">
{currentTrack.lyrics.map((line: { time: number; text: string }, i: number) => {
const isActive = currentTime >= line.time && (i === currentTrack.lyrics!.length - 1 || currentTime < currentTrack.lyrics![i+1].time);
return (
<p
key={i}
className={`text-2xl md:text-3xl font-bold transition-all duration-500 cursor-pointer hover:text-white ${isActive ? 'text-white scale-105 origin-center' : 'text-white/20 blur-[1px]'}`}
onClick={() => seek((line.time / duration) * 100)}
>
{line.text}
</p>
);
})}
</div>
</div>
);
};

View file

@ -0,0 +1,92 @@
import React from 'react';
import { Maximize2, Heart, ListMusic, Mic2, Cast } from 'lucide-react';
import { useAudio } from '../../context/AudioContext';
import { useToast } from '../../context/ToastContext';
import { PlayerControls } from './PlayerControls';
interface MiniPlayerProps {
onExpand: () => void;
onToggleQueue: () => void;
isQueueOpen: boolean;
}
export const MiniPlayer: React.FC<MiniPlayerProps> = ({ onExpand, onToggleQueue, isQueueOpen }) => {
const { currentTrack, progress, seek, duration, currentTime } = useAudio();
const { addToast } = useToast();
if (!currentTrack) return null;
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const p = Math.max(0, Math.min(100, (x / rect.width) * 100));
seek(p);
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
};
return (
<div className="fixed bottom-0 left-0 right-0 h-24 bg-kodo-void/95 backdrop-blur-2xl border-t border-kodo-steel/40 z-50 px-4 md:px-6 flex items-center justify-between shadow-[0_-10px_40px_rgba(0,0,0,0.6)] animate-slideUp">
{/* 1. Track Info */}
<div className="flex items-center gap-4 w-[30%] min-w-[200px]">
<div className="w-14 h-14 rounded-lg overflow-hidden relative group cursor-pointer shadow-lg border border-white/5">
<img src={currentTrack.coverUrl} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/50 hidden group-hover:flex items-center justify-center" onClick={onExpand}>
<Maximize2 className="w-6 h-6 text-white" />
</div>
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-white font-heading font-bold truncate text-sm hover:underline cursor-pointer" onClick={onExpand}>{currentTrack.title}</h4>
</div>
<p className="text-gray-400 text-xs truncate hover:text-white cursor-pointer">{currentTrack.artist}</p>
</div>
<button className="text-gray-500 hover:text-kodo-magenta transition-colors ml-2" onClick={() => addToast("Added to Liked Songs")}>
<Heart className="w-5 h-5" />
</button>
</div>
{/* 2. Center Controls */}
<div className="flex flex-col items-center flex-1 max-w-2xl px-4">
<PlayerControls layout="compact" />
<div className="w-full flex items-center gap-3 group mt-1">
<span className="text-[10px] font-mono text-gray-500 w-8 text-right">{formatTime(currentTime)}</span>
{/* Progress Bar */}
<div
className="flex-1 h-1 bg-kodo-steel rounded-full cursor-pointer relative group/bar"
onClick={handleSeek}
>
<div className="absolute top-0 left-0 h-full bg-white rounded-full" style={{width: `${progress}%`}}></div>
<div className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover/bar:opacity-100 shadow-lg transition-opacity" style={{left: `${progress}%`}}></div>
</div>
<span className="text-[10px] font-mono text-gray-500 w-8">{formatTime(duration)}</span>
</div>
</div>
{/* 3. Right Actions */}
<div className="flex items-center justify-end gap-3 w-[30%] min-w-[200px]">
<button className="text-gray-400 hover:text-kodo-magenta transition-colors hidden xl:block" title="Lyrics" onClick={onExpand}>
<Mic2 className="w-4 h-4" />
</button>
<button className="text-gray-400 hover:text-kodo-lime transition-colors" title="Devices" onClick={() => addToast("Device Menu")}>
<Cast className="w-4 h-4" />
</button>
<button
className={`text-gray-400 hover:text-kodo-cyan transition-colors ${isQueueOpen ? 'text-kodo-cyan' : ''}`}
onClick={onToggleQueue}
>
<ListMusic className="w-5 h-5" />
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,71 @@
import React from 'react';
import { X, Gauge } from 'lucide-react';
import { useAudio } from '../../context/AudioContext';
interface PlaybackSpeedModalProps {
onClose: () => void;
}
export const PlaybackSpeedModal: React.FC<PlaybackSpeedModalProps> = ({ onClose }) => {
const { playbackRate, setPlaybackRate, pitchCorrection, togglePitchCorrection } = useAudio();
const presets = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
return (
<div className="absolute bottom-20 right-0 md:right-auto md:left-1/2 md:-translate-x-1/2 w-80 bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl z-50 animate-fadeIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2 text-sm">
<Gauge className="w-4 h-4 text-kodo-gold" /> Playback Speed
</h3>
<button onClick={onClose}><X className="w-4 h-4 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 space-y-6">
{/* Slider */}
<div className="space-y-2">
<div className="flex justify-between text-xs text-gray-400">
<span>0.5x</span>
<span className="font-bold text-kodo-gold text-lg">{playbackRate}x</span>
<span>2.0x</span>
</div>
<input
type="range"
min="0.5"
max="2.0"
step="0.05"
value={playbackRate}
onChange={(e) => setPlaybackRate(parseFloat(e.target.value))}
className="w-full h-1.5 bg-kodo-steel rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-kodo-gold [&::-webkit-slider-thumb]:rounded-full"
/>
</div>
{/* Presets */}
<div className="grid grid-cols-3 gap-2">
{presets.map(rate => (
<button
key={rate}
onClick={() => setPlaybackRate(rate)}
className={`px-2 py-1.5 rounded text-xs font-bold transition-all ${playbackRate === rate ? 'bg-kodo-gold text-black' : 'bg-kodo-slate text-gray-400 hover:text-white'}`}
>
{rate}x
</button>
))}
</div>
{/* Pitch Correction */}
<div className="flex items-center justify-between p-3 bg-kodo-ink rounded-lg border border-kodo-steel/50">
<div className="flex flex-col">
<span className="text-sm font-bold text-white">Pitch Correction</span>
<span className="text-[10px] text-gray-500">Maintain key when changing speed</span>
</div>
<div
onClick={togglePitchCorrection}
className={`w-10 h-5 rounded-full relative cursor-pointer transition-colors ${pitchCorrection ? 'bg-kodo-gold' : 'bg-gray-600'}`}
>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${pitchCorrection ? 'left-6' : 'left-1'}`}></div>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { Play, Pause, SkipBack, SkipForward, Shuffle, Repeat, Volume2, VolumeX, Gauge, SlidersHorizontal } from 'lucide-react';
import { useAudio } from '../../context/AudioContext';
import { PlaybackSpeedModal } from './PlaybackSpeedModal';
import { VisualizerSettingsModal } from './VisualizerSettingsModal';
interface PlayerControlsProps {
layout?: 'compact' | 'full';
}
export const PlayerControls: React.FC<PlayerControlsProps> = ({ layout = 'compact' }) => {
const {
isPlaying, togglePlay, nextTrack, prevTrack,
shuffle, toggleShuffle, repeatMode, toggleRepeat,
volume, setVolume, isMuted, toggleMute, playbackRate: _playbackRate
} = useAudio();
const [showSpeed, setShowSpeed] = useState(false);
const [showVisualizer, setShowVisualizer] = useState(false);
return (
<div className={`flex items-center ${layout === 'full' ? 'justify-between w-full max-w-4xl' : 'justify-center gap-4'}`}>
{/* 1. Playback Modifiers */}
<div className="flex items-center gap-4 relative">
<button
className={`transition-colors hover:text-white ${shuffle ? 'text-kodo-cyan' : 'text-gray-500'}`}
onClick={toggleShuffle}
title="Shuffle"
>
<Shuffle className={layout === 'full' ? "w-5 h-5" : "w-4 h-4"} />
</button>
<button
className={`transition-colors hover:text-white relative ${repeatMode !== 'off' ? 'text-kodo-cyan' : 'text-gray-500'}`}
onClick={toggleRepeat}
title="Repeat"
>
<Repeat className={layout === 'full' ? "w-5 h-5" : "w-4 h-4"} />
{repeatMode === 'one' && <span className="absolute -top-1 -right-1 text-[8px] font-bold">1</span>}
</button>
</div>
{/* 2. Main Transport */}
<div className="flex items-center gap-6">
<button className="text-gray-400 hover:text-white transition-colors hover:scale-110" onClick={prevTrack}>
<SkipBack className={layout === 'full' ? "w-8 h-8 fill-current" : "w-5 h-5 fill-current"} />
</button>
<button
onClick={togglePlay}
className={`${layout === 'full' ? "w-14 h-14" : "w-10 h-10"} rounded-full bg-white text-black flex items-center justify-center hover:scale-105 hover:shadow-[0_0_15px_rgba(255,255,255,0.5)] transition-all`}
>
{isPlaying ? <Pause className={layout === 'full' ? "w-6 h-6 fill-current" : "w-5 h-5 fill-current"} /> : <Play className={layout === 'full' ? "w-6 h-6 fill-current ml-1" : "w-5 h-5 fill-current ml-1"} />}
</button>
<button className="text-gray-400 hover:text-white transition-colors hover:scale-110" onClick={nextTrack}>
<SkipForward className={layout === 'full' ? "w-8 h-8 fill-current" : "w-5 h-5 fill-current"} />
</button>
</div>
{/* 3. Volume & Speed */}
<div className="flex items-center gap-3 relative">
{layout === 'full' && (
<>
<button
className={`text-gray-400 hover:text-kodo-gold transition-colors ${showSpeed ? 'text-kodo-gold' : ''}`}
onClick={() => { setShowSpeed(!showSpeed); setShowVisualizer(false); }}
title="Playback Speed"
>
<Gauge className="w-5 h-5" />
</button>
<button
className={`text-gray-400 hover:text-kodo-cyan transition-colors ${showVisualizer ? 'text-kodo-cyan' : ''}`}
onClick={() => { setShowVisualizer(!showVisualizer); setShowSpeed(false); }}
title="Visualizer Settings"
>
<SlidersHorizontal className="w-5 h-5" />
</button>
</>
)}
{/* Volume */}
<div className="flex items-center gap-2 group w-24">
<button onClick={toggleMute} className="text-gray-400 hover:text-white">
{isMuted || volume === 0 ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</button>
<div className="flex-1 h-1 bg-kodo-steel rounded-full cursor-pointer relative" onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setVolume(((e.clientX - rect.left) / rect.width) * 100);
}}>
<div
className={`absolute top-0 left-0 h-full bg-white rounded-full ${isMuted ? 'opacity-50' : 'opacity-100'}`}
style={{ width: `${isMuted ? 0 : volume}%` }}
></div>
</div>
</div>
{/* Modals Positioned Relative */}
{showSpeed && <PlaybackSpeedModal onClose={() => setShowSpeed(false)} />}
{showVisualizer && <VisualizerSettingsModal onClose={() => setShowVisualizer(false)} />}
</div>
</div>
);
};

View file

@ -0,0 +1,78 @@
import React from 'react';
import { X, Activity } from 'lucide-react';
import { useAudio, VisualizerSettings } from '../../context/AudioContext';
interface VisualizerSettingsModalProps {
onClose: () => void;
}
export const VisualizerSettingsModal: React.FC<VisualizerSettingsModalProps> = ({ onClose }) => {
const { visualizerSettings, setVisualizerSettings } = useAudio();
const updateSetting = (key: keyof VisualizerSettings, value: any) => {
setVisualizerSettings({ ...visualizerSettings, [key]: value });
};
const colors = ['#66FCF1', '#8A7EA4', '#36E5D1', '#E4B314', '#E63946'];
return (
<div className="absolute bottom-20 right-0 md:right-auto md:left-1/2 md:-translate-x-1/2 w-72 bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl z-50 animate-fadeIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2 text-sm">
<Activity className="w-4 h-4 text-kodo-cyan" /> Visualizer
</h3>
<button onClick={onClose}><X className="w-4 h-4 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-5 space-y-5">
{/* Mode */}
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Display Mode</label>
<div className="grid grid-cols-2 gap-2">
{['waveform', 'spectrogram', 'bars', 'off'].map(mode => (
<button
key={mode}
onClick={() => updateSetting('mode', mode)}
className={`px-2 py-1.5 rounded text-xs font-bold capitalize transition-all border ${visualizerSettings.mode === mode ? 'bg-kodo-cyan/10 border-kodo-cyan text-kodo-cyan' : 'bg-kodo-slate border-transparent text-gray-400 hover:text-white'}`}
>
{mode}
</button>
))}
</div>
</div>
{/* Sensitivity */}
<div>
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>Sensitivity</span>
<span>{visualizerSettings.sensitivity}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={visualizerSettings.sensitivity}
onChange={(e) => updateSetting('sensitivity', Number(e.target.value))}
className="w-full h-1 bg-kodo-steel rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-kodo-cyan [&::-webkit-slider-thumb]:rounded-full"
/>
</div>
{/* Color */}
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Accent Color</label>
<div className="flex gap-3">
{colors.map(c => (
<div
key={c}
onClick={() => updateSetting('color', c)}
className={`w-6 h-6 rounded-full cursor-pointer transition-transform hover:scale-110 ${visualizerSettings.color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-kodo-graphite' : ''}`}
style={{ backgroundColor: c }}
></div>
))}
</div>
</div>
</div>
</div>
);
};

View file

@ -5,6 +5,7 @@ import { searchTracks } from '@/features/tracks/services/trackListService';
import { searchPlaylists } from '@/features/playlists/services/playlistService';
import { searchUsers } from '@/features/search/services/searchService';
import { useTranslation } from '@/hooks/useTranslation';
import { logger } from '@/utils/logger';
/**
* FE-COMP-008: Global search bar component with autocomplete
@ -86,7 +87,10 @@ export function GlobalSearchBar({
return results.slice(0, 8); // Limit to 8 total suggestions
} catch (error) {
console.error('Error fetching search suggestions:', error);
logger.error('Error fetching search suggestions', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
return [];
}
},
@ -131,7 +135,9 @@ export function GlobalSearchBar({
showSuggestions={true}
showHistory={true}
className={className}
debounceDelay={300}
// CRITIQUE FIX #21: Augmenter le délai de debouncing à 500ms pour réduire les requêtes API
// Un délai de 500ms est optimal pour équilibrer réactivité et performance
debounceDelay={500}
/>
);
}

View file

@ -11,6 +11,7 @@ import {
List,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { logger } from '@/utils/logger';
export interface SearchResult {
id: string;
@ -80,7 +81,11 @@ export function Search({
const results = await Promise.resolve(fetchSuggestions(debouncedQuery));
setSuggestions(results);
} catch (error) {
console.error('Error fetching suggestions:', error);
logger.error('Error fetching suggestions', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
query: debouncedQuery,
});
setSuggestions([]);
} finally {
setIsLoading(false);

View file

@ -0,0 +1,139 @@
import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Clock, ArrowUpRight, Music, User, Disc } from 'lucide-react';
interface SearchBarProps {
initialQuery?: string;
onSearch: (query: string) => void;
placeholder?: string;
className?: string;
}
// Mock Suggestions
const SUGGESTIONS = [
{ type: 'artist', label: 'Neon Pulse', id: 'u1' },
{ type: 'track', label: 'Neon Nights', id: 't1' },
{ type: 'genre', label: 'Synthwave', id: 'g1' },
{ type: 'track', label: 'Cyber City 2099', id: 't2' },
{ type: 'artist', label: 'Deadmau5', id: 'u4' },
];
export const SearchBar: React.FC<SearchBarProps> = ({ initialQuery = '', onSearch, placeholder = "Search tracks, artists, gear...", className }) => {
const [query, setQuery] = useState(initialQuery);
const [isFocused, setIsFocused] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>(['Techno', 'Apollo Twin', 'Serum Presets']);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setIsFocused(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (!query.trim()) return;
onSearch(query);
setIsFocused(false);
if (!recentSearches.includes(query)) {
setRecentSearches(prev => [query, ...prev].slice(0, 5));
}
};
const handleSuggestionClick = (text: string) => {
setQuery(text);
onSearch(text);
setIsFocused(false);
};
const filteredSuggestions = query
? SUGGESTIONS.filter(s => s.label.toLowerCase().includes(query.toLowerCase()))
: [];
const getIcon = (type: string) => {
switch(type) {
case 'artist': return <User className="w-3 h-3" />;
case 'track': return <Music className="w-3 h-3" />;
case 'album': return <Disc className="w-3 h-3" />;
default: return <Search className="w-3 h-3" />;
}
};
return (
<div ref={wrapperRef} className={`relative w-full ${className}`}>
<form onSubmit={handleSubmit} className="relative z-20">
<div className={`flex items-center bg-kodo-ink border rounded-xl px-4 py-3 transition-all ${isFocused ? 'border-kodo-cyan shadow-neon-cyan/10 ring-1 ring-kodo-cyan/20' : 'border-kodo-steel'}`}>
<Search className={`w-5 h-5 mr-3 transition-colors ${isFocused ? 'text-kodo-cyan' : 'text-gray-500'}`} />
<input
type="text"
className="bg-transparent border-none text-white placeholder-gray-500 w-full focus:ring-0 outline-none text-base"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setIsFocused(true)}
/>
{query && (
<button type="button" onClick={() => setQuery('')} className="text-gray-500 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
)}
</div>
</form>
{isFocused && (
<div className="absolute top-full left-0 right-0 mt-2 bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl z-50 overflow-hidden animate-slideUp">
{query ? (
<div>
{filteredSuggestions.length > 0 ? (
<div className="py-2">
<div className="px-4 py-2 text-[10px] font-bold text-gray-500 uppercase">Top Results</div>
{filteredSuggestions.map((item, i) => (
<div
key={i}
className="px-4 py-2 hover:bg-white/5 cursor-pointer flex items-center gap-3 transition-colors"
onClick={() => handleSuggestionClick(item.label)}
>
<div className="w-8 h-8 rounded bg-kodo-slate flex items-center justify-center text-gray-400">
{getIcon(item.type)}
</div>
<div>
<div className="text-sm font-bold text-white">{item.label}</div>
<div className="text-xs text-gray-500 capitalize">{item.type}</div>
</div>
</div>
))}
</div>
) : (
<div className="p-4 text-center text-gray-500 text-sm">No results found for "{query}"</div>
)}
</div>
) : (
<div className="py-2">
<div className="px-4 py-2 flex justify-between items-center">
<span className="text-[10px] font-bold text-gray-500 uppercase">Recent Searches</span>
<button onClick={() => setRecentSearches([])} className="text-[10px] text-kodo-red hover:underline">Clear</button>
</div>
{recentSearches.map((term, i) => (
<div
key={i}
className="px-4 py-2 hover:bg-white/5 cursor-pointer flex items-center justify-between group transition-colors"
onClick={() => handleSuggestionClick(term)}
>
<div className="flex items-center gap-3 text-gray-300 group-hover:text-white">
<Clock className="w-4 h-4 text-gray-500" />
<span className="text-sm">{term}</span>
</div>
<ArrowUpRight className="w-3 h-3 text-gray-600 opacity-0 group-hover:opacity-100" />
</div>
))}
</div>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,189 @@
import React, { useState } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { useToast } from '../../context/ToastContext';
import { Save, UploadCloud, Image as ImageIcon, Music, Tag, DollarSign } from 'lucide-react';
interface LicenseConfig {
type: 'personal' | 'commercial' | 'exclusive';
enabled: boolean;
price: string;
}
export const CreateProductView: React.FC = () => {
const { addToast } = useToast();
const [_step, _setStep] = useState(1);
// Form State
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState('Sample Pack');
const [tags, setTags] = useState('');
const [bpm, setBpm] = useState('');
const [key, setKey] = useState('');
const [licenses, setLicenses] = useState<LicenseConfig[]>([
{ type: 'personal', enabled: true, price: '29.99' },
{ type: 'commercial', enabled: false, price: '49.99' },
{ type: 'exclusive', enabled: false, price: '199.99' },
]);
const updateLicense = (type: string, field: keyof LicenseConfig, value: any) => {
setLicenses(prev => prev.map(l => l.type === type ? { ...l, [field]: value } : l));
};
const handlePublish = () => {
if (!title || !description) {
addToast("Please fill in required fields", "error");
return;
}
addToast("Product published successfully!", "success");
// Redirect logic would go here
};
return (
<div className="animate-fadeIn max-w-4xl mx-auto pb-20">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-3xl font-display font-bold text-white mb-2">CREATE PRODUCT</h2>
<p className="text-gray-400 font-mono text-sm">Upload and monetize your sound assets.</p>
</div>
<div className="flex gap-3">
<Button variant="ghost" onClick={() => addToast("Draft saved")}>
<Save className="w-4 h-4 mr-2" /> Save Draft
</Button>
<Button variant="primary" onClick={handlePublish}>
Publish Product
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left: Media Uploads */}
<div className="space-y-6">
<Card variant="default">
<h3 className="font-bold text-white mb-4 flex items-center gap-2 text-sm uppercase tracking-wider">
<ImageIcon className="w-4 h-4 text-kodo-cyan" /> Cover Art
</h3>
<div className="aspect-square bg-kodo-ink border-2 border-dashed border-kodo-steel rounded-xl flex flex-col items-center justify-center text-gray-500 hover:text-white hover:border-kodo-cyan cursor-pointer transition-colors group">
<UploadCloud className="w-8 h-8 mb-2 group-hover:scale-110 transition-transform" />
<span className="text-xs font-bold uppercase">Upload Image</span>
</div>
<p className="text-xs text-gray-500 mt-2 text-center">3000x3000px JPG/PNG</p>
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-4 flex items-center gap-2 text-sm uppercase tracking-wider">
<Music className="w-4 h-4 text-kodo-magenta" /> Product Files
</h3>
<div className="space-y-4">
<div>
<label className="text-xs text-gray-400 mb-1 block">Main File (ZIP/RAR)</label>
<div className="h-20 bg-kodo-ink border border-kodo-steel rounded-lg flex items-center justify-center cursor-pointer hover:border-gray-500">
<span className="text-xs text-gray-500">Drop full product here</span>
</div>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Audio Preview (MP3)</label>
<div className="h-12 bg-kodo-ink border border-kodo-steel rounded-lg flex items-center justify-center cursor-pointer hover:border-gray-500">
<span className="text-xs text-gray-500">Drop preview audio</span>
</div>
</div>
</div>
</Card>
</div>
{/* Center/Right: Details & Pricing */}
<div className="lg:col-span-2 space-y-6">
<Card variant="default">
<h3 className="font-bold text-white mb-6 border-b border-kodo-steel pb-2">Product Details</h3>
<div className="space-y-4">
<Input label="Product Title" placeholder="e.g. Neon Nights Vol. 1" value={title} onChange={(e) => setTitle(e.target.value)} />
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">Description</label>
<textarea
className="w-full bg-kodo-graphite border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-cyan outline-none min-h-[120px]"
placeholder="Describe your sound pack..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">Category</label>
<select
className="w-full bg-kodo-graphite border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-cyan outline-none"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option>Sample Pack</option>
<option>Presets</option>
<option>DAW Template</option>
<option>MIDI Pack</option>
</select>
</div>
<Input label="Tags (comma separated)" placeholder="Techno, Drums, Dark" value={tags} onChange={(e) => setTags(e.target.value)} icon={<Tag className="w-4 h-4" />} />
</div>
<div className="grid grid-cols-3 gap-4">
<Input label="BPM" placeholder="128" value={bpm} onChange={(e) => setBpm(e.target.value)} />
<Input label="Key" placeholder="Fmin" value={key} onChange={(e) => setKey(e.target.value)} />
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">Format</label>
<div className="p-3 bg-kodo-ink border border-kodo-steel rounded-lg text-gray-400 text-sm">WAV 24-bit</div>
</div>
</div>
</div>
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-6 border-b border-kodo-steel pb-2 flex items-center gap-2">
<DollarSign className="w-5 h-5 text-kodo-gold" /> Pricing & Licenses
</h3>
<div className="space-y-4">
{licenses.map((lic) => (
<div key={lic.type} className={`p-4 rounded-xl border transition-all ${lic.enabled ? 'bg-kodo-ink border-kodo-cyan/30' : 'bg-kodo-ink/30 border-kodo-steel opacity-60'}`}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div
onClick={() => updateLicense(lic.type, 'enabled', !lic.enabled)}
className={`w-10 h-5 rounded-full relative cursor-pointer transition-colors ${lic.enabled ? 'bg-kodo-cyan' : 'bg-gray-600'}`}
>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${lic.enabled ? 'left-6' : 'left-1'}`}></div>
</div>
<div>
<div className="font-bold text-white capitalize">{lic.type} License</div>
<div className="text-xs text-gray-400">
{lic.type === 'personal' && 'Royalty-free for non-commercial use.'}
{lic.type === 'commercial' && 'Royalty-free for commercial releases.'}
{lic.type === 'exclusive' && 'Full rights transfer. Product removed after sale.'}
</div>
</div>
</div>
<div className="w-32">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
className="w-full bg-kodo-void border border-kodo-steel rounded-lg py-2 pl-6 pr-2 text-white focus:border-kodo-cyan outline-none text-right font-mono"
value={lic.price}
onChange={(e) => updateLicense(lic.type, 'price', e.target.value)}
disabled={!lic.enabled}
/>
</div>
</div>
</div>
</div>
))}
</div>
</Card>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Plus, TrendingUp, DollarSign, Package, Users, Eye, MoreHorizontal, Zap, Loader2 } from 'lucide-react';
import { FlashSaleModal } from './modals/FlashSaleModal';
import { useToast } from '../../context/ToastContext';
import { Product } from '../../types';
import { marketplaceService } from '../../services/marketplaceService';
import { commerceService } from '../../services/commerceService';
import { logger } from '@/utils/logger';
interface SellerDashboardProps {
onCreateProduct: () => void;
}
export const SellerDashboardView: React.FC<SellerDashboardProps> = ({ onCreateProduct }) => {
const { addToast } = useToast();
const [showFlashSale, setShowFlashSale] = useState(false);
const [products, setProducts] = useState<Product[]>([]);
const [sales, setSales] = useState<any[]>([]);
const [stats, setStats] = useState<any>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [prods, salesData, statsData] = await Promise.all([
marketplaceService.listProducts({ seller_id: 'me' }),
commerceService.getSales(),
commerceService.getSellerStats()
]);
setProducts(prods);
setSales(salesData);
setStats(statsData);
} catch (e) {
logger.error('Error loading seller dashboard data', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) return <div className="flex justify-center py-20"><Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" /></div>;
return (
<div className="animate-fadeIn space-y-8 pb-20">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-end gap-4">
<div>
<h2 className="text-3xl font-display font-bold text-white mb-2">SELLER DASHBOARD</h2>
<p className="text-gray-400 font-mono text-sm">Manage your products, sales, and analytics.</p>
</div>
<div className="flex gap-3">
<Button variant="gaming" icon={<Zap className="w-4 h-4" />} onClick={() => setShowFlashSale(true)}>
FLASH SALE
</Button>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={onCreateProduct}>
CREATE PRODUCT
</Button>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<DollarSign className="w-16 h-16 text-kodo-gold" />
</div>
<div className="text-gray-400 text-xs font-bold uppercase mb-1">Total Revenue</div>
<div className="text-3xl font-mono font-bold text-white mb-2">${stats.revenue?.toLocaleString()}</div>
<div className="text-xs text-kodo-lime flex items-center gap-1"><TrendingUp className="w-3 h-3" /> +12.5% this month</div>
</Card>
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Package className="w-16 h-16 text-kodo-cyan" />
</div>
<div className="text-gray-400 text-xs font-bold uppercase mb-1">Total Sales</div>
<div className="text-3xl font-mono font-bold text-white mb-2">{stats.sales}</div>
<div className="text-xs text-kodo-lime flex items-center gap-1"><TrendingUp className="w-3 h-3" /> +5.0% this month</div>
</Card>
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Eye className="w-16 h-16 text-kodo-magenta" />
</div>
<div className="text-gray-400 text-xs font-bold uppercase mb-1">Page Views</div>
<div className="text-3xl font-mono font-bold text-white mb-2">{stats.views > 1000 ? `${(stats.views/1000).toFixed(1) }K` : stats.views}</div>
<div className="text-xs text-kodo-red flex items-center gap-1"><TrendingUp className="w-3 h-3 rotate-180" /> -2.4% this month</div>
</Card>
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Users className="w-16 h-16 text-white" />
</div>
<div className="text-gray-400 text-xs font-bold uppercase mb-1">Conversion Rate</div>
<div className="text-3xl font-mono font-bold text-white mb-2">{stats.conversion}%</div>
<div className="text-xs text-kodo-lime flex items-center gap-1"><TrendingUp className="w-3 h-3" /> +0.8% this month</div>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Top Products */}
<div className="lg:col-span-2">
<Card variant="default" className="h-full">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-white">Top Products</h3>
<Button variant="ghost" size="sm">View All</Button>
</div>
<div className="space-y-4">
{products.map((product, i) => (
<div key={product.id} className="flex items-center gap-4 p-3 bg-kodo-ink rounded-lg border border-transparent hover:border-kodo-steel transition-all">
<div className="w-8 text-center font-mono text-gray-500">{i + 1}</div>
<img src={product.coverUrl} className="w-12 h-12 rounded object-cover" />
<div className="flex-1 min-w-0">
<div className="font-bold text-white truncate">{product.title}</div>
<div className="text-xs text-gray-400">{product.reviewCount} reviews {product.rating} stars</div>
</div>
<div className="text-right">
<div className="font-bold text-white">${product.price}</div>
<div className="text-xs text-kodo-cyan">{Math.floor(Math.random() * 100)} sales</div>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="w-4 h-4" /></Button>
</div>
))}
</div>
</Card>
</div>
{/* Recent Sales */}
<div>
<Card variant="default" className="h-full">
<h3 className="font-bold text-white mb-6">Recent Sales</h3>
<div className="space-y-4 relative">
<div className="absolute left-2.5 top-2 bottom-2 w-px bg-kodo-steel"></div>
{sales.map((sale) => (
<div key={sale.id} className="relative pl-8">
<div className="absolute left-0 top-1.5 w-5 h-5 bg-kodo-graphite border border-kodo-lime rounded-full flex items-center justify-center">
<div className="w-2 h-2 bg-kodo-lime rounded-full"></div>
</div>
<div className="text-sm text-white font-bold">{sale.product}</div>
<div className="text-xs text-gray-400 flex justify-between mt-1">
<span>{sale.buyer}</span>
<span>${sale.amount}</span>
</div>
<div className="text-[10px] text-gray-500 mt-1">{sale.date}</div>
</div>
))}
</div>
</Card>
</div>
</div>
{showFlashSale && (
<FlashSaleModal
products={products}
onClose={() => setShowFlashSale(false)}
onStart={(config) => addToast(`Flash Sale started for ${config.productIds.length} products!`, "success")}
/>
)}
</div>
);
};

View file

@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { X, Zap, Calendar, Percent, CheckSquare, Square } from 'lucide-react';
import { Product } from '../../../types';
import { useToast } from '../../../context/ToastContext';
interface FlashSaleModalProps {
products: Product[];
onClose: () => void;
onStart: (config: any) => void;
}
export const FlashSaleModal: React.FC<FlashSaleModalProps> = ({ products, onClose, onStart }) => {
const { addToast } = useToast();
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [discount, setDiscount] = useState(20);
const [duration, setDuration] = useState(24); // Hours
const toggleProduct = (id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(pid => pid !== id) : [...prev, id]);
};
const handleStart = () => {
if (selectedIds.length === 0) {
addToast("Select at least one product", "error");
return;
}
onStart({ productIds: selectedIds, discount, duration });
onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-2xl bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden flex flex-col max-h-[85vh]">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-kodo-gold" /> Start Flash Sale
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 flex flex-col md:flex-row gap-6 flex-1 overflow-hidden">
{/* Left: Configuration */}
<div className="w-full md:w-1/2 space-y-6">
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Discount Percentage</label>
<div className="relative">
<Percent className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="number"
className="w-full bg-kodo-void border border-kodo-steel rounded pl-10 pr-4 py-2 text-white focus:border-kodo-gold outline-none"
value={discount}
onChange={(e) => setDiscount(Number(e.target.value))}
min={5} max={90}
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Duration (Hours)</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<select
className="w-full bg-kodo-void border border-kodo-steel rounded pl-10 pr-4 py-2 text-white focus:border-kodo-gold outline-none appearance-none"
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
>
<option value={1}>1 Hour</option>
<option value={6}>6 Hours</option>
<option value={12}>12 Hours</option>
<option value={24}>24 Hours</option>
<option value={48}>48 Hours</option>
<option value={72}>3 Days</option>
</select>
</div>
</div>
<div className="bg-kodo-gold/10 border border-kodo-gold/30 p-4 rounded-lg">
<h4 className="text-kodo-gold font-bold text-sm mb-1">Impact Summary</h4>
<p className="text-xs text-gray-300">
Applying a <span className="font-bold text-white">{discount}%</span> discount to <span className="font-bold text-white">{selectedIds.length}</span> products.
Sale ends in {duration} hours.
</p>
</div>
</div>
{/* Right: Product Selection */}
<div className="w-full md:w-1/2 flex flex-col">
<div className="flex justify-between items-center mb-2">
<label className="block text-xs font-bold text-gray-400 uppercase">Select Products</label>
<button
className="text-xs text-kodo-cyan hover:underline"
onClick={() => setSelectedIds(selectedIds.length === products.length ? [] : products.map(p => p.id))}
>
{selectedIds.length === products.length ? 'Deselect All' : 'Select All'}
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar border border-kodo-steel rounded-lg bg-kodo-void p-2 space-y-1">
{products.map(product => (
<div
key={product.id}
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${selectedIds.includes(product.id) ? 'bg-kodo-gold/10 border border-kodo-gold/30' : 'hover:bg-kodo-ink border border-transparent'}`}
onClick={() => toggleProduct(product.id)}
>
<div className={`text-gray-500 ${selectedIds.includes(product.id) ? 'text-kodo-gold' : ''}`}>
{selectedIds.includes(product.id) ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
</div>
<img src={product.coverUrl} className="w-8 h-8 rounded object-cover" />
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-white truncate">{product.title}</div>
<div className="text-xs text-gray-500">${product.price}</div>
</div>
</div>
))}
</div>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="gaming" onClick={handleStart} className="border-kodo-gold text-kodo-gold hover:bg-kodo-gold/10">Launch Sale</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,128 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { Eye, Type, MousePointer } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
import { Switch } from '../../ui/switch';
export const AccessibilitySettingsView: React.FC = () => {
const { addToast } = useToast();
// States
const [toggles, setToggles] = useState({
highContrast: false,
reduceMotion: false,
keyboardHints: true,
screenReader: false,
dyslexiaFont: false,
underlineLinks: false,
largeCursor: false
});
const toggle = (key: keyof typeof toggles) => {
setToggles(prev => ({ ...prev, [key]: !prev[key] }));
addToast("Accessibility settings updated", "info");
};
return (
<div className="space-y-8 animate-fadeIn max-w-4xl mx-auto pb-20">
{/* Header */}
<div className="flex justify-between items-end border-b border-kodo-steel/50 pb-6">
<div>
<h2 className="text-3xl font-display font-bold text-white mb-2">ACCESSIBILITY</h2>
<p className="text-gray-400 font-mono text-sm">Tools to make the platform work for you.</p>
</div>
<Button variant="primary" onClick={() => addToast("Settings saved", "success")}>
Save Changes
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Visuals */}
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Eye className="w-5 h-5 text-kodo-cyan" /> Visuals
</h3>
<div className="space-y-4">
<ToggleRow
label="High Contrast"
desc="Increase contrast between text and background"
active={toggles.highContrast}
onToggle={() => toggle('highContrast')}
/>
<ToggleRow
label="Reduce Motion"
desc="Minimize animations and transitions"
active={toggles.reduceMotion}
onToggle={() => toggle('reduceMotion')}
/>
<ToggleRow
label="Underline Links"
desc="Add underlines to all hyperlinks"
active={toggles.underlineLinks}
onToggle={() => toggle('underlineLinks')}
/>
</div>
</Card>
{/* Typography & Input */}
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Type className="w-5 h-5 text-kodo-magenta" /> Text & Input
</h3>
<div className="space-y-4">
<ToggleRow
label="Dyslexia Friendly Font"
desc="Use OpenDyslexic font face"
active={toggles.dyslexiaFont}
onToggle={() => toggle('dyslexiaFont')}
/>
<ToggleRow
label="Screen Reader Optimization"
desc="Enhanced ARIA labels and structure"
active={toggles.screenReader}
onToggle={() => toggle('screenReader')}
/>
<ToggleRow
label="Keyboard Hints"
desc="Show shortcuts on interactive elements"
active={toggles.keyboardHints}
onToggle={() => toggle('keyboardHints')}
/>
</div>
</Card>
{/* Mouse & Focus */}
<Card variant="default" className="md:col-span-2">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<MousePointer className="w-5 h-5 text-kodo-gold" /> Cursor & Focus
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ToggleRow
label="Large Cursor"
desc="Increase mouse cursor size"
active={toggles.largeCursor}
onToggle={() => toggle('largeCursor')}
/>
</div>
</Card>
</div>
</div>
);
};
const ToggleRow: React.FC<{ label: string, desc: string, active: boolean, onToggle: () => void }> = ({ label, desc, active, onToggle }) => (
<div
onClick={onToggle}
className="flex items-center justify-between p-3 rounded-lg hover:bg-white/5 cursor-pointer group"
>
<div>
<div className="text-sm font-bold text-white group-hover:text-kodo-cyan transition-colors">{label}</div>
<div className="text-xs text-gray-400">{desc}</div>
</div>
<Switch checked={active} onChange={onToggle} />
</div>
);

View file

@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { useTheme } from '../../../context/ThemeContext';
import { ThemeVariant } from '../../../types';
import { Mail, User, Globe, Bell, Lock, ShieldAlert, Monitor, Moon, Sun, Laptop } from 'lucide-react';
import { ChangeEmailModal } from './ChangeEmailModal';
import { ChangeUsernameModal } from './ChangeUsernameModal';
import { DeleteAccountView } from './DeleteAccountView';
import { useToast } from '../../../context/ToastContext';
import { Switch } from '../../ui/switch';
export const AccountSettings: React.FC = () => {
const { theme, setTheme } = useTheme();
const { addToast } = useToast();
// State for sub-views and modals
const [view, setView] = useState<'main' | 'delete'>('main');
const [showEmailModal, setShowEmailModal] = useState(false);
const [showUsernameModal, setShowUsernameModal] = useState(false);
// Mock User Data
const [user] = useState({
username: 'Cyber_Producer',
email: 'alex@veza.io',
language: 'English (US)',
});
// Toggles State
const [toggles, setToggles] = useState({
emailNotif: true,
pushNotif: true,
publicProfile: true,
showActivity: true
});
const toggle = (key: keyof typeof toggles) => {
setToggles(prev => ({ ...prev, [key]: !prev[key] }));
addToast("Settings updated", "info");
};
if (view === 'delete') {
return <DeleteAccountView onBack={() => setView('main')} onLogout={() => window.location.reload()} />;
}
return (
<div className="space-y-8 animate-fadeIn max-w-4xl mx-auto pb-10">
{/* 1. IDENTITY SECTION */}
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<User className="w-5 h-5 text-kodo-cyan" /> Identity & Login
</h3>
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 bg-kodo-ink rounded-lg border border-kodo-steel/50">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-kodo-slate rounded-full flex items-center justify-center text-gray-400">
<Mail className="w-5 h-5" />
</div>
<div>
<div className="text-sm font-bold text-white">Email Address</div>
<div className="text-xs text-gray-400">{user.email}</div>
</div>
</div>
<Button variant="ghost" size="sm" className="border border-kodo-steel" onClick={() => setShowEmailModal(true)}>Change Email</Button>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 bg-kodo-ink rounded-lg border border-kodo-steel/50">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-kodo-slate rounded-full flex items-center justify-center text-gray-400">
<User className="w-5 h-5" />
</div>
<div>
<div className="text-sm font-bold text-white">Username</div>
<div className="text-xs text-gray-400">@{user.username}</div>
</div>
</div>
<Button variant="ghost" size="sm" className="border border-kodo-steel" onClick={() => setShowUsernameModal(true)}>Change Username</Button>
</div>
</div>
</Card>
{/* 2. PREFERENCES SECTION */}
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Monitor className="w-5 h-5 text-kodo-magenta" /> Preferences
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<label className="block text-sm font-bold text-gray-400 mb-3">Interface Theme</label>
<div className="grid grid-cols-3 gap-2">
{[
{ id: ThemeVariant.NEON, label: 'Dark', icon: <Moon className="w-4 h-4" /> },
{ id: ThemeVariant.LIGHT, label: 'Light', icon: <Sun className="w-4 h-4" /> },
{ id: ThemeVariant.GAMING, label: 'Game', icon: <Laptop className="w-4 h-4" /> },
].map((opt) => (
<button
key={opt.id}
onClick={() => setTheme(opt.id)}
className={`flex flex-col items-center justify-center gap-2 p-3 rounded-lg border transition-all ${theme === opt.id ? 'bg-kodo-cyan/10 border-kodo-cyan text-kodo-cyan' : 'bg-kodo-ink border-kodo-steel text-gray-400 hover:text-white'}`}
>
{opt.icon}
<span className="text-xs font-bold">{opt.label}</span>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-400 mb-3">Language</label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<select className="w-full bg-kodo-ink border border-kodo-steel rounded-lg py-2.5 pl-10 pr-4 text-white text-sm focus:border-kodo-cyan outline-none appearance-none cursor-pointer hover:border-gray-500 transition-colors">
<option>English (US)</option>
<option>Japanese</option>
<option>French</option>
<option>Spanish</option>
<option>German</option>
</select>
</div>
</div>
</div>
</Card>
{/* 3. NOTIFICATIONS & PRIVACY */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Bell className="w-5 h-5 text-kodo-lime" /> Notifications
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-300">Email Notifications</span>
<Switch checked={toggles.emailNotif} onChange={() => toggle('emailNotif')} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-300">Push Notifications</span>
<Switch checked={toggles.pushNotif} onChange={() => toggle('pushNotif')} />
</div>
</div>
</Card>
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Lock className="w-5 h-5 text-kodo-gold" /> Privacy
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-300 block">Public Profile</span>
<span className="text-[10px] text-gray-500">Allow others to find you</span>
</div>
<Switch checked={toggles.publicProfile} onChange={() => toggle('publicProfile')} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-gray-300 block">Activity Status</span>
<span className="text-[10px] text-gray-500">Show when you are online</span>
</div>
<Switch checked={toggles.showActivity} onChange={() => toggle('showActivity')} />
</div>
</div>
</Card>
</div>
{/* 4. DANGER ZONE */}
<Card variant="default" className="border-kodo-red/30 relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-kodo-red"></div>
<h3 className="text-lg font-bold text-white mb-2 flex items-center gap-2">
<ShieldAlert className="w-5 h-5 text-kodo-red" /> Danger Zone
</h3>
<p className="text-gray-400 text-sm mb-6">
Irreversible actions. Proceed with caution.
</p>
<div className="flex flex-col md:flex-row items-center justify-between gap-4 p-4 bg-kodo-red/5 rounded border border-kodo-red/20">
<div>
<div className="font-bold text-white text-sm">Delete Account</div>
<div className="text-xs text-gray-500">Permanently remove your account and all data.</div>
</div>
<Button
variant="ghost"
className="text-kodo-red hover:bg-kodo-red hover:text-white border border-kodo-red/50 w-full md:w-auto"
onClick={() => setView('delete')}
>
DELETE ACCOUNT
</Button>
</div>
</Card>
{/* MODALS */}
{showEmailModal && <ChangeEmailModal onClose={() => setShowEmailModal(false)} currentEmail={user.email} />}
{showUsernameModal && <ChangeUsernameModal onClose={() => setShowUsernameModal(false)} currentUsername={user.username} />}
</div>
);
};

View file

@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { X, Mail } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface ChangeEmailModalProps {
onClose: () => void;
currentEmail: string;
}
export const ChangeEmailModal: React.FC<ChangeEmailModalProps> = ({ onClose, currentEmail }) => {
const { addToast } = useToast();
const [newEmail, setNewEmail] = useState('');
const [password, setPassword] = useState('');
const [step, setStep] = useState(1);
const handleSendVerify = () => {
if (!newEmail || !password) {
addToast("Please fill in all fields", "error");
return;
}
if (newEmail === currentEmail) {
addToast("Please enter a different email", "error");
return;
}
setStep(2);
// Simulate API
addToast("Verification email sent", "success");
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white">Change Email Address</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6">
{step === 1 ? (
<div className="space-y-4">
<p className="text-sm text-gray-400">Current email: <span className="text-white font-mono">{currentEmail}</span></p>
<Input
label="New Email Address"
placeholder="name@example.com"
icon={<Mail className="w-4 h-4" />}
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
<Input
type="password"
label="Current Password"
placeholder="Confirm with password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<div className="bg-kodo-ink p-3 rounded text-xs text-gray-400 border border-kodo-steel">
We will send a verification link to your new email address. You must verify it before the change takes effect.
</div>
</div>
) : (
<div className="text-center py-4">
<div className="w-16 h-16 bg-kodo-cyan/20 rounded-full flex items-center justify-center mx-auto mb-4 text-kodo-cyan">
<Mail className="w-8 h-8" />
</div>
<h4 className="text-lg font-bold text-white mb-2">Check your inbox</h4>
<p className="text-sm text-gray-400 mb-6">
We've sent a verification link to <span className="text-white">{newEmail}</span>.
</p>
<Button variant="ghost" onClick={onClose}>Close</Button>
</div>
)}
</div>
{step === 1 && (
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSendVerify}>Send Verification</Button>
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { X, Check, Loader2, AlertCircle } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface ChangeUsernameModalProps {
onClose: () => void;
currentUsername: string;
}
export const ChangeUsernameModal: React.FC<ChangeUsernameModalProps> = ({ onClose, currentUsername }) => {
const { addToast } = useToast();
const [username, setUsername] = useState('');
const [isChecking, setIsChecking] = useState(false);
const [isAvailable, setIsAvailable] = useState<boolean | null>(null);
// Debounced check
useEffect(() => {
if (!username || username === currentUsername) {
setIsAvailable(null);
return;
}
setIsChecking(true);
const timer = setTimeout(() => {
setIsChecking(false);
// Mock logic: 'taken' is taken, otherwise available
setIsAvailable(username.toLowerCase() !== 'taken');
}, 500);
return () => clearTimeout(timer);
}, [username, currentUsername]);
const handleConfirm = () => {
if (isAvailable) {
addToast("Username updated successfully", "success");
onClose();
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white">Change Username</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 space-y-6">
<div className="relative">
<Input
label="New Username"
placeholder={currentUsername}
value={username}
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
maxLength={20}
/>
<div className="absolute right-4 top-[38px]">
{isChecking && <Loader2 className="w-4 h-4 animate-spin text-gray-400" />}
{!isChecking && isAvailable === true && <Check className="w-4 h-4 text-kodo-lime" />}
{!isChecking && isAvailable === false && <X className="w-4 h-4 text-kodo-red" />}
</div>
{/* Feedback Text */}
<div className="mt-2 text-xs h-4">
{!isChecking && isAvailable === true && <span className="text-kodo-lime">Username is available!</span>}
{!isChecking && isAvailable === false && <span className="text-kodo-red">That username is taken.</span>}
</div>
</div>
<div className="bg-kodo-gold/10 border border-kodo-gold/30 p-3 rounded flex gap-3">
<AlertCircle className="w-5 h-5 text-kodo-gold flex-shrink-0" />
<div className="text-xs text-gray-300">
<span className="font-bold text-kodo-gold block mb-1">Warning</span>
Changing your username will change your profile URL. Your old username <span className="font-mono text-white">{currentUsername}</span> will become available for anyone else to claim.
</div>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleConfirm} disabled={!isAvailable}>Confirm Change</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,81 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { X, AlertTriangle, Loader2 } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface DeleteAccountConfirmModalProps {
onClose: () => void;
onConfirm: () => void;
}
export const DeleteAccountConfirmModal: React.FC<DeleteAccountConfirmModalProps> = ({ onClose, onConfirm }) => {
const { addToast } = useToast();
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleDelete = () => {
if (!password) {
addToast("Please enter your password to confirm", "error");
return;
}
setLoading(true);
// Simulate API call
setTimeout(() => {
setLoading(false);
onConfirm();
}, 2000);
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={loading ? undefined : onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-red rounded-xl shadow-2xl overflow-hidden animate-scaleIn">
<div className="p-4 border-b border-kodo-red/30 bg-kodo-red/10 flex justify-between items-center">
<h3 className="font-bold text-kodo-red flex items-center gap-2">
<AlertTriangle className="w-5 h-5 fill-current" /> PERMANENT DELETION
</h3>
<button onClick={onClose} disabled={loading} className="text-gray-400 hover:text-white disabled:opacity-50"><X className="w-5 h-5" /></button>
</div>
<div className="p-6 space-y-4">
<p className="text-gray-300 text-sm">
This is the final step. Once you click delete, your account will be queued for immediate removal. You will be logged out.
</p>
<div className="bg-kodo-ink p-4 rounded border border-kodo-steel">
<ul className="text-xs text-gray-400 space-y-2 list-disc pl-4">
<li>All uploaded tracks and assets will be deleted.</li>
<li>Your username will be released.</li>
<li>Any active subscriptions will be cancelled immediately.</li>
</ul>
</div>
<div>
<label className="block text-sm font-bold text-white mb-2">Confirm Password</label>
<Input
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose} disabled={loading}>Cancel</Button>
<Button
variant="primary"
className="bg-red-600 hover:bg-red-700 border-red-500 text-white"
onClick={handleDelete}
disabled={loading}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'DELETE FOREVER'}
</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { AlertTriangle, ArrowLeft, Trash2 } from 'lucide-react';
import { DeleteAccountConfirmModal } from './DeleteAccountConfirmModal';
import { useToast } from '../../../context/ToastContext';
interface DeleteAccountViewProps {
onBack: () => void;
onLogout: () => void;
}
export const DeleteAccountView: React.FC<DeleteAccountViewProps> = ({ onBack, onLogout }) => {
const { addToast } = useToast();
const [confirmText, setConfirmText] = useState('');
const [checks, setChecks] = useState({
dataLoss: false,
irreversible: false,
subscription: false,
});
const [showFinalModal, setShowFinalModal] = useState(false);
const isFormValid = checks.dataLoss && checks.irreversible && checks.subscription && confirmText === 'DELETE';
const handleFinalDelete = () => {
addToast("Account deleted. Goodbye.", "info");
onLogout();
};
return (
<div className="animate-fadeIn max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-4 mb-2">
<button onClick={onBack} className="p-2 hover:bg-white/5 rounded-full text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</button>
<h2 className="text-2xl font-bold text-kodo-red">Delete Account</h2>
</div>
<Card variant="default" className="border-kodo-red/30 relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-kodo-red"></div>
<div className="flex gap-4 mb-6">
<div className="w-12 h-12 bg-kodo-red/10 rounded-full flex items-center justify-center text-kodo-red flex-shrink-0">
<AlertTriangle className="w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-bold text-white mb-2">We're sorry to see you go</h3>
<p className="text-gray-400 text-sm leading-relaxed">
Deleting your account is permanent and cannot be undone. Please read the warnings below carefully before proceeding.
</p>
</div>
</div>
<div className="space-y-4 mb-8">
<label className="flex items-start gap-3 p-4 bg-kodo-ink rounded border border-kodo-steel cursor-pointer hover:border-gray-500 transition-colors">
<input
type="checkbox"
className="mt-1 bg-kodo-graphite border-gray-600 text-kodo-red focus:ring-kodo-red rounded"
checked={checks.dataLoss}
onChange={(e) => setChecks({...checks, dataLoss: e.target.checked})}
/>
<div className="text-sm">
<span className="font-bold text-gray-200 block mb-1">I lose all my data</span>
<span className="text-gray-500">All uploaded tracks, projects, and assets will be permanently removed from our servers.</span>
</div>
</label>
<label className="flex items-start gap-3 p-4 bg-kodo-ink rounded border border-kodo-steel cursor-pointer hover:border-gray-500 transition-colors">
<input
type="checkbox"
className="mt-1 bg-kodo-graphite border-gray-600 text-kodo-red focus:ring-kodo-red rounded"
checked={checks.irreversible}
onChange={(e) => setChecks({...checks, irreversible: e.target.checked})}
/>
<div className="text-sm">
<span className="font-bold text-gray-200 block mb-1">This action is irreversible</span>
<span className="text-gray-500">I understand that Veza support cannot restore my account once deleted.</span>
</div>
</label>
<label className="flex items-start gap-3 p-4 bg-kodo-ink rounded border border-kodo-steel cursor-pointer hover:border-gray-500 transition-colors">
<input
type="checkbox"
className="mt-1 bg-kodo-graphite border-gray-600 text-kodo-red focus:ring-kodo-red rounded"
checked={checks.subscription}
onChange={(e) => setChecks({...checks, subscription: e.target.checked})}
/>
<div className="text-sm">
<span className="font-bold text-gray-200 block mb-1">Subscriptions will be cancelled</span>
<span className="text-gray-500">Any active Pro or Enterprise subscriptions will be terminated immediately. No refunds will be issued.</span>
</div>
</label>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-bold text-white mb-2">To confirm, type "<span className="font-mono text-kodo-red">DELETE</span>" below</label>
<Input
placeholder="DELETE"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
className="border-kodo-red/50 focus:border-kodo-red focus:ring-kodo-red"
/>
</div>
<Button
variant="primary"
className="w-full bg-red-600 hover:bg-red-700 text-white border-red-500 disabled:bg-gray-800 disabled:border-gray-700 disabled:text-gray-500"
disabled={!isFormValid}
onClick={() => setShowFinalModal(true)}
icon={<Trash2 className="w-4 h-4" />}
>
Delete Account
</Button>
</div>
</Card>
{showFinalModal && (
<DeleteAccountConfirmModal
onClose={() => setShowFinalModal(false)}
onConfirm={handleFinalDelete}
/>
)}
</div>
);
};

View file

@ -0,0 +1,166 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { useTheme } from '../../../context/ThemeContext';
import { ThemeVariant } from '../../../types';
import { Moon, Sun, Monitor, Type, Layout, Grid, Palette, Check } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
import { Switch } from '../../ui/switch';
export const AppearanceSettingsView: React.FC = () => {
const { theme, setTheme } = useTheme();
const { addToast } = useToast();
const [density, setDensity] = useState<'comfortable' | 'compact' | 'cozy'>('comfortable');
const [fontSize, setFontSize] = useState(16);
const [accentColor, setAccentColor] = useState('cyan');
const [showSidebar, setShowSidebar] = useState(true);
const accents = [
{ id: 'cyan', hex: '#66FCF1' },
{ id: 'magenta', hex: '#8A7EA4' },
{ id: 'lime', hex: '#36E5D1' },
{ id: 'gold', hex: '#E4B314' },
{ id: 'red', hex: '#E63946' },
];
return (
<div className="space-y-8 animate-fadeIn max-w-4xl mx-auto pb-20">
{/* Header */}
<div className="flex justify-between items-end border-b border-kodo-steel/50 pb-6">
<div>
<h2 className="text-3xl font-display font-bold text-white mb-2">INTERFACE</h2>
<p className="text-gray-400 font-mono text-sm">Customize your visual experience.</p>
</div>
<Button variant="primary" onClick={() => addToast("Appearance settings saved", "success")}>
Save Changes
</Button>
</div>
{/* Theme Selection */}
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Palette className="w-5 h-5 text-kodo-cyan" /> Theme
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ id: ThemeVariant.NEON, label: 'Neon Dark', icon: <Moon className="w-6 h-6" /> },
{ id: ThemeVariant.LIGHT, label: 'Light Mode', icon: <Sun className="w-6 h-6" /> },
{ id: ThemeVariant.GAMING, label: 'High Contrast', icon: <Monitor className="w-6 h-6" /> },
].map((opt) => (
<div
key={opt.id}
onClick={() => setTheme(opt.id)}
className={`
cursor-pointer p-6 rounded-xl border-2 transition-all flex flex-col items-center gap-3 relative
${theme === opt.id ? 'border-kodo-cyan bg-kodo-cyan/5' : 'border-kodo-steel bg-kodo-ink hover:border-gray-500'}
`}
>
<div className={`p-3 rounded-full ${theme === opt.id ? 'bg-kodo-cyan text-black' : 'bg-kodo-slate text-gray-400'}`}>
{opt.icon}
</div>
<span className={`font-bold ${theme === opt.id ? 'text-white' : 'text-gray-400'}`}>{opt.label}</span>
{theme === opt.id && <div className="absolute top-2 right-2 text-kodo-cyan"><Check className="w-4 h-4" /></div>}
</div>
))}
</div>
</Card>
{/* Display Density */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Grid className="w-5 h-5 text-kodo-magenta" /> Density
</h3>
<div className="space-y-3">
{[
{ id: 'comfortable', label: 'Comfortable', desc: 'More whitespace for readability' },
{ id: 'cozy', label: 'Cozy', desc: 'Balanced spacing' },
{ id: 'compact', label: 'Compact', desc: 'Maximum data density' },
].map((opt) => (
<div
key={opt.id}
onClick={() => setDensity(opt.id as any)}
className={`
flex items-center gap-4 p-3 rounded-lg border cursor-pointer transition-all
${density === opt.id ? 'bg-kodo-magenta/10 border-kodo-magenta' : 'bg-kodo-ink border-kodo-steel hover:bg-white/5'}
`}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${density === opt.id ? 'border-kodo-magenta' : 'border-gray-500'}`}>
{density === opt.id && <div className="w-2 h-2 rounded-full bg-kodo-magenta"></div>}
</div>
<div>
<div className={`text-sm font-bold ${density === opt.id ? 'text-white' : 'text-gray-300'}`}>{opt.label}</div>
<div className="text-xs text-gray-500">{opt.desc}</div>
</div>
</div>
))}
</div>
</Card>
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Type className="w-5 h-5 text-kodo-gold" /> Typography
</h3>
<div className="space-y-6">
<div>
<div className="flex justify-between text-sm text-gray-400 mb-2">
<span>Font Size</span>
<span>{fontSize}px</span>
</div>
<input
type="range"
min="12"
max="20"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="w-full h-2 bg-kodo-steel rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-kodo-gold [&::-webkit-slider-thumb]:rounded-full"
/>
<div className="mt-4 p-4 bg-kodo-ink rounded border border-kodo-steel text-gray-300" style={{ fontSize: `${fontSize}px` }}>
The quick brown fox jumps over the lazy dog.
</div>
</div>
</div>
</Card>
</div>
{/* Colors & Layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Palette className="w-5 h-5 text-kodo-lime" /> Accent Color
</h3>
<div className="flex gap-4">
{accents.map((col) => (
<div
key={col.id}
onClick={() => setAccentColor(col.id)}
className={`w-10 h-10 rounded-full cursor-pointer flex items-center justify-center transition-transform hover:scale-110 ring-2 ring-offset-2 ring-offset-kodo-void ${accentColor === col.id ? 'ring-white' : 'ring-transparent'}`}
style={{ backgroundColor: col.hex }}
>
{accentColor === col.id && <Check className="w-5 h-5 text-black" />}
</div>
))}
</div>
</Card>
<Card variant="default">
<h3 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Layout className="w-5 h-5 text-gray-400" /> Layout
</h3>
<div
className="flex items-center justify-between p-4 bg-kodo-ink rounded-lg border border-kodo-steel cursor-pointer hover:border-gray-500"
onClick={() => setShowSidebar(!showSidebar)}
>
<div>
<div className="text-sm font-bold text-white">Show Sidebar</div>
<div className="text-xs text-gray-400">Toggle the main navigation sidebar</div>
</div>
<Switch checked={showSidebar} onChange={() => setShowSidebar(!showSidebar)} />
</div>
</Card>
</div>
</div>
);
};

View file

@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { Backup } from '../../../types';
import { Database, Clock, Download, RefreshCcw, CheckCircle, AlertTriangle, HardDrive } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
const MOCK_BACKUPS: Backup[] = [
{ id: 'b1', date: '2023-10-25 04:00 AM', type: 'Full', size: '4.2 GB', status: 'Success', location: 'Cloud' },
{ id: 'b2', date: '2023-10-24 04:00 AM', type: 'Incremental', size: '120 MB', status: 'Success', location: 'Cloud' },
{ id: 'b3', date: '2023-10-23 04:00 AM', type: 'Incremental', size: '450 MB', status: 'Failed', location: 'Cloud' },
];
export const BackupsView: React.FC = () => {
const { addToast } = useToast();
const [backups, setBackups] = useState<Backup[]>(MOCK_BACKUPS);
const [autoBackup, setAutoBackup] = useState(true);
const handleCreateBackup = () => {
addToast("Backup process started...", "info");
setTimeout(() => {
const newBackup: Backup = {
id: `b-${Date.now()}`,
date: 'Just now',
type: 'Full',
size: '1.5 GB',
status: 'Success',
location: 'Cloud'
};
setBackups([newBackup, ...backups]);
addToast("Backup completed successfully", "success");
}, 2000);
};
const handleRestore = (id: string) => {
addToast(`Restoring from backup ${id}...`, "info");
};
return (
<div className="max-w-5xl mx-auto space-y-8 animate-fadeIn">
{/* Header Actions */}
<div className="flex flex-col md:flex-row justify-between items-end gap-4">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Database className="w-6 h-6 text-kodo-gold" /> System Backups
</h2>
<p className="text-gray-400 text-sm">Manage restore points and redundancy.</p>
</div>
<Button variant="primary" icon={<HardDrive className="w-4 h-4" />} onClick={handleCreateBackup}>
CREATE BACKUP NOW
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* List */}
<div className="lg:col-span-2 space-y-4">
<Card variant="default">
<h3 className="font-bold text-white mb-4">Recent Backups</h3>
<div className="space-y-2">
{backups.map(backup => (
<div key={backup.id} className="flex flex-col md:flex-row items-center justify-between p-4 bg-kodo-ink rounded-lg border border-kodo-steel hover:border-kodo-cyan/30 transition-all">
<div className="flex items-center gap-4 mb-2 md:mb-0 w-full md:w-auto">
<div className={`p-2 rounded-full ${backup.status === 'Success' ? 'bg-kodo-lime/10 text-kodo-lime' : 'bg-kodo-red/10 text-kodo-red'}`}>
{backup.status === 'Success' ? <CheckCircle className="w-5 h-5" /> : <AlertTriangle className="w-5 h-5" />}
</div>
<div>
<div className="font-bold text-white text-sm">{backup.date}</div>
<div className="text-xs text-gray-400 flex gap-2">
<span>{backup.type}</span>
<span></span>
<span>{backup.size}</span>
<span></span>
<span>{backup.location}</span>
</div>
</div>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button variant="ghost" size="sm" className="flex-1 md:flex-none border border-kodo-steel" onClick={() => handleRestore(backup.id)}>
<RefreshCcw className="w-4 h-4 mr-2" /> Restore
</Button>
<Button variant="ghost" size="sm" className="flex-1 md:flex-none border border-kodo-steel">
<Download className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
</Card>
</div>
{/* Scheduling Sidebar */}
<div className="space-y-6">
<Card variant="gaming">
<h3 className="font-bold text-white mb-4 flex items-center gap-2 text-sm uppercase tracking-wider">
<Clock className="w-4 h-4 text-kodo-cyan" /> Schedule
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-white font-bold">Auto-Backup</div>
<div className="text-xs text-gray-400">Daily incremental backups</div>
</div>
<div
onClick={() => setAutoBackup(!autoBackup)}
className={`w-10 h-5 rounded-full relative cursor-pointer transition-colors ${autoBackup ? 'bg-kodo-cyan' : 'bg-gray-600'}`}
>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${autoBackup ? 'left-6' : 'left-1'}`}></div>
</div>
</div>
{autoBackup && (
<div className="animate-fadeIn space-y-3 pt-2 border-t border-gray-700">
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-1">Frequency</label>
<select className="w-full bg-kodo-void border border-kodo-steel rounded p-2 text-white text-sm">
<option>Daily</option>
<option>Weekly</option>
<option>Monthly</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-1">Time</label>
<input type="time" className="w-full bg-kodo-void border border-kodo-steel rounded p-2 text-white text-sm" defaultValue="04:00" />
</div>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-1">Retention</label>
<select className="w-full bg-kodo-void border border-kodo-steel rounded p-2 text-white text-sm">
<option>Keep last 7 days</option>
<option>Keep last 30 days</option>
<option>Keep forever</option>
</select>
</div>
</div>
)}
</div>
</Card>
<div className="bg-kodo-orange/10 border border-kodo-orange/30 p-4 rounded-xl flex gap-3">
<AlertTriangle className="w-6 h-6 text-kodo-orange flex-shrink-0" />
<div>
<h4 className="font-bold text-kodo-orange text-sm mb-1">Disaster Recovery</h4>
<p className="text-xs text-gray-300">
Off-site cold storage is available for Enterprise plans. <a href="#" className="underline">Learn more</a>.
</p>
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,143 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Cloud, CheckCircle, RefreshCw, Server, Shield } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
import { ProgressBar } from '../../ui/progress';
export const CloudIntegrationView: React.FC = () => {
const { addToast } = useToast();
const [isConnected, setIsConnected] = useState(false);
const [url, setUrl] = useState('');
const [username, setUsername] = useState('');
const [autoSync, setAutoSync] = useState(true);
const handleConnect = () => {
if (!url || !username) return;
// Simulate connection
addToast("Connecting to Nextcloud...", "info");
setTimeout(() => {
setIsConnected(true);
addToast("Connected to Cloud Storage", "success");
}, 1500);
};
return (
<div className="max-w-4xl mx-auto space-y-8 animate-fadeIn">
{/* Connection Status */}
<Card variant="default" className="border-t-4 border-t-kodo-cyan">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div className="flex items-center gap-4">
<div className={`w-16 h-16 rounded-full flex items-center justify-center ${isConnected ? 'bg-kodo-cyan text-black' : 'bg-gray-700 text-gray-400'}`}>
<Cloud className="w-8 h-8" />
</div>
<div>
<h2 className="text-2xl font-bold text-white">Nextcloud Integration</h2>
<p className="text-gray-400 text-sm">Sync your projects, samples, and presets.</p>
</div>
</div>
{isConnected && (
<div className="flex items-center gap-2 text-kodo-lime bg-kodo-lime/10 px-4 py-2 rounded-lg border border-kodo-lime/20">
<CheckCircle className="w-5 h-5" /> Connected
</div>
)}
</div>
{!isConnected ? (
<div className="mt-8 space-y-4 max-w-md">
<Input label="Server URL" placeholder="https://cloud.example.com" value={url} onChange={(e) => setUrl(e.target.value)} icon={<GlobeIcon className="w-4 h-4" />} />
<Input label="Username" placeholder="user@example.com" value={username} onChange={(e) => setUsername(e.target.value)} icon={<UserIcon className="w-4 h-4" />} />
<Input label="Password / App Token" type="password" placeholder="••••••••" icon={<LockIcon className="w-4 h-4" />} />
<Button variant="primary" className="w-full" onClick={handleConnect}>Connect Account</Button>
</div>
) : (
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-kodo-ink p-4 rounded-lg border border-kodo-steel text-center">
<Server className="w-6 h-6 text-kodo-cyan mx-auto mb-2" />
<div className="text-sm font-bold text-white">{url}</div>
<div className="text-xs text-gray-500">Host</div>
</div>
<div className="bg-kodo-ink p-4 rounded-lg border border-kodo-steel text-center">
<RefreshCw className="w-6 h-6 text-kodo-gold mx-auto mb-2" />
<div className="text-sm font-bold text-white">Every 15 mins</div>
<div className="text-xs text-gray-500">Sync Frequency</div>
</div>
<div className="bg-kodo-ink p-4 rounded-lg border border-kodo-steel text-center">
<Shield className="w-6 h-6 text-kodo-lime mx-auto mb-2" />
<div className="text-sm font-bold text-white">Encrypted</div>
<div className="text-xs text-gray-500">Status</div>
</div>
</div>
)}
</Card>
{/* Sync Settings */}
{isConnected && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<Card variant="default">
<h3 className="font-bold text-white mb-4 border-b border-kodo-steel pb-2">Sync Preferences</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-white font-bold">Auto-Sync</div>
<div className="text-xs text-gray-400">Automatically upload new projects</div>
</div>
<div
onClick={() => setAutoSync(!autoSync)}
className={`w-10 h-5 rounded-full relative cursor-pointer transition-colors ${autoSync ? 'bg-kodo-cyan' : 'bg-gray-600'}`}
>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${autoSync ? 'left-6' : 'left-1'}`}></div>
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Sync Frequency</label>
<select className="w-full bg-kodo-ink border border-kodo-steel rounded p-2 text-white outline-none focus:border-kodo-cyan">
<option>Every 15 minutes</option>
<option>Hourly</option>
<option>Daily</option>
<option>Manual Only</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Selective Sync</label>
<div className="flex gap-2">
{['Projects', 'Samples', 'Presets'].map(type => (
<span key={type} className="px-3 py-1 bg-kodo-slate rounded text-xs text-white border border-kodo-steel cursor-pointer hover:border-kodo-cyan">
{type}
</span>
))}
</div>
</div>
</div>
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-4 border-b border-kodo-steel pb-2">Storage Quota</h3>
<div className="space-y-6">
<div className="text-center">
<div className="text-4xl font-mono font-bold text-white mb-1">65.4 GB</div>
<div className="text-sm text-gray-400">used of 100 GB</div>
</div>
<ProgressBar value={65.4} color="cyan" />
<div className="text-xs text-gray-500 flex justify-between">
<span>0 GB</span>
<span>100 GB</span>
</div>
<Button variant="gaming" className="w-full">UPGRADE STORAGE</Button>
</div>
</Card>
</div>
)}
</div>
);
};
// Helper icons
const GlobeIcon = ({className}:{className?:string}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>;
const UserIcon = ({className}:{className?:string}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>;
const LockIcon = ({className}:{className?:string}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>;

View file

@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { X, Database } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface DataExportModalProps {
onClose: () => void;
onRequest: (data: any) => void;
}
export const DataExportModal: React.FC<DataExportModalProps> = ({ onClose, onRequest }) => {
const { addToast } = useToast();
const [format, setFormat] = useState('JSON');
const [options, setOptions] = useState({
profile: true,
tracks: true,
activity: false,
billing: false
});
const toggleOption = (key: keyof typeof options) => {
setOptions(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleSubmit = () => {
addToast("Export request submitted. Check your email shortly.", "success");
onRequest({ format, options });
onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<Database className="w-4 h-4 text-kodo-cyan" /> Request Data Export
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6 space-y-6">
<p className="text-sm text-gray-400">
In compliance with GDPR/CCPA, you can request a copy of your personal data.
Generating this report usually takes <span className="text-white font-bold">24-48 hours</span>.
</p>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Export Format</label>
<select
className="w-full bg-kodo-void border border-kodo-steel rounded p-2 text-white outline-none focus:border-kodo-cyan"
value={format}
onChange={(e) => setFormat(e.target.value)}
>
<option>JSON (Machine Readable)</option>
<option>CSV (Spreadsheet)</option>
<option>HTML (Readable)</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-gray-400 uppercase mb-2">Include Data</label>
<div className="space-y-2">
<label className="flex items-center gap-3 p-3 bg-kodo-ink rounded border border-kodo-steel cursor-pointer hover:border-gray-500">
<input type="checkbox" checked={options.profile} onChange={() => toggleOption('profile')} className="rounded border-gray-600 bg-transparent text-kodo-cyan" />
<span className="text-sm text-gray-300">Profile & Identity</span>
</label>
<label className="flex items-center gap-3 p-3 bg-kodo-ink rounded border border-kodo-steel cursor-pointer hover:border-gray-500">
<input type="checkbox" checked={options.tracks} onChange={() => toggleOption('tracks')} className="rounded border-gray-600 bg-transparent text-kodo-cyan" />
<span className="text-sm text-gray-300">Uploaded Content Metadata</span>
</label>
<label className="flex items-center gap-3 p-3 bg-kodo-ink rounded border border-kodo-steel cursor-pointer hover:border-gray-500">
<input type="checkbox" checked={options.activity} onChange={() => toggleOption('activity')} className="rounded border-gray-600 bg-transparent text-kodo-cyan" />
<span className="text-sm text-gray-300">Activity Logs & History</span>
</label>
<label className="flex items-center gap-3 p-3 bg-kodo-ink rounded border border-kodo-steel cursor-pointer hover:border-gray-500">
<input type="checkbox" checked={options.billing} onChange={() => toggleOption('billing')} className="rounded border-gray-600 bg-transparent text-kodo-cyan" />
<span className="text-sm text-gray-300">Billing & Transactions</span>
</label>
</div>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit}>Request Export</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { Download, FileText, Shield } from 'lucide-react';
import { DataExportModal } from './DataExportModal';
export const DataExportView: React.FC = () => {
const [showModal, setShowModal] = useState(false);
// Mock History
const [exports] = useState([
{ id: 'e1', date: 'Oct 10, 2023', type: 'Full Archive (JSON)', status: 'ready', size: '45 MB' },
{ id: 'e2', date: 'Jan 15, 2023', type: 'Profile Only (CSV)', status: 'expired', size: '2 MB' },
]);
return (
<div className="max-w-4xl mx-auto space-y-8 animate-fadeIn pb-20">
<div>
<h2 className="text-2xl font-bold text-white mb-2">YOUR DATA</h2>
<p className="text-gray-400 font-mono text-sm">Manage, export, and control your personal information.</p>
</div>
<Card variant="default">
<div className="flex items-start gap-4 mb-6">
<div className="p-3 bg-kodo-cyan/10 rounded-full text-kodo-cyan">
<Shield className="w-6 h-6" />
</div>
<div>
<h3 className="font-bold text-white text-lg">Request Data Archive</h3>
<p className="text-gray-400 text-sm mt-1 max-w-xl">
You can request a download of all the data Veza has associated with your account.
This includes your profile, uploaded content metadata, comments, and purchase history.
</p>
</div>
</div>
<Button variant="primary" icon={<Download className="w-4 h-4" />} onClick={() => setShowModal(true)}>
Start New Export
</Button>
</Card>
<Card variant="default">
<h3 className="font-bold text-white mb-4">Export History</h3>
<div className="space-y-3">
{exports.map(item => (
<div key={item.id} className="flex flex-col md:flex-row items-center justify-between p-4 bg-kodo-ink rounded-lg border border-kodo-steel">
<div className="flex items-center gap-4 mb-2 md:mb-0 w-full md:w-auto">
<FileText className="w-5 h-5 text-gray-500" />
<div>
<div className="text-white font-bold text-sm">{item.type}</div>
<div className="text-xs text-gray-500 flex gap-2">
<span>{item.date}</span>
<span></span>
<span>{item.size}</span>
</div>
</div>
</div>
{item.status === 'ready' ? (
<Button variant="secondary" size="sm" icon={<Download className="w-4 h-4" />}>Download</Button>
) : (
<span className="text-xs text-gray-500 bg-gray-800 px-3 py-1 rounded">Expired</span>
)}
</div>
))}
</div>
</Card>
{showModal && <DataExportModal onClose={() => setShowModal(false)} onRequest={() => {}} />}
</div>
);
};

View file

@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { CheckCircle, ExternalLink, RefreshCw, AlertCircle } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface Integration {
id: string;
name: string;
description: string;
icon: string; // URL or placeholder
connected: boolean;
status: 'active' | 'error' | 'syncing' | 'disconnected';
lastSync?: string;
}
const MOCK_INTEGRATIONS: Integration[] = [
{ id: '1', name: 'Spotify', description: 'Display your top tracks and sync playlists.', icon: 'https://upload.wikimedia.org/wikipedia/commons/1/19/Spotify_logo_without_text.svg', connected: true, status: 'active', lastSync: '10 mins ago' },
{ id: '2', name: 'SoundCloud', description: 'Import tracks directly from your SC account.', icon: 'https://a-v2.sndcdn.com/assets/images/sc-icons/ios-a62dfc8f.png', connected: false, status: 'disconnected' },
{ id: '3', name: 'Discord', description: 'Show your production status in rich presence.', icon: 'https://assets-global.website-files.com/6257adef93867e56f84d3092/636e0a6a49cf127bf92de1e2_icon_clyde_blurple_RGB.png', connected: true, status: 'active', lastSync: 'Live' },
{ id: '4', name: 'Stripe', description: 'Process payments for your marketplace sales.', icon: 'https://upload.wikimedia.org/wikipedia/commons/b/ba/Stripe_Logo%2C_revised_2016.svg', connected: true, status: 'error', lastSync: 'Failed 2h ago' },
{ id: '5', name: 'Dropbox', description: 'Auto-backup your projects to the cloud.', icon: 'https://aem.dropbox.com/cms/content/dam/dropbox/www/en-us/branding/app-icons/dropbox-app-icon-blue.svg', connected: false, status: 'disconnected' },
];
export const IntegrationsView: React.FC = () => {
const { addToast } = useToast();
const [integrations, setIntegrations] = useState<Integration[]>(MOCK_INTEGRATIONS);
const toggleConnection = (id: string) => {
setIntegrations(prev => prev.map(int => {
if (int.id === id) {
const newState = !int.connected;
addToast(newState ? `Connected to ${int.name}` : `Disconnected from ${int.name}`, newState ? 'success' : 'info');
return { ...int, connected: newState, status: newState ? 'active' : 'disconnected' };
}
return int;
}));
};
return (
<div className="space-y-6 animate-fadeIn pb-20">
<div>
<h2 className="text-2xl font-bold text-white mb-2">CONNECTED APPS</h2>
<p className="text-gray-400 font-mono text-sm">Supercharge your workflow with external tools.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{integrations.map(integration => (
<Card key={integration.id} variant="default" className={`flex flex-col h-full ${integration.connected ? 'border-kodo-cyan/30' : 'border-kodo-steel/50'}`}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-white rounded-xl p-2 flex items-center justify-center">
<img src={integration.icon} alt={integration.name} className="w-full h-full object-contain" />
</div>
<div>
<h3 className="font-bold text-white text-lg">{integration.name}</h3>
<div className="flex items-center gap-2 text-xs">
{integration.status === 'active' && <span className="text-kodo-lime flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Active</span>}
{integration.status === 'error' && <span className="text-kodo-red flex items-center gap-1"><AlertCircle className="w-3 h-3" /> Error</span>}
{integration.status === 'disconnected' && <span className="text-gray-500">Not Connected</span>}
{integration.lastSync && <span className="text-gray-500"> {integration.lastSync}</span>}
</div>
</div>
</div>
{integration.connected && (
<Button variant="ghost" size="icon" title="Sync Now" onClick={() => addToast("Syncing...")}>
<RefreshCw className="w-4 h-4 text-gray-400 hover:text-white" />
</Button>
)}
</div>
<p className="text-gray-400 text-sm mb-6 flex-1">{integration.description}</p>
<div className="flex gap-3 mt-auto">
{integration.connected ? (
<>
<Button variant="ghost" className="flex-1 border border-kodo-steel text-gray-300" onClick={() => addToast("Settings opened")}>Settings</Button>
<Button variant="ghost" className="flex-1 text-kodo-red hover:bg-kodo-red/10 border border-kodo-red/30" onClick={() => toggleConnection(integration.id)}>Disconnect</Button>
</>
) : (
<Button variant="primary" className="w-full" onClick={() => toggleConnection(integration.id)}>
Connect {integration.name} <ExternalLink className="w-3 h-3 ml-2" />
</Button>
)}
</div>
</Card>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Upload, Camera, Save } from 'lucide-react';
import { ImageCropper } from '../../ui/ImageCropper';
import { useToast } from '../../../context/ToastContext';
import { useAuth } from '../../../context/AuthContext';
import { userService } from '../../../services/userService';
import { logger } from '@/utils/logger';
// Utilities for Canvas Cropping (keeping helper)
const createImage = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (error) => reject(error));
image.setAttribute('crossOrigin', 'anonymous');
image.src = url;
});
async function getCroppedImg(imageSrc: string, pixelCrop: any): Promise<string> {
const image = await createImage(imageSrc);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return '';
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
);
return new Promise((resolve) => {
canvas.toBlob((blob) => {
if (!blob) return;
resolve(URL.createObjectURL(blob));
}, 'image/jpeg');
});
}
export const EditProfile: React.FC = () => {
const { user } = useAuth();
const { addToast } = useToast();
// State: Images
const [avatar, setAvatar] = useState('https://via.placeholder.com/400');
const [banner, setBanner] = useState('https://via.placeholder.com/1200x400');
const [cropImage, setCropImage] = useState<string | null>(null);
const [cropType, setCropType] = useState<'avatar' | 'banner' | null>(null);
const [loading, setLoading] = useState(false);
// State: Form
const [formData, setFormData] = useState({
username: '',
firstName: '',
lastName: '',
bio: '',
location: '',
gender: 'Prefer not to say',
birthdate: ''
});
// Fetch initial data
useEffect(() => {
const fetchProfile = async () => {
if (!user) return;
try {
const res = await userService.getProfile(user.id);
const p = res.profile;
setFormData({
username: p.username || '',
firstName: p.firstName || '',
lastName: p.lastName || '',
bio: p.bio || '',
location: p.location || '',
gender: p.gender || 'Prefer not to say',
birthdate: p.birthdate || ''
});
if(p.avatar) setAvatar(p.avatar);
if(p.banner) setBanner(p.banner);
} catch (e) {
logger.error('Failed to load profile settings', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
userId: user?.id,
});
addToast("Failed to load profile settings", "error");
}
};
fetchProfile();
}, [user]);
const handleSave = async () => {
if (!user) return;
setLoading(true);
try {
await userService.updateProfile(user.id, formData);
addToast("Profile updated successfully", "success");
} catch (e) {
addToast("Failed to update profile", "error");
} finally {
setLoading(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>, type: 'avatar' | 'banner') => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
const imageDataUrl = URL.createObjectURL(file);
setCropImage(imageDataUrl);
setCropType(type);
}
};
const handleCropComplete = async (croppedAreaPixels: any) => {
if (cropImage && cropType) {
try {
const croppedImage = await getCroppedImg(cropImage, croppedAreaPixels);
if (cropType === 'avatar') setAvatar(croppedImage);
else setBanner(croppedImage);
setCropImage(null);
setCropType(null);
addToast("Image cropped (Need backend upload to persist)", "info");
} catch (e) {
addToast("Failed to crop image", "error");
}
}
};
return (
<div className="space-y-8 animate-fadeIn max-w-4xl mx-auto">
{/* 1. IMAGES SECTION */}
<Card variant="default" className="p-0 overflow-hidden relative group">
{/* Banner */}
<div className="h-48 md:h-64 bg-gray-900 relative">
<img src={banner} className="w-full h-full object-cover opacity-80" />
<div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<label className="cursor-pointer bg-black/60 hover:bg-black/80 text-white px-4 py-2 rounded-lg flex items-center gap-2 backdrop-blur border border-white/10">
<Camera className="w-4 h-4" /> Change Banner
<input type="file" className="hidden" accept="image/*" onChange={(e) => handleFileChange(e, 'banner')} />
</label>
</div>
</div>
{/* Avatar */}
<div className="px-8 relative -mt-16 mb-6 flex items-end justify-between">
<div className="relative group/avatar">
<div className="w-32 h-32 rounded-full border-4 border-kodo-graphite bg-black overflow-hidden relative">
<img src={avatar} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover/avatar:opacity-100 transition-opacity">
<label className="cursor-pointer p-2 bg-black/50 rounded-full hover:bg-black/70 text-white">
<Upload className="w-5 h-5" />
<input type="file" className="hidden" accept="image/*" onChange={(e) => handleFileChange(e, 'avatar')} />
</label>
</div>
</div>
</div>
<Button variant="primary" icon={<Save className="w-4 h-4" />} onClick={handleSave} disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 2. MAIN FORM */}
<div className="lg:col-span-2 space-y-6">
<Card variant="default">
<h3 className="font-bold text-white mb-6 border-b border-kodo-steel pb-2">Identity</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<Input label="Username" value={formData.username} onChange={(e) => setFormData({...formData, username: e.target.value})} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<Input label="First Name" value={formData.firstName} onChange={(e) => setFormData({...formData, firstName: e.target.value})} />
<Input label="Last Name" value={formData.lastName} onChange={(e) => setFormData({...formData, lastName: e.target.value})} />
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-400 mb-2">Bio</label>
<textarea
className="w-full bg-kodo-graphite border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-cyan outline-none min-h-[100px]"
value={formData.bio}
onChange={(e) => setFormData({...formData, bio: e.target.value})}
maxLength={500}
/>
<p className="text-xs text-gray-500 text-right mt-1">{formData.bio.length}/500</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input label="Location" value={formData.location} onChange={(e) => setFormData({...formData, location: e.target.value})} />
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">Gender</label>
<select
className="w-full bg-kodo-graphite border border-kodo-steel rounded-lg p-3 text-white focus:border-kodo-cyan outline-none"
value={formData.gender}
onChange={(e) => setFormData({...formData, gender: e.target.value})}
>
<option>Male</option>
<option>Female</option>
<option>Other</option>
<option>Prefer not to say</option>
</select>
</div>
</div>
</Card>
</div>
{/* 3. SIDEBAR */}
<div className="space-y-6">
<Card variant="gaming">
<h3 className="font-bold text-white mb-4 text-sm uppercase tracking-wider">Verification</h3>
<p className="text-xs text-gray-400 mb-4">Complete your profile to get verified.</p>
<Button variant="secondary" size="sm" className="w-full" onClick={() => addToast("Verification request sent")}>Request Verification</Button>
</Card>
</div>
</div>
{/* Crop Modal */}
{cropImage && cropType && (
<ImageCropper
imageSrc={cropImage}
aspectRatio={cropType === 'avatar' ? 1 : 3}
circularCrop={cropType === 'avatar'}
onCancel={() => { setCropImage(null); setCropType(null); }}
onCropComplete={handleCropComplete}
/>
)}
</div>
);
};

View file

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { SearchInput } from '../../ui/input';
import { Download, Filter, Smartphone, Monitor, Globe } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface LoginLog {
id: string;
date: string;
time: string;
ip: string;
location: string;
device: string;
browser: string;
status: 'Success' | 'Failed' | '2FA Challenge';
}
const MOCK_LOGS: LoginLog[] = [
{ id: '1', date: '2023-10-24', time: '14:30:22', ip: '203.0.113.45', location: 'Tokyo, JP', device: 'Desktop', browser: 'Chrome 118', status: 'Success' },
{ id: '2', date: '2023-10-24', time: '09:12:05', ip: '203.0.113.45', location: 'Tokyo, JP', device: 'Desktop', browser: 'Chrome 118', status: 'Success' },
{ id: '3', date: '2023-10-23', time: '18:45:00', ip: '198.51.100.23', location: 'Osaka, JP', device: 'Mobile', browser: 'Safari iOS', status: 'Success' },
{ id: '4', date: '2023-10-22', time: '03:15:12', ip: '192.0.2.1', location: 'London, UK', device: 'Desktop', browser: 'Firefox', status: 'Failed' },
{ id: '5', date: '2023-10-22', time: '03:15:45', ip: '192.0.2.1', location: 'London, UK', device: 'Desktop', browser: 'Firefox', status: '2FA Challenge' },
];
export const LoginHistory: React.FC = () => {
const { addToast } = useToast();
const [filter, setFilter] = useState('');
const [logs] = useState<LoginLog[]>(MOCK_LOGS);
const filteredLogs = logs.filter(log =>
log.ip.includes(filter) || log.location.toLowerCase().includes(filter.toLowerCase())
);
const handleExport = () => {
addToast('Exporting logs to CSV...', 'info');
setTimeout(() => addToast('Download ready', 'success'), 1000);
};
return (
<Card variant="default">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
<div>
<h3 className="text-xl font-bold text-white">Login History</h3>
<p className="text-sm text-gray-400">Monitor access to your account.</p>
</div>
<div className="flex gap-2 w-full md:w-auto">
<div className="flex-1 md:w-64">
<SearchInput placeholder="Search IP or Location..." value={filter} onChange={(e) => setFilter(e.target.value)} />
</div>
<Button variant="ghost" icon={<Filter className="w-4 h-4" />}>Filter</Button>
<Button variant="secondary" icon={<Download className="w-4 h-4" />} onClick={handleExport}>Export</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-kodo-steel text-gray-500 font-mono text-xs uppercase bg-kodo-ink/30">
<th className="p-3">Status</th>
<th className="p-3">Date & Time</th>
<th className="p-3">Device Info</th>
<th className="p-3">IP Address</th>
<th className="p-3">Location</th>
</tr>
</thead>
<tbody className="divide-y divide-kodo-steel/30 text-sm">
{filteredLogs.map(log => (
<tr key={log.id} className="hover:bg-white/5 transition-colors">
<td className="p-3">
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase border ${
log.status === 'Success' ? 'border-kodo-lime/30 text-kodo-lime bg-kodo-lime/5' :
log.status === 'Failed' ? 'border-kodo-red/30 text-kodo-red bg-kodo-red/5' :
'border-kodo-gold/30 text-kodo-gold bg-kodo-gold/5'
}`}>
{log.status}
</span>
</td>
<td className="p-3 text-white font-mono text-xs">
<div>{log.date}</div>
<div className="text-gray-500">{log.time}</div>
</td>
<td className="p-3">
<div className="flex items-center gap-2 text-gray-300">
{log.device === 'Mobile' ? <Smartphone className="w-4 h-4" /> : <Monitor className="w-4 h-4" />}
<span>{log.browser}</span>
</div>
</td>
<td className="p-3 text-kodo-cyan font-mono text-xs">{log.ip}</td>
<td className="p-3 text-gray-400">
<div className="flex items-center gap-1">
<Globe className="w-3 h-3" /> {log.location}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
};

View file

@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Fingerprint, X, Loader2, CheckCircle } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface PasskeyModalProps {
onClose: () => void;
onSuccess: () => void;
}
export const PasskeyModal: React.FC<PasskeyModalProps> = ({ onClose, onSuccess }) => {
const { addToast } = useToast();
const [passkeyName, setPasskeyName] = useState('');
const [_loading, _setLoading] = useState(false);
const [step, _setStep] = useState<'name' | 'registering' | 'success'>('name');
const handleCreate = () => {
if (!passkeyName) {
addToast('Please name your passkey', 'error');
return;
}
setStep('registering');
setLoading(true);
// Simulate WebAuthn API call
setTimeout(() => {
setLoading(false);
setStep('success');
addToast('Passkey created successfully', 'success');
}, 2000);
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-md bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl overflow-hidden animate-scaleIn">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white flex items-center gap-2">
<Fingerprint className="w-4 h-4 text-kodo-cyan" /> Add Passkey
</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-6">
{step === 'name' && (
<div className="space-y-4">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-kodo-cyan/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Fingerprint className="w-8 h-8 text-kodo-cyan" />
</div>
<p className="text-sm text-gray-300">
Passkeys allow you to sign in safely using your fingerprint, face, or device PIN.
</p>
</div>
<Input
label="Passkey Name"
placeholder="e.g. MacBook Pro, iPhone 13"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
autoFocus
/>
<div className="pt-2">
<Button variant="primary" className="w-full" onClick={handleCreate}>
Create Passkey
</Button>
</div>
</div>
)}
{step === 'registering' && (
<div className="text-center py-8">
<Loader2 className="w-12 h-12 text-kodo-cyan animate-spin mx-auto mb-4" />
<h4 className="text-lg font-bold text-white mb-2">Waiting for device...</h4>
<p className="text-sm text-gray-400">Follow the instructions on your device/browser.</p>
</div>
)}
{step === 'success' && (
<div className="text-center py-4">
<div className="w-16 h-16 bg-kodo-lime/20 rounded-full flex items-center justify-center mx-auto mb-4 text-kodo-lime">
<CheckCircle className="w-8 h-8" />
</div>
<h4 className="text-xl font-bold text-white mb-2">Passkey Added!</h4>
<p className="text-sm text-gray-400 mb-6">You can now use this device to log in.</p>
<Button variant="primary" className="w-full" onClick={() => { onSuccess(); onClose(); }}>
Done
</Button>
</div>
)}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,136 @@
import React, { useState } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Lock, Key, Plus, AlertCircle } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
import { PasswordStrengthIndicator } from '../../auth/PasswordStrengthIndicator';
import { TwoFactorSetup } from './TwoFactorSetup';
import { PasskeyModal } from './PasskeyModal';
import { SessionManagement } from './SessionManagement';
import { LoginHistory } from './LoginHistory';
export const SecuritySettings: React.FC = () => {
const { addToast } = useToast();
const [view, setView] = useState<'main' | '2fa' | 'sessions' | 'history'>('main');
// Forms & Modals
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPasskeyModal, setShowPasskeyModal] = useState(false);
const [is2FAEnabled, setIs2FAEnabled] = useState(false);
const handlePasswordUpdate = () => {
if (newPassword !== confirmPassword) {
addToast("Passwords do not match", "error");
return;
}
addToast("Password successfully updated", "success");
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
};
if (view === '2fa') {
return <TwoFactorSetup onBack={() => setView('main')} onComplete={() => { setIs2FAEnabled(true); setView('main'); }} />;
}
return (
<div className="space-y-8 animate-fadeIn pb-10">
{/* 1. PASSWORD CHANGE */}
<Card variant="default">
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
<Lock className="w-5 h-5 text-kodo-cyan" /> Password & Authentication
</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-4">
<Input
type="password"
label="Current Password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
<div className="relative">
<Input
type="password"
label="New Password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
{newPassword && <PasswordStrengthIndicator password={newPassword} />}
</div>
<Input
type="password"
label="Confirm New Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<div className="pt-2 flex justify-end">
<Button variant="primary" onClick={handlePasswordUpdate} disabled={!currentPassword || !newPassword}>
Update Password
</Button>
</div>
</div>
<div className="space-y-6">
{/* 2FA Summary */}
<div className="bg-kodo-ink p-6 rounded-xl border border-kodo-steel">
<div className="flex justify-between items-start mb-4">
<div>
<h4 className="font-bold text-white text-sm">Two-Factor Authentication</h4>
<p className="text-xs text-gray-400 mt-1">Add an extra layer of security to your account.</p>
</div>
{is2FAEnabled ? (
<span className="text-kodo-lime flex items-center gap-1 text-xs font-bold border border-kodo-lime/30 px-2 py-1 rounded bg-kodo-lime/5"><CheckCircle className="w-3 h-3" /> ENABLED</span>
) : (
<span className="text-kodo-red flex items-center gap-1 text-xs font-bold border border-kodo-red/30 px-2 py-1 rounded bg-kodo-red/5"><AlertCircle className="w-3 h-3" /> DISABLED</span>
)}
</div>
<Button
variant="secondary"
className="w-full"
onClick={() => setView('2fa')}
>
{is2FAEnabled ? 'Manage 2FA Settings' : 'Enable 2FA'}
</Button>
</div>
{/* Passkeys */}
<div className="bg-kodo-ink p-6 rounded-xl border border-kodo-steel">
<div className="flex justify-between items-start mb-4">
<div>
<h4 className="font-bold text-white text-sm flex items-center gap-2">
<Key className="w-4 h-4 text-kodo-gold" /> Passkeys
</h4>
<p className="text-xs text-gray-400 mt-1">Sign in with FaceID, TouchID, or device PIN.</p>
</div>
<Button variant="ghost" size="icon" onClick={() => setShowPasskeyModal(true)}><Plus className="w-4 h-4" /></Button>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center text-sm p-2 rounded bg-white/5">
<span className="text-gray-300">MacBook Pro (Chrome)</span>
<span className="text-xs text-gray-500">Added 2d ago</span>
</div>
</div>
</div>
</div>
</div>
</Card>
{/* 2. SESSIONS & HISTORY */}
<SessionManagement />
<LoginHistory />
{/* MODALS */}
{showPasskeyModal && (
<PasskeyModal
onClose={() => setShowPasskeyModal(false)}
onSuccess={() => addToast("Passkey registered", "success")}
/>
)}
</div>
);
};

View file

@ -0,0 +1,110 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../../ui/card';
import { Button } from '../../ui/button';
import { Smartphone, Monitor, Clock } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
import { sessionService, Session } from '../../../services/sessionService';
import { logger } from '@/utils/logger';
export const SessionManagement: React.FC = () => {
const { addToast } = useToast();
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSessions();
}, []);
const loadSessions = async () => {
try {
setLoading(true);
const res = await sessionService.getSessions();
setSessions(res.sessions);
} catch (error) {
logger.error('Error loading sessions', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
} finally {
setLoading(false);
}
};
const handleRevoke = async (id: string) => {
try {
await sessionService.revokeSession(id);
setSessions(prev => prev.filter(s => s.id !== id));
addToast('Session revoked successfully', 'success');
} catch (error) {
addToast('Failed to revoke session', 'error');
}
};
const handleRevokeAll = async () => {
try {
await sessionService.logoutAll();
// Ideally reload or clear all except current, but for safety re-fetch
loadSessions();
addToast('All other sessions have been logged out', 'success');
} catch (error) {
addToast('Failed to log out all devices', 'error');
}
};
if (loading) return <div className="text-center p-4 text-gray-500">Loading sessions...</div>;
return (
<Card variant="default">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-xl font-bold text-white">Active Sessions</h3>
<p className="text-sm text-gray-400">Manage devices logged into your account.</p>
</div>
<Button variant="ghost" className="text-kodo-red hover:bg-kodo-red/10 border-kodo-red/30" onClick={handleRevokeAll}>
Log Out All Other Devices
</Button>
</div>
<div className="space-y-4">
{sessions.map(session => {
// Simple heuristics for icon since backend might not provide device type explicitly yet
const isMobile = session.user_agent.toLowerCase().includes('mobile');
return (
<div key={session.id} className="flex flex-col md:flex-row md:items-center justify-between p-4 bg-kodo-ink rounded-xl border border-kodo-steel hover:border-kodo-cyan/30 transition-colors">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-full ${session.is_current ? 'bg-kodo-cyan/10 text-kodo-cyan' : 'bg-kodo-slate text-gray-400'}`}>
{isMobile ? <Smartphone className="w-6 h-6" /> : <Monitor className="w-6 h-6" />}
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-white text-sm">{session.ip_address}</h4>
{session.is_current && (
<span className="bg-kodo-lime/10 text-kodo-lime text-[10px] px-2 py-0.5 rounded border border-kodo-lime/30 font-bold">CURRENT DEVICE</span>
)}
</div>
<p className="text-xs text-gray-400 mt-1 truncate max-w-xs">{session.user_agent}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> Active: {new Date(session.last_activity).toLocaleString()}</span>
</div>
</div>
</div>
{!session.is_current && (
<Button
variant="ghost"
size="sm"
className="mt-4 md:mt-0 text-gray-400 hover:text-white border border-kodo-steel hover:bg-white/5"
onClick={() => handleRevoke(session.id)}
>
Revoke Access
</Button>
)}
</div>
);
})}
{sessions.length === 0 && <p className="text-gray-500 text-sm">No active sessions found.</p>}
</div>
</Card>
);
};

View file

@ -0,0 +1,187 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Smartphone, QrCode, ArrowLeft, Copy, Download, AlertTriangle } from 'lucide-react';
import { useToast } from '../../../context/ToastContext';
interface TwoFactorSetupProps {
onBack: () => void;
onComplete: () => void;
}
export const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({ onBack, onComplete }) => {
const { addToast } = useToast();
const [step, setStep] = useState(1);
const [method, setMethod] = useState<'totp' | 'sms'>('totp');
const [verificationCode, setVerificationCode] = useState('');
const [backupCodes] = useState(Array.from({length: 10}, () => Math.random().toString(36).substr(2, 8).toUpperCase()));
const handleVerify = () => {
if (verificationCode.length < 6) {
addToast('Invalid code', 'error');
return;
}
setStep(3);
addToast('2FA Verified Successfully', 'success');
};
const copyCodes = () => {
navigator.clipboard.writeText(backupCodes.join('\n'));
addToast('Backup codes copied to clipboard');
};
const downloadCodes = () => {
const element = document.createElement("a");
const file = new Blob([backupCodes.join('\n')], {type: 'text/plain'});
element.href = URL.createObjectURL(file);
element.download = "veza-backup-codes.txt";
document.body.appendChild(element);
element.click();
addToast('Backup codes downloaded');
};
return (
<div className="animate-fadeIn max-w-2xl mx-auto">
<div className="mb-6 flex items-center gap-4">
<button onClick={onBack} className="p-2 hover:bg-white/5 rounded-full text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h2 className="text-2xl font-bold text-white">Enable Two-Factor Authentication</h2>
<p className="text-gray-400 text-sm">Protect your account with an extra layer of security.</p>
</div>
</div>
{/* STEP 1: CHOOSE METHOD */}
{step === 1 && (
<div className="grid gap-4">
<div
onClick={() => { setMethod('totp'); setStep(2); }}
className="p-6 border border-kodo-steel rounded-xl bg-kodo-ink hover:bg-white/5 cursor-pointer transition-all hover:border-kodo-cyan group"
>
<div className="flex items-center gap-4 mb-2">
<div className="w-12 h-12 rounded-full bg-kodo-cyan/10 flex items-center justify-center group-hover:bg-kodo-cyan/20">
<QrCode className="w-6 h-6 text-kodo-cyan" />
</div>
<div>
<h3 className="text-lg font-bold text-white group-hover:text-kodo-cyan">Authenticator App</h3>
<p className="text-sm text-gray-400">Use Google Authenticator, Authy, or 1Password. (Recommended)</p>
</div>
</div>
</div>
<div
onClick={() => { setMethod('sms'); setStep(2); }}
className="p-6 border border-kodo-steel rounded-xl bg-kodo-ink hover:bg-white/5 cursor-pointer transition-all hover:border-kodo-gold group"
>
<div className="flex items-center gap-4 mb-2">
<div className="w-12 h-12 rounded-full bg-kodo-gold/10 flex items-center justify-center group-hover:bg-kodo-gold/20">
<Smartphone className="w-6 h-6 text-kodo-gold" />
</div>
<div>
<h3 className="text-lg font-bold text-white group-hover:text-kodo-gold">SMS / Text Message</h3>
<p className="text-sm text-gray-400">Receive a code via text message to your phone.</p>
</div>
</div>
</div>
</div>
)}
{/* STEP 2: CONFIGURE & VERIFY */}
{step === 2 && method === 'totp' && (
<div className="space-y-8 bg-kodo-ink p-8 rounded-xl border border-kodo-steel">
<div className="text-center">
<div className="bg-white p-4 inline-block rounded-xl mb-4">
{/* Mock QR */}
<div className="w-48 h-48 bg-gray-900 flex items-center justify-center relative overflow-hidden">
<QrCode className="w-full h-full text-black opacity-20 absolute" />
<span className="relative font-bold text-black text-xs">MOCK QR CODE</span>
<div className="absolute inset-0 border-4 border-black/10"></div>
{/* Decorative pixel pattern simulated */}
<div className="absolute top-2 left-2 w-10 h-10 bg-black"></div>
<div className="absolute top-2 right-2 w-10 h-10 bg-black"></div>
<div className="absolute bottom-2 left-2 w-10 h-10 bg-black"></div>
</div>
</div>
<p className="text-sm text-gray-300 mb-2">Scan this QR code with your authenticator app.</p>
<p className="text-xs text-gray-500 font-mono bg-black/30 py-1 px-2 rounded inline-block cursor-pointer hover:text-white" onClick={() => { navigator.clipboard.writeText("VEZA-SECRET-KEY-123"); addToast("Key copied"); }}>
KEY: VEZA-SECRET-KEY-123
</p>
</div>
<div className="border-t border-kodo-steel pt-6">
<h4 className="font-bold text-white mb-4">Verify Configuration</h4>
<div className="flex gap-3">
<Input
placeholder="Enter 6-digit code"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g,'').slice(0,6))}
className="font-mono text-center tracking-widest text-lg"
/>
<Button variant="primary" onClick={handleVerify} disabled={verificationCode.length !== 6}>
VERIFY
</Button>
</div>
</div>
</div>
)}
{step === 2 && method === 'sms' && (
<div className="bg-kodo-ink p-8 rounded-xl border border-kodo-steel text-center">
<Smartphone className="w-16 h-16 text-kodo-gold mx-auto mb-4" />
<h3 className="text-xl font-bold text-white mb-2">SMS Setup</h3>
<p className="text-gray-400 mb-6">Enter your phone number to receive a verification code.</p>
<div className="flex gap-2 max-w-sm mx-auto">
<Input placeholder="+1 (555) 000-0000" />
<Button variant="primary" onClick={() => addToast("Code sent to your phone", "info")}>SEND</Button>
</div>
<div className="mt-8 border-t border-kodo-steel pt-6 text-left">
<h4 className="font-bold text-white mb-4">Enter Verification Code</h4>
<div className="flex gap-3">
<Input
placeholder="000000"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g,'').slice(0,6))}
className="font-mono text-center tracking-widest text-lg"
/>
<Button variant="primary" onClick={handleVerify} disabled={verificationCode.length !== 6}>
VERIFY
</Button>
</div>
</div>
</div>
)}
{/* STEP 3: BACKUP CODES */}
{step === 3 && (
<div className="space-y-6 bg-kodo-ink p-8 rounded-xl border border-kodo-steel">
<div className="flex items-center gap-4 text-kodo-lime mb-2">
<CheckCircle className="w-8 h-8" />
<h3 className="text-xl font-bold">2FA Enabled Successfully</h3>
</div>
<div className="bg-kodo-orange/10 border border-kodo-orange/30 p-4 rounded-lg flex gap-3">
<AlertTriangle className="w-6 h-6 text-kodo-orange flex-shrink-0" />
<div>
<h4 className="font-bold text-kodo-orange text-sm mb-1">Save these backup codes</h4>
<p className="text-xs text-gray-300">If you lose your device, these codes are the only way to access your account. Keep them safe.</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 bg-black/40 p-4 rounded-lg font-mono text-sm text-gray-300 text-center border border-kodo-steel/50">
{backupCodes.map(code => (
<div key={code} className="py-1 tracking-wider">{code}</div>
))}
</div>
<div className="flex gap-3 pt-2">
<Button variant="secondary" className="flex-1" icon={<Copy className="w-4 h-4" />} onClick={copyCodes}>Copy All</Button>
<Button variant="secondary" className="flex-1" icon={<Download className="w-4 h-4" />} onClick={downloadCodes}>Download</Button>
<Button variant="primary" className="flex-1" onClick={onComplete}>Done</Button>
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,68 @@
import React, { useState } from 'react';
import { Comment } from '../../types';
import { Heart, MoreHorizontal } from 'lucide-react';
interface CommentItemProps {
comment: Comment;
onLike: (id: string) => void;
onReply: (authorHandle: string) => void;
}
export const CommentItem: React.FC<CommentItemProps> = ({ comment, onLike, onReply }) => {
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(comment.likes);
const handleLike = () => {
setLiked(!liked);
setLikeCount(liked ? likeCount - 1 : likeCount + 1);
onLike(comment.id);
};
return (
<div className="flex gap-3 text-sm py-2">
<img
src={comment.author.avatar}
alt={comment.author.name}
className="w-8 h-8 rounded-full object-cover flex-shrink-0 cursor-pointer"
/>
<div className="flex-1 min-w-0">
<div className="bg-kodo-ink/50 p-3 rounded-2xl rounded-tl-sm">
<div className="flex justify-between items-baseline mb-1">
<div className="flex gap-2 items-baseline">
<span className="font-bold text-white cursor-pointer hover:underline">{comment.author.name}</span>
<span className="text-gray-500 text-xs">{comment.timestamp}</span>
</div>
</div>
<p className="text-gray-300 leading-relaxed whitespace-pre-wrap">{comment.content}</p>
</div>
<div className="flex items-center gap-4 mt-1 pl-2 text-xs text-gray-500">
<button
onClick={handleLike}
className={`flex items-center gap-1 hover:text-kodo-magenta transition-colors ${liked ? 'text-kodo-magenta' : ''}`}
>
<Heart className={`w-3 h-3 ${liked ? 'fill-current' : ''}`} /> {likeCount > 0 ? likeCount : 'Like'}
</button>
<button
onClick={() => onReply(comment.author.handle)}
className="hover:text-white transition-colors"
>
Reply
</button>
<button className="hover:text-white transition-colors">
<MoreHorizontal className="w-3 h-3" />
</button>
</div>
{comment.replies && comment.replies.length > 0 && (
<div className="mt-2 pl-4 border-l border-kodo-steel/50">
{comment.replies.map(reply => (
<CommentItem key={reply.id} comment={reply} onLike={onLike} onReply={onReply} />
))}
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,118 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { X, Image as ImageIcon, Video, Mic2, BarChart, Hash } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
interface CreatePostModalProps {
onClose: () => void;
onCreate: (postData: { content: string; visibility: string; type: string }) => void;
}
export const CreatePostModal: React.FC<CreatePostModalProps> = ({ onClose, onCreate }) => {
const { addToast } = useToast();
const [content, setContent] = useState('');
const [visibility, setVisibility] = useState('public');
const [postType, setPostType] = useState('text'); // text, image, audio, etc.
const handleSubmit = () => {
if (!content) return;
onCreate({ content, visibility, type: postType });
onClose();
};
const insertHashtag = (tag: string) => {
setContent(prev => `${prev } #${tag} `);
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-lg bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden flex flex-col">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white">Create Post</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-4 flex-1">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-gray-700 overflow-hidden flex-shrink-0">
<img src="https://picsum.photos/id/237/100/100" className="w-full h-full object-cover" />
</div>
<div className="flex-1">
<div className="mb-2">
<select
className="bg-kodo-ink border border-kodo-steel rounded px-2 py-1 text-xs text-kodo-cyan focus:outline-none cursor-pointer"
value={visibility}
onChange={(e) => setVisibility(e.target.value)}
>
<option value="public">Public</option>
<option value="followers">Followers Only</option>
<option value="private">Private Draft</option>
</select>
</div>
<textarea
className="w-full bg-transparent border-none text-white placeholder-gray-500 focus:ring-0 resize-none h-32 text-base"
placeholder="What's happening in your studio?"
value={content}
onChange={(e) => setContent(e.target.value)}
autoFocus
/>
</div>
</div>
{/* Suggestions */}
<div className="mt-4 flex flex-wrap gap-2 text-xs">
<span className="text-gray-500 flex items-center gap-1"><Hash className="w-3 h-3" /> Trending:</span>
{['Synthwave', 'NewGear', 'StudioLife', 'WIP'].map(tag => (
<button
key={tag}
onClick={() => insertHashtag(tag)}
className="text-kodo-cyan hover:underline"
>
#{tag}
</button>
))}
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-between items-center">
<div className="flex gap-2">
<button
className={`p-2 rounded hover:bg-white/10 text-gray-400 hover:text-kodo-cyan ${postType === 'image' ? 'text-kodo-cyan' : ''}`}
onClick={() => { setPostType('image'); addToast("Image upload simulated"); }}
title="Photo"
>
<ImageIcon className="w-5 h-5" />
</button>
<button
className="p-2 rounded hover:bg-white/10 text-gray-400 hover:text-kodo-magenta"
onClick={() => { setPostType('video'); addToast("Video upload simulated"); }}
title="Video"
>
<Video className="w-5 h-5" />
</button>
<button
className="p-2 rounded hover:bg-white/10 text-gray-400 hover:text-kodo-lime"
onClick={() => { setPostType('audio'); addToast("Audio upload simulated"); }}
title="Audio"
>
<Mic2 className="w-5 h-5" />
</button>
<button
className="p-2 rounded hover:bg-white/10 text-gray-400 hover:text-kodo-gold"
onClick={() => { setPostType('poll'); addToast("Poll creator simulated"); }}
title="Poll"
>
<BarChart className="w-5 h-5" />
</button>
</div>
<Button variant="primary" onClick={handleSubmit} disabled={!content}>
Post
</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,157 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../ui/button';
import { SearchInput } from '../ui/input';
import { Play, Heart, Filter, Zap, TrendingUp, Star, Loader2 } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
import { trackService } from '../../services/trackService';
import { socialService } from '../../services/socialService';
import { logger } from '@/utils/logger';
// Derived mock type for explore grid
interface ExploreItem {
id: string;
type: 'image' | 'audio' | 'video';
thumbnail: string;
likes: number;
comments: number;
title: string;
author: string;
}
// const GENRES = [
// { name: 'Synthwave', color: 'from-pink-500 to-purple-600' },
// { name: 'Techno', color: 'from-gray-700 to-black' },
// { name: 'Ambient', color: 'from-blue-400 to-teal-500' },
// { name: 'Lo-Fi', color: 'from-orange-300 to-red-400' },
// { name: 'Drum & Bass', color: 'from-yellow-400 to-orange-600' },
// { name: 'House', color: 'from-indigo-500 to-blue-600' },
// ];
export const ExploreView: React.FC = () => {
const { addToast } = useToast();
const [activeTab, setActiveTab] = useState<'for_you' | 'trending' | 'new' | 'popular'>('for_you');
const [filter, setFilter] = useState('All');
const [items, setItems] = useState<ExploreItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
// Aggregate data from tracks and social feed to simulate explore grid
const [tracksRes, feedRes] = await Promise.all([
trackService.list({ sort_by: 'trending', limit: 6 }),
socialService.getFeed({ limit: 6 })
]);
const trackItems: ExploreItem[] = tracksRes.tracks.map(t => ({
id: t.id,
type: 'audio',
thumbnail: t.coverUrl,
likes: t.likes,
comments: 0,
title: t.title,
author: t.artist
}));
const postItems: ExploreItem[] = feedRes.posts.map(p => ({
id: p.id,
type: (p.type === 'image' || p.type === 'video') ? p.type : 'image', // Fallback to image for layout
thumbnail: p.image || p.audioTrack?.coverUrl || p.author.avatar,
likes: p.likes,
comments: p.comments,
title: `${p.content.substring(0, 30) }...`,
author: p.author.name
}));
setItems([...trackItems, ...postItems].sort(() => 0.5 - Math.random()));
} catch (e) {
logger.error('Error loading explore data', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
activeTab,
});
} finally {
setLoading(false);
}
};
fetchData();
}, [activeTab]);
return (
<div className="space-y-6 animate-fadeIn">
{/* Navigation & Search */}
<div className="flex flex-col md:flex-row justify-between items-center gap-4 bg-kodo-ink/50 p-2 rounded-xl border border-kodo-steel/50">
<div className="flex gap-2 overflow-x-auto w-full md:w-auto p-1">
{[
{ id: 'for_you', label: 'For You', icon: <Zap className="w-4 h-4" /> },
{ id: 'trending', label: 'Trending', icon: <TrendingUp className="w-4 h-4" /> },
{ id: 'new', label: 'New', icon: <Clock className="w-4 h-4" /> },
{ id: 'popular', label: 'Popular', icon: <Star className="w-4 h-4" /> },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold transition-all whitespace-nowrap ${activeTab === tab.id ? 'bg-kodo-cyan text-black shadow-neon-cyan' : 'text-gray-400 hover:text-white hover:bg-white/5'}`}
>
{tab.icon} {tab.label}
</button>
))}
</div>
<div className="flex gap-2 w-full md:w-auto">
<div className="w-full md:w-64">
<SearchInput placeholder="Search explore..." />
</div>
<Button variant="ghost" size="icon" className="border border-kodo-steel"><Filter className="w-4 h-4" /></Button>
</div>
</div>
{/* Filters */}
<div className="flex gap-2 overflow-x-auto pb-2">
{['All', 'Images', 'Audio', 'Video', 'Polls'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 rounded-full text-xs font-bold border transition-colors ${filter === f ? 'bg-white text-black border-white' : 'border-gray-600 text-gray-400 hover:border-gray-400'}`}
>
{f}
</button>
))}
</div>
{/* Grid Content */}
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" /></div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{items.map((item, i) => (
<div
key={item.id}
className={`relative group cursor-pointer overflow-hidden rounded-xl bg-gray-900 aspect-square ${i === 0 ? 'col-span-2 row-span-2' : ''}`}
onClick={() => addToast(`Opening ${item.title}`)}
>
<img src={item.thumbnail} className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110 opacity-80 group-hover:opacity-100" />
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-4">
<h4 className="text-white font-bold truncate text-sm mb-1">{item.title}</h4>
<div className="flex justify-between items-center text-xs text-gray-300">
<span>@{item.author}</span>
<div className="flex gap-2">
<span className="flex items-center gap-1"><Heart className="w-3 h-3 fill-current" /> {item.likes}</span>
</div>
</div>
</div>
{/* Type Indicator */}
<div className="absolute top-2 right-2 bg-black/50 backdrop-blur p-1.5 rounded-full text-white">
{item.type === 'audio' ? <Play className="w-3 h-3 fill-current" /> : item.type === 'video' ? <Play className="w-3 h-3" /> : <div className="w-3 h-3 bg-white rounded-full"></div>}
</div>
</div>
))}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Post } from '../../types';
import { PostCard } from './PostCard';
import { CreatePostModal } from './CreatePostModal';
import { ImageIcon, Video, Mic2, BarChart, Loader2 } from 'lucide-react';
import { useToast } from '../../context/ToastContext';
import { socialService } from '../../services/socialService';
import { logger } from '@/utils/logger';
export const FeedView: React.FC = () => {
const { addToast } = useToast();
const [posts, setPosts] = useState<Post[]>([]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
useEffect(() => {
loadFeed();
}, []);
const loadFeed = async () => {
setLoading(true);
try {
const res = await socialService.getFeed();
setPosts(res.posts);
} catch (e) {
logger.error('Error loading feed', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
const handleCreatePost = async (data: { content: string, visibility: string, type: string }) => {
try {
const res = await socialService.createPost(data);
setPosts([res.post, ...posts]);
addToast("Post published successfully", "success");
} catch (e) {
addToast("Failed to post", "error");
}
};
const loadMore = async () => {
setLoadingMore(true);
try {
const res = await socialService.getFeed({ page: 2 });
// In real app, append unique posts. Here just appending same mock for demo length
setPosts(prev => [...prev, ...res.posts.map(p => ({...p, id: `more-${Math.random()}`}))]);
} catch (e) {
logger.error('Error loading more feed posts', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoadingMore(false);
}
};
if (loading) return <div className="flex justify-center py-20"><Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" /></div>;
return (
<div className="space-y-6">
{/* Create Post Widget */}
<Card variant="default" className="border-t-4 border-t-kodo-cyan p-4">
<div className="flex gap-4">
<div className="w-10 h-10 rounded-full bg-gray-700 flex-shrink-0 overflow-hidden cursor-pointer">
<img src="https://picsum.photos/id/237/100/100" className="w-full h-full object-cover" />
</div>
<div className="flex-1 cursor-pointer" onClick={() => setShowCreateModal(true)}>
<div className="w-full bg-kodo-void/50 border border-kodo-steel rounded-full px-4 py-2.5 text-gray-500 hover:bg-kodo-void hover:border-kodo-cyan/50 transition-all text-sm">
What are you working on today?
</div>
</div>
</div>
<div className="flex justify-between items-center mt-3 pl-14">
<div className="flex gap-4">
<button className="flex items-center gap-1 text-gray-400 hover:text-kodo-cyan text-xs font-bold" onClick={() => setShowCreateModal(true)}><ImageIcon className="w-4 h-4" /> Photo</button>
<button className="flex items-center gap-1 text-gray-400 hover:text-kodo-magenta text-xs font-bold" onClick={() => setShowCreateModal(true)}><Video className="w-4 h-4" /> Video</button>
<button className="flex items-center gap-1 text-gray-400 hover:text-kodo-lime text-xs font-bold" onClick={() => setShowCreateModal(true)}><Mic2 className="w-4 h-4" /> Audio</button>
<button className="flex items-center gap-1 text-gray-400 hover:text-kodo-gold text-xs font-bold" onClick={() => setShowCreateModal(true)}><BarChart className="w-4 h-4" /> Poll</button>
</div>
</div>
</Card>
{/* Posts Feed */}
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
{/* Load More Trigger */}
<div className="text-center py-4">
<Button variant="ghost" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading...' : 'Load More'}
</Button>
</div>
{showCreateModal && (
<CreatePostModal
onClose={() => setShowCreateModal(false)}
onCreate={handleCreatePost}
/>
)}
</div>
);
};

View file

@ -0,0 +1,188 @@
import React, { useState } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Post, Comment } from '../../types';
import { Heart, MessageSquare, Repeat, Share2, MoreHorizontal, Play } from 'lucide-react';
import { CommentItem } from './CommentItem';
import { SharePostModal } from './SharePostModal';
import { useToast } from '../../context/ToastContext';
interface PostCardProps {
post: Post;
}
export const PostCard: React.FC<PostCardProps> = ({ post }) => {
const { addToast } = useToast();
const [isLiked, setIsLiked] = useState(post.isLiked || false);
const [likesCount, setLikesCount] = useState(post.likes);
const [showComments, setShowComments] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
// Mock comments
const comments: Comment[] = post.recentComments || [
{ id: 'c1', author: { name: 'Fan_01', handle: '@fan', avatar: 'https://picsum.photos/50' }, content: 'This is fire! 🔥', timestamp: '10m', likes: 2 },
{ id: 'c2', author: { name: 'Producer_X', handle: '@pro_x', avatar: 'https://picsum.photos/51' }, content: 'What snare is that?', timestamp: '30m', likes: 5, replies: [] }
];
const handleLike = () => {
setIsLiked(!isLiked);
setLikesCount(prev => isLiked ? prev - 1 : prev + 1);
if (!isLiked) addToast("Liked post");
};
const handleShareConfirm = (type: 'repost' | 'quote', _text?: string) => {
addToast(type === 'repost' ? 'Reposted!' : 'Quote posted!', 'success');
};
return (
<>
<Card variant="default" className="p-0 overflow-hidden border-transparent hover:border-kodo-steel/50 transition-all animate-fadeIn mb-4">
{/* Repost Header */}
{post.isRepost && (
<div className="px-4 pt-3 pb-0 flex items-center gap-2 text-xs text-gray-500 font-bold uppercase tracking-wider">
<Repeat className="w-3 h-3" /> {post.repostAuthor} Reposted
</div>
)}
{/* Post Header */}
<div className="p-4 flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-700 overflow-hidden border border-kodo-steel cursor-pointer">
<img src={post.author.avatar} className="w-full h-full object-cover" />
</div>
<div>
<div className="font-bold text-white flex items-center gap-1 cursor-pointer hover:underline">
{post.author.name}
{post.author.isVerified && <Badge label="PRO" variant="cyan" className="scale-75 origin-left" />}
</div>
<div className="text-xs text-gray-500">{post.author.handle} {post.timestamp}</div>
</div>
</div>
<Button variant="ghost" size="sm"><MoreHorizontal className="w-4 h-4" /></Button>
</div>
{/* Content */}
<div className="px-4 pb-2 text-gray-200 whitespace-pre-wrap leading-relaxed text-sm">
{post.content}
{post.tags && (
<div className="mt-2 flex flex-wrap gap-2">
{post.tags.map(tag => (
<span key={tag} className="text-kodo-cyan hover:underline cursor-pointer text-xs">{tag}</span>
))}
</div>
)}
</div>
{/* Media Rendering */}
{post.type === 'image' && post.image && (
<div className="mt-2 w-full max-h-96 bg-black flex items-center justify-center overflow-hidden cursor-pointer">
<img src={post.image} className="w-full h-full object-cover hover:scale-105 transition-transform duration-500" />
</div>
)}
{post.type === 'audio' && post.audioTrack && (
<div className="px-4 py-2">
<div className="bg-gradient-to-r from-kodo-ink to-kodo-slate p-3 rounded-xl flex items-center gap-4 border border-kodo-steel hover:border-kodo-cyan/50 transition-colors">
<div className="w-12 h-12 bg-gray-800 rounded overflow-hidden relative group cursor-pointer">
<img src={post.audioTrack.coverUrl} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Play className="w-5 h-5 text-white fill-current" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-bold text-white truncate">{post.audioTrack.title}</div>
<div className="text-xs text-gray-400 truncate">{post.audioTrack.artist}</div>
<div className="h-6 flex items-center gap-0.5 mt-1 opacity-50">
{Array.from({length: 40}).map((_, i) => (
<div key={i} className="w-1 bg-kodo-cyan" style={{height: `${Math.random() * 100 }%`}}></div>
))}
</div>
</div>
</div>
</div>
)}
{post.type === 'poll' && post.pollOptions && (
<div className="px-4 py-2 space-y-2">
{post.pollOptions.map((opt, i) => (
<div key={i} className="relative h-10 bg-kodo-slate rounded overflow-hidden cursor-pointer hover:bg-kodo-steel/50 transition-colors border border-kodo-steel">
<div className="absolute top-0 left-0 h-full bg-kodo-cyan/20" style={{width: `${opt.votes}%`}}></div>
<div className="absolute inset-0 flex items-center justify-between px-4">
<span className="text-sm font-bold text-white">{opt.label}</span>
<span className="text-xs text-gray-400">{opt.votes}%</span>
</div>
</div>
))}
<div className="text-xs text-gray-500 px-1">Total votes: 124 2 days left</div>
</div>
)}
{/* Footer Actions */}
<div className="p-4 border-t border-kodo-steel flex items-center justify-between text-gray-400 text-sm">
<button
className={`flex items-center gap-2 hover:text-kodo-magenta transition-colors group ${isLiked ? 'text-kodo-magenta' : ''}`}
onClick={handleLike}
>
<Heart className={`w-5 h-5 group-hover:scale-110 transition-transform ${isLiked ? 'fill-current' : ''}`} />
<span>{likesCount}</span>
</button>
<button
className="flex items-center gap-2 hover:text-white transition-colors"
onClick={() => setShowComments(!showComments)}
>
<MessageSquare className="w-5 h-5" /> <span>{post.comments}</span>
</button>
<button className="flex items-center gap-2 hover:text-kodo-lime transition-colors" onClick={() => setShowShareModal(true)}>
<Repeat className="w-5 h-5" /> <span>{post.shares}</span>
</button>
<button className="flex items-center gap-2 hover:text-white transition-colors" onClick={() => setShowShareModal(true)}>
<Share2 className="w-5 h-5" />
</button>
</div>
{/* Comments Section */}
{showComments && (
<div className="bg-kodo-ink border-t border-kodo-steel p-4 animate-slideUp">
<div className="space-y-4">
{comments.map(c => (
<CommentItem
key={c.id}
comment={c}
onLike={(_id) => addToast("Liked comment")}
onReply={(handle) => addToast(`Replying to ${handle}`)}
/>
))}
</div>
{post.comments > 2 && (
<button className="w-full text-center text-xs text-kodo-cyan mt-4 hover:underline">
View all {post.comments} comments
</button>
)}
<div className="mt-4 flex gap-3">
<div className="w-8 h-8 rounded-full bg-gray-700 overflow-hidden flex-shrink-0">
<img src="https://picsum.photos/id/100/100/100" className="w-full h-full object-cover" />
</div>
<div className="flex-1 relative">
<input
className="w-full bg-kodo-void border border-kodo-steel rounded-full px-4 py-2 text-sm text-white focus:border-kodo-cyan outline-none"
placeholder="Write a comment..."
/>
</div>
</div>
</div>
)}
</Card>
{showShareModal && (
<SharePostModal
post={post}
onClose={() => setShowShareModal(false)}
onConfirm={handleShareConfirm}
/>
)}
</>
);
};

View file

@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { Button } from '../ui/button';
import { X, Repeat, MessageSquare, Link, Mail } from 'lucide-react';
import { Post } from '../../types';
import { useToast } from '../../context/ToastContext';
interface SharePostModalProps {
post: Post;
onClose: () => void;
onConfirm: (type: 'repost' | 'quote', text?: string) => void;
}
export const SharePostModal: React.FC<SharePostModalProps> = ({ post, onClose, onConfirm }) => {
const { addToast } = useToast();
const [mode, setMode] = useState<'options' | 'quote'>('options');
const [quoteText, setQuoteText] = useState('');
const handleCopyLink = () => {
navigator.clipboard.writeText(`https://veza.io/post/${post.id}`);
addToast("Link copied to clipboard", "success");
onClose();
};
if (mode === 'quote') {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-lg bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white">Quote Post</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-4">
<textarea
className="w-full bg-transparent border-none text-white placeholder-gray-500 focus:ring-0 resize-none h-24 mb-4"
placeholder="Add a comment..."
value={quoteText}
onChange={(e) => setQuoteText(e.target.value)}
autoFocus
/>
<div className="border border-kodo-steel rounded-lg p-3 bg-kodo-ink/30 opacity-70">
<div className="flex items-center gap-2 mb-2">
<img src={post.author.avatar} className="w-5 h-5 rounded-full" />
<span className="font-bold text-xs text-white">{post.author.name}</span>
</div>
<p className="text-xs text-gray-400 line-clamp-2">{post.content}</p>
</div>
</div>
<div className="p-4 border-t border-kodo-steel bg-kodo-ink flex justify-end gap-2">
<Button variant="ghost" onClick={() => setMode('options')}>Back</Button>
<Button variant="primary" onClick={() => { onConfirm('quote', quoteText); onClose(); }}>Quote</Button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-kodo-void/90 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative w-full max-w-sm bg-kodo-graphite border border-kodo-steel rounded-xl shadow-2xl animate-scaleIn overflow-hidden">
<div className="p-4 border-b border-kodo-steel bg-kodo-ink flex justify-between items-center">
<h3 className="font-bold text-white">Share Post</h3>
<button onClick={onClose}><X className="w-5 h-5 text-gray-400 hover:text-white" /></button>
</div>
<div className="p-2">
<button
className="w-full flex items-center gap-4 p-3 hover:bg-white/5 rounded-lg transition-colors group"
onClick={() => { onConfirm('repost'); onClose(); }}
>
<div className="w-10 h-10 rounded-full bg-kodo-lime/10 flex items-center justify-center text-kodo-lime group-hover:bg-kodo-lime/20">
<Repeat className="w-5 h-5" />
</div>
<div className="text-left">
<div className="text-white font-bold">Repost</div>
<div className="text-xs text-gray-400">Instantly share with your followers</div>
</div>
</button>
<button
className="w-full flex items-center gap-4 p-3 hover:bg-white/5 rounded-lg transition-colors group"
onClick={() => setMode('quote')}
>
<div className="w-10 h-10 rounded-full bg-kodo-cyan/10 flex items-center justify-center text-kodo-cyan group-hover:bg-kodo-cyan/20">
<MessageSquare className="w-5 h-5" />
</div>
<div className="text-left">
<div className="text-white font-bold">Quote</div>
<div className="text-xs text-gray-400">Repost with your own thoughts</div>
</div>
</button>
<div className="h-px bg-kodo-steel/50 my-2 mx-3"></div>
<button
className="w-full flex items-center gap-4 p-3 hover:bg-white/5 rounded-lg transition-colors group"
onClick={handleCopyLink}
>
<div className="w-10 h-10 rounded-full bg-gray-800 flex items-center justify-center text-gray-400 group-hover:text-white">
<Link className="w-5 h-5" />
</div>
<div className="text-left">
<div className="text-white font-bold">Copy Link</div>
</div>
</button>
<button
className="w-full flex items-center gap-4 p-3 hover:bg-white/5 rounded-lg transition-colors group"
onClick={() => { addToast("Sent via DM"); onClose(); }}
>
<div className="w-10 h-10 rounded-full bg-gray-800 flex items-center justify-center text-gray-400 group-hover:text-white">
<Mail className="w-5 h-5" />
</div>
<div className="text-left">
<div className="text-white font-bold">Send via Direct Message</div>
</div>
</button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,87 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../../ui/button';
import { SearchInput } from '../../ui/input';
import { UserCard } from '../../user/UserCard';
import { User } from '../../../types';
import { useToast } from '../../../context/ToastContext';
import { Loader2 } from 'lucide-react';
import { searchService } from '../../../services/searchService';
import { logger } from '@/utils/logger';
interface ConnectionsViewProps {
type: 'followers' | 'following';
userId: string; // To fetch data
}
export const ConnectionsView: React.FC<ConnectionsViewProps> = ({ type, userId }) => {
const { addToast } = useToast();
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<'all' | 'mutual' | 'recent'>('all');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUsers = async () => {
setLoading(true);
try {
// Simulating fetching followers/following via search service for now
// In real app, call userService.getFollowers(userId)
const res = await searchService.global('');
setUsers(res.users);
} catch (e) {
logger.error('Error loading connections', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
type,
userId,
});
} finally {
setLoading(false);
}
};
fetchUsers();
}, [userId, type]);
const filteredUsers = users.filter(u => u.username.toLowerCase().includes(search.toLowerCase()));
return (
<div className="space-y-6 animate-fadeIn pb-20">
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
<div>
<h2 className="text-3xl font-display font-bold text-white mb-2 capitalize">{type}</h2>
<p className="text-gray-400 font-mono text-sm">Managing your network.</p>
</div>
</div>
<div className="flex flex-col md:flex-row gap-4 items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50">
<div className="w-full md:w-96">
<SearchInput placeholder={`Search ${type}...`} value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{type === 'followers' && (
<div className="flex gap-2">
<Button variant="ghost" size="sm" className={filter === 'all' ? 'text-white' : 'text-gray-500'} onClick={() => setFilter('all')}>All</Button>
<Button variant="ghost" size="sm" className={filter === 'mutual' ? 'text-white' : 'text-gray-500'} onClick={() => setFilter('mutual')}>Mutual</Button>
<Button variant="ghost" size="sm" className={filter === 'recent' ? 'text-white' : 'text-gray-500'} onClick={() => setFilter('recent')}>Recent</Button>
</div>
)}
</div>
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" /></div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredUsers.map(user => (
<UserCard
key={user.id}
user={user}
isFollowing={type === 'following'}
onFollow={() => addToast(type === 'following' ? "Unfollowed" : "Followed")}
onView={() => addToast(`Viewing ${user.username}`)}
/>
))}
</div>
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show more