learnix / src /app /upload /TopicCard.js
shashidharak99's picture
Upload files
7d51e81 verified
"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 (
<div className="tc-card">
{/* ── Rename Modal ── */}
{renameModal && (
<div className="tc-modal-overlay" onClick={cancelRenameTopic}>
<div className="tc-modal" onClick={(e) => e.stopPropagation()}>
<div className="tc-modal-icon tc-modal-icon--edit"><FiEdit2 /></div>
<h4 className="tc-modal-title">Rename topic</h4>
<p className="tc-modal-desc">Enter a new name for <strong>{topic.topic}</strong>.</p>
<input
className="tc-modal-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="New topic name"
autoFocus
disabled={!!isRenamingTopic}
onKeyDown={(e) => { if (e.key === "Enter") confirmRenameTopic(); if (e.key === "Escape") cancelRenameTopic(); }}
/>
<div className="tc-modal-actions">
<button className="tc-modal-btn tc-modal-btn--cancel" onClick={cancelRenameTopic} disabled={!!isRenamingTopic}>Cancel</button>
<button className="tc-modal-btn tc-modal-btn--save" onClick={confirmRenameTopic} disabled={!!isRenamingTopic}>
{isRenamingTopic ? <><FiLoader className="tc-spin" /> Saving…</> : "Save"}
</button>
</div>
</div>
</div>
)}
{/* ── Delete Topic Modal ── */}
{deleteModal && (
<div className="tc-modal-overlay" onClick={cancelDeleteTopic}>
<div className="tc-modal" onClick={(e) => e.stopPropagation()}>
<div className="tc-modal-icon tc-modal-icon--danger"><FiTrash2 /></div>
<h4 className="tc-modal-title">Delete topic?</h4>
<p className="tc-modal-desc">Delete <strong>{topic.topic}</strong>? This can't be undone.</p>
<div className="tc-modal-actions">
<button className="tc-modal-btn tc-modal-btn--cancel" onClick={cancelDeleteTopic} disabled={!!isDeletingTopic}>Cancel</button>
<button className="tc-modal-btn tc-modal-btn--danger" onClick={confirmDeleteTopic} disabled={!!isDeletingTopic}>
{isDeletingTopic ? <><FiLoader className="tc-spin" /> Deleting…</> : "Delete"}
</button>
</div>
</div>
</div>
)}
{/* ── Delete Image Modal ── */}
{deleteConfirm.show && (
<div className="tc-modal-overlay" onClick={cancelDelete}>
<div className="tc-modal" onClick={(e) => e.stopPropagation()}>
<div className="tc-modal-icon tc-modal-icon--danger"><FiAlertTriangle /></div>
<h4 className="tc-modal-title">Delete image?</h4>
<p className="tc-modal-desc">This action cannot be undone.</p>
<div className="tc-modal-actions">
<button className="tc-modal-btn tc-modal-btn--cancel" onClick={cancelDelete}>Cancel</button>
<button className="tc-modal-btn tc-modal-btn--danger" onClick={confirmDelete} disabled={isLoading}>
<FiTrash2 /> Delete
</button>
</div>
</div>
</div>
)}
{/* ══════════════════════════════════
HEADER
══════════════════════════════════ */}
<div
className={`tc-header ${isCollapsed ? "tc-header--collapsed" : "tc-header--expanded"}`}
role="button"
aria-expanded={!isCollapsed}
tabIndex={0}
onClick={(e) => { if (shouldIgnore(e.target)) return; toggleCollapse(e); }}
onKeyDown={(e) => { if (shouldIgnore(e.target)) return; if (e.key === "Enter" || e.key === " ") toggleCollapse(e); }}
>
<div className="tc-header-left">
<span className="tc-caret">
{isCollapsed ? <FiChevronRight /> : <FiChevronDown />}
</span>
<span className="tc-topic-icon"><FiFileText /></span>
<div className="tc-title-wrap">
{isCollapsed ? (
<h4 className="tc-title">{topic.topic}</h4>
) : (
<Link href={`/works/${topic._id}`} className="tc-title-link" onClick={(e) => e.stopPropagation()}>
<h4 className="tc-title">{topic.topic}</h4>
</Link>
)}
</div>
</div>
<div className="tc-header-right">
<span className="tc-date">
<FiCalendar className="tc-date-icon" />
{new Date(topic.timestamp).toLocaleDateString()}
</span>
<span className={`tc-badge ${topicPublic ? "tc-badge--public" : "tc-badge--private"}`}>
{topicPublic ? "Public" : "Private"}
</span>
<div className="tc-menu-wrap" ref={menuRef}>
<button
type="button"
className={`tc-menu-btn${openMenu ? " tc-menu-btn--active" : ""}`}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setOpenMenu((p) => !p); }}
aria-label="More options"
aria-expanded={openMenu}
>
<FiMoreVertical />
</button>
{openMenu && (
<div className="tc-dropdown" onClick={(e) => e.stopPropagation()}>
<button className="tc-dropdown-item" onClick={handleOpenTopic}>
<FiFileText className="tc-dropdown-icon" /><span>Open</span>
</button>
<button className="tc-dropdown-item" onClick={handleOpenReviews}>
<FiMessageSquare className="tc-dropdown-icon" /><span>Reviews</span>
</button>
<button className="tc-dropdown-item" onClick={requestRenameTopic}>
<FiEdit2 className="tc-dropdown-icon" /><span>Rename</span>
</button>
<button className="tc-dropdown-item" onClick={handleShare}>
<FiShare2 className="tc-dropdown-icon" /><span>Share</span>
</button>
<button className="tc-dropdown-item" onClick={handlePublicToggle} disabled={isLoading || isTogglingPublic}>
{topicPublic ? <FiLock className="tc-dropdown-icon" /> : <FiUnlock className="tc-dropdown-icon" />}
<span>{isTogglingPublic ? "Processing…" : topicPublic ? "Make Private" : "Make Public"}</span>
</button>
<div className="tc-dropdown-divider" />
<button className="tc-dropdown-item tc-dropdown-item--danger" onClick={requestDeleteTopic}>
<FiTrash2 className="tc-dropdown-icon" /><span>Delete</span>
</button>
</div>
)}
</div>
</div>
</div>
{/* ══════════════════════════════════
EXPANDED BODY
══════════════════════════════════ */}
{!isCollapsed && (
<div className="tc-body">
{/* Images */}
<div className="tc-section">
<div className="tc-section-header">
<FiImage className="tc-section-icon tc-section-icon--sky" />
<span className="tc-section-title">Images</span>
<span className="tc-count-badge">{validImages.length}</span>
</div>
{validImages.length > 0 && (
<>
<div className="tc-images-grid">
{validImages
.slice(0, expandedImages[topicKey] ? validImages.length : 3)
.map((img, i) => (
<div key={i} className="tc-image-tile">
<img src={img} alt={`Image ${i + 1}`} className="tc-image" loading="lazy" />
<div className="tc-image-overlay">
<button className="tc-image-delete-btn" onClick={() => showDeleteConfirmation(img)} disabled={isLoading}>
<FiTrash2 />
</button>
</div>
</div>
))}
</div>
{validImages.length > 3 && (
<button className="tc-ghost-btn tc-ghost-btn--sm tc-expand-btn" onClick={toggleImageExpansion}>
{expandedImages[topicKey]
? <><FiChevronUp /> Show less</>
: <><FiChevronDown /> View {validImages.length - 3} more</>}
</button>
)}
</>
)}
{validImages.length === 0 && <p className="tc-empty-hint">No images yet.</p>}
</div>
{/* Upload */}
<div className="tc-section">
<div className="tc-section-header">
<FiUpload className="tc-section-icon tc-section-icon--yellow" />
<span className="tc-section-title">Add Content</span>
</div>
<button className="tc-ghost-btn" onClick={toggleCaptureOptions}>
<FiCamera />
{showCaptureOptions[topicKey] ? "Hide options" : "Add images / PDF"}
{showCaptureOptions[topicKey] ? <FiChevronUp /> : <FiChevronDown />}
</button>
{showCaptureOptions[topicKey] && (
<div className="tc-capture-bar">
<button className="tc-pill-btn" onClick={triggerCameraCapture} disabled={isLoading || compressionStates[topicKey]}>
<FiCamera /> Capture Photo
</button>
<label className={`tc-pill-btn${(isLoading || compressionStates[topicKey]) ? " tc-pill-btn--disabled" : ""}`}>
<FiFile /> Browse Images
<input type="file" multiple onChange={handleMultipleFileChange} accept="image/*" className="tc-hidden-input" disabled={isLoading || compressionStates[topicKey]} />
</label>
<button className="tc-pill-btn" onClick={togglePDFProcessor} disabled={isLoading}>
<FiFileText /> Upload PDF/DOCX
</button>
<input ref={(el) => (cameraInputRefs.current[topicKey] = el)} type="file" accept="image/*" capture="environment" onChange={handleCameraCapture} className="tc-hidden-input" />
</div>
)}
{showPDFProcessor[topicKey] && (
<div className="tc-pdf-wrap">
<PDFProcessor usn={usn} subject={subject} topic={topic.topic} onClose={togglePDFProcessor} onUploadSuccess={handlePDFUploadSuccess} onUploadError={(e) => showMessage(`PDF upload failed: ${e}`, "error")} />
</div>
)}
{compressionStates[topicKey] && (
<div className="tc-status-row">
<FiLoader className="tc-spin tc-status-icon" />
<span>Processing images…</span>
</div>
)}
{uploadComplete[topicKey] && (
<div className="tc-success-banner">
<FiCheckCircle className="tc-success-icon" />
<span>All images uploaded successfully!</span>
</div>
)}
{/* Multiple files */}
{isMultipleFiles && filesForTopic?.length > 0 && !uploadComplete[topicKey] && (
<div className="tc-multi-wrap">
<div className="tc-multi-grid">
{filesForTopic.map((fd, i) => {
const done = uploadedFiles[topicKey]?.has(i + 1);
return (
<div key={i} className={`tc-file-card${done ? " tc-file-card--done" : ""}`}>
<div className="tc-file-card-img-wrap">
<img src={filePreviewMap[topicKey]?.[i]} alt={`Preview ${i + 1}`} className="tc-file-thumb" />
{done && <span className="tc-file-done-check"><FiCheck /></span>}
</div>
<div className="tc-file-card-footer">
<span className="tc-file-name">{fd.name}</span>
<span className="tc-file-size">{(fd.compressedSize / 1024 / 1024).toFixed(2)} MB</span>
</div>
</div>
);
})}
</div>
{!uploadingStates[topicKey] && (
<button className="tc-primary-btn" onClick={uploadMultipleFilesSequentially} disabled={isLoading}>
<FiUpload /> Upload all ({filesForTopic.length})
</button>
)}
{uploadingStates[topicKey] && (
<div className="tc-progress-wrap">
<div className="tc-progress-track">
<div className="tc-progress-bar" style={{ width: `${uploadProgress[topicKey] || 0}%` }} />
</div>
<span className="tc-progress-label">{uploadProgress[topicKey] || 0}% completed</span>
</div>
)}
</div>
)}
{/* Single file */}
{!isMultipleFiles && filePreviewMap[topicKey] && !uploadComplete[topicKey] && (
<div className="tc-single-preview">
<div className="tc-single-thumb-wrap">
<img src={filePreviewMap[topicKey]} alt="Preview" className="tc-single-thumb" />
<button className="tc-single-remove" onClick={() => handleSingleFileChange(null)} disabled={isLoading || compressionStates[topicKey]}>
<FiX />
</button>
</div>
<button className="tc-primary-btn" onClick={handleUploadImage} disabled={uploadingStates[topicKey] || !filesMap[topicKey] || compressionStates[topicKey]}>
{uploadingStates[topicKey]
? <><FiLoader className="tc-spin" /> Uploading…</>
: <><FiUpload /> Upload</>}
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}