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.`,
+ };
+}