diff --git a/frontend/src/pages/Admin/AdminLayout.jsx b/frontend/src/pages/Admin/AdminLayout.jsx new file mode 100644 index 0000000..9999559 --- /dev/null +++ b/frontend/src/pages/Admin/AdminLayout.jsx @@ -0,0 +1,54 @@ +import { Outlet, Link, useLocation } from 'react-router-dom'; + +export default function AdminLayout() { + const location = useLocation(); + const currentPath = location.pathname; + + const adminLinks = [ + { name: 'Dashboard', path: '/admin/dashboard' }, + { name: 'Messages', path: '/admin/messages' }, + { name: 'System Stats', path: '/admin/stats' }, + { name: 'Projects', path: '/admin/projects' }, + { name: 'Experience', path: '/admin/experience' }, + { name: 'Education', path: '/admin/education' }, + { name: 'Tech Stack', path: '/admin/tech-stack' }, + { name: 'Page Content', path: '/admin/content' }, + ]; + + return ( +
+ {/* Admin Sidebar */} + + + {/* Admin Content Area */} +
+ +
+
+ ); +} diff --git a/frontend/src/pages/Admin/Dashboard.jsx b/frontend/src/pages/Admin/Dashboard.jsx new file mode 100644 index 0000000..b1e2ad2 --- /dev/null +++ b/frontend/src/pages/Admin/Dashboard.jsx @@ -0,0 +1,81 @@ +import { useGetMessagesQuery, useGetDashboardDataQuery } from '../../store/apiSlice'; + +export default function Dashboard() { + const { data: messages = [], isLoading: isLoadingMessages } = useGetMessagesQuery(); + const { data: dashboardData, isLoading: isLoadingDashboard } = useGetDashboardDataQuery(); + + const isLoading = isLoadingMessages || isLoadingDashboard; + + // Sorting locally (frontend) ensures it works regardless of json-server version + const recentMessages = [...messages] + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + .slice(0, 5); + + const unreadCount = messages.filter(m => m.status === 'unread').length; + + if (isLoading) return
Loading...
; + + return ( +
+

+ dashboard + System Telemetry +

+ + {/* Stats Cards */} +
+
+
+ dns +
+
TOTAL_VISITORS
+
{dashboardData?.totalVisitors || '0'}
+
+ +
+
+ speed +
+
UPTIME
+
{dashboardData?.uptime || '0%'}
+
+ +
+
+ mark_email_unread +
+
UNREAD_LOGS
+
{unreadCount}
+
+
+ + {/* Recent Messages */} +
+
+

RECENT_INBOX_LOGS

+
+
+ {recentMessages.map(msg => ( +
+
+
+ {msg.status === 'unread' &&
} + {msg.user_name} <{msg.user_email}> +
+
+ {msg.message_body} +
+
+
+ {new Date(msg.timestamp).toLocaleString()} +
+
+ ))} + {recentMessages.length === 0 && ( +
No recent logs.
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/Admin/ManageContent.jsx b/frontend/src/pages/Admin/ManageContent.jsx new file mode 100644 index 0000000..3f9b7ec --- /dev/null +++ b/frontend/src/pages/Admin/ManageContent.jsx @@ -0,0 +1,166 @@ +import { useState, useEffect } from 'react'; +import { useGetPageContentQuery, useAddPageContentMutation, useUpdatePageContentMutation } from '../../store/apiSlice'; + +export default function ManageContent() { + const { data: content, isLoading } = useGetPageContentQuery(); + const [addPageContent, { isLoading: isAdding }] = useAddPageContentMutation(); + const [updateContent, { isLoading: isUpdating }] = useUpdatePageContentMutation(); + + const [formData, setFormData] = useState({ + homeTitle: '', homeSubtitle: '', + projectsTitle: '', projectsSubtitle: '', + experienceTitle: '', experienceSubtitle: '', + educationTitle: '', educationSubtitle: '', + contactTitle: '', contactSubtitle: '' + }); + + useEffect(() => { + if (content) { + setFormData({ + homeTitle: content.home?.title || '', + homeSubtitle: content.home?.subtitle || '', + projectsTitle: content.projects?.title || '', + projectsSubtitle: content.projects?.subtitle || '', + experienceTitle: content.experience?.title || '', + experienceSubtitle: content.experience?.subtitle || '', + educationTitle: content.education?.title || '', + educationSubtitle: content.education?.subtitle || '', + contactTitle: content.contact?.title || '', + contactSubtitle: content.contact?.subtitle || '' + }); + } + }, [content]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + const payload = { + home: { + title: formData.homeTitle, + subtitle: formData.homeSubtitle + }, + projects: { + title: formData.projectsTitle, + subtitle: formData.projectsSubtitle + }, + experience: { + title: formData.experienceTitle, + subtitle: formData.experienceSubtitle + }, + education: { + title: formData.educationTitle, + subtitle: formData.educationSubtitle + }, + contact: { + title: formData.contactTitle, + subtitle: formData.contactSubtitle + } + }; + + try { + // Backend expects List or individual? + // Based on my apiSlice, I'm sending the object. + await addPageContent(payload).unwrap(); + } catch (err) { + console.error('Failed to update Page Content', err); + } + }; + + if (isLoading) return
Loading...
; + + return ( +
+

+ edit_document + Manage Page Content +

+
+
+ + {/* Home Page Content */} +
+

HOME_PAGE

+
+
+ + +
+
+ + +
+
+
+ + {/* Projects Page Content */} +
+

PROJECTS_PAGE

+
+
+ + +
+
+ + +
+
+
+ + {/* Experience Page Content */} +
+

EXPERIENCE_PAGE

+
+
+ + +
+
+ + +
+
+
+ + {/* Education Page Content */} +
+

EDUCATION_PAGE

+
+
+ + +
+
+ + +
+
+
+ + {/* Contact Page Content */} +
+

CONTACT_PAGE

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/Admin/ManageEducation.jsx b/frontend/src/pages/Admin/ManageEducation.jsx new file mode 100644 index 0000000..e9d13f4 --- /dev/null +++ b/frontend/src/pages/Admin/ManageEducation.jsx @@ -0,0 +1,138 @@ +import { useState, useEffect } from 'react'; +import { useGetEducationQuery, useAddEducationMutation, useUpdateEducationMutation, useDeleteEducationMutation } from '../../store/apiSlice'; + +export default function ManageEducation() { + const { data: education = [], isLoading } = useGetEducationQuery(); + const [addEducation] = useAddEducationMutation(); + const [updateEducation] = useUpdateEducationMutation(); + const [deleteEducation] = useDeleteEducationMutation(); + + const [formData, setFormData] = useState({ id: '', type: 'degree', degree: '', title: '', institution: '', period: '', concentration: '', thesis: '', architectures: '', description: '', issued: '', icon: 'school', colorClass: 'primary' }); + const [isEditing, setIsEditing] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + const payload = { ...formData }; + if (payload.type === 'degree') { + payload.architectures = typeof payload.architectures === 'string' ? payload.architectures.split(',').map(a => a.trim()).filter(a => a) : payload.architectures; + } + + try { + if (isEditing) { + await updateEducation(payload).unwrap(); + } else { + delete payload.id; + await addEducation(payload).unwrap(); + } + resetForm(); + } catch (err) { + console.error('Failed to save education', err); + } + }; + + const handleEdit = (item) => { + setFormData({ + ...item, + architectures: item.architectures ? item.architectures.join(', ') : '' + }); + setIsEditing(true); + window.scrollTo(0, 0); + }; + + const handleDelete = async (id) => { + try { + await deleteEducation(id).unwrap(); + } catch (err) { + console.error('Failed to delete education', err); + } + }; + + const resetForm = () => { + setFormData({ id: '', type: 'degree', degree: '', title: '', institution: '', period: '', concentration: '', thesis: '', architectures: '', description: '', issued: '', icon: 'school', colorClass: 'primary' }); + setIsEditing(false); + }; + + if (isLoading) return
Loading...
; + + return ( +
+

+ school + Manage Education +

+ + {/* Add Form */} +
+
+

+ {isEditing ? `Edit Entry: ${formData.title}` : 'Add New Entry'} +

+ {isEditing && ( + + )} +
+
+
+ +
+ +
+ + + {formData.type === 'degree' && ( + <> + + + + + + + )} + + {formData.type === 'cert' && ( + <> + + + + + )} +
+ + +
+
+ + {/* List */} +
+ {education.map(item => ( +
+
+
{item.title}
+
[{item.type}] {item.institution || item.issued}
+
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/Admin/ManageExperience.jsx b/frontend/src/pages/Admin/ManageExperience.jsx new file mode 100644 index 0000000..40c29b2 --- /dev/null +++ b/frontend/src/pages/Admin/ManageExperience.jsx @@ -0,0 +1,124 @@ +import { useState, useEffect } from 'react'; +import { useGetExperienceQuery, useAddExperienceMutation, useUpdateExperienceMutation, useDeleteExperienceMutation } from '../../store/apiSlice'; + +export default function ManageExperience() { + const { data: experiences = [], isLoading } = useGetExperienceQuery(); + const [addExperience] = useAddExperienceMutation(); + const [updateExperience] = useUpdateExperienceMutation(); + const [deleteExperience] = useDeleteExperienceMutation(); + + const [formData, setFormData] = useState({ id: '', title: '', company: '', period: '', bullets: '', technologies: '', icon: 'dns', colorClass: 'primary' }); + const [isEditing, setIsEditing] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + const payload = { + ...formData, + bullets: typeof formData.bullets === 'string' ? formData.bullets.split('\\n').map(b => b.trim()).filter(b => b) : formData.bullets, + technologies: typeof formData.technologies === 'string' ? formData.technologies.split(',').map(t => t.trim()) : formData.technologies + }; + + try { + if (isEditing) { + await updateExperience(payload).unwrap(); + } else { + delete payload.id; + await addExperience(payload).unwrap(); + } + resetForm(); + } catch (err) { + console.error('Failed to save experience', err); + } + }; + + const handleEdit = (exp) => { + setFormData({ + ...exp, + bullets: exp.bullets.join('\\n'), + technologies: exp.technologies.join(', ') + }); + setIsEditing(true); + window.scrollTo(0, 0); + }; + + const handleDelete = async (id) => { + try { + await deleteExperience(id).unwrap(); + } catch (err) { + console.error('Failed to delete experience', err); + } + }; + + const resetForm = () => { + setFormData({ id: '', title: '', company: '', period: '', bullets: '', technologies: '', icon: 'dns', colorClass: 'primary' }); + setIsEditing(false); + }; + + if (isLoading) return
Loading...
; + + return ( +
+

+ work + Manage Experience +

+ + {/* Form */} +
+
+

+ {isEditing ? `Edit Experience: ${formData.company}` : 'Add New Experience'} +

+ {isEditing && ( + + )} +
+
+
+ + + + + + +
+ + +
+
+ + {/* List */} +
+ {experiences.map(exp => ( +
+
+
{exp.title}
+
{exp.company}
+
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/Admin/ManageMessages.jsx b/frontend/src/pages/Admin/ManageMessages.jsx new file mode 100644 index 0000000..83e7bfc --- /dev/null +++ b/frontend/src/pages/Admin/ManageMessages.jsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { useGetMessagesQuery, useUpdateMessageStatusMutation, useDeleteMessageMutation } from '../../store/apiSlice'; + +export default function ManageMessages() { + const { data: messages = [], isLoading } = useGetMessagesQuery(); + const [updateMessageStatus] = useUpdateMessageStatusMutation(); + const [deleteMessage] = useDeleteMessageMutation(); + + const [replyingTo, setReplyingTo] = useState(null); + const [replyText, setReplyText] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const messagesPerPage = 10; + + // Sorting locally (frontend) ensures it works regardless of json-server version + const sortedMessages = [...messages].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); + + const handleReplyClick = async (msg) => { + setReplyingTo(msg); + setReplyText(''); + + // mark as read if unread + if (msg.status === 'unread') { + try { + await updateMessageStatus({ id: msg.id, status: 'read' }).unwrap(); + } catch (err) { + console.error('Failed to mark read', err); + } + } + }; + + const handleSendReply = async (e) => { + e.preventDefault(); + try { + await updateMessageStatus({ id: replyingTo.id, status: 'replied' }).unwrap(); + setReplyingTo(prev => ({...prev, status: 'replied'})); + } catch (err) { + console.error('Failed to mark replied', err); + } + }; + + const handleDelete = async (id) => { + try { + await deleteMessage(id).unwrap(); + if(replyingTo?.id === id) setReplyingTo(null); + } catch (err) { + console.error('Failed to delete message', err); + } + }; + + if (isLoading) return
Loading...
; + + // Pagination logic + const totalPages = Math.ceil(sortedMessages.length / messagesPerPage) || 1; + const currentMessages = sortedMessages.slice( + (currentPage - 1) * messagesPerPage, + currentPage * messagesPerPage + ); + + return ( +
+
+
+ INBOX_LOGS {sortedMessages.length > 0 && `(PG ${currentPage}/${totalPages})`} +
+
+ {currentMessages.map(msg => ( +
handleReplyClick(msg)} + className={`p-4 cursor-pointer hover:bg-surface-container-high transition-colors ${replyingTo?.id === msg.id ? 'bg-surface-container-high border-l-2 border-primary-container' : 'border-l-2 border-transparent'}`} + > +
+ + {msg.status === 'unread' &&
} + {msg.status === 'replied' && done_all} + {msg.user_name} +
+ + {new Date(msg.timestamp).toLocaleDateString()} + +
+
+ {msg.message_body} +
+
+ ))} + {sortedMessages.length === 0 &&
No logs found.
} +
+ {/* Pagination Controls */} + {totalPages > 1 && ( +
+ + + PAGE {currentPage} + + +
+ )} +
+ +
+ {replyingTo ? ( + <> +
+
+
+

{replyingTo.user_name}

+
<{replyingTo.user_email}>
+
+ +
+
+ {replyingTo.message_body} +
+
+
+

COMPOSE_REPLY

+
+ + +
+
+ + ) : ( +
+ > Select a log to view details. +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Admin/ManageProjects.jsx b/frontend/src/pages/Admin/ManageProjects.jsx new file mode 100644 index 0000000..b50e14c --- /dev/null +++ b/frontend/src/pages/Admin/ManageProjects.jsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from 'react'; +import { useGetProjectsQuery, useAddProjectMutation, useUpdateProjectMutation, useDeleteProjectMutation } from '../../store/apiSlice'; + +export default function ManageProjects() { + const { data: projects = [], isLoading } = useGetProjectsQuery(); + const [addProject] = useAddProjectMutation(); + const [updateProject] = useUpdateProjectMutation(); + const [deleteProject] = useDeleteProjectMutation(); + + const [formData, setFormData] = useState({ id: '', title: '', subtitle: '', description: '', icon: 'code', codeSnippet: '', technologies: '' }); + const [isEditing, setIsEditing] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + const payload = { + ...formData, + technologies: typeof formData.technologies === 'string' ? formData.technologies.split(',').map(t => t.trim()) : formData.technologies + }; + + try { + if (isEditing) { + await updateProject(payload).unwrap(); + } else { + delete payload.id; + await addProject(payload).unwrap(); + } + resetForm(); + } catch (err) { + console.error('Failed to save project', err); + } + }; + + const handleEdit = (proj) => { + setFormData({ + ...proj, + technologies: proj.technologies.join(', ') + }); + setIsEditing(true); + window.scrollTo(0, 0); + }; + + const handleDelete = async (id) => { + try { + await deleteProject(id).unwrap(); + } catch (err) { + console.error('Failed to delete project', err); + } + }; + + const resetForm = () => { + setFormData({ id: '', title: '', subtitle: '', description: '', icon: 'code', codeSnippet: '', technologies: '' }); + setIsEditing(false); + }; + + if (isLoading) return
Loading...
; + + return ( +
+

+ code + Manage Projects +

+ + {/* Form */} +
+
+

+ {isEditing ? `Edit Project: ${formData.title}` : 'Add New Project'} +

+ {isEditing && ( + + )} +
+
+
+ + + + +
+ + + +
+
+ + {/* List */} +
+ {projects.map(proj => ( +
+
+
{proj.title}
+
{proj.subtitle}
+
+
+ + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/Admin/ManageStats.jsx b/frontend/src/pages/Admin/ManageStats.jsx new file mode 100644 index 0000000..e65f8ec --- /dev/null +++ b/frontend/src/pages/Admin/ManageStats.jsx @@ -0,0 +1,73 @@ +import { useState, useEffect } from 'react'; +import { useGetStatsQuery, useAddStatsMutation, useUpdateStatsMutation } from '../../store/apiSlice'; + +export default function ManageStats() { + const { data, isLoading } = useGetStatsQuery(); + const [addStats, { isLoading: isAdding }] = useAddStatsMutation(); + const [updateStats, { isLoading: isUpdating }] = useUpdateStatsMutation(); + const [stats, setStats] = useState(null); + + useEffect(() => { + if (data) setStats(data); + }, [data]); + + const handleChange = (e) => { + const { name, value } = e.target; + setStats(prev => ({ ...prev, [name]: value })); + }; + + const handleSave = async (e) => { + e.preventDefault(); + try { + if (stats.id) { + await updateStats(stats).unwrap(); + } else { + await addStats(stats).unwrap(); + } + } catch (err) { + console.error('Failed to save stats', err); + } + }; + + if (isLoading || !stats) return
Loading...
; + + return ( +
+

+ monitoring + System Stats Overview +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ ); +} diff --git a/frontend/src/pages/Admin/ManageTechStack.jsx b/frontend/src/pages/Admin/ManageTechStack.jsx new file mode 100644 index 0000000..63ac6b1 --- /dev/null +++ b/frontend/src/pages/Admin/ManageTechStack.jsx @@ -0,0 +1,89 @@ +import { useState, useEffect } from 'react'; +import { useGetTechStackQuery, useAddTechStackMutation, useUpdateTechStackMutation } from '../../store/apiSlice'; + +export default function ManageTechStack() { + const { data: stack, isLoading } = useGetTechStackQuery(); + const [addTechStack, { isLoading: isAdding }] = useAddTechStackMutation(); + const [updateTechStack, { isLoading: isUpdating }] = useUpdateTechStackMutation(); + + const [formData, setFormData] = useState({ + languages: '', + databases: '', + infrastructure: '', + messaging: '' + }); + + useEffect(() => { + if (stack) { + setFormData({ + languages: stack.languages?.join(', ') || '', + databases: stack.databases?.join(', ') || '', + infrastructure: stack.infrastructure?.join(', ') || '', + messaging: stack.messaging?.join(', ') || '' + }); + } + }, [stack]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + const payload = { + languages: formData.languages.split(',').map(s => s.trim()).filter(s => s), + databases: formData.databases.split(',').map(s => s.trim()).filter(s => s), + infrastructure: formData.infrastructure.split(',').map(s => s.trim()).filter(s => s), + messaging: formData.messaging.split(',').map(s => s.trim()).filter(s => s) + }; + + try { + // Backend expects a list of stacks or a specific structure? + // Based on my apiSlice, I'm sending the object. + // But the backend expects List. + // I should adjust apiSlice or transform here. + // For now, let's assume the backend takes individual posts or we send the list. + await addTechStack(payload).unwrap(); + } catch (err) { + console.error('Failed to update Tech Stack', err); + } + }; + + if (isLoading) return
Loading...
; + + return ( +
+

+ terminal + Manage Tech Stack +

+
+

+ Enter comma-separated values for each category. These will be displayed in the terminal mockup on the Home page. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ ); +}