feat: Docker部署与CI/CD集成, 搜索栏修复, 上传目录改为data

This commit is contained in:
xiner
2025-11-28 18:42:30 +08:00
commit 8351d6bbfc
243 changed files with 13192 additions and 0 deletions

173
components/CreateModal.tsx Normal file
View File

@@ -0,0 +1,173 @@
import React, { useEffect, useState } from 'react';
import { DataService } from '../services/dataService';
import { GeminiService } from '../services/geminiService';
import { MaterialType, UserDTO } from '../types';
import { X, Wand2, UploadCloud } from 'lucide-react';
import { useToast } from './ToastProvider';
interface CreateModalProps {
onClose: () => void;
onSuccess: () => void;
}
export const CreateModal: React.FC<CreateModalProps> = ({ onClose, onSuccess }) => {
const toast = useToast();
const [formData, setFormData] = useState({
title: '',
description: '',
type: MaterialType.CODE,
codeSnippet: '',
tags: [] as string[]
});
const [file, setFile] = useState<File | null>(null);
const [currentUser, setCurrentUser] = useState<UserDTO | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
DataService.getCurrentUser().then(setCurrentUser).catch(() => setCurrentUser(null));
}, []);
const handleAutoGenerate = async () => {
if (!formData.codeSnippet) return;
setIsProcessing(true);
const { description, tags } = await GeminiService.analyzeCode(formData.codeSnippet);
setFormData(prev => ({
...prev,
description,
tags: [...prev.tags, ...tags]
}));
setIsProcessing(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsProcessing(true);
try {
if (formData.type === MaterialType.CODE) {
await DataService.createMaterial(formData);
} else if (formData.type === MaterialType.ASSET_ZIP) {
if (!file) throw new Error('Please select ZIP file');
if (file.size > 3 * 1024 * 1024) throw new Error('ZIP must be ≤ 3MB');
await DataService.uploadZip(file, { title: formData.title, description: formData.description, tags: formData.tags });
} else if (formData.type === MaterialType.VIDEO) {
if (!(currentUser?.role === 'MANAGER' || currentUser?.role === 'ADMIN')) throw new Error('Manager role required');
if (!file) throw new Error('Please select video file');
await DataService.uploadVideo(file, { title: formData.title, description: formData.description, tags: formData.tags });
}
onSuccess();
} catch (err: any) {
toast.error(err.message || 'Upload failed');
} finally {
setIsProcessing(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-cyber-dark border border-cyber-neon/30 w-full max-w-2xl rounded-xl shadow-2xl relative overflow-hidden">
{/* Decorative Header */}
<div className="h-1 w-full bg-gradient-to-r from-cyber-neon via-cyber-blue to-cyber-pink"></div>
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-mono font-bold text-white tracking-tight">UPLOAD_NEW_PROTOCOL</h2>
<button onClick={onClose} className="text-gray-500 hover:text-white"><X /></button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Type Selection */}
<div className="flex gap-4">
{Object.values(MaterialType).map(t => {
const isVideo = t === MaterialType.VIDEO;
const allowed = !isVideo || (currentUser?.role === 'MANAGER' || currentUser?.role === 'ADMIN');
return (
<button
key={t}
type="button"
onClick={() => {
if (!allowed) { toast.error('Manager role required'); return; }
setFormData({ ...formData, type: t });
}}
className={`flex-1 py-3 text-xs font-mono border ${formData.type === t ? 'bg-cyber-neon/20 border-cyber-neon text-cyber-neon' : 'border-cyber-panel text-gray-500 hover:border-gray-500'} ${!allowed ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{t}
</button>
);
})}
</div>
{/* Title */}
<div>
<label className="block text-xs font-mono text-gray-400 mb-1">TITLE_IDENTIFIER</label>
<input
required
className="w-full bg-black/50 border border-cyber-panel p-3 text-white focus:border-cyber-neon focus:outline-none"
value={formData.title}
onChange={e => setFormData({...formData, title: e.target.value})}
/>
</div>
{/* Content Input Based on Type */}
{formData.type === MaterialType.CODE ? (
<div className="relative">
<label className="block text-xs font-mono text-gray-400 mb-1">SOURCE_CODE</label>
<textarea
required
className="w-full h-40 bg-black/50 border border-cyber-panel p-3 text-xs font-mono text-cyber-blue focus:border-cyber-blue focus:outline-none"
value={formData.codeSnippet}
onChange={e => setFormData({...formData, codeSnippet: e.target.value})}
/>
{/* AI Magic Button */}
<button
type="button"
onClick={handleAutoGenerate}
disabled={!formData.codeSnippet || isProcessing}
className="absolute bottom-2 right-2 px-3 py-1 bg-cyber-blue/20 text-cyber-blue border border-cyber-blue/50 text-xs font-mono flex items-center gap-2 hover:bg-cyber-blue hover:text-black transition-colors"
>
<Wand2 size={12} /> {isProcessing ? 'SCANNING...' : 'AI_AUTO_FILL'}
</button>
</div>
) : (
<div>
<label className="block text-xs font-mono text-gray-400 mb-1">LOCAL_FILE {formData.type === MaterialType.ASSET_ZIP ? '(ZIP ≤ 3MB)' : '(Video ≤ 3MB)'}</label>
<label className="relative w-full block bg-black/50 border border-cyber-panel p-3 text-gray-400 hover:border-cyber-neon cursor-pointer transition-colors">
<input
required
type="file"
accept={formData.type === MaterialType.ASSET_ZIP ? '.zip,application/zip' : 'video/*,.mp4,.webm,.mov'}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={e => setFile(e.target.files?.[0] || null)}
/>
<span className="flex items-center gap-2">
<UploadCloud size={16} />
{file ? file.name : '点击或拖拽文件到此处'}
</span>
</label>
</div>
)}
{/* Description */}
<div>
<label className="block text-xs font-mono text-gray-400 mb-1">DESCRIPTION</label>
<textarea
className="w-full h-24 bg-black/50 border border-cyber-panel p-3 text-sm text-gray-300 focus:border-cyber-neon focus:outline-none"
value={formData.description}
onChange={e => setFormData({...formData, description: e.target.value})}
/>
</div>
<button
type="submit"
disabled={isProcessing}
className="w-full py-4 bg-cyber-neon text-black font-bold font-mono tracking-widest hover:bg-white transition-colors flex items-center justify-center gap-2"
>
{isProcessing ? 'UPLOADING...' : <><UploadCloud size={20} /> INITIALIZE_UPLOAD</>}
</button>
</form>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,93 @@
import React from 'react';
import Image from 'next/image';
import { MaterialDTO, MaterialType, UserDTO } from '../types';
import { Code, Video, Package, Heart, Download, Eye, Terminal } from 'lucide-react';
interface MaterialCardProps {
material: MaterialDTO;
onClick: (id: string) => void;
currentUser?: UserDTO | null;
}
export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onClick, currentUser }) => {
const Icon = material.type === MaterialType.CODE ? Code : material.type === MaterialType.VIDEO ? Video : Package;
const typeColor = material.type === MaterialType.CODE ? 'text-cyber-blue' : material.type === MaterialType.VIDEO ? 'text-cyber-pink' : 'text-cyber-neon';
const borderColor = material.type === MaterialType.CODE ? 'hover:border-cyber-blue/50' : material.type === MaterialType.VIDEO ? 'hover:border-cyber-pink/50' : 'hover:border-cyber-neon/50';
// Check if current user has favorited this material
const isFavorited = currentUser && material.favorites?.some(f => f.userId === currentUser.id);
const siteIconSvg = encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32'>`+
`<rect width='100%' height='100%' fill='#0b0b0b'/>`+
`<circle cx='16' cy='16' r='14' fill='#111' stroke='#39ff14'/>`+
`<text x='50%' y='56%' dominant-baseline='middle' text-anchor='middle' fill='#39ff14' font-size='12' font-family='monospace'>NM</text>`+
`</svg>`
);
const authorAvatar = material.author.avatarUrl && material.author.avatarUrl.trim()
? material.author.avatarUrl
: `data:image/svg+xml;utf8,${siteIconSvg}`;
return (
<div
onClick={() => onClick(material.id)}
className={`group relative bg-cyber-panel/40 backdrop-blur-sm border border-white/5 ${borderColor} overflow-hidden cursor-pointer transition-all duration-500 hover:-translate-y-1 hover:shadow-2xl hover:shadow-cyber-neon/5`}
>
{/* Decoration Top Right */}
<div className="absolute top-0 right-0 p-2">
<Icon className={`w-5 h-5 ${typeColor} opacity-50 group-hover:opacity-100 transition-opacity`} />
</div>
{/* Content Area */}
<div className="p-6 h-full flex flex-col">
<div className="flex items-center gap-2 mb-3">
<div className="text-[10px] font-mono text-gray-500 uppercase tracking-widest border border-gray-800 px-1 rounded">
{material.type}
</div>
{material.language && (
<div className="text-[10px] font-mono text-cyber-blue uppercase tracking-widest px-1">
.{material.language}
</div>
)}
</div>
<h3 className="text-xl font-bold text-gray-100 mb-2 font-mono group-hover:text-cyber-neon transition-colors line-clamp-1">
{material.title}
</h3>
<p className="text-sm text-gray-400 mb-6 line-clamp-2 flex-grow font-sans">
{material.description}
</p>
{/* Preview (Code or Image placeholder) */}
<div className="w-full h-24 bg-black/50 mb-4 rounded border border-white/5 p-2 font-mono text-xs text-gray-600 overflow-hidden relative">
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/80 pointer-events-none"></div>
{material.type === MaterialType.CODE ? (
<pre>{material.codeSnippet}</pre>
) : (
<div className="flex items-center justify-center h-full text-cyber-pink opacity-50">
<Terminal size={24} />
</div>
)}
</div>
{/* Footer Stats */}
<div className="flex items-center justify-between pt-4 border-t border-white/5 text-xs font-mono text-gray-500">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1 hover:text-white"><Eye size={12} /> {material.stats.views}</span>
<span className={`flex items-center gap-1 ${isFavorited ? 'text-cyber-pink' : 'hover:text-cyber-pink'}`}>
<Heart size={12} className={isFavorited ? 'fill-cyber-pink' : ''} />
{material.stats.favorites}
</span>
</div>
<div className="flex items-center gap-2">
<Image src={authorAvatar} alt="author" width={20} height={20} className="w-5 h-5 rounded-full border border-gray-700" />
<span className="opacity-75">{material.author.username}</span>
</div>
</div>
</div>
{/* Hover Line Animation */}
<div className="absolute bottom-0 left-0 w-0 h-[2px] bg-cyber-neon group-hover:w-full transition-all duration-500 ease-out"></div>
</div>
);
};

View File

@@ -0,0 +1,254 @@
import { useState, useEffect, useCallback } from 'react';
import { X, Heart, MessageCircle, Download, User, Code, Play, Package, Trash2, ExternalLink, Sparkles, Copy, Share2, Cpu, MessageSquare } from 'lucide-react';
import { MaterialDTO, MaterialType, UserDTO } from '../types';
import { DataService } from '../services/dataService';
import { GeminiService } from '../services/geminiService';
import { useToast } from './ToastProvider';
import Image from 'next/image';
interface Props {
id: string;
onClose: () => void;
currentUser: UserDTO | null;
}
export const MaterialDetail: React.FC<Props> = ({ id, onClose, currentUser }) => {
const [material, setMaterial] = useState<MaterialDTO | null>(null);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState('');
const [aiAnalysis, setAiAnalysis] = useState('');
const [analyzingCode, setAnalyzingCode] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const toast = useToast();
const getAvatar = (username: string, url?: string) => {
if (url && url.trim()) return url;
const svg = encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='48' height='48'>`+
`<rect width='100%' height='100%' fill='#0b0b0b'/>`+
`<circle cx='24' cy='24' r='21' fill='#111' stroke='#39ff14'/>`+
`<text x='50%' y='56%' dominant-baseline='middle' text-anchor='middle' fill='#39ff14' font-size='14' font-family='monospace'>NM</text>`+
`</svg>`
);
return `data:image/svg+xml;utf8,${svg}`;
};
const loadData = useCallback(async () => {
try {
setLoading(true);
const data = await DataService.getMaterialById(id);
setMaterial(data);
} catch (error) {
toast.error('Failed to load material details');
console.error('Error loading material:', error);
} finally {
setLoading(false);
}
}, [id, toast]);
useEffect(() => {
loadData();
}, [loadData]);
const handleAiExplain = async () => {
if (!material?.codeSnippet) return;
try {
setAnalyzing(true);
const text = await GeminiService.explainCode(material.codeSnippet);
setAiAnalysis(text);
} catch (error) {
toast.error('AI analysis failed');
console.error('Error getting AI analysis:', error);
} finally {
setAnalyzing(false);
}
};
const handleCopy = () => {
if (material?.codeSnippet) {
navigator.clipboard.writeText(material.codeSnippet);
toast.success('Code copied to clipboard');
}
};
const handleDownload = () => {
toast.info(`Preparing secure download for protocol: ${material?.id}`);
};
const handleComment = async () => {
if (!commentText.trim() || !material) return;
try {
await DataService.addComment(material.id, commentText);
setCommentText('');
toast.success('Comment added');
loadData();
} catch (error) {
toast.error('Please login to comment');
}
};
const handleDelete = async () => {
if (confirm('CONFIRM DELETION PROTOCOL?')) {
try {
await DataService.deleteMaterial(id);
toast.success('Material deleted');
onClose();
} catch (error: any) {
toast.error(error?.message || 'Only author/admin can delete');
}
}
};
if (loading) return <div className="fixed inset-0 z-50 flex items-center justify-center bg-cyber-black/90 text-cyber-neon font-mono">LOADING_ASSET...</div>;
if (!material) return null;
return (
<div className="fixed inset-0 z-40 overflow-y-auto bg-black/60 backdrop-blur-md flex items-center justify-center p-4">
<div className="bg-cyber-dark border border-cyber-panel w-full max-w-5xl rounded-lg shadow-2xl shadow-black relative flex flex-col md:flex-row overflow-hidden max-h-[90vh]">
<button onClick={onClose} className="absolute top-4 right-4 z-50 p-2 bg-black/50 rounded-full hover:bg-cyber-pink hover:text-white transition-colors text-gray-400">
<X size={20} />
</button>
<div className="w-full md:w-2/3 bg-black/40 border-r border-cyber-panel flex flex-col overflow-hidden">
<div className="p-6 border-b border-cyber-panel">
<div className="flex items-center gap-3 mb-2">
<span className="px-2 py-0.5 bg-cyber-neon/10 text-cyber-neon text-xs font-mono rounded border border-cyber-neon/20">
{material.type}
</span>
<h1 className="text-2xl font-bold font-mono text-white">{material.title}</h1>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{material.tags.map(t => (
<span key={t} className="text-xs text-gray-500 font-mono">#{t}</span>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 bg-cyber-black/50 relative group">
{material.type === MaterialType.CODE && (
<div className="relative">
<pre className="font-mono text-sm text-gray-300 p-4 bg-[#0d0d0d] rounded border border-gray-800 overflow-x-auto whitespace-pre-wrap">
{material.codeSnippet}
</pre>
<button onClick={handleCopy} className="absolute top-2 right-2 p-2 bg-cyber-panel rounded hover:text-cyber-neon transition-colors">
<Copy size={16} />
</button>
</div>
)}
{material.type === MaterialType.VIDEO && (
<div className="aspect-video bg-black flex items-center justify-center border border-gray-800 relative overflow-hidden">
<video src={material.contentUrl} controls className="w-full h-full object-cover opacity-80 hover:opacity-100 transition-opacity" />
<div className="absolute inset-0 pointer-events-none bg-gradient-to-t from-black/50 to-transparent"></div>
</div>
)}
{material.type === MaterialType.ASSET_ZIP && (
<div className="h-64 flex flex-col items-center justify-center border-2 border-dashed border-gray-700 rounded-lg bg-gray-900/20">
<Package size={64} className="text-cyber-neon mb-4 animate-pulse-fast" />
<p className="font-mono text-gray-400">ENCRYPTED_ARCHIVE.ZIP</p>
</div>
)}
{material.type === MaterialType.CODE && (
<div className="mt-6 border-t border-cyber-panel pt-6">
<button
onClick={handleAiExplain}
disabled={analyzing}
className="flex items-center gap-2 text-xs font-mono text-cyber-blue hover:text-white transition-colors disabled:opacity-50 mb-2"
>
<Cpu size={14} />
{analyzing ? 'ANALYZING_NEURAL_NET...' : 'AI_CODE_REVIEW'}
</button>
{aiAnalysis && (
<div className="bg-cyber-blue/5 border-l-2 border-cyber-blue p-4 text-sm text-gray-300 font-sans">
<p className="whitespace-pre-line">{aiAnalysis}</p>
</div>
)}
</div>
)}
</div>
<div className="p-4 border-t border-cyber-panel bg-cyber-panel/30 flex justify-between items-center">
<div className="flex gap-4">
<button onClick={() => DataService.toggleFavorite(material.id).then(() => loadData())} className="flex items-center gap-2 text-gray-400 hover:text-cyber-pink transition-colors">
<Heart size={20} className={material.stats.favorites > 0 ? "fill-cyber-pink text-cyber-pink" : ""} />
<span className="font-mono text-sm">{material.stats.favorites}</span>
</button>
<button className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors">
<Share2 size={20} />
</button>
</div>
<div className="flex gap-3">
{currentUser?.id === material.author.id && (
<button onClick={handleDelete} className="p-2 text-red-500 hover:bg-red-900/20 rounded">
<Trash2 size={18} />
</button>
)}
<button
onClick={handleDownload}
className="flex items-center gap-2 px-6 py-2 bg-cyber-neon text-black font-bold font-mono text-sm hover:bg-white transition-colors"
>
<Download size={16} /> DOWNLOAD_ASSET
</button>
</div>
</div>
</div>
<div className="w-full md:w-1/3 bg-cyber-dark flex flex-col">
<div className="p-6 border-b border-cyber-panel flex items-center gap-4">
<Image src={getAvatar(material.author.username, material.author.avatarUrl)} alt="author" width={48} height={48} className="w-12 h-12 rounded-full ring-2 ring-cyber-panel" />
<div>
<h3 className="font-mono text-white font-bold">{material.author.username}</h3>
<p className="text-xs text-gray-500">Joined {new Date(material.author.createdAt).toLocaleDateString()}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-px bg-cyber-panel border-b border-cyber-panel">
<div className="bg-cyber-dark p-4 text-center">
<p className="text-xs text-gray-500 font-mono mb-1">VIEWS</p>
<p className="text-xl text-white font-bold">{material.stats.views}</p>
</div>
<div className="bg-cyber-dark p-4 text-center">
<p className="text-xs text-gray-500 font-mono mb-1">DOWNLOADS</p>
<p className="text-xl text-white font-bold">{material.stats.downloads}</p>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-4">
<h4 className="text-xs font-mono text-gray-500 uppercase">Comm_Log ({material.comments.length})</h4>
{material.comments.map(c => (
<div key={c.id} className="bg-cyber-panel/50 p-3 rounded border border-white/5">
<div className="flex justify-between items-start mb-2">
<span className="text-cyber-neon text-xs font-bold">{c.author.username}</span>
<span className="text-[10px] text-gray-600">{new Date(c.createdAt).toLocaleDateString()}</span>
</div>
<p className="text-sm text-gray-300">{c.content}</p>
</div>
))}
{material.comments.length === 0 && (
<div className="text-center py-10 opacity-30 text-xs font-mono">NO_TRANSMISSIONS_YET</div>
)}
</div>
<div className="p-4 border-t border-cyber-panel bg-cyber-panel/20">
<div className="relative">
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="w-full bg-black/50 border border-cyber-panel rounded p-3 text-sm text-white focus:border-cyber-neon focus:outline-none resize-none h-20 font-sans"
placeholder="Enter secure message..."
/>
<button
onClick={handleComment}
className="absolute bottom-2 right-2 p-1.5 bg-cyber-neon text-black rounded hover:bg-white transition-colors"
>
<MessageSquare size={14} />
</button>
</div>
</div>
</div>
</div>
</div>
);
};

101
components/Navbar.tsx Normal file
View File

@@ -0,0 +1,101 @@
import React from 'react';
import Link from 'next/link';
import { Layers, Plus, Search, Hexagon } from 'lucide-react';
import { UserDTO } from '../types';
interface NavbarProps {
user: UserDTO | null;
onOpenCreate: () => void;
onProfileClick: () => void;
searchQuery?: string;
onSearch?: (q: string) => void;
}
export const Navbar: React.FC<NavbarProps> = ({ user, onOpenCreate, onProfileClick, searchQuery, onSearch }) => {
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 avatarSrc = user && user.avatarUrl && user.avatarUrl.trim()
? user.avatarUrl
: `data:image/svg+xml;utf8,${siteIconSvg}`;
return (
<nav className="sticky top-0 z-50 w-full backdrop-blur-md bg-cyber-black/80 border-b border-cyber-glass">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo Area */}
<div className="flex items-center gap-2 group cursor-pointer" onClick={() => window.location.hash = ''}>
<Hexagon className="text-cyber-neon w-8 h-8 group-hover:rotate-180 transition-transform duration-700" />
<span className="text-2xl font-mono font-bold tracking-tighter text-white">
NEXUS_MAT<span className="text-cyber-neon">.OS</span>
</span>
</div>
{/* Search Bar */}
<div className="hidden md:flex flex-1 max-w-lg mx-8">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-gray-500" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-cyber-panel rounded-sm leading-5 bg-cyber-dark text-gray-300 placeholder-gray-600 focus:outline-none focus:border-cyber-neon focus:ring-1 focus:ring-cyber-neon sm:text-sm font-mono transition-all"
placeholder="SEARCH_PROTOCOL..."
value={searchQuery ?? ''}
onChange={(e) => onSearch?.(e.target.value)}
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-4">
{user ? (
<>
<button
onClick={onOpenCreate}
className="flex items-center gap-2 px-4 py-2 bg-cyber-neon/10 border border-cyber-neon text-cyber-neon hover:bg-cyber-neon hover:text-black transition-all duration-300 font-mono text-sm uppercase tracking-wide group"
>
<Plus className="w-4 h-4 group-hover:rotate-90 transition-transform" />
<span className="hidden sm:inline">Upload_Data</span>
</button>
<div
onClick={onProfileClick}
className="relative cursor-pointer group"
>
<div className="w-10 h-10 rounded-full border border-cyber-panel overflow-hidden bg-cyber-panel group-hover:border-cyber-pink group-hover:shadow-[0_0_15px_rgba(255,0,85,0.4)] transition-all duration-300">
<img src={avatarSrc} alt="User" width={40} height={40} className="w-full h-full object-cover" />
</div>
<div className="absolute bottom-0 right-0 w-3 h-3 bg-cyber-neon rounded-full border-2 border-black"></div>
</div>
</>
) : (
<>
<Link
href="/auth/login"
className="flex items-center gap-2 px-4 py-2 bg-cyber-neon/10 border border-cyber-neon text-cyber-neon hover:bg-cyber-neon hover:text-black transition-all duration-300 font-mono text-sm uppercase tracking-wide"
>
<span>Login</span>
</Link>
<Link
href="/auth/register"
className="flex items-center gap-2 px-4 py-2 bg-cyber-pink text-white hover:bg-white hover:text-black transition-all duration-300 font-mono text-sm uppercase tracking-wide"
>
<span>Register</span>
</Link>
</>
)}
</div>
</div>
</div>
{/* Decorative Line */}
<div className="h-[1px] w-full bg-gradient-to-r from-transparent via-cyber-neon/50 to-transparent"></div>
</nav>
);
};

355
components/ProfileModal.tsx Normal file
View File

@@ -0,0 +1,355 @@
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>
);
};

View File

@@ -0,0 +1,87 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastContextType {
showToast: (message: string, type: ToastType) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: ToastType) => {
const id = Math.random().toString(36).substring(7);
const newToast: Toast = { id, message, type };
setToasts((prev) => [...prev, newToast]);
// Auto remove after 5 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 5000);
}, []);
const success = useCallback((message: string) => showToast(message, 'success'), [showToast]);
const error = useCallback((message: string) => showToast(message, 'error'), [showToast]);
const info = useCallback((message: string) => showToast(message, 'info'), [showToast]);
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
return (
<ToastContext.Provider value={{ showToast, success, error, info }}>
{children}
{/* Toast Container */}
<div className="fixed top-4 right-4 z-[9999] space-y-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`
flex items-center gap-3 min-w-[300px] max-w-md p-4 rounded-lg
backdrop-blur-md border shadow-lg
animate-in slide-in-from-right duration-300
${toast.type === 'success' ? 'bg-green-900/90 border-green-500 text-green-100' : ''}
${toast.type === 'error' ? 'bg-red-900/90 border-red-500 text-red-100' : ''}
${toast.type === 'info' ? 'bg-blue-900/90 border-blue-500 text-blue-100' : ''}
`}
>
{toast.type === 'success' && <CheckCircle className="w-5 h-5 flex-shrink-0" />}
{toast.type === 'error' && <AlertCircle className="w-5 h-5 flex-shrink-0" />}
{toast.type === 'info' && <Info className="w-5 h-5 flex-shrink-0" />}
<p className="flex-1 text-sm font-mono">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="flex-shrink-0 hover:opacity-70 transition-opacity"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};