chore: remove production logs in components
This commit is contained in:
parent
81d08a4680
commit
83f12a6e42
264 changed files with 33688 additions and 1129 deletions
164
apps/web/src/components/admin/AdminDashboardView.tsx
Normal file
164
apps/web/src/components/admin/AdminDashboardView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
116
apps/web/src/components/admin/AdminModerationView.tsx
Normal file
116
apps/web/src/components/admin/AdminModerationView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
105
apps/web/src/components/admin/AdminSettingsView.tsx
Normal file
105
apps/web/src/components/admin/AdminSettingsView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
137
apps/web/src/components/admin/AdminUsersView.tsx
Normal file
137
apps/web/src/components/admin/AdminUsersView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
88
apps/web/src/components/admin/UserTableRow.tsx
Normal file
88
apps/web/src/components/admin/UserTableRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
104
apps/web/src/components/admin/modals/BanUserModal.tsx
Normal file
104
apps/web/src/components/admin/modals/BanUserModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
136
apps/web/src/components/analytics/TrackAnalyticsView.tsx
Normal file
136
apps/web/src/components/analytics/TrackAnalyticsView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
54
apps/web/src/components/commerce/CartItem.tsx
Normal file
54
apps/web/src/components/commerce/CartItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
91
apps/web/src/components/commerce/OrderSummary.tsx
Normal file
91
apps/web/src/components/commerce/OrderSummary.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
103
apps/web/src/components/commerce/WishlistView.tsx
Normal file
103
apps/web/src/components/commerce/WishlistView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
68
apps/web/src/components/commerce/modals/PromoCodeModal.tsx
Normal file
68
apps/web/src/components/commerce/modals/PromoCodeModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
90
apps/web/src/components/dashboard/StatCard.tsx
Normal file
90
apps/web/src/components/dashboard/StatCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
147
apps/web/src/components/dashboard/TrackList.tsx
Normal file
147
apps/web/src/components/dashboard/TrackList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
129
apps/web/src/components/developer/APIPlaygroundView.tsx
Normal file
129
apps/web/src/components/developer/APIPlaygroundView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
133
apps/web/src/components/developer/DeveloperDashboardView.tsx
Normal file
133
apps/web/src/components/developer/DeveloperDashboardView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
apps/web/src/components/developer/WebhooksView.tsx
Normal file
97
apps/web/src/components/developer/WebhooksView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
124
apps/web/src/components/developer/modals/CreateAPIKeyModal.tsx
Normal file
124
apps/web/src/components/developer/modals/CreateAPIKeyModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
85
apps/web/src/components/education/CourseCard.tsx
Normal file
85
apps/web/src/components/education/CourseCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
226
apps/web/src/components/education/CourseDetailView.tsx
Normal file
226
apps/web/src/components/education/CourseDetailView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
262
apps/web/src/components/education/CourseLearningView.tsx
Normal file
262
apps/web/src/components/education/CourseLearningView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
100
apps/web/src/components/education/MyCoursesView.tsx
Normal file
100
apps/web/src/components/education/MyCoursesView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
141
apps/web/src/components/education/modals/QuizModal.tsx
Normal file
141
apps/web/src/components/education/modals/QuizModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
apps/web/src/components/gamification/AchievementCard.tsx
Normal file
62
apps/web/src/components/gamification/AchievementCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
102
apps/web/src/components/gamification/AchievementsView.tsx
Normal file
102
apps/web/src/components/gamification/AchievementsView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
132
apps/web/src/components/gamification/LeaderboardView.tsx
Normal file
132
apps/web/src/components/gamification/LeaderboardView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
154
apps/web/src/components/gamification/ProfileXPView.tsx
Normal file
154
apps/web/src/components/gamification/ProfileXPView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
67
apps/web/src/components/gamification/XPBar.tsx
Normal file
67
apps/web/src/components/gamification/XPBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
129
apps/web/src/components/inventory/AddEquipmentView.tsx
Normal file
129
apps/web/src/components/inventory/AddEquipmentView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
apps/web/src/components/inventory/EquipmentCard.tsx
Normal file
55
apps/web/src/components/inventory/EquipmentCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
195
apps/web/src/components/inventory/EquipmentDetailView.tsx
Normal file
195
apps/web/src/components/inventory/EquipmentDetailView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
126
apps/web/src/components/inventory/InventoryView.tsx
Normal file
126
apps/web/src/components/inventory/InventoryView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
188
apps/web/src/components/layout/AudioPlayer.tsx
Normal file
188
apps/web/src/components/layout/AudioPlayer.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
176
apps/web/src/components/layout/Navbar.tsx
Normal file
176
apps/web/src/components/layout/Navbar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
100
apps/web/src/components/library/AutoMetadataDetectionModal.tsx
Normal file
100
apps/web/src/components/library/AutoMetadataDetectionModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
128
apps/web/src/components/library/WatermarkSettingsModal.tsx
Normal file
128
apps/web/src/components/library/WatermarkSettingsModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
120
apps/web/src/components/library/playlists/EditPlaylistModal.tsx
Normal file
120
apps/web/src/components/library/playlists/EditPlaylistModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
182
apps/web/src/components/library/playlists/PlaylistDetailView.tsx
Normal file
182
apps/web/src/components/library/playlists/PlaylistDetailView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
119
apps/web/src/components/library/playlists/PlaylistsView.tsx
Normal file
119
apps/web/src/components/library/playlists/PlaylistsView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
149
apps/web/src/components/library/playlists/QueueView.tsx
Normal file
149
apps/web/src/components/library/playlists/QueueView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
147
apps/web/src/components/live/LiveStreamDetailView.tsx
Normal file
147
apps/web/src/components/live/LiveStreamDetailView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
apps/web/src/components/live/modals/TipStreamerModal.tsx
Normal file
113
apps/web/src/components/live/modals/TipStreamerModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
52
apps/web/src/components/marketplace/LicenceCard.tsx
Normal file
52
apps/web/src/components/marketplace/LicenceCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
apps/web/src/components/marketplace/ProductCard.tsx
Normal file
90
apps/web/src/components/marketplace/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
261
apps/web/src/components/marketplace/ProductDetailView.tsx
Normal file
261
apps/web/src/components/marketplace/ProductDetailView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
128
apps/web/src/components/modals/CreatorModal.tsx
Normal file
128
apps/web/src/components/modals/CreatorModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
86
apps/web/src/components/notifications/NotificationBell.tsx
Normal file
86
apps/web/src/components/notifications/NotificationBell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
65
apps/web/src/components/notifications/NotificationItem.tsx
Normal file
65
apps/web/src/components/notifications/NotificationItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
116
apps/web/src/components/player/FullPlayer.tsx
Normal file
116
apps/web/src/components/player/FullPlayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
63
apps/web/src/components/player/LyricsPanel.tsx
Normal file
63
apps/web/src/components/player/LyricsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
92
apps/web/src/components/player/MiniPlayer.tsx
Normal file
92
apps/web/src/components/player/MiniPlayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
apps/web/src/components/player/PlaybackSpeedModal.tsx
Normal file
71
apps/web/src/components/player/PlaybackSpeedModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
103
apps/web/src/components/player/PlayerControls.tsx
Normal file
103
apps/web/src/components/player/PlayerControls.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
78
apps/web/src/components/player/VisualizerSettingsModal.tsx
Normal file
78
apps/web/src/components/player/VisualizerSettingsModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
139
apps/web/src/components/search/SearchBar.tsx
Normal file
139
apps/web/src/components/search/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
189
apps/web/src/components/seller/CreateProductView.tsx
Normal file
189
apps/web/src/components/seller/CreateProductView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
171
apps/web/src/components/seller/SellerDashboardView.tsx
Normal file
171
apps/web/src/components/seller/SellerDashboardView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
130
apps/web/src/components/seller/modals/FlashSaleModal.tsx
Normal file
130
apps/web/src/components/seller/modals/FlashSaleModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
199
apps/web/src/components/settings/account/AccountSettings.tsx
Normal file
199
apps/web/src/components/settings/account/AccountSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
127
apps/web/src/components/settings/account/DeleteAccountView.tsx
Normal file
127
apps/web/src/components/settings/account/DeleteAccountView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
156
apps/web/src/components/settings/backups/BackupsView.tsx
Normal file
156
apps/web/src/components/settings/backups/BackupsView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
143
apps/web/src/components/settings/cloud/CloudIntegrationView.tsx
Normal file
143
apps/web/src/components/settings/cloud/CloudIntegrationView.tsx
Normal 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>;
|
||||
93
apps/web/src/components/settings/data/DataExportModal.tsx
Normal file
93
apps/web/src/components/settings/data/DataExportModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
apps/web/src/components/settings/data/DataExportView.tsx
Normal file
72
apps/web/src/components/settings/data/DataExportView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
241
apps/web/src/components/settings/profile/EditProfile.tsx
Normal file
241
apps/web/src/components/settings/profile/EditProfile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
103
apps/web/src/components/settings/security/LoginHistory.tsx
Normal file
103
apps/web/src/components/settings/security/LoginHistory.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
96
apps/web/src/components/settings/security/PasskeyModal.tsx
Normal file
96
apps/web/src/components/settings/security/PasskeyModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
136
apps/web/src/components/settings/security/SecuritySettings.tsx
Normal file
136
apps/web/src/components/settings/security/SecuritySettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
110
apps/web/src/components/settings/security/SessionManagement.tsx
Normal file
110
apps/web/src/components/settings/security/SessionManagement.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
187
apps/web/src/components/settings/security/TwoFactorSetup.tsx
Normal file
187
apps/web/src/components/settings/security/TwoFactorSetup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
68
apps/web/src/components/social/CommentItem.tsx
Normal file
68
apps/web/src/components/social/CommentItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
118
apps/web/src/components/social/CreatePostModal.tsx
Normal file
118
apps/web/src/components/social/CreatePostModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
157
apps/web/src/components/social/ExploreView.tsx
Normal file
157
apps/web/src/components/social/ExploreView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
apps/web/src/components/social/FeedView.tsx
Normal file
113
apps/web/src/components/social/FeedView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
188
apps/web/src/components/social/PostCard.tsx
Normal file
188
apps/web/src/components/social/PostCard.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
124
apps/web/src/components/social/SharePostModal.tsx
Normal file
124
apps/web/src/components/social/SharePostModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
Loading…
Reference in a new issue