"use client"; import { useState, useRef, useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import axios from "axios"; import imageCompression from "browser-image-compression"; import { FiFileText, FiUpload, FiTrash2, FiImage, FiCalendar, FiCheck, FiChevronDown, FiChevronUp, FiX, FiAlertTriangle, FiCamera, FiFile, FiCheckCircle, FiLoader, FiShare2, FiMessageSquare, FiMoreVertical, FiEdit2, FiLock, FiUnlock, FiChevronRight, } from "react-icons/fi"; import PDFProcessor from "./PDFProcessor"; import "./styles/TopicCard.css"; export default function TopicCard({ subject, topic, usn, isLoading, onTopicDelete, onRefreshSubjects, showMessage }) { const router = useRouter(); const [isCollapsed, setIsCollapsed] = useState(true); const [uploadingStates, setUploadingStates] = useState({}); const [compressionStates, setCompressionStates] = useState({}); const [filesMap, setFilesMap] = useState({}); const [filePreviewMap, setFilePreviewMap] = useState({}); const [expandedImages, setExpandedImages] = useState({}); const [deleteConfirm, setDeleteConfirm] = useState({ show: false, subject: "", topic: "", imageUrl: "" }); const [showCaptureOptions, setShowCaptureOptions] = useState({}); const [showPDFProcessor, setShowPDFProcessor] = useState({}); const [uploadProgress, setUploadProgress] = useState({}); const [uploadedFiles, setUploadedFiles] = useState({}); const [uploadComplete, setUploadComplete] = useState({}); const [isTogglingPublic, setIsTogglingPublic] = useState(false); const [topicPublic, setTopicPublic] = useState(topic.public !== false); const [topicImages, setTopicImages] = useState(Array.isArray(topic.images) ? [...topic.images] : []); const [openMenu, setOpenMenu] = useState(false); const [deleteModal, setDeleteModal] = useState(false); const [isDeletingTopic, setIsDeletingTopic] = useState(false); const [renameModal, setRenameModal] = useState(false); const [renameValue, setRenameValue] = useState(""); const [isRenamingTopic, setIsRenamingTopic] = useState(false); const menuRef = useRef(null); const cameraInputRefs = useRef({}); const topicKey = `${subject}-${topic.topic}`; /* ── collapse ── */ const toggleCollapse = (e) => { if (e) { e.preventDefault(); e.stopPropagation(); } setOpenMenu(false); setIsCollapsed((p) => !p); }; const shouldIgnore = (target) => { if (!target || typeof target.closest !== "function") return false; return Boolean(target.closest(".tc-menu-wrap, .tc-dropdown, .tc-menu-btn, .tc-dropdown-item, a, button, input, select, textarea, label")); }; /* ── cleanup preview URLs ── */ useEffect(() => { return () => { Object.values(filePreviewMap).forEach((urls) => { if (Array.isArray(urls)) urls.forEach((u) => URL.revokeObjectURL(u)); else if (urls) URL.revokeObjectURL(urls); }); }; }, [filePreviewMap]); /* ── close menu on outside click ── */ useEffect(() => { const close = (e) => { if (menuRef.current && !menuRef.current.contains(e.target)) setOpenMenu(false); }; if (openMenu) document.addEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close); }, [openMenu]); // keep local public state in sync when parent updates topic prop useEffect(() => { setTopicPublic(topic.public !== false); }, [topic.public]); // keep local images in sync when topic prop updates from parent useEffect(() => { setTopicImages(Array.isArray(topic.images) ? [...topic.images] : []); }, [topic.images]); /* ── image compression ── */ const compressImage = async (file) => { return imageCompression(file, { maxSizeMB: 0.5, maxWidthOrHeight: 1920, useWebWorker: true, quality: 0.8, fileType: "image/jpeg" }); }; const toggleCaptureOptions = () => setShowCaptureOptions((p) => ({ ...p, [topicKey]: !p[topicKey] })); const togglePDFProcessor = () => setShowPDFProcessor((p) => ({ ...p, [topicKey]: !p[topicKey] })); const triggerCameraCapture = () => cameraInputRefs.current[topicKey]?.click(); const handleCameraCapture = async (e) => { const file = e.target.files[0]; if (file) { await handleSingleFileChange(file); setShowCaptureOptions((p) => ({ ...p, [topicKey]: false })); } }; const handleSingleFileChange = async (file) => { if (!file) { setFilesMap({ ...filesMap, [topicKey]: null }); if (filePreviewMap[topicKey]) { URL.revokeObjectURL(filePreviewMap[topicKey]); const m = { ...filePreviewMap }; delete m[topicKey]; setFilePreviewMap(m); } return; } if (!file.type.startsWith("image/")) { showMessage("Please select a valid image file.", "error"); return; } try { setCompressionStates((p) => ({ ...p, [topicKey]: true })); showMessage("Compressing image...", ""); const compressed = await compressImage(file); const final = new File([compressed], file.name, { type: compressed.type, lastModified: Date.now() }); setFilesMap({ ...filesMap, [topicKey]: final }); setFilePreviewMap({ ...filePreviewMap, [topicKey]: URL.createObjectURL(final) }); const ratio = ((file.size - final.size) / file.size * 100).toFixed(1); showMessage(`Compressed by ${ratio}% (${(file.size/1024/1024).toFixed(2)}MB → ${(final.size/1024/1024).toFixed(2)}MB)`, "success"); } catch { showMessage("Image compression failed.", "error"); } finally { setCompressionStates((p) => { const n = { ...p }; delete n[topicKey]; return n; }); } }; const handleMultipleFileChange = async (e) => { const files = Array.from(e.target.files).filter((f) => f.type.startsWith("image/")); if (!files.length) { showMessage("Please select valid image files.", "error"); return; } files.sort((a, b) => a.lastModified - b.lastModified); try { setCompressionStates((p) => ({ ...p, [topicKey]: true })); const processed = [], previews = []; for (let i = 0; i < files.length; i++) { showMessage(`Compressing image ${i + 1}/${files.length}…`, ""); const c = await compressImage(files[i]); const f = new File([c], files[i].name, { type: c.type, lastModified: Date.now() }); processed.push({ file: f, originalSize: files[i].size, compressedSize: f.size, name: files[i].name }); previews.push(URL.createObjectURL(f)); } setFilesMap((p) => ({ ...p, [topicKey]: processed })); setFilePreviewMap((p) => ({ ...p, [topicKey]: previews })); const tot = processed.reduce((s, f) => s + f.originalSize, 0); const comp = processed.reduce((s, f) => s + f.compressedSize, 0); showMessage(`${files.length} images processed. Compression: ${((tot - comp) / tot * 100).toFixed(1)}%`, "success"); } catch { showMessage("Image compression failed.", "error"); } finally { setCompressionStates((p) => { const n = { ...p }; delete n[topicKey]; return n; }); } }; const toggleImageExpansion = () => setExpandedImages((p) => ({ ...p, [topicKey]: !p[topicKey] })); const showDeleteConfirmation = (imageUrl) => setDeleteConfirm({ show: true, subject, topic: topic.topic, imageUrl }); const cancelDelete = () => setDeleteConfirm({ show: false, subject: "", topic: "", imageUrl: "" }); const confirmDelete = async () => { await handleDeleteImage(deleteConfirm.imageUrl); cancelDelete(); }; const handleUploadImage = async () => { const file = filesMap[topicKey]; if (!file) { showMessage("Please select a file first.", "error"); return; } setUploadingStates((p) => ({ ...p, [topicKey]: true })); const fd = new FormData(); fd.append("usn", usn); fd.append("subject", subject); fd.append("topic", topic.topic); fd.append("file", file); try { const res = await axios.post("/api/topic/upload", fd, { headers: { "Content-Type": "multipart/form-data" } }); const img = res?.data?.imageUrl; if (img) { // update UI locally without full reload setTopicImages((prev) => [...prev, img]); } setFilesMap({ ...filesMap, [topicKey]: null }); if (filePreviewMap[topicKey]) { URL.revokeObjectURL(filePreviewMap[topicKey]); const m = { ...filePreviewMap }; delete m[topicKey]; setFilePreviewMap(m); } showMessage("Image uploaded successfully!", "success"); } catch (err) { showMessage(err.response?.data?.error || "Upload failed", "error"); } finally { setUploadingStates((p) => { const n = { ...p }; delete n[topicKey]; return n; }); } }; const uploadSingleFile = async (fileData, idx, total) => { const fd = new FormData(); fd.append("usn", usn); fd.append("subject", subject); fd.append("topic", topic.topic); fd.append("file", fileData.file, fileData.name); try { const res = await axios.post("/api/topic/upload", fd, { headers: { "Content-Type": "multipart/form-data" } }); const img = res?.data?.imageUrl; if (img) setTopicImages((prev) => [...prev, img]); setUploadedFiles((p) => ({ ...p, [topicKey]: new Set([...(p[topicKey] || []), idx]) })); setUploadProgress((p) => ({ ...p, [topicKey]: Math.round((idx / total) * 100) })); } catch { showMessage(`Failed to upload ${fileData.name}`, "error"); } }; const uploadMultipleFilesSequentially = async () => { const files = filesMap[topicKey]; if (!files?.length) { showMessage("Please select files first.", "error"); return; } setUploadingStates((p) => ({ ...p, [topicKey]: true })); setUploadProgress((p) => ({ ...p, [topicKey]: 0 })); setUploadedFiles((p) => ({ ...p, [topicKey]: new Set() })); setUploadComplete((p) => ({ ...p, [topicKey]: false })); for (let i = 0; i < files.length; i++) await uploadSingleFile(files[i], i + 1, files.length); setUploadingStates((p) => { const n = { ...p }; delete n[topicKey]; return n; }); setUploadComplete((p) => ({ ...p, [topicKey]: true })); showMessage("All images uploaded successfully!", "success"); setTimeout(() => { setFilesMap((p) => { const n = { ...p }; delete n[topicKey]; return n; }); if (filePreviewMap[topicKey]) { filePreviewMap[topicKey].forEach((u) => URL.revokeObjectURL(u)); setFilePreviewMap((p) => { const n = { ...p }; delete n[topicKey]; return n; }); } setUploadComplete((p) => { const n = { ...p }; delete n[topicKey]; return n; }); }, 2000); }; const handleDeleteImage = async (imageUrl) => { try { await axios.put("/api/topic/deleteimage", { usn, subject, topic: topic.topic, imageUrl }); // update local UI immediately setTopicImages((prev) => prev.filter((img) => img !== imageUrl)); showMessage("Image deleted successfully!", "success"); } catch (err) { showMessage(err.response?.data?.error || "Failed to delete image", "error"); } }; const handlePublicToggle = async () => { if (isLoading || isTogglingPublic) return; setIsTogglingPublic(true); try { await axios.put("/api/topic/public", { usn, subject, topic: topic.topic, public: !topicPublic }); // update local UI immediately setTopicPublic((p) => !p); onRefreshSubjects(); showMessage(`Topic is now ${!topicPublic ? "public" : "private"}`, "success"); } catch (err) { showMessage(err.response?.data?.error || "Failed to update visibility", "error"); } finally { setIsTogglingPublic(false); } }; const handlePDFUploadSuccess = () => { onRefreshSubjects(); showMessage("PDF pages uploaded successfully!", "success"); setShowPDFProcessor((p) => ({ ...p, [topicKey]: false })); }; const handleShare = async () => { const url = `${window.location.origin}/works/${topic._id}`; if (navigator.share) { try { await navigator.share({ title: topic.topic, url }); return; } catch {} } navigator.clipboard.writeText(url) .then(() => showMessage("Link copied to clipboard!", "success")) .catch(() => showMessage("Failed to copy link", "error")); }; const handleOpenTopic = () => { setOpenMenu(false); router.push(`/works/${topic._id}`); }; const handleOpenReviews = () => { setOpenMenu(false); router.push(`/reviews/${topic._id}`); }; const requestRenameTopic = () => { setOpenMenu(false); setRenameValue(topic.topic || ""); setRenameModal(true); }; const cancelRenameTopic = () => { if (isRenamingTopic) return; setRenameModal(false); setRenameValue(""); }; const confirmRenameTopic = async () => { const next = String(renameValue || "").trim(); if (!next) { showMessage("Please enter a topic name", "error"); return; } if (next.toLowerCase() === topic.topic?.trim().toLowerCase()) { cancelRenameTopic(); return; } setIsRenamingTopic(true); try { const res = await fetch("/api/topic/rename", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ usn, topicId: topic._id, newTopic: next }) }); const data = await res.json(); if (!res.ok) { showMessage(data.error || "Failed to rename topic", "error"); return; } await onRefreshSubjects(); showMessage("Topic renamed successfully!", "success"); cancelRenameTopic(); } catch { showMessage("Error renaming topic", "error"); } finally { setIsRenamingTopic(false); } }; const requestDeleteTopic = () => { setOpenMenu(false); setDeleteModal(true); }; const cancelDeleteTopic = () => { if (isDeletingTopic) return; setDeleteModal(false); }; const confirmDeleteTopic = async () => { if (!usn) return; setIsDeletingTopic(true); try { const res = await fetch("/api/topic/delete", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ usn, subject, topic: topic.topic }) }); const data = await res.json(); if (!res.ok) { showMessage(data.error || "Failed to delete topic", "error"); return; } if (onTopicDelete) onTopicDelete(data.subjects); showMessage("Topic deleted successfully!", "success"); setDeleteModal(false); } catch { showMessage("Error deleting topic", "error"); } finally { setIsDeletingTopic(false); } }; const getValidImages = (images) => images.filter((img) => img && img.trim()); const validImages = getValidImages(topicImages || []); const filesForTopic = filesMap[topicKey]; const isMultipleFiles = Array.isArray(filesForTopic); return (
{/* ── Rename Modal ── */} {renameModal && (
e.stopPropagation()}>

Rename topic

Enter a new name for {topic.topic}.

setRenameValue(e.target.value)} placeholder="New topic name" autoFocus disabled={!!isRenamingTopic} onKeyDown={(e) => { if (e.key === "Enter") confirmRenameTopic(); if (e.key === "Escape") cancelRenameTopic(); }} />
)} {/* ── Delete Topic Modal ── */} {deleteModal && (
e.stopPropagation()}>

Delete topic?

Delete {topic.topic}? This can't be undone.

)} {/* ── Delete Image Modal ── */} {deleteConfirm.show && (
e.stopPropagation()}>

Delete image?

This action cannot be undone.

)} {/* ══════════════════════════════════ HEADER ══════════════════════════════════ */}
{ if (shouldIgnore(e.target)) return; toggleCollapse(e); }} onKeyDown={(e) => { if (shouldIgnore(e.target)) return; if (e.key === "Enter" || e.key === " ") toggleCollapse(e); }} >
{isCollapsed ? : }
{isCollapsed ? (

{topic.topic}

) : ( e.stopPropagation()}>

{topic.topic}

)}
{new Date(topic.timestamp).toLocaleDateString()} {topicPublic ? "Public" : "Private"}
{openMenu && (
e.stopPropagation()}>
)}
{/* ══════════════════════════════════ EXPANDED BODY ══════════════════════════════════ */} {!isCollapsed && (
{/* Images */}
Images {validImages.length}
{validImages.length > 0 && ( <>
{validImages .slice(0, expandedImages[topicKey] ? validImages.length : 3) .map((img, i) => (
{`Image
))}
{validImages.length > 3 && ( )} )} {validImages.length === 0 &&

No images yet.

}
{/* Upload */}
Add Content
{showCaptureOptions[topicKey] && (
(cameraInputRefs.current[topicKey] = el)} type="file" accept="image/*" capture="environment" onChange={handleCameraCapture} className="tc-hidden-input" />
)} {showPDFProcessor[topicKey] && (
showMessage(`PDF upload failed: ${e}`, "error")} />
)} {compressionStates[topicKey] && (
Processing images…
)} {uploadComplete[topicKey] && (
All images uploaded successfully!
)} {/* Multiple files */} {isMultipleFiles && filesForTopic?.length > 0 && !uploadComplete[topicKey] && (
{filesForTopic.map((fd, i) => { const done = uploadedFiles[topicKey]?.has(i + 1); return (
{`Preview {done && }
{fd.name} {(fd.compressedSize / 1024 / 1024).toFixed(2)} MB
); })}
{!uploadingStates[topicKey] && ( )} {uploadingStates[topicKey] && (
{uploadProgress[topicKey] || 0}% completed
)}
)} {/* Single file */} {!isMultipleFiles && filePreviewMap[topicKey] && !uploadComplete[topicKey] && (
Preview
)}
)}
); }