356 lines
18 KiB
TypeScript
356 lines
18 KiB
TypeScript
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { UserDTO, MaterialDTO } from '../types';
|
|
import { DataService } from '../services/dataService';
|
|
import { X, Save, Shield, User, Calendar, LogOut, Grid, Heart, Package, Code, Video } from 'lucide-react';
|
|
import { MaterialCard } from './MaterialCard';
|
|
import Image from 'next/image';
|
|
import { useToast } from './ToastProvider';
|
|
|
|
interface ProfileModalProps {
|
|
user: UserDTO;
|
|
onClose: () => void;
|
|
onUpdate: () => void;
|
|
}
|
|
|
|
export const ProfileModal: React.FC<ProfileModalProps> = ({ user, onClose, onUpdate }) => {
|
|
const [formData, setFormData] = useState({
|
|
username: user.username,
|
|
avatarUrl: user.avatarUrl,
|
|
});
|
|
const [loading, setLoading] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<'info' | 'created' | 'favorites'>('info');
|
|
const [userMaterials, setUserMaterials] = useState<MaterialDTO[]>([]);
|
|
const [favoriteMaterials, setFavoriteMaterials] = useState<MaterialDTO[]>([]);
|
|
const [loadingMaterials, setLoadingMaterials] = useState(false);
|
|
const [createdPage, setCreatedPage] = useState(1);
|
|
const [favoritesPage, setFavoritesPage] = useState(1);
|
|
const pageSize = 20;
|
|
const [createdTotal, setCreatedTotal] = useState(0);
|
|
const [createdHasNext, setCreatedHasNext] = useState(false);
|
|
const [favoritesTotal, setFavoritesTotal] = useState(0);
|
|
const [favoritesHasNext, setFavoritesHasNext] = useState(false);
|
|
const toast = useToast();
|
|
const siteIconSvg = encodeURIComponent("<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'><rect width='100%' height='100%' fill='#0b0b0b'/><circle cx='32' cy='32' r='28' fill='#111' stroke='#39ff14'/><text x='50%' y='56%' dominant-baseline='middle' text-anchor='middle' fill='#39ff14' font-size='18' font-family='monospace'>NM</text></svg>");
|
|
const siteIcon = `data:image/svg+xml;utf8,${siteIconSvg}`;
|
|
|
|
const loadFavorites = useCallback(async () => {
|
|
try {
|
|
setLoadingMaterials(true);
|
|
const result = await DataService.getUserFavorites(favoritesPage, pageSize);
|
|
setFavoriteMaterials(result.items);
|
|
setFavoritesTotal(result.total);
|
|
setFavoritesHasNext(result.hasNext);
|
|
} catch (error) {
|
|
toast.error('Failed to load favorites');
|
|
} finally {
|
|
setLoadingMaterials(false);
|
|
}
|
|
}, [toast, favoritesPage]);
|
|
|
|
const loadUserMaterials = useCallback(async () => {
|
|
try {
|
|
setLoadingMaterials(true);
|
|
const result = await DataService.getUserMaterials(createdPage, pageSize);
|
|
setUserMaterials(result.items);
|
|
setCreatedTotal(result.total);
|
|
setCreatedHasNext(result.hasNext);
|
|
} catch (error) {
|
|
toast.error('Failed to load materials');
|
|
} finally {
|
|
setLoadingMaterials(false);
|
|
}
|
|
}, [toast, createdPage]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'created') {
|
|
loadUserMaterials();
|
|
} else if (activeTab === 'favorites') {
|
|
loadFavorites();
|
|
}
|
|
}, [activeTab, loadFavorites, loadUserMaterials]);
|
|
|
|
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
setLoading(true);
|
|
await DataService.updateUserProfile(user.id, formData);
|
|
toast.success('Profile updated');
|
|
onUpdate();
|
|
onClose();
|
|
} catch (error) {
|
|
toast.error('Failed to update profile');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await fetch('/api/v1/auth/logout', { method: 'POST' });
|
|
localStorage.removeItem('NEXUS_DATA_MODE');
|
|
window.location.href = '/auth/login';
|
|
} catch (error) {
|
|
toast.error('Logout failed');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4">
|
|
<div className="bg-cyber-dark border border-cyber-pink/30 w-[900px] h-[640px] rounded-lg shadow-[0_0_50px_rgba(255,0,85,0.15)] relative overflow-hidden flex flex-col">
|
|
|
|
{/* Background Grids */}
|
|
<div className="absolute inset-0 opacity-10 pointer-events-none"
|
|
style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, rgba(255,0,85,0.5) 1px, transparent 0)', backgroundSize: '24px 24px' }}>
|
|
</div>
|
|
|
|
{/* Close Button */}
|
|
<button onClick={onClose} className="absolute top-4 right-4 z-50 text-gray-500 hover:text-white transition-colors">
|
|
<X size={20} />
|
|
</button>
|
|
|
|
{/* Header with Tabs */}
|
|
<div className="border-b border-cyber-panel p-6">
|
|
<div className="flex items-center gap-4 mb-6">
|
|
<div className="relative w-16 h-16">
|
|
<div className="absolute inset-0 bg-cyber-pink rounded-full blur-lg opacity-20"></div>
|
|
<Image
|
|
src={(formData.avatarUrl && formData.avatarUrl.trim()) ? formData.avatarUrl : (user.avatarUrl && user.avatarUrl.trim()) ? user.avatarUrl : siteIcon}
|
|
alt="Profile"
|
|
width={64}
|
|
height={64}
|
|
className="w-full h-full rounded-full border-2 border-cyber-pink object-cover relative z-10"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<h2 className="text-2xl font-bold font-mono text-white">{formData.username}</h2>
|
|
<p className="text-xs text-gray-500 font-mono">{user.role} • {user.status}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setActiveTab('info')}
|
|
className={`px-4 py-2 font-mono text-sm transition-all ${activeTab === 'info'
|
|
? 'bg-cyber-pink text-black'
|
|
: 'text-gray-400 hover:text-white'
|
|
}`}
|
|
>
|
|
<User size={14} className="inline mr-2" />
|
|
INFO
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('created')}
|
|
className={`px-4 py-2 font-mono text-sm transition-all ${activeTab === 'created'
|
|
? 'bg-cyber-pink text-black'
|
|
: 'text-gray-400 hover:text-white'
|
|
}`}
|
|
>
|
|
<Grid size={14} className="inline mr-2" />
|
|
CREATED ({createdTotal})
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('favorites')}
|
|
className={`px-4 py-2 font-mono text-sm transition-all ${activeTab === 'favorites'
|
|
? 'bg-cyber-pink text-black'
|
|
: 'text-gray-400 hover:text-white'
|
|
}`}
|
|
>
|
|
<Heart size={14} className="inline mr-2" />
|
|
FAVORITES ({favoritesTotal})
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{activeTab === 'info' && (
|
|
<form onSubmit={handleSubmit} className="space-y-6 max-w-[760px]">
|
|
<div className="grid grid-cols-2 gap-4 text-sm mb-6">
|
|
<div className="flex items-center justify-between border-b border-white/5 pb-2">
|
|
<span className="text-gray-500 flex items-center gap-2"><Shield size={12} /> STATUS</span>
|
|
<span className="text-cyber-neon">{user.status}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between border-b border-white/5 pb-2">
|
|
<span className="text-gray-500 flex items-center gap-2"><Calendar size={12} /> JOINED</span>
|
|
<span className="text-gray-300">{new Date(user.createdAt).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-mono text-cyber-pink/80 mb-2 uppercase">Username</label>
|
|
<input
|
|
type="text"
|
|
value={formData.username}
|
|
onChange={e => setFormData({ ...formData, username: e.target.value })}
|
|
className="w-full bg-black/40 border border-cyber-panel p-3 text-white focus:border-cyber-pink focus:outline-none rounded-sm font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-mono text-cyber-pink/80 mb-2 uppercase">Avatar URL</label>
|
|
<input
|
|
type="url"
|
|
value={formData.avatarUrl}
|
|
onChange={e => setFormData({ ...formData, avatarUrl: e.target.value })}
|
|
className="w-full bg-black/40 border border-cyber-panel p-3 text-white focus:border-cyber-pink focus:outline-none rounded-sm font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-4">
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="flex-1 bg-cyber-pink text-black font-bold font-mono py-3 px-6 hover:bg-white transition-all flex items-center justify-center gap-2"
|
|
>
|
|
{loading ? 'SAVING...' : <><Save size={16} /> SAVE CHANGES</>}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleLogout}
|
|
className="px-6 border border-red-500 text-red-500 hover:bg-red-500 hover:text-black transition-all font-mono uppercase text-sm flex items-center gap-2"
|
|
>
|
|
<LogOut size={16} /> LOGOUT
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{activeTab === 'created' && (
|
|
<div>
|
|
<h3 className="text-lg font-mono text-white mb-4">MY CREATIONS</h3>
|
|
<div className="mb-4 flex gap-2">
|
|
<UploadZipButton onUploaded={() => loadUserMaterials()} />
|
|
{(user.role === 'MANAGER' || user.role === 'ADMIN') && (
|
|
<UploadVideoButton onUploaded={() => loadUserMaterials()} userRole={user.role} />
|
|
)}
|
|
</div>
|
|
{loadingMaterials ? (
|
|
<div className="text-center py-10 text-cyber-neon font-mono">LOADING...</div>
|
|
) : userMaterials.length > 0 ? (
|
|
<>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{userMaterials.map(m => (
|
|
<MaterialCard key={m.id} material={m} onClick={() => { }} currentUser={user} />
|
|
))}
|
|
</div>
|
|
<div className="mt-6 flex items-center justify-center gap-4">
|
|
<button onClick={() => setCreatedPage(p => Math.max(1, p - 1))} className="px-3 py-2 border border-white/10 text-gray-400 hover:text-white">Prev</button>
|
|
<span className="text-xs font-mono text-gray-500">PAGE {createdPage} / {Math.max(1, Math.ceil(createdTotal / pageSize))}</span>
|
|
<button onClick={() => setCreatedPage(p => p + 1)} disabled={!createdHasNext} className="px-3 py-2 border border-white/10 text-gray-400 hover:text-white disabled:opacity-50">Next</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-center py-20 border border-dashed border-gray-800 rounded">
|
|
<Package size={48} className="mx-auto text-gray-700 mb-4" />
|
|
<p className="text-gray-600 font-mono">NO CREATIONS YET</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'favorites' && (
|
|
<div>
|
|
<h3 className="text-lg font-mono text-white mb-4">MY FAVORITES</h3>
|
|
{loadingMaterials ? (
|
|
<div className="text-center py-10 text-cyber-neon font-mono">LOADING...</div>
|
|
) : favoriteMaterials.length > 0 ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{favoriteMaterials.map(m => (
|
|
<MaterialCard key={m.id} material={m} onClick={() => { }} currentUser={user} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-20 border border-dashed border-gray-800 rounded">
|
|
<Heart size={48} className="mx-auto text-gray-700 mb-4" />
|
|
<p className="text-gray-600 font-mono">NO FAVORITES YET</p>
|
|
</div>
|
|
)}
|
|
<div className="mt-6 flex items-center justify-center gap-4">
|
|
<button onClick={() => setFavoritesPage(p => Math.max(1, p - 1))} className="px-3 py-2 border border-white/10 text-gray-400 hover:text-white">Prev</button>
|
|
<span className="text-xs font-mono text-gray-500">PAGE {favoritesPage} / {Math.max(1, Math.ceil(favoritesTotal / pageSize))}</span>
|
|
<button onClick={() => setFavoritesPage(p => p + 1)} disabled={!favoritesHasNext} className="px-3 py-2 border border-white/10 text-gray-400 hover:text-white disabled:opacity-50">Next</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const UploadZipButton: React.FC<{ onUploaded: () => void }> = ({ onUploaded }) => {
|
|
const toast = useToast();
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const onClick = () => inputRef.current?.click();
|
|
const onChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
if (file.size > 3 * 1024 * 1024) {
|
|
toast.error('ZIP size must be ≤ 3MB');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
await DataService.uploadZip(file, { title: file.name });
|
|
toast.success('ZIP uploaded');
|
|
onUploaded();
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Upload failed');
|
|
} finally {
|
|
setLoading(false);
|
|
if (inputRef.current) inputRef.current.value = '';
|
|
}
|
|
};
|
|
return (
|
|
<div>
|
|
<input ref={inputRef} type="file" accept=".zip,application/zip" className="hidden" onChange={onChange} />
|
|
<button onClick={onClick} disabled={loading} className="px-3 py-2 border border-cyber-neon text-cyber-neon hover:bg-cyber-neon hover:text-black transition-colors text-xs font-mono">
|
|
{loading ? 'UPLOADING...' : 'UPLOAD_ZIP'}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const UploadVideoButton: React.FC<{ onUploaded: () => void; userRole: UserDTO['role'] }> = ({ onUploaded, userRole }) => {
|
|
const toast = useToast();
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const disabled = !(userRole === 'MANAGER' || userRole === 'ADMIN');
|
|
const onClick = () => {
|
|
if (disabled) {
|
|
toast.error('Manager role required');
|
|
return;
|
|
}
|
|
inputRef.current?.click();
|
|
};
|
|
const onChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setLoading(true);
|
|
try {
|
|
await DataService.uploadVideo(file, { title: file.name });
|
|
toast.success('Video uploaded');
|
|
onUploaded();
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Upload failed');
|
|
} finally {
|
|
setLoading(false);
|
|
if (inputRef.current) inputRef.current.value = '';
|
|
}
|
|
};
|
|
return (
|
|
<div>
|
|
<input ref={inputRef} type="file" accept="video/*,.mp4,.webm,.mov" className="hidden" onChange={onChange} />
|
|
<button onClick={onClick} disabled={loading} className="px-3 py-2 border border-cyber-pink text-cyber-pink hover:bg-cyber-pink hover:text-black transition-colors text-xs font-mono">
|
|
{loading ? 'UPLOADING...' : 'UPLOAD_VIDEO'}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|