Implement CDN functionality for image uploads and management in account routes

This commit is contained in:
Space-Banane
2026-02-17 13:32:42 +01:00
parent d3cfca16f9
commit c00e18f5ce
8 changed files with 585 additions and 23 deletions

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
COOKIE_DOMAIN=localhost
DB_CONN_STRING=mongodb://localhost:27017/exams
PORT=8000
CDN_KEY=
CDN_SECRET=
CDN_BUCKET=
CDN_HOST=
CDN_PORT=
CDN_USE_SSL=

View File

@@ -5,6 +5,7 @@
"@types/node": "^25.0.3",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"minio": "^8.0.2",
"mongodb": "^7.0.0",
"rimraf": "^6.1.2",
"rjweb-server": "^9.8.6",

19
backend/src/lib/CDN.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as Minio from 'minio';
import { env } from 'node:process';
export const minioClient = new Minio.Client({
endPoint: env.CDN_HOST!,
port: parseInt(env.CDN_PORT || '443'),
useSSL: env.CDN_USE_SSL === 'true',
accessKey: env.CDN_KEY!,
secretKey: env.CDN_SECRET!
});
export const CDN_BUCKET = env.CDN_BUCKET!;
export function getPublicUrl(filename: string) {
const protocol = env.CDN_USE_SSL === 'true' ? 'https' : 'http';
const port = (env.CDN_PORT && env.CDN_PORT !== '80' && env.CDN_PORT !== '443') ? `:${env.CDN_PORT}` : '';
return `${protocol}://${env.CDN_HOST}${port}/${CDN_BUCKET}/${filename}`;
}

View File

@@ -0,0 +1,180 @@
import { COOKIENAME, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
import { minioClient, CDN_BUCKET, getPublicUrl } from "../../lib/CDN";
import crypto from "crypto";
export = new fileRouter.Path("/").http(
"POST",
"/api/account/cdn/upload",
(http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? (ctr.headers.get("api-authentication") as string) || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ error: auth.message });
}
const [data, error] = await ctr.bindBody((z) =>
z.object({
images: z.array(z.object({
image: z.string().min(1), // base64
filename: z.string().min(1).max(200),
contentType: z.string().min(1).max(100),
})).min(1),
})
);
if (!data)
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
try {
const urls: string[] = [];
const ALLOWED_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf"]);
for (const item of data.images) {
const buffer = Buffer.from(item.image, 'base64');
const rawExtension = item.filename.split('.').pop() || "";
const extension = rawExtension.toLowerCase();
if (!ALLOWED_EXTENSIONS.has(extension)) {
return ctr
.status(ctr.$status.BAD_REQUEST)
.print({ error: "Unsupported file extension" });
}
const objectName = `${auth.userId}/${crypto.randomUUID()}.${extension}`;
await minioClient.putObject(CDN_BUCKET, objectName, buffer, buffer.length, {
'Content-Type': item.contentType,
});
urls.push(getPublicUrl(objectName));
}
return ctr.print({
success: true,
urls: urls
});
} catch (error: unknown) {
console.error(error);
const details =
error instanceof Error ? error.message : "Unknown error during image upload";
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({
error: "Failed to upload images to CDN",
errorCode: "CDN_UPLOAD_FAILED",
details
});
}
})
).http(
"GET",
"/api/account/cdn/list",
(http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? (ctr.headers.get("api-authentication") as string) || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ error: auth.message });
}
try {
const objects: any[] = [];
const stream = minioClient.listObjectsV2(CDN_BUCKET, `${auth.userId}/`, true);
for await (const obj of stream) {
objects.push({
name: obj.name,
url: getPublicUrl(obj.name),
size: obj.size,
lastModified: obj.lastModified
});
}
return ctr.print({
success: true,
images: objects
});
} catch (e) {
console.error(e);
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({
error: "Failed to list images from CDN"
});
}
})
).http(
"DELETE",
"/api/account/cdn/delete",
(http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? (ctr.headers.get("api-authentication") as string) || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ error: auth.message });
}
const [data, error] = await ctr.bindBody((z) =>
z.object({
urls: z.array(z.string().url()).min(1),
})
);
if (!data)
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
try {
const objectsToDelete: string[] = [];
for (const url of data.urls) {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(Boolean);
// Expected: [bucket, userId, filename]
if (pathParts.length < 3) continue;
const bucket = pathParts[0];
const userId = pathParts[1];
const filename = pathParts.slice(2).join('/');
const objectName = `${userId}/${filename}`;
if (bucket === CDN_BUCKET && userId === auth.userId) {
objectsToDelete.push(objectName);
}
}
if (objectsToDelete.length === 0) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: "No valid objects to delete" });
}
await minioClient.removeObjects(CDN_BUCKET, objectsToDelete);
return ctr.print({
success: true,
message: `${objectsToDelete.length} image(s) deleted successfully`
});
} catch (e) {
console.error(e);
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({
error: "Failed to delete images"
});
}
})
);

View File

@@ -1,5 +1,6 @@
import { COOKIENAME, db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
import { minioClient, CDN_BUCKET } from "../../lib/CDN";
import bcrypt from "bcryptjs";
export = new fileRouter.Path("/").http(
@@ -15,6 +16,22 @@ export = new fileRouter.Path("/").http(
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
// Delete all CDN objects for the user
try {
const objectsToDelete: string[] = [];
const stream = minioClient.listObjectsV2(CDN_BUCKET, `${auth.userId}/`, true);
for await (const obj of stream) {
if (obj.name) objectsToDelete.push(obj.name);
}
if (objectsToDelete.length > 0) {
await minioClient.removeObjects(CDN_BUCKET, objectsToDelete);
}
} catch (e) {
console.error("Failed to cleanup CDN during account deletion:", e);
}
// Delete all exams for the user
await db.collection("exams").deleteMany({ userId: auth.userId });

View File

@@ -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"

View File

@@ -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>

View File

@@ -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}