Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useEffect, useState, useRef } from "react"; | |
| import Link from 'next/link'; | |
| import { FiTrash2, FiEdit2, FiSave, FiX, FiUser, FiClock, FiExternalLink, FiChevronRight, FiAlertCircle, FiAlertTriangle, FiUpload, FiDownload, FiEye } from "react-icons/fi"; | |
| import './styles/UpdatesList.css'; | |
| export default function UpdatesList() { | |
| const [updates, setUpdates] = useState([]); | |
| const [page, setPage] = useState(1); | |
| const [loading, setLoading] = useState(false); | |
| const [hasMore, setHasMore] = useState(false); | |
| const [currentUserId, setCurrentUserId] = useState(null); | |
| const [toast, setToast] = useState(null); | |
| const pendingRef = useRef({}); | |
| const [editingId, setEditingId] = useState(null); | |
| const [editTitle, setEditTitle] = useState(""); | |
| const [editContent, setEditContent] = useState(""); | |
| const [editLinksText, setEditLinksText] = useState(""); | |
| const [editFiles, setEditFiles] = useState([]); | |
| const [editIsUploading, setEditIsUploading] = useState(false); | |
| // Modal states | |
| const [deleteModal, setDeleteModal] = useState(null); | |
| const [editModalOpen, setEditModalOpen] = useState(false); | |
| const fetchPage = async (p = 1, append = false) => { | |
| setLoading(true); | |
| try { | |
| const res = await fetch(`/api/updates/latest?index=${p}`); | |
| if (!res.ok) throw new Error('Failed to load updates'); | |
| const data = await res.json(); | |
| const items = data?.updates || []; | |
| setHasMore(items.length === 10); | |
| setUpdates(prev => (append ? [...prev, ...items] : items)); | |
| } catch (err) { | |
| console.error(err); | |
| showToast('Failed to load updates', 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| fetchPage(1, false); | |
| const usn = typeof window !== 'undefined' ? localStorage.getItem('usn') : null; | |
| if (!usn) return; | |
| (async () => { | |
| try { | |
| const res = await fetch(`/api/user/id?usn=${encodeURIComponent(usn)}`); | |
| if (res.ok) { | |
| const d = await res.json(); | |
| if (d?.userId) setCurrentUserId(d.userId); | |
| } | |
| } catch (e) { | |
| console.error('Failed to resolve current user id', e); | |
| } | |
| })(); | |
| }, []); | |
| const loadMore = () => { | |
| const next = page + 1; | |
| setPage(next); | |
| fetchPage(next, true); | |
| }; | |
| const getRelativeTime = (iso) => { | |
| try { | |
| const date = new Date(iso); | |
| const now = new Date(); | |
| const diffMs = now - date; | |
| const diffSeconds = Math.floor(diffMs / 1000); | |
| const diffMinutes = Math.floor(diffSeconds / 60); | |
| const diffHours = Math.floor(diffMinutes / 60); | |
| const diffDays = Math.floor(diffHours / 24); | |
| if (diffDays < 1) { | |
| if (diffSeconds < 60) return `${diffSeconds} second${diffSeconds !== 1 ? 's' : ''} ago`; | |
| if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`; | |
| return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; | |
| } | |
| const dateStr = date.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' }); | |
| const timeStr = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }); | |
| return { date: dateStr, time: timeStr }; | |
| } catch (e) { | |
| return iso; | |
| } | |
| }; | |
| const parseLinks = (text) => { | |
| if (!text) return []; | |
| return text.split(/[,\n]+/).map(s => s.trim()).filter(Boolean); | |
| }; | |
| const showToast = (message, type = 'info') => { | |
| setToast({ message, type }); | |
| setTimeout(() => setToast(null), type === 'error' ? 3000 : 2000); | |
| }; | |
| const openEditWindow = (u) => { | |
| setEditingId(String(u._id)); | |
| setEditTitle(u.title || ""); | |
| setEditContent(u.content || ""); | |
| setEditLinksText((u.links || []).join('\n')); | |
| setEditFiles(Array.isArray(u.files) ? [...u.files] : []); | |
| setEditModalOpen(true); | |
| }; | |
| const closeEditWindow = () => { | |
| setEditModalOpen(false); | |
| setEditingId(null); | |
| setEditTitle(''); | |
| setEditContent(''); | |
| setEditLinksText(''); | |
| }; | |
| const saveEdit = async (updateId) => { | |
| if (!currentUserId) { | |
| showToast('You must be signed in to edit', 'error'); | |
| return; | |
| } | |
| const payload = { | |
| updateId, | |
| userId: currentUserId, | |
| title: editTitle, | |
| content: editContent, | |
| links: parseLinks(editLinksText), | |
| files: editFiles | |
| }; | |
| try { | |
| const res = await fetch('/api/updates/edit', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data?.error || 'Edit failed'); | |
| setUpdates(prev => prev.map(it => it._id === updateId ? { | |
| ...it, | |
| title: data.update.title, | |
| content: data.update.content, | |
| links: data.update.links || [], | |
| files: data.update.files || [] | |
| } : it)); | |
| showToast('Update saved successfully', 'success'); | |
| closeEditWindow(); | |
| } catch (err) { | |
| console.error('Edit error', err); | |
| showToast('Failed to save update', 'error'); | |
| } | |
| }; | |
| const handleEditFilesSelected = async (e) => { | |
| const files = Array.from(e.target.files || []); | |
| if (!files.length) return; | |
| setEditIsUploading(true); | |
| try { | |
| for (let i = 0; i < files.length; i++) { | |
| const f = files[i]; | |
| const fd = new FormData(); | |
| fd.append('file', f); | |
| if (currentUserId) fd.append('userId', currentUserId); | |
| const res = await fetch('/api/updates/upload', { method: 'POST', body: fd }); | |
| const data = await res.json(); | |
| if (res.ok && data?.file) { | |
| if (editingId && currentUserId) { | |
| try { | |
| const addRes = await fetch('/api/updates/files/add', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ updateId: editingId, userId: currentUserId, file: data.file }), | |
| }); | |
| const addData = await addRes.json(); | |
| if (addRes.ok && addData?.update) { | |
| setEditFiles(Array.isArray(addData.update.files) ? addData.update.files : (editFiles) => [...editFiles, data.file]); | |
| setUpdates((prev) => prev.map(u => u._id === addData.update._id ? ({ ...u, files: addData.update.files || [] }) : u)); | |
| } else { | |
| setEditFiles((p) => [...p, data.file]); | |
| showToast(addData?.error || `Failed to attach ${f.name} to update`, 'error'); | |
| } | |
| } catch (err) { | |
| console.error('Failed to add file to update', err); | |
| setEditFiles((p) => [...p, data.file]); | |
| showToast('Failed to persist file to update', 'error'); | |
| } | |
| } else { | |
| setEditFiles((p) => [...p, data.file]); | |
| } | |
| } else { | |
| showToast(data?.error || `Failed to upload ${f.name}`, 'error'); | |
| } | |
| } | |
| } catch (err) { | |
| console.error('Edit file upload error', err); | |
| showToast('File upload failed', 'error'); | |
| } finally { | |
| setEditIsUploading(false); | |
| e.target.value = null; | |
| } | |
| }; | |
| const removeEditFile = (idx) => { | |
| setEditFiles((p) => p.filter((_, i) => i !== idx)); | |
| }; | |
| const openDeleteModal = (updateId, title) => { | |
| setDeleteModal({ updateId, title }); | |
| }; | |
| const closeDeleteModal = () => { | |
| setDeleteModal(null); | |
| }; | |
| const confirmDelete = async () => { | |
| if (!deleteModal) return; | |
| const updateId = deleteModal.updateId; | |
| closeDeleteModal(); | |
| if (!currentUserId) { | |
| showToast('You must be signed in to delete', 'error'); | |
| return; | |
| } | |
| const prev = updates; | |
| setUpdates(updates.filter(u => u._id !== updateId)); | |
| pendingRef.current[updateId] = true; | |
| try { | |
| const res = await fetch('/api/updates/delete', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ updateId, userId: currentUserId }), | |
| }); | |
| let data; | |
| try { data = await res.json(); } catch (e) { data = null; } | |
| if (!res.ok) throw new Error(data?.error || `Delete failed (status ${res.status})`); | |
| showToast('Update deleted', 'success'); | |
| } catch (err) { | |
| console.error('Delete error', err); | |
| showToast('Failed to delete update', 'error'); | |
| setUpdates(prev); | |
| } finally { | |
| delete pendingRef.current[updateId]; | |
| } | |
| }; | |
| const UpdateSkeleton = () => ( | |
| <div className="upl-card upl-skeleton"> | |
| <div className="upl-card-header"> | |
| <div className="upl-skeleton-avatar"></div> | |
| <div className="upl-skeleton-text-group"> | |
| <div className="upl-skeleton-line upl-skeleton-title"></div> | |
| <div className="upl-skeleton-line upl-skeleton-meta"></div> | |
| </div> | |
| </div> | |
| <div className="upl-skeleton-content"> | |
| <div className="upl-skeleton-line upl-skeleton-text"></div> | |
| <div className="upl-skeleton-line upl-skeleton-text upl-skeleton-text-short"></div> | |
| </div> | |
| </div> | |
| ); | |
| return ( | |
| <section className="upl-container"> | |
| <div className="upl-header-section"> | |
| <div className="upl-header-icon"><FiClock /></div> | |
| <h4 className="upl-section-title">Recent Updates</h4> | |
| </div> | |
| {/* Toast */} | |
| {toast && ( | |
| <div className={`upl-toast upl-toast-${toast.type}`}> | |
| <FiAlertCircle className="upl-toast-icon" /> | |
| <span>{toast.message}</span> | |
| </div> | |
| )} | |
| {/* Delete Modal */} | |
| {deleteModal && ( | |
| <div className="upl-modal-overlay" onClick={closeDeleteModal}> | |
| <div className="upl-modal" onClick={(e) => e.stopPropagation()}> | |
| <div className="upl-modal-header"> | |
| <FiAlertTriangle className="upl-modal-icon upl-modal-icon-danger" /> | |
| <h3 className="upl-modal-title">Delete Update</h3> | |
| </div> | |
| <div className="upl-modal-body"> | |
| <p className="upl-modal-text">Are you sure you want to delete <strong>"{deleteModal.title}"</strong>?</p> | |
| <p className="upl-modal-subtext">This action cannot be undone.</p> | |
| </div> | |
| <div className="upl-modal-actions"> | |
| <button onClick={closeDeleteModal} className="upl-modal-btn upl-modal-btn-cancel"><FiX /><span>Cancel</span></button> | |
| <button onClick={confirmDelete} className="upl-modal-btn upl-modal-btn-danger"><FiTrash2 /><span>Delete</span></button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Edit Modal */} | |
| {editModalOpen && ( | |
| <div className="upl-modal-overlay" onClick={closeEditWindow}> | |
| <div className="upl-modal upl-modal-large" onClick={(e) => e.stopPropagation()}> | |
| <div className="upl-modal-header"> | |
| <FiEdit2 className="upl-modal-icon upl-modal-icon-primary" /> | |
| <h3 className="upl-modal-title">Edit Update</h3> | |
| </div> | |
| <div className="upl-modal-body"> | |
| <div className="upl-edit-field"> | |
| <label className="upl-edit-label">Title</label> | |
| <input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} className="upl-edit-title-input" placeholder="Update title..." /> | |
| </div> | |
| <div className="upl-edit-field"> | |
| <label className="upl-edit-label">Content</label> | |
| <textarea value={editContent} onChange={(e) => setEditContent(e.target.value)} rows={5} className="upl-edit-textarea" placeholder="Update content..." /> | |
| </div> | |
| <div className="upl-edit-field"> | |
| <label className="upl-edit-label">Links (one per line)</label> | |
| <textarea value={editLinksText} onChange={(e) => setEditLinksText(e.target.value)} rows={3} className="upl-edit-textarea" placeholder="/internal-link or https://external-link.com" /> | |
| </div> | |
| <div className="upl-edit-field"> | |
| <label className="upl-edit-label">Files (optional)</label> | |
| <div className="upl-edit-files-row"> | |
| <label className="upl-file-btn"> | |
| <FiUpload /> | |
| <span>Add files</span> | |
| <input type="file" multiple onChange={handleEditFilesSelected} className="upl-hidden-input" /> | |
| </label> | |
| {editIsUploading && <span className="upl-file-uploading">Uploading…</span>} | |
| </div> | |
| {editFiles && editFiles.length > 0 && ( | |
| <div className="upl-edit-files-list"> | |
| {editFiles.map((f, i) => ( | |
| <div key={i} className="upl-edit-file-item"> | |
| <a | |
| href={`https://docs.google.com/gview?embedded=true&url=${encodeURIComponent(f.url)}`} | |
| className="upl-edit-file-link" | |
| target="_blank" | |
| rel="noreferrer noopener" | |
| > | |
| {f.name || f.url.split('/').pop()} | |
| </a> | |
| <button type="button" className="upl-edit-file-remove" onClick={() => removeEditFile(i)} title="Remove file"> | |
| <FiTrash2 /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="upl-modal-actions"> | |
| <button onClick={closeEditWindow} className="upl-modal-btn upl-modal-btn-cancel"><FiX /><span>Cancel</span></button> | |
| <button onClick={() => saveEdit(editingId)} className="upl-modal-btn upl-modal-btn-primary"><FiSave /><span>Save Changes</span></button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Empty State */} | |
| {updates.length === 0 && !loading && ( | |
| <div className="upl-empty-state"> | |
| <FiClock className="upl-empty-icon" /> | |
| <p className="upl-empty-text">No updates yet.</p> | |
| </div> | |
| )} | |
| {/* Skeleton */} | |
| {loading && page === 1 && ( | |
| <div className="upl-list"> | |
| <UpdateSkeleton /><UpdateSkeleton /><UpdateSkeleton /> | |
| </div> | |
| )} | |
| {/* Updates List */} | |
| <div className="upl-list"> | |
| {updates.map((u, idx) => { | |
| const timeData = getRelativeTime(u.createdAt); | |
| const isRelative = typeof timeData === 'string'; | |
| return ( | |
| <article key={u._id} className="upl-card" style={{ animationDelay: `${idx * 50}ms` }}> | |
| {/* Card Header */} | |
| <div className="upl-card-header"> | |
| <div className="upl-avatar-wrapper"> | |
| {u.profileUrl ? ( | |
| <img src={u.profileUrl} alt={u.name || 'profile'} className="upl-avatar" /> | |
| ) : ( | |
| <div className="upl-avatar upl-avatar-placeholder"><FiUser /></div> | |
| )} | |
| </div> | |
| <div className="upl-user-info"> | |
| <div className="upl-title-section"> | |
| <strong className="upl-user-title">{u.title}</strong> | |
| <div className="upl-meta-row"> | |
| {currentUserId && u.userId && String(currentUserId) === String(u.userId) && ( | |
| <div className="upl-actions"> | |
| <button onClick={() => openEditWindow(u)} className="upl-action-btn upl-action-edit" title="Edit" aria-label="Edit update"><FiEdit2 /></button> | |
| <button onClick={() => openDeleteModal(String(u._id), u.title)} className="upl-action-btn upl-action-delete" title="Delete" aria-label="Delete update"><FiTrash2 /></button> | |
| </div> | |
| )} | |
| <div className="upl-timestamp"> | |
| <FiClock className="upl-time-icon" /> | |
| {isRelative ? ( | |
| <span className="upl-time-relative">{timeData}</span> | |
| ) : ( | |
| <div className="upl-time-absolute"> | |
| <span className="upl-time-date">{timeData.date}</span> | |
| <span className="upl-time-clock">{timeData.time}</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Content */} | |
| {u.content && <p className="upl-content">{u.content}</p>} | |
| {/* Links */} | |
| {u.links && u.links.length > 0 && ( | |
| <div className="upl-links"> | |
| {u.links.map((ln, idx) => { | |
| const raw = String(ln || '').trim(); | |
| if (!raw) return null; | |
| const internal = raw.startsWith('/'); | |
| if (internal) { | |
| return ( | |
| <Link key={idx} href={raw} className="upl-link upl-link-internal"> | |
| <span>Visit</span><FiChevronRight className="upl-link-icon" /> | |
| </Link> | |
| ); | |
| } | |
| const hasScheme = /^https?:\/\//i.test(raw) || /^mailto:/i.test(raw); | |
| const href = hasScheme ? raw : `https://${raw}`; | |
| return ( | |
| <a key={idx} href={href} target="_blank" rel="noreferrer noopener" className="upl-link upl-link-external"> | |
| <span>{raw}</span><FiExternalLink className="upl-link-icon" /> | |
| </a> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {/* Files — clicking anywhere opens Drive viewer */} | |
| {u.files && u.files.length > 0 && ( | |
| <div className="upl-files"> | |
| {u.files.map((f, idx) => { | |
| const url = f.url || f; | |
| const name = f.name || url.split('/').pop(); | |
| const viewUrl = `https://docs.google.com/gview?embedded=true&url=${encodeURIComponent(url)}`; | |
| return ( | |
| <div key={idx} className="upl-file-card"> | |
| <a href={viewUrl} target="_blank" rel="noreferrer noopener" className="upl-file-card-name" title={`View ${name}`}> | |
| <span className="upl-file-card-icon">📄</span> | |
| <span className="upl-file-card-label">{name}</span> | |
| </a> | |
| <div className="upl-file-card-actions"> | |
| <a href={viewUrl} target="_blank" rel="noreferrer noopener" className="upl-file-action-btn upl-file-action-view" title="View"> | |
| <FiEye /> | |
| </a> | |
| <a href={url} download={name} target="_blank" rel="noreferrer noopener" className="upl-file-action-btn upl-file-action-download" title="Download"> | |
| <FiDownload /> | |
| </a> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </article> | |
| ); | |
| })} | |
| </div> | |
| {/* Load More */} | |
| <div className="upl-load-more-section"> | |
| {loading && page > 1 ? ( | |
| <button className="upl-load-more-btn" disabled><span className="upl-spinner"></span><span>Loading...</span></button> | |
| ) : hasMore ? ( | |
| <button onClick={loadMore} className="upl-load-more-btn"><span>Load More</span><FiChevronRight className="upl-btn-icon" /></button> | |
| ) : ( | |
| updates.length > 0 && <div className="upl-end-message"><span>No more updates</span></div> | |
| )} | |
| </div> | |
| </section> | |
| ); | |
| } |