diff --git a/src/components/generic/Dashboard/TableDashboard.jsx b/src/components/generic/Dashboard/TableDashboard.jsx index 90f96dd..2ec8c77 100644 --- a/src/components/generic/Dashboard/TableDashboard.jsx +++ b/src/components/generic/Dashboard/TableDashboard.jsx @@ -82,6 +82,7 @@ export function TableDashboard({ } function handleStatClick(stat) { + if (stat.onClick) return stat.onClick(); if (!stat.filterId) return; toggleFilter(stat.filterId, stat.filterValue); } @@ -106,7 +107,7 @@ export function TableDashboard({ label={mapping.label ?? s.label} value={s.value} icon={mapping.icon} - onClick={s.filterId ? () => handleStatClick(s) : undefined} + onClick={s.filterId || s.onClick ? () => handleStatClick(s) : undefined} className={active ? "ring-2 ring-primary border-primary" : ""} /> ); diff --git a/src/components/generic/UploadProgressToast.jsx b/src/components/generic/UploadProgressToast.jsx new file mode 100644 index 0000000..8ed1327 --- /dev/null +++ b/src/components/generic/UploadProgressToast.jsx @@ -0,0 +1,102 @@ +// components/generic/UploadProgressToast.jsx +// +// Floating widget mounted once in AdminLayout (outside the router Outlet's +// unmount cycle) so it keeps showing asset-upload progress no matter what +// admin page you navigate to mid-upload. State comes from UploadQueueContext, +// which lives above the router for the same reason. + +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ChevronUp, ChevronDown, X, Loader2, CheckCircle2, XCircle } from "lucide-react"; + +import { useUploadQueue } from "@/contexts/UploadQueueContext"; +import { Progress } from "@/components/ui/progress"; +import { Button } from "@/components/ui/button"; + +const ACTIVE = new Set(["uploading", "queued"]); +const FAILED = new Set(["failed", "invalid"]); + +export default function UploadProgressToast() { + const { jobs, retryJob } = useUploadQueue(); + const navigate = useNavigate(); + const [dismissed, setDismissed] = useState(false); + const [expanded, setExpanded] = useState(false); + const prevActiveCountRef = useRef(0); + + const active = jobs.filter((j) => ACTIVE.has(j.status)); + const finished = jobs.filter((j) => !ACTIVE.has(j.status)); + + // A fresh batch starting up should always resurface the widget, even if + // the previous one was dismissed. + useEffect(() => { + if (prevActiveCountRef.current === 0 && active.length > 0) setDismissed(false); + prevActiveCountRef.current = active.length; + }, [active.length]); + + if (dismissed || jobs.length === 0) return null; + + const aggregatePct = active.length + ? Math.round(active.reduce((sum, j) => sum + j.progress, 0) / active.length) + : 100; + + const failedCount = finished.filter((j) => FAILED.has(j.status)).length; + + const label = active.length + ? `Uploading ${active.length} file${active.length === 1 ? "" : "s"}… ${aggregatePct}%` + : failedCount + ? `${finished.length - failedCount} of ${finished.length} file${finished.length === 1 ? "" : "s"} uploaded` + : `${finished.length} file${finished.length === 1 ? "" : "s"} uploaded`; + + return ( +
+
setExpanded((v) => !v)} + > + {active.length > 0 + ? + : failedCount + ? + : } + {label} + + +
+ + {active.length > 0 && } + + {expanded && ( +
+ {jobs.map((job) => ( +
+ {job.status === "uploaded" && } + {FAILED.has(job.status) && } + {ACTIVE.has(job.status) && } + {job.name} + {job.status === "failed" && ( + + )} +
+ ))} + +
+ )} +
+ ); +} diff --git a/src/contexts/UploadQueueContext.jsx b/src/contexts/UploadQueueContext.jsx new file mode 100644 index 0000000..afb1433 --- /dev/null +++ b/src/contexts/UploadQueueContext.jsx @@ -0,0 +1,160 @@ +// contexts/UploadQueueContext.jsx +// +// Global, app-shell-mounted upload queue for the Add Asset multi-file drop +// zone. Lives above the router (see AdminProvider.jsx) so an in-flight +// batch survives navigating away from the Add Asset page — the floating +// UploadProgressToast reads the same state from anywhere in /admin. +// +// One POST /admin/assets/batch per addBatch() call — axios onUploadProgress +// only reports aggregate bytes for the whole multipart body, so every job in +// a batch shares one progress number while "uploading" and only splits into +// per-file uploaded/failed once the response comes back. Retries re-submit +// a single file through the same endpoint so they can track their own +// progress independently of whatever batch they originally belonged to. + +import { createContext, useCallback, useContext, useRef, useState } from "react"; +import { nanoid } from "nanoid"; +import api from "@/utils/api.util"; +import { validateAssetFile } from "@/utils/assetUpload.util"; + +const UploadQueueContext = createContext(null); + +export function useUploadQueue() { + const ctx = useContext(UploadQueueContext); + if (!ctx) throw new Error("useUploadQueue must be used within an UploadQueueProvider"); + return ctx; +} + +const baseNameOf = (filename = "") => filename.replace(/\.[^.]+$/, ""); + +export function UploadQueueProvider({ children }) { + const [jobs, setJobs] = useState([]); + const onSettledRef = useRef(new Map()); // batchId -> callback + + const patchJobs = useCallback((ids, patch) => { + const idSet = new Set(ids); + setJobs((prev) => prev.map((j) => (idSet.has(j.id) ? { ...j, ...(typeof patch === "function" ? patch(j) : patch) } : j))); + }, []); + + // ─── Submit a batch of already-validated files ───────────────────────── + const runBatch = useCallback(async (batchId, batchJobs, meta) => { + const ids = batchJobs.map((j) => j.id); + patchJobs(ids, { status: "uploading", progress: 0 }); + + const form = new FormData(); + batchJobs.forEach((j) => form.append("files", j.file)); + Object.entries(meta).forEach(([k, v]) => { + if (v !== undefined && v !== null) form.append(k, v); + }); + + try { + const { data } = await api.post("/admin/assets/batch", form, { + onUploadProgress: (evt) => { + const pct = evt.total ? Math.round((evt.loaded / evt.total) * 100) : 0; + patchJobs(ids, { progress: pct }); + }, + }); + + const results = data?.data?.results ?? []; + results.forEach((result, idx) => { + const job = batchJobs[idx]; + if (!job) return; + patchJobs([job.id], result.success + ? { status: "uploaded", progress: 100, asset: result.data, error: null } + : { status: "failed", progress: 100, error: result.message || "Upload failed." }); + }); + + } catch (err) { + // No sonner toast() here — the floating widget (both corners + // would collide, see UploadProgressToast) already surfaces this + // via each job's failed status. + const message = err?.response?.data?.message ?? "Upload failed."; + patchJobs(ids, { status: "failed", progress: 100, error: message }); + } finally { + onSettledRef.current.get(batchId)?.(); + onSettledRef.current.delete(batchId); + } + }, [patchJobs]); + + // files: File[]; meta: { is_public, storage_provider, createdBy } + const addBatch = useCallback((files, meta, { onSettled } = {}) => { + const batchId = nanoid(); + const newJobs = files.map((file) => { + const { ok, reason } = validateAssetFile(file); + return { + id: nanoid(), + batchId, + file, + name: file.name, + size: file.size, + mime: file.type, + status: ok ? "queued" : "invalid", + progress: ok ? 0 : 100, + error: ok ? null : reason, + asset: null, + meta, + }; + }); + + setJobs((prev) => [...prev, ...newJobs]); + + const uploadable = newJobs.filter((j) => j.status === "queued"); + if (onSettled) { + if (uploadable.length) onSettledRef.current.set(batchId, onSettled); + else onSettled(); + } + if (uploadable.length) runBatch(batchId, uploadable, meta); + + return batchId; + }, [runBatch]); + + // ─── Retry a single failed job ────────────────────────────────────────── + const retryJob = useCallback(async (jobId) => { + let target = null; + setJobs((prev) => prev.map((j) => { + if (j.id !== jobId) return j; + target = j; + return { ...j, status: "uploading", progress: 0, error: null }; + })); + if (!target) return; + + const form = new FormData(); + form.append("files", target.file); + Object.entries(target.meta).forEach(([k, v]) => { + if (v !== undefined && v !== null) form.append(k, v); + }); + // Bulk display_name derivation only applies when >1 file — a lone + // retry should keep whatever name the file originally resolved to. + if (!target.meta.display_name) form.append("display_name", baseNameOf(target.name)); + + try { + const { data } = await api.post("/admin/assets/batch", form, { + onUploadProgress: (evt) => { + const pct = evt.total ? Math.round((evt.loaded / evt.total) * 100) : 0; + patchJobs([jobId], { progress: pct }); + }, + }); + const result = data?.data?.results?.[0]; + patchJobs([jobId], result?.success + ? { status: "uploaded", progress: 100, asset: result.data, error: null } + : { status: "failed", progress: 100, error: result?.message || "Upload failed." }); + } catch (err) { + const message = err?.response?.data?.message ?? "Upload failed."; + patchJobs([jobId], { status: "failed", progress: 100, error: message }); + } + }, [patchJobs]); + + const removeJob = useCallback((jobId) => { + setJobs((prev) => prev.filter((j) => j.id !== jobId || j.status === "uploading")); + }, []); + + const clearFinished = useCallback(() => { + setJobs((prev) => prev.filter((j) => j.status === "uploading" || j.status === "queued")); + }, []); + + return ( + + {children} + + ); +} diff --git a/src/contexts/provider/AdminProvider.jsx b/src/contexts/provider/AdminProvider.jsx index ffbd07e..ee92c14 100644 --- a/src/contexts/provider/AdminProvider.jsx +++ b/src/contexts/provider/AdminProvider.jsx @@ -1,5 +1,6 @@ // ─── AdminProvider.jsx ───────────────────────────────────────────────────────── import { AssetsProvider } from "../AdminAssetsContext"; +import { UploadQueueProvider } from "../UploadQueueContext"; import { AdminDashboardProvider } from "../AdminDashboardContext" import { UserProvider } from "../AdminUserContext"; import { UserGroupProvider } from "../AdminUserGroupContext"; @@ -20,6 +21,7 @@ export const AdminProvider = ({ children }) => { + @@ -41,6 +43,7 @@ export const AdminProvider = ({ children }) => { + diff --git a/src/data/adminDashboard.data.jsx b/src/data/adminDashboard.data.jsx index bf51735..2f93090 100644 --- a/src/data/adminDashboard.data.jsx +++ b/src/data/adminDashboard.data.jsx @@ -4,7 +4,7 @@ import { Users, UserCheck, UserMinus, ShieldCheck, - Archive, FolderOpen, Layers, + Archive, Layers, } from "lucide-react"; // ─── Users ─────────────────────────────────────────────────────────────────── @@ -20,9 +20,7 @@ export const USER_STAT_MAP = { // ─── User Groups ────────────────────────────────────────────────────────────── export const GROUP_STAT_MAP = { - total: { label: "Total Groups", icon: }, - active: { label: "Active", icon: }, - inactive: { label: "Inactive", icon: }, - archived: { label: "Archived", icon: }, - empty: { label: "Empty Groups", icon: }, + total: { label: "Total Groups", icon: }, + active: { label: "Active", icon: }, + inactive: { label: "Inactive", icon: }, }; \ No newline at end of file diff --git a/src/modules/admin/components/user_groups/GroupTable.jsx b/src/modules/admin/components/user_groups/GroupTable.jsx index 7b6a3b1..bb81da4 100644 --- a/src/modules/admin/components/user_groups/GroupTable.jsx +++ b/src/modules/admin/components/user_groups/GroupTable.jsx @@ -94,14 +94,15 @@ export default function GroupTable() { fetchGroupsDashboard(); }; - // ─── Attach filterId + filterValue to each stat ─────────────────────────── - // "archived" and "empty" have no direct column to filter on in this table + // ─── Attach filterId/filterValue (or onClick) to each stat ──────────────── + // "inactive" groups are effectively archived groups (deactivateGroup sets + // is_active=false and soft-deletes in the same action), so route straight + // to the archived groups page instead of toggling an in-table filter. const dashboardStats = (groupsDashboard?.stats ?? []).map((s) => ({ ...s, - filterId: s.key === "archived" || s.key === "empty" || s.key === "total" ? null : "is_active", - filterValue: s.key === "active" ? ["true"] - : s.key === "inactive" ? ["false"] - : null, + filterId: s.key === "active" ? "is_active" : null, + filterValue: s.key === "active" ? ["true"] : null, + onClick: s.key === "inactive" ? () => navigate("/admin/groups/archived") : undefined, })); // ─── top_groups bar has no filterable column in this table ──────────────── diff --git a/src/modules/admin/config/assets/toolbar.config.jsx b/src/modules/admin/config/assets/toolbar.config.jsx index 0bf4b2c..07bc600 100644 --- a/src/modules/admin/config/assets/toolbar.config.jsx +++ b/src/modules/admin/config/assets/toolbar.config.jsx @@ -1,5 +1,5 @@ // ─── config/toolbar.config.jsx ──────────────────────────────────────────────── -import { RefreshCw, Download, Plus, Archive } from "lucide-react"; +import { RefreshCw, Download, Plus, Archive, UploadCloud } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; /** @@ -47,6 +47,15 @@ export function buildToolbarActions({ fetchAssets, pagination, exportConfig, nav className: "text-primary-foreground", onClick: () => navigate("add"), }, + { + key: "bulk-upload", + type: "button", + icon: , + label: "Bulk Upload", + variant: "secondary", + className: "border border-border", + onClick: () => navigate("add/bulk"), + }, { key: "archived-users", type: "button", diff --git a/src/modules/admin/layouts/AdminLayout.jsx b/src/modules/admin/layouts/AdminLayout.jsx index 0af611e..98af361 100644 --- a/src/modules/admin/layouts/AdminLayout.jsx +++ b/src/modules/admin/layouts/AdminLayout.jsx @@ -23,6 +23,7 @@ import { cn } from "@/lib/utils" import UserMenu from "@/components/generic/UserMenu" import NotificationBell from "@/components/generic/NotificationBell" import AdminStickyAnnouncementBar from "@/components/generic/AdminStickyAnnouncementBar" +import UploadProgressToast from "@/components/generic/UploadProgressToast" import { ROLE_CONFIG } from "@/data/profile.data" const AdminLayout = () => { @@ -88,6 +89,7 @@ const AdminLayout = () => {
+
diff --git a/src/modules/admin/pages/assets/AddAsset.jsx b/src/modules/admin/pages/assets/AddAsset.jsx index 9b1adef..56be841 100644 --- a/src/modules/admin/pages/assets/AddAsset.jsx +++ b/src/modules/admin/pages/assets/AddAsset.jsx @@ -1,11 +1,11 @@ // modules/admin/pages/assets/AddAsset.jsx import { useState, useRef } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useForm, Controller } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react"; +import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react"; import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAuth } from "@/contexts/AuthContext"; @@ -230,6 +230,12 @@ export default function AddAsset() { error={errors._file?.message} /> + + Uploading multiple files? Use Bulk Upload + {/* ── Thumbnail (video required / audio optional) ── */} diff --git a/src/modules/admin/pages/assets/AddAssetsBulk.jsx b/src/modules/admin/pages/assets/AddAssetsBulk.jsx new file mode 100644 index 0000000..a9a038a --- /dev/null +++ b/src/modules/admin/pages/assets/AddAssetsBulk.jsx @@ -0,0 +1,222 @@ +// modules/admin/pages/assets/AddAssetsBulk.jsx + +import { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + ArrowLeft, UploadCloud, X, RotateCcw, FileVideo, FileText, Image, FileAudio, + CheckCircle2, XCircle, AlertTriangle, Loader2, Trash2, +} from "lucide-react"; + +import { useAssets } from "@/contexts/AdminAssetsContext"; +import { useUploadQueue } from "@/contexts/UploadQueueContext"; +import { useAuth } from "@/contexts/AuthContext"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +// ─── File type icon ─────────────────────────────────────────────────────────── + +function FileTypeIcon({ mime = "" }) { + if (mime.startsWith("video/")) return ; + if (mime.startsWith("image/")) return ; + if (mime.startsWith("audio/")) return ; + return ; +} + +function formatSize(bytes = 0) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// ─── Drop zone ──────────────────────────────────────────────────────────────── + +function DropZone({ onFiles }) { + const inputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + + return ( +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + if (e.dataTransfer.files?.length) onFiles([...e.dataTransfer.files]); + }} + onClick={() => inputRef.current?.click()} + className={[ + "rounded-lg border-2 border-dashed cursor-pointer transition-colors", + dragOver ? "border-primary/60 bg-primary/5" : "border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20", + ].join(" ")} + > + { + if (e.target.files?.length) onFiles([...e.target.files]); + e.target.value = ""; + }} + /> +
+ +

+ Drag & drop or browse to upload +
+ Images, videos, audio, documents — multiple files allowed +

+
+
+ ); +} + +// ─── File row ───────────────────────────────────────────────────────────────── + +function FileRow({ job, onRetry, onRemove }) { + const isUploading = job.status === "uploading" || job.status === "queued"; + const isFailed = job.status === "failed" || job.status === "invalid"; + const isUploaded = job.status === "uploaded"; + + const barClass = isUploaded ? "[&>div]:bg-green-500" : isFailed ? "[&>div]:bg-destructive" : ""; + + return ( +
+ +
+
+

{job.name}

+ {formatSize(job.size)} +
+ +
+ {isUploading && <>{job.status === "queued" ? "Queued" : "Uploading…"}} + {isUploaded && <>Uploaded} + {job.status === "failed" && <>Upload failed} + {job.status === "invalid" && <>Rejected} +
+ {isFailed && job.error &&

{job.error}

} +
+
+ {job.status === "failed" && ( + + )} + +
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function AddAssetsBulk() { + const navigate = useNavigate(); + const { fetchAssets } = useAssets(); + const { jobs, addBatch, retryJob, removeJob, clearFinished } = useUploadQueue(); + const { user } = useAuth(); + + const [isPublic, setIsPublic] = useState("false"); + const [storageProvider, setStorageProvider] = useState("s3"); + + const total = jobs.length; + const done = jobs.filter((j) => j.status === "uploaded").length; + const failed = jobs.filter((j) => j.status === "failed" || j.status === "invalid").length; + + const handleFiles = (files) => { + addBatch(files, { + is_public: isPublic === "true", + storage_provider: storageProvider, + createdBy: user?.user_id, + }, { + onSettled: () => fetchAssets({ force: true }), + }); + }; + + return ( +
+ + {/* ── Header ── */} +
+ +
+

Uploading Assets

+

+ {total ? `${total} file${total === 1 ? "" : "s"} · ${done} done · ${failed} failed` : "Upload one or more files to the asset library."} +

+
+
+ + {/* ── Batch settings ── */} +
+
+ + +
+
+ + +
+

+ Applies to files added below. Video thumbnails and per-asset details can be edited afterward. +

+
+ + {/* ── Drop zone ── */} + + + {/* ── File list ── */} + {total > 0 && ( +
+
+ Files + {(done > 0 || failed > 0) && ( + + )} +
+ {jobs.map((job) => ( + + ))} +
+ )} + + {/* ── Actions ── */} +
+ +
+
+ ); +} diff --git a/src/modules/admin/routes/AdminRoutes.jsx b/src/modules/admin/routes/AdminRoutes.jsx index 2ea5c35..5646b60 100644 --- a/src/modules/admin/routes/AdminRoutes.jsx +++ b/src/modules/admin/routes/AdminRoutes.jsx @@ -21,6 +21,7 @@ import ArchivedGroupList from '../pages/user_groups/ArchivedGroupList' // Assets import AddAsset from "../pages/assets/AddAsset"; +import AddAssetsBulk from "../pages/assets/AddAssetsBulk"; import ArchivedAssets from "../pages/assets/ArchivedAssets"; import ViewImageAsset from "../pages/assets/ViewImageAsset"; import ViewVideoAsset from "../pages/assets/ViewVideoAsset"; @@ -171,6 +172,7 @@ export const AdminRoutes = { children: [ { index: true, element: }, { path: 'add', element: }, + { path: 'add/bulk', element: }, { path: 'archived', element: }, { path: 'view/image/:assetId', element: }, { path: 'view/video/:assetId', element: }, diff --git a/src/utils/assetUpload.util.js b/src/utils/assetUpload.util.js new file mode 100644 index 0000000..7d726a3 --- /dev/null +++ b/src/utils/assetUpload.util.js @@ -0,0 +1,54 @@ +// utils/assetUpload.util.js +// +// Client-side gate for the Add Asset multi-file drop zone. Mirrors what the +// backend actually knows how to classify (resolveFileType() in +// assets.controller.js: image/video/audio by MIME prefix, everything else +// under application/* or text/* becomes "document") but rejects generic +// binaries up front instead of letting them 500 on the server. + +const ALLOWED_DOCUMENT_MIME_TYPES = new Set([ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/zip", + "application/x-zip-compressed", + "application/json", + "application/rtf", + "text/plain", + "text/csv", + "text/markdown", +]); + +const ALLOWED_DOCUMENT_EXTENSIONS = new Set([ + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", + "zip", "json", "rtf", "txt", "csv", "md", +]); + +export function fileExtension(filename = "") { + const dot = filename.lastIndexOf("."); + return dot > 0 ? filename.slice(dot + 1).toLowerCase() : ""; +} + +// { ok: true } or { ok: false, reason } +export function validateAssetFile(file) { + const mime = file.type || ""; + + if (mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/")) { + return { ok: true }; + } + + const ext = fileExtension(file.name); + if (ALLOWED_DOCUMENT_MIME_TYPES.has(mime) || ALLOWED_DOCUMENT_EXTENSIONS.has(ext)) { + return { ok: true }; + } + + const label = ext ? `.${ext}` : (mime || "unknown"); + return { + ok: false, + reason: `Unsupported file type "${label}". Allowed formats: images, video, audio, documents.`, + }; +}