Final commit

This commit is contained in:
2026-06-23 09:37:43 +05:30
parent 2c28c27120
commit 512c04bb6f
90 changed files with 26358 additions and 0 deletions

View File

@@ -0,0 +1,684 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Bell,
Trash2,
Plus,
Layers,
Settings,
Upload,
Calendar,
X,
CheckCircle2,
AlertTriangle,
Globe,
Loader2,
FileImage
} from 'lucide-react';
import { cn } from '../../lib/utils';
import { API_BASE_URL } from '../../lib/config';
import { useDialog } from '../../context/DialogContext';
interface Notice {
id: string;
title: string;
message: string;
timestamp: string;
type: 'INFO' | 'URGENT' | 'DELAY';
image?: string;
expiresAt?: string;
}
interface SpecialEvent {
id: string;
title: string;
description: string;
link: string;
created_at: string;
}
export const ManageNoticesView: React.FC = () => {
const { showAlert, showConfirm } = useDialog();
// Navigation Tabs
const [activeTab, setActiveTab] = useState<'notices' | 'special-events'>('notices');
// Data States
const [notices, setNotices] = useState<Notice[]>([]);
const [specialEvents, setSpecialEvents] = useState<SpecialEvent[]>([]);
const [loading, setLoading] = useState(true);
// Modal States
const [isNoticeModalOpen, setIsNoticeModalOpen] = useState(false);
const [isEventModalOpen, setIsEventModalOpen] = useState(false);
// Form States (Notice)
const [noticeTitle, setNoticeTitle] = useState('');
const [noticeMessage, setNoticeMessage] = useState('');
const [noticeType, setNoticeType] = useState<'INFO' | 'URGENT' | 'DELAY'>('INFO');
const [noticeImage, setNoticeImage] = useState<string | null>(null);
// Form States (Special Event)
const [eventTitle, setEventTitle] = useState('');
const [eventDesc, setEventDesc] = useState('');
const [eventLink, setEventLink] = useState('');
const [eventImage, setEventImage] = useState<string | null>(null);
const handleEventImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 1.5 * 1024 * 1024) {
showAlert('File Too Large', 'Please upload an image smaller than 1.5MB.', 'error');
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setEventImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
useEffect(() => {
fetchInitialData();
}, []);
const fetchInitialData = async () => {
try {
setLoading(true);
const [noticesRes, eventsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/announcements`),
fetch(`${API_BASE_URL}/api/special-events`)
]);
if (noticesRes.ok) {
const noticesData = await noticesRes.json();
setNotices(noticesData);
}
if (eventsRes.ok) {
const eventsData = await eventsRes.json();
setSpecialEvents(eventsData);
}
} catch (err) {
console.error('Failed to load notice configuration data:', err);
} finally {
setLoading(false);
}
};
// Sticky notice limits check
const handleOpenNoticeModal = () => {
if (notices.length >= 3) {
showAlert(
'Limit Reached',
'A maximum of 3 notices can be active on the campus notice board at any time. Please delete an existing notice first.',
'info'
);
return;
}
setNoticeTitle('');
setNoticeMessage('');
setNoticeType('INFO');
setNoticeImage(null);
setIsNoticeModalOpen(true);
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 1.5 * 1024 * 1024) {
showAlert('File Too Large', 'Please upload an image smaller than 1.5MB.', 'error');
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setNoticeImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleAddNotice = async (e: React.FormEvent) => {
e.preventDefault();
if (!noticeTitle.trim() || !noticeMessage.trim()) {
showAlert('Required Fields', 'Please fill in both title and message.', 'error');
return;
}
try {
const response = await fetch(`${API_BASE_URL}/api/announcements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: noticeTitle.trim(),
message: noticeMessage.trim(),
type: noticeType,
image: noticeImage
})
});
if (response.ok) {
setIsNoticeModalOpen(false);
fetchInitialData();
showAlert('Success', 'Notice posted successfully to Campus Notice Board.', 'success');
}
} catch (err) {
console.error('Failed to post notice:', err);
showAlert('Error', 'Failed to save notice. Please try again.', 'error');
}
};
const handleDeleteNotice = async (id: string) => {
showConfirm(
'Remove Notice',
'Are you sure you want to remove this notice from the board?',
async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/announcements/${id}`, {
method: 'DELETE'
});
if (response.ok) {
fetchInitialData();
showAlert('Success', 'Notice removed successfully.', 'success');
}
} catch (err) {
console.error('Delete failed:', err);
showAlert('Error', 'Failed to delete notice.', 'error');
}
}
);
};
// Special Events Updates
const handleAddSpecialEvent = async (e: React.FormEvent) => {
e.preventDefault();
if (!eventTitle.trim() || !eventDesc.trim() || !eventLink.trim()) {
showAlert('Required Fields', 'Please fill in all special event details.', 'error');
return;
}
try {
const response = await fetch(`${API_BASE_URL}/api/special-events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: eventTitle.trim(),
description: eventDesc.trim(),
link: eventLink.trim(),
created_by: 'ADMIN',
image: eventImage
})
});
if (response.ok) {
setIsEventModalOpen(false);
setEventTitle('');
setEventDesc('');
setEventLink('');
setEventImage(null);
fetchInitialData();
showAlert('Success', 'Special event added to the registry successfully.', 'success');
}
} catch (err) {
console.error('Post failed:', err);
showAlert('Error', 'Failed to create special event.', 'error');
}
};
const handleDeleteSpecialEvent = async (id: string) => {
showConfirm(
'Delete Special Event',
'Are you sure you want to remove this event from the registry?',
async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/special-events/${id}`, {
method: 'DELETE'
});
if (response.ok) {
fetchInitialData();
showAlert('Success', 'Special event deleted successfully.', 'success');
}
} catch (err) {
console.error('Delete failed:', err);
showAlert('Error', 'Failed to delete event.', 'error');
}
}
);
};
if (loading) {
return (
<div className="flex items-center justify-center p-24">
<Loader2 className="w-8 h-8 text-brand-indigo animate-spin" />
</div>
);
}
return (
<div className="space-y-10 font-sans">
{/* Title */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div>
<h1 className="text-4xl font-black text-text-dark tracking-tight mb-1">Notices & Banner Settings</h1>
<p className="text-text-muted font-black uppercase tracking-widest text-[10px]">Configure notice boards, scroll announcements, & special events</p>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-slate-100 gap-6">
<button
onClick={() => setActiveTab('notices')}
className={cn(
"pb-4 text-xs font-black uppercase tracking-widest border-b-2 transition-all flex items-center gap-2",
activeTab === 'notices'
? "border-brand-indigo text-brand-indigo"
: "border-transparent text-text-muted hover:text-brand-indigo/70"
)}
>
<Bell className="w-4 h-4" />
Notice Board ({notices.length}/3)
</button>
<button
onClick={() => setActiveTab('special-events')}
className={cn(
"pb-4 text-xs font-black uppercase tracking-widest border-b-2 transition-all flex items-center gap-2",
activeTab === 'special-events'
? "border-brand-indigo text-brand-indigo"
: "border-transparent text-text-muted hover:text-brand-indigo/70"
)}
>
<Layers className="w-4 h-4" />
Special Events
</button>
</div>
{/* Notices Tab */}
{activeTab === 'notices' && (
<div className="space-y-6">
<div className="flex justify-between items-center bg-slate-50 p-6 rounded-3xl border border-slate-100">
<div>
<h2 className="text-lg font-black text-text-dark">Active Campus Notices</h2>
<p className="text-xs text-text-muted font-bold">These cards are pinned to the student Campus Notice Board (Maximum 3 notices).</p>
</div>
<button
onClick={handleOpenNoticeModal}
className="flex items-center gap-2 bg-brand-indigo text-white px-5 py-3 rounded-2xl font-black text-xs uppercase tracking-widest hover:scale-[1.02] transition-all shadow-md shadow-brand-indigo/15"
>
<Plus className="w-4 h-4" />
Post Notice
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{notices.map(notice => (
<div key={notice.id} className="bg-white border border-slate-100 p-6 rounded-[2rem] premium-shadow flex flex-col justify-between min-h-[250px] relative">
<div>
<div className="flex items-center justify-between mb-4">
<span className={cn(
"px-2.5 py-1 text-[8px] font-black uppercase tracking-wider rounded",
notice.type === 'URGENT' ? 'bg-red-550/10 text-rose-600' :
notice.type === 'DELAY' ? 'bg-amber-500/10 text-amber-600' :
'bg-slate-100 text-slate-600'
)}>
{notice.type}
</span>
<button
onClick={() => handleDeleteNotice(notice.id)}
className="p-2 hover:bg-rose-50 text-slate-400 hover:text-rose-500 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{notice.image && (
<img
src={notice.image}
alt="Notice Attachment"
className="w-full h-36 object-cover rounded-2xl mb-4 border border-slate-100"
/>
)}
<h3 className="text-base font-black text-text-dark tracking-tight mb-2 leading-tight">{notice.title}</h3>
<p className="text-xs text-text-muted font-medium leading-relaxed mb-4">{notice.message}</p>
</div>
<div className="text-[10px] font-bold text-slate-400 border-t border-slate-50 pt-3 mt-auto">
{new Date(notice.timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}
</div>
</div>
))}
{notices.length === 0 && (
<div className="col-span-full py-16 text-center text-slate-400 font-bold uppercase tracking-wider text-xs border-2 border-dashed border-slate-200 rounded-[2.5rem]">
No active announcements on the notice board.
</div>
)}
</div>
</div>
)}
{/* Special Events Tab */}
{activeTab === 'special-events' && (
<div className="space-y-6">
<div className="flex justify-between items-center bg-slate-50 p-6 rounded-3xl border border-slate-100">
<div>
<h2 className="text-lg font-black text-text-dark">Special Events Registry</h2>
<p className="text-xs text-text-muted font-bold">These events are rendered in the modern slanted registry section of the student dashboard.</p>
</div>
<button
onClick={() => setIsEventModalOpen(true)}
className="flex items-center gap-2 bg-brand-indigo text-white px-5 py-3 rounded-2xl font-black text-xs uppercase tracking-widest hover:scale-[1.02] transition-all shadow-md shadow-brand-indigo/15"
>
<Plus className="w-4 h-4" />
Add Special Event
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{specialEvents.map(event => (
<div key={event.id} className="bg-white border border-slate-100 p-6 rounded-[2rem] premium-shadow flex flex-col justify-between min-h-[220px]">
<div>
<div className="flex items-center justify-between mb-4">
<span className="px-2.5 py-1 bg-cyan-50 text-cyan-600 border border-cyan-100 text-[8px] font-black uppercase tracking-wider rounded">
Special Event
</span>
<button
onClick={() => handleDeleteSpecialEvent(event.id)}
className="p-2 hover:bg-rose-50 text-slate-400 hover:text-rose-500 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<h3 className="text-base font-black text-text-dark tracking-tight mb-2 leading-tight">{event.title}</h3>
<p className="text-xs text-text-muted font-medium leading-relaxed mb-4 line-clamp-3">{event.description}</p>
</div>
<div className="border-t border-slate-50 pt-3 mt-auto flex items-center justify-between">
<a
href={event.link}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-black text-brand-indigo uppercase tracking-wider hover:underline flex items-center gap-1.5"
>
View Registry Link
<Globe className="w-3.5 h-3.5" />
</a>
</div>
</div>
))}
{specialEvents.length === 0 && (
<div className="col-span-full py-16 text-center text-slate-400 font-bold uppercase tracking-wider text-xs border-2 border-dashed border-slate-200 rounded-[2.5rem]">
No special events currently registered.
</div>
)}
</div>
</div>
)}
{/* Notice Dialog Modal */}
<AnimatePresence>
{isNoticeModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsNoticeModalOpen(false)}
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-lg bg-white rounded-[2.5rem] premium-shadow overflow-hidden flex flex-col max-h-[90vh]"
>
<form onSubmit={handleAddNotice} className="flex flex-col overflow-hidden">
{/* Header */}
<div className="p-8 bg-brand-navy text-white flex justify-between items-start shrink-0">
<div>
<h3 className="text-2xl font-black tracking-tight">Post New Notice</h3>
<p className="text-white/60 text-xs font-medium mt-1">Configure campus notice board bulletin.</p>
</div>
<button
type="button"
onClick={() => setIsNoticeModalOpen(false)}
className="p-2 hover:bg-white/10 rounded-xl transition-all"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Body */}
<div className="p-8 space-y-5 overflow-y-auto custom-scrollbar flex-1 max-h-[60vh]">
{/* Title */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Notice Title</label>
<input
type="text"
required
value={noticeTitle}
onChange={e => setNoticeTitle(e.target.value)}
placeholder="Enter short, descriptive title"
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all"
/>
</div>
{/* Message */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Notice Message</label>
<textarea
rows={3}
required
value={noticeMessage}
onChange={e => setNoticeMessage(e.target.value)}
placeholder="Write announcement body..."
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all"
/>
</div>
{/* Alert Type */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Alert Casing</label>
<select
value={noticeType}
onChange={e => setNoticeType(e.target.value as any)}
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all appearance-none"
>
<option value="INFO">Information (INFO)</option>
<option value="URGENT">Urgent Announcement (URGENT)</option>
<option value="DELAY">Schedule Delay / Change (DELAY)</option>
</select>
</div>
{/* Image Dropzone */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Notice Banner Image</label>
<div className="border-2 border-dashed border-slate-200 hover:border-brand-indigo/40 rounded-2xl p-6 bg-slate-50/50 flex flex-col items-center justify-center text-center relative group transition-colors">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full z-10"
/>
{noticeImage ? (
<div className="space-y-3 z-20">
<img
src={noticeImage}
alt="Notice preview"
className="max-h-28 object-cover rounded-xl border border-slate-200"
/>
<button
type="button"
onClick={() => setNoticeImage(null)}
className="text-[10px] font-black text-rose-500 hover:text-rose-700 uppercase tracking-widest block mx-auto transition-all"
>
Remove Banner
</button>
</div>
) : (
<div className="space-y-2 pointer-events-none">
<FileImage className="w-8 h-8 text-slate-300 mx-auto group-hover:text-brand-indigo/60 transition-colors" />
<div>
<span className="text-[10px] font-black text-brand-indigo uppercase tracking-wider">Upload banner image</span>
<p className="text-[9px] text-slate-400 font-bold mt-0.5">JPEG, PNG up to 1.5MB</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="shrink-0 w-full">
<button
type="submit"
className="w-full bg-brand-navy hover:bg-[#0c365c] text-white py-5 font-black text-xs uppercase tracking-[0.2em] transition-all flex items-center justify-center"
>
Post Notice Bulletin
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Special Event Dialog Modal */}
<AnimatePresence>
{isEventModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsEventModalOpen(false)}
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-lg bg-white rounded-[2.5rem] premium-shadow overflow-hidden flex flex-col max-h-[90vh]"
>
<form onSubmit={handleAddSpecialEvent} className="flex flex-col overflow-hidden">
{/* Header */}
<div className="p-8 bg-brand-navy text-white flex justify-between items-start shrink-0">
<div>
<h3 className="text-2xl font-black tracking-tight">Add Special Event</h3>
<p className="text-white/60 text-xs font-medium mt-1">Configure special event registry entry.</p>
</div>
<button
type="button"
onClick={() => setIsEventModalOpen(false)}
className="p-2 hover:bg-white/10 rounded-xl transition-all"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Body */}
<div className="p-8 space-y-5 overflow-y-auto custom-scrollbar flex-1 max-h-[60vh]">
{/* Title */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Event Title</label>
<input
type="text"
required
value={eventTitle}
onChange={e => setEventTitle(e.target.value)}
placeholder="Hackathon, techfest, etc."
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all"
/>
</div>
{/* Description */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Event Description</label>
<textarea
rows={3}
required
value={eventDesc}
onChange={e => setEventDesc(e.target.value)}
placeholder="Write brief description..."
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all"
/>
</div>
{/* Link */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Registry External Link</label>
<input
type="url"
required
value={eventLink}
onChange={e => setEventLink(e.target.value)}
placeholder="https://example.com/registration"
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all"
/>
</div>
{/* Image Dropzone */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Event Cover Image (Optional)</label>
<div className="border-2 border-dashed border-slate-200 hover:border-brand-indigo/40 rounded-2xl p-6 bg-slate-50/50 flex flex-col items-center justify-center text-center relative group transition-colors">
<input
type="file"
accept="image/*"
onChange={handleEventImageUpload}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full z-10"
/>
{eventImage ? (
<div className="space-y-3 z-20">
<img
src={eventImage}
alt="Event preview"
className="max-h-28 object-cover rounded-xl border border-slate-200"
/>
<button
type="button"
onClick={() => setEventImage(null)}
className="text-[10px] font-black text-rose-500 hover:text-rose-700 uppercase tracking-widest block mx-auto transition-all"
>
Remove Cover Image
</button>
</div>
) : (
<div className="space-y-2 pointer-events-none">
<FileImage className="w-8 h-8 text-slate-300 mx-auto group-hover:text-brand-indigo/60 transition-colors" />
<div>
<span className="text-[10px] font-black text-brand-indigo uppercase tracking-wider">Upload cover image</span>
<p className="text-[9px] text-slate-400 font-bold mt-0.5">JPEG, PNG up to 1.5MB</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="shrink-0 w-full">
<button
type="submit"
className="w-full bg-brand-navy hover:bg-[#0c365c] text-white py-5 font-black text-xs uppercase tracking-[0.2em] transition-all flex items-center justify-center"
>
Register Special Event
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};