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

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}