Implement CDN functionality for image uploads and management in account routes
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { X, Plus, Trash2, Link as LinkIcon } from 'lucide-react';
|
||||
import { X, Plus, Trash2, Link as LinkIcon, Image as ImageIcon, Loader2, FileText } from 'lucide-react';
|
||||
import { Exam } from '../types';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface ExamModalProps {
|
||||
show: boolean;
|
||||
@@ -14,6 +16,7 @@ interface ExamModalProps {
|
||||
result: string;
|
||||
duration: string;
|
||||
studyMaterials: string[];
|
||||
imageUrls: string[];
|
||||
};
|
||||
setFormData: React.Dispatch<React.SetStateAction<{
|
||||
name: string;
|
||||
@@ -23,6 +26,7 @@ interface ExamModalProps {
|
||||
result: string;
|
||||
duration: string;
|
||||
studyMaterials: string[];
|
||||
imageUrls: string[];
|
||||
}>>;
|
||||
editingExam: Exam | null;
|
||||
}
|
||||
@@ -36,6 +40,91 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
||||
editingExam
|
||||
}) => {
|
||||
const [newMaterial, setNewMaterial] = React.useState('');
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const processFiles = async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const allowedTypes = ['image/', 'application/pdf'];
|
||||
const validFiles = files.filter(file => allowedTypes.some(type => file.type.startsWith(type)));
|
||||
const invalidFiles = files.filter(file => !allowedTypes.some(type => file.type.startsWith(type)));
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
const invalidNames = invalidFiles.map(file => file.name).join(', ');
|
||||
toast.error(`The following file(s) are not supported (images/PDFs only): ${invalidNames}`);
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploadPromises = validFiles.map(file => {
|
||||
return new Promise<{image: string, filename: string, contentType: string}>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve({
|
||||
image: (reader.result as string).split(',')[1],
|
||||
filename: file.name,
|
||||
contentType: file.type
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
});
|
||||
|
||||
const items = await Promise.all(uploadPromises);
|
||||
const res = await axios.post('/api/account/cdn/upload', {
|
||||
images: items
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
imageUrls: [...prev.imageUrls, ...res.data.urls]
|
||||
}));
|
||||
toast.success(`${items.length} image(s) uploaded successfully`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Failed to upload images');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
await processFiles(files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
await processFiles(files);
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
imageUrls: formData.imageUrls.filter((_, i) => i !== index)
|
||||
});
|
||||
};
|
||||
|
||||
const addMaterial = () => {
|
||||
const trimmedMaterial = newMaterial.trim();
|
||||
@@ -72,8 +161,8 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-md flex items-center justify-center z-[100] p-4 animate-in fade-in duration-300">
|
||||
<div className="glass w-full max-w-lg rounded-apple-lg shadow-2xl overflow-hidden transform animate-in zoom-in duration-300 border border-white/10">
|
||||
<div className="px-8 py-6 flex justify-between items-center border-b border-white/5">
|
||||
<div className="glass w-full max-w-lg rounded-apple-lg shadow-2xl overflow-hidden transform animate-in zoom-in duration-300 border border-white/10 flex flex-col max-h-[90vh]">
|
||||
<div className="px-8 py-6 flex justify-between items-center border-b border-white/5 flex-shrink-0">
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight">{editingExam ? 'Exam Details' : 'New Schedule'}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -82,7 +171,7 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="p-8 space-y-6">
|
||||
<form onSubmit={onSubmit} className="overflow-y-auto p-8 pt-6 space-y-5 custom-scrollbar flex-1">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Event Name</label>
|
||||
<input
|
||||
@@ -94,7 +183,7 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Subject</label>
|
||||
<input
|
||||
@@ -117,7 +206,7 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Result / Status</label>
|
||||
<input
|
||||
@@ -146,7 +235,7 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50 h-28 resize-none"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50 h-24 resize-none"
|
||||
placeholder="Topic coverage, materials needed..."
|
||||
/>
|
||||
</div>
|
||||
@@ -175,12 +264,12 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
{formData.studyMaterials.length > 0 && (
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div className="space-y-2 max-h-24 overflow-y-auto pr-2 custom-scrollbar">
|
||||
{formData.studyMaterials.map((url, index) => (
|
||||
<div key={index} className="flex items-center justify-between bg-white/5 rounded-lg px-3 py-2 border border-white/5 group">
|
||||
<div className="flex items-center gap-2 truncate text-sm text-apple-muted">
|
||||
<LinkIcon size={14} className="flex-shrink-0" />
|
||||
<span className="truncate">{url}</span>
|
||||
<span className="truncate text-xs">{url}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -194,7 +283,58 @@ const ExamModal: React.FC<ExamModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<div className="space-y-3">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Images & PDFs</label>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`flex gap-2 transition-all duration-200 ${isDragging ? 'scale-[1.01]' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className={`flex-1 bg-white/5 border-2 border-dashed rounded-xl px-4 py-4 flex flex-col items-center justify-center gap-1 hover:bg-white/10 transition-all text-apple-muted hover:text-white disabled:opacity-50 ${isDragging ? 'border-apple-accent bg-apple-accent/5' : 'border-white/10'}`}
|
||||
>
|
||||
{uploading ? <Loader2 size={20} className="animate-spin text-apple-accent" /> : <div className="flex gap-2"><ImageIcon size={20} /><FileText size={20} /></div>}
|
||||
<div className="text-center">
|
||||
<span className="block text-sm font-semibold">{uploading ? 'Uploading...' : 'Click or drag files'}</span>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
accept="image/*,.pdf"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
{formData.imageUrls.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2 max-h-24 overflow-y-auto pr-2 custom-scrollbar">
|
||||
{formData.imageUrls.map((url, index) => (
|
||||
<div key={index} className="relative aspect-square bg-white/5 rounded-lg border border-white/5 group overflow-hidden">
|
||||
{url.toLowerCase().endsWith('.pdf') ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-white/5">
|
||||
<FileText size={24} className="text-apple-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<img src={url} alt={`Upload ${index}`} className="w-full h-full object-cover" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(index)}
|
||||
className="absolute top-1 right-1 bg-black/60 text-white hover:text-red-400 p-1 rounded-full opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2 flex-shrink-0">
|
||||
<button
|
||||
type="submit"
|
||||
className="apple-button w-full bg-apple-accent hover:opacity-90 text-white font-semibold py-4 shadow-lg shadow-apple-accent/20 h-14"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Trash2, Download, Loader2, Palette, Check, Save } from 'lucide-react';
|
||||
import { Trash2, Download, Loader2, Palette, Check, Save, Upload, Link as LinkIcon, Image as ImageIcon, ExternalLink, FileText } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { User, Exam } from '../types';
|
||||
|
||||
@@ -24,8 +24,27 @@ const AccountPage: React.FC<AccountPageProps> = ({ onLogout, onUpdateUser }) =>
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [iconUrl, setIconUrl] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [cdnImages, setCdnImages] = useState<{name: string, url: string, lastModified: string, size: number}[]>([]);
|
||||
const [cdnLoading, setCdnLoading] = useState(false);
|
||||
const [selectedImages, setSelectedImages] = useState<string[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchCdnImages = async () => {
|
||||
setCdnLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/account/cdn/list');
|
||||
if (res.data.success) {
|
||||
setCdnImages(res.data.images);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load CDN images');
|
||||
} finally {
|
||||
setCdnLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -41,6 +60,8 @@ const AccountPage: React.FC<AccountPageProps> = ({ onLogout, onUpdateUser }) =>
|
||||
|
||||
const allSubjects = (examsRes.data.exams as Exam[]).map(e => e.subject);
|
||||
setSubjects(Array.from(new Set(allSubjects)));
|
||||
|
||||
fetchCdnImages();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
@@ -51,6 +72,57 @@ const AccountPage: React.FC<AccountPageProps> = ({ onLogout, onUpdateUser }) =>
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleToggleImageSelection = (url: string) => {
|
||||
setSelectedImages(prev =>
|
||||
prev.includes(url) ? prev.filter(u => u !== url) : [...prev, url]
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteSelectedImages = async () => {
|
||||
if (selectedImages.length === 0) return;
|
||||
if (!window.confirm(`Are you sure you want to delete ${selectedImages.length} image(s)?`)) return;
|
||||
|
||||
try {
|
||||
await axios.delete('/api/account/cdn/delete', { data: { urls: selectedImages } });
|
||||
toast.success('Images deleted successfully');
|
||||
setSelectedImages([]);
|
||||
fetchCdnImages();
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete images');
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const base64 = (reader.result as string).split(',')[1];
|
||||
const res = await axios.post('/api/account/cdn/upload', {
|
||||
images: [{
|
||||
image: base64,
|
||||
filename: file.name,
|
||||
contentType: file.type
|
||||
}]
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
setIconUrl(res.data.urls[0]);
|
||||
toast.success('Icon uploaded successfully! Don\'t forget to save profile changes.');
|
||||
fetchCdnImages();
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (err) {
|
||||
toast.error('Failed to upload icon.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateColor = (subject: string, color: string) => {
|
||||
setSubjectColors(prev => ({ ...prev, [subject]: color }));
|
||||
};
|
||||
@@ -163,13 +235,31 @@ const AccountPage: React.FC<AccountPageProps> = ({ onLogout, onUpdateUser }) =>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-bold text-apple-muted uppercase tracking-widest ml-1">Icon URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={iconUrl}
|
||||
onChange={(e) => setIconUrl(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||
placeholder="https://example.com/avatar.png"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={iconUrl}
|
||||
onChange={(e) => setIconUrl(e.target.value)}
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50"
|
||||
placeholder="https://example.com/avatar.png"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="bg-white/5 border border-white/10 rounded-xl px-4 flex items-center justify-center text-apple-muted hover:text-white transition-all disabled:opacity-50"
|
||||
title="Upload Icon"
|
||||
>
|
||||
{uploading ? <Loader2 size={20} className="animate-spin" /> : <Upload size={20} />}
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleIconUpload}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -230,6 +320,71 @@ const AccountPage: React.FC<AccountPageProps> = ({ onLogout, onUpdateUser }) =>
|
||||
|
||||
<div className="h-px bg-white/10 w-full" />
|
||||
|
||||
<section>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-apple-muted uppercase tracking-widest mb-1">CDN Assets</h3>
|
||||
<p className="text-xs text-apple-muted">Manage your uploaded images and attachments.</p>
|
||||
</div>
|
||||
{selectedImages.length > 0 && (
|
||||
<button
|
||||
onClick={handleDeleteSelectedImages}
|
||||
className="flex items-center space-x-2 text-red-400 hover:text-red-300 transition-colors text-sm font-semibold"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span>Delete Selected ({selectedImages.length})</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cdnLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="animate-spin text-apple-accent" size={32} />
|
||||
</div>
|
||||
) : cdnImages.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-4">
|
||||
{cdnImages.map((img) => (
|
||||
<button
|
||||
key={img.url}
|
||||
onClick={() => handleToggleImageSelection(img.url)}
|
||||
className={`relative aspect-square rounded-xl overflow-hidden border-2 transition-all group ${selectedImages.includes(img.url) ? 'border-apple-accent scale-95' : 'border-white/10 hover:border-white/20'}`}
|
||||
>
|
||||
{img.url.toLowerCase().endsWith('.pdf') ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-white/5">
|
||||
<FileText size={32} className="text-apple-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<img src={img.url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
<div className={`absolute inset-0 bg-apple-accent/20 transition-opacity ${selectedImages.includes(img.url) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} />
|
||||
{selectedImages.includes(img.url) && (
|
||||
<div className="absolute top-2 right-2 bg-apple-accent text-white rounded-full p-1 shadow-lg">
|
||||
<Check size={12} strokeWidth={4} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(img.url, '_blank');
|
||||
}}
|
||||
className="absolute bottom-2 right-2 bg-black/60 text-white p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
|
||||
title="View File"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-white/5 rounded-2xl border border-dashed border-white/10">
|
||||
<ImageIcon className="mx-auto text-apple-muted mb-2" size={32} />
|
||||
<p className="text-apple-muted italic text-sm">No images uploaded yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="h-px bg-white/10 w-full" />
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-apple-muted uppercase tracking-widest mb-4">Export & Visibility</h3>
|
||||
<p className="text-apple-muted mb-6 text-sm">Download all your stored examination data in JSON format for backup or migration purposes.</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Plus, Edit2, Trash2, Calendar, BookOpen, Loader2, Clock, ChevronDown, ChevronRight, Sparkles, Link as LinkIcon, ExternalLink } from 'lucide-react';
|
||||
import { Plus, Edit2, Trash2, Calendar, BookOpen, Loader2, Clock, ChevronDown, ChevronRight, Sparkles, Link as LinkIcon, ExternalLink, FileText } from 'lucide-react';
|
||||
import { format, isFuture, differenceInCalendarDays, formatDistanceToNow } from 'date-fns';
|
||||
import { Exam, ApiResponse, User } from '../types';
|
||||
import ExamModal from '../modals/ExamModal';
|
||||
@@ -27,7 +27,16 @@ function Home({ user }: HomeProps) {
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [editingExam, setEditingExam] = useState<Exam | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', subject: '', date: '', description: '', result: '', duration: '', studyMaterials: [] as string[] });
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
subject: '',
|
||||
date: '',
|
||||
description: '',
|
||||
result: '',
|
||||
duration: '',
|
||||
studyMaterials: [] as string[],
|
||||
imageUrls: [] as string[]
|
||||
});
|
||||
|
||||
const fetchExams = async () => {
|
||||
try {
|
||||
@@ -57,11 +66,21 @@ function Home({ user }: HomeProps) {
|
||||
description: exam.description || '',
|
||||
result: exam.result || '',
|
||||
duration: exam.duration?.toString() || '',
|
||||
studyMaterials: exam.studyMaterials || []
|
||||
studyMaterials: exam.studyMaterials || [],
|
||||
imageUrls: exam.imageUrls || []
|
||||
});
|
||||
} else {
|
||||
setEditingExam(null);
|
||||
setFormData({ name: '', subject: '', date: '', description: '', result: '', duration: '', studyMaterials: [] });
|
||||
setFormData({
|
||||
name: '',
|
||||
subject: '',
|
||||
date: '',
|
||||
description: '',
|
||||
result: '',
|
||||
duration: '',
|
||||
studyMaterials: [],
|
||||
imageUrls: []
|
||||
});
|
||||
}
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -172,6 +191,27 @@ function Home({ user }: HomeProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exam.imageUrls && exam.imageUrls.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{exam.imageUrls.map((url, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative h-12 w-12 rounded-lg overflow-hidden border border-white/10 hover:border-apple-accent transition-all flex items-center justify-center bg-white/5"
|
||||
>
|
||||
{url.toLowerCase().endsWith('.pdf') ? (
|
||||
<FileText size={20} className="text-apple-accent" />
|
||||
) : (
|
||||
<img src={url} alt={`Exam ${i}`} className="w-full h-full object-cover" />
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exam.description && (
|
||||
<p className="text-apple-muted text-sm line-clamp-2 mb-6 h-10">
|
||||
{exam.description}
|
||||
|
||||
Reference in New Issue
Block a user