Files
Nexus_Mat/components/ProfileModal.tsx

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