feat: Docker部署与CI/CD集成, 搜索栏修复, 上传目录改为data
This commit is contained in:
173
components/CreateModal.tsx
Normal file
173
components/CreateModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
93
components/MaterialCard.tsx
Normal file
93
components/MaterialCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
254
components/MaterialDetail.tsx
Normal file
254
components/MaterialDetail.tsx
Normal 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
101
components/Navbar.tsx
Normal 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
355
components/ProfileModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
87
components/ToastProvider.tsx
Normal file
87
components/ToastProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user