keep trying

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 14:49:23 +08:00
parent 69ca47e00a
commit e9b99f8243
12 changed files with 576 additions and 16 deletions
@@ -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" : ""}
/>
);
@@ -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 (
<div className="fixed bottom-4 right-4 z-[60] w-80 rounded-lg border bg-card shadow-lg overflow-hidden">
<div
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer select-none"
onClick={() => setExpanded((v) => !v)}
>
{active.length > 0
? <Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
: failedCount
? <XCircle className="h-4 w-4 text-destructive shrink-0" />
: <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />}
<span className="text-sm font-medium flex-1 truncate">{label}</span>
<Button type="button" variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />}
</Button>
<Button
type="button" variant="ghost" size="icon" className="h-6 w-6"
disabled={active.length > 0}
onClick={(e) => { e.stopPropagation(); setDismissed(true); }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{active.length > 0 && <Progress value={aggregatePct} className="h-1 rounded-none" />}
{expanded && (
<div className="max-h-64 overflow-y-auto border-t divide-y">
{jobs.map((job) => (
<div key={job.id} className="flex items-center gap-2 px-3 py-2 text-xs">
{job.status === "uploaded" && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
{FAILED.has(job.status) && <XCircle className="h-3.5 w-3.5 text-destructive shrink-0" />}
{ACTIVE.has(job.status) && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground shrink-0" />}
<span className="flex-1 truncate">{job.name}</span>
{job.status === "failed" && (
<button type="button" className="text-primary hover:underline shrink-0" onClick={() => retryJob(job.id)}>
Retry
</button>
)}
</div>
))}
<button
type="button"
className="w-full text-xs text-primary hover:underline py-2"
onClick={() => navigate("/admin/assets/add")}
>
View upload details
</button>
</div>
)}
</div>
);
}
+160
View File
@@ -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 (
<UploadQueueContext.Provider value={{ jobs, addBatch, retryJob, removeJob, clearFinished }}>
{children}
</UploadQueueContext.Provider>
);
}
+3
View File
@@ -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 }) => {
<AdminDashboardProvider>
<ProfileProvider apiBase="/admin">
<AssetsProvider>
<UploadQueueProvider>
<AdvertisementsProvider>
<NotificationBroadcastsProvider>
<UserProvider>
@@ -41,6 +43,7 @@ export const AdminProvider = ({ children }) => {
</UserProvider>
</NotificationBroadcastsProvider>
</AdvertisementsProvider>
</UploadQueueProvider>
</AssetsProvider>
</ProfileProvider>
</AdminDashboardProvider>
+4 -6
View File
@@ -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: <Layers className="size-5 text-muted-foreground" /> },
active: { label: "Active", icon: <UserCheck className="size-5 text-green-500" /> },
inactive: { label: "Inactive", icon: <UserMinus className="size-5 text-red-400" /> },
archived: { label: "Archived", icon: <Archive className="size-5 text-muted-foreground" /> },
empty: { label: "Empty Groups", icon: <FolderOpen className="size-5 text-amber-500" /> },
total: { label: "Total Groups", icon: <Layers className="size-5 text-muted-foreground" /> },
active: { label: "Active", icon: <UserCheck className="size-5 text-green-500" /> },
inactive: { label: "Inactive", icon: <UserMinus className="size-5 text-red-400" /> },
};
@@ -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 ────────────────
@@ -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: <UploadCloud className="h-3.5 w-3.5" />,
label: "Bulk Upload",
variant: "secondary",
className: "border border-border",
onClick: () => navigate("add/bulk"),
},
{
key: "archived-users",
type: "button",
@@ -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 = () => {
<div id="main-body" className="bg-background flex-1 flex flex-col" style={{ paddingTop: 'var(--navbar-h)' }}>
<Outlet />
<UploadProgressToast />
<Toaster position="bottom-right" richColors />
</div>
</AdminProvider>
+8 -2
View File
@@ -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}
/>
<FieldError message={errors._file?.message} />
<Link
to="/admin/assets/add/bulk"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Uploading multiple files? Use Bulk Upload <ArrowRight className="h-3 w-3" />
</Link>
</div>
{/* ── Thumbnail (video required / audio optional) ── */}
@@ -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 <FileVideo className="h-8 w-8 text-blue-400 shrink-0" />;
if (mime.startsWith("image/")) return <Image className="h-8 w-8 text-green-400 shrink-0" />;
if (mime.startsWith("audio/")) return <FileAudio className="h-8 w-8 text-purple-400 shrink-0" />;
return <FileText className="h-8 w-8 text-orange-400 shrink-0" />;
}
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 (
<div
onDragOver={(e) => { 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(" ")}
>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) onFiles([...e.target.files]);
e.target.value = "";
}}
/>
<div className="flex flex-col items-center justify-center gap-2 py-10 px-4">
<UploadCloud className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground text-center">
Drag & drop or <span className="text-primary font-medium">browse</span> to upload
<br />
<span className="text-xs">Images, videos, audio, documents — multiple files allowed</span>
</p>
</div>
</div>
);
}
// ─── 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 (
<div className="flex items-center gap-3 p-4 border rounded-lg bg-card">
<FileTypeIcon mime={job.mime} />
<div className="flex-1 min-w-0 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium truncate">{job.name}</p>
<span className="text-xs text-muted-foreground shrink-0">{formatSize(job.size)}</span>
</div>
<Progress value={job.progress} className={barClass} />
<div className="flex items-center gap-1.5 text-xs">
{isUploading && <><Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /><span className="text-muted-foreground">{job.status === "queued" ? "Queued" : "Uploading…"}</span></>}
{isUploaded && <><CheckCircle2 className="h-3 w-3 text-green-500" /><span className="text-green-600">Uploaded</span></>}
{job.status === "failed" && <><XCircle className="h-3 w-3 text-destructive" /><span className="text-destructive">Upload failed</span></>}
{job.status === "invalid" && <><AlertTriangle className="h-3 w-3 text-destructive" /><span className="text-destructive">Rejected</span></>}
</div>
{isFailed && job.error && <p className="text-xs text-destructive">{job.error}</p>}
</div>
<div className="flex items-center gap-1 shrink-0">
{job.status === "failed" && (
<Button type="button" variant="ghost" size="sm" onClick={() => onRetry(job.id)}>
<RotateCcw className="h-3.5 w-3.5 mr-1" /> Retry
</Button>
)}
<Button
type="button" variant="ghost" size="icon"
disabled={job.status === "uploading"}
onClick={() => onRemove(job.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
}
// ─── 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 (
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Uploading Assets</h1>
<p className="text-sm text-muted-foreground">
{total ? `${total} file${total === 1 ? "" : "s"} · ${done} done · ${failed} failed` : "Upload one or more files to the asset library."}
</p>
</div>
</div>
{/* ── Batch settings ── */}
<div className="rounded-lg border bg-card p-4 flex items-center gap-4">
<div className="flex-1 space-y-1.5">
<label className="text-sm font-medium">Access</label>
<Select value={isPublic} onValueChange={setIsPublic}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="false">Private</SelectItem>
<SelectItem value="true">Public</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-1.5">
<label className="text-sm font-medium">Storage</label>
<Select value={storageProvider} onValueChange={setStorageProvider}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="chibisafe">Chibisafe</SelectItem>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground max-w-[12rem] self-end pb-2">
Applies to files added below. Video thumbnails and per-asset details can be edited afterward.
</p>
</div>
{/* ── Drop zone ── */}
<DropZone onFiles={handleFiles} />
{/* ── File list ── */}
{total > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Files</span>
{(done > 0 || failed > 0) && (
<Button type="button" variant="ghost" size="sm" onClick={clearFinished}>
<Trash2 className="h-3.5 w-3.5 mr-1" /> Clear finished
</Button>
)}
</div>
{jobs.map((job) => (
<FileRow key={job.id} job={job} onRetry={retryJob} onRemove={removeJob} />
))}
</div>
)}
{/* ── Actions ── */}
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
Done
</Button>
</div>
</div>
);
}
+2
View File
@@ -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: <AssetList /> },
{ path: 'add', element: <AddAsset /> },
{ path: 'add/bulk', element: <AddAssetsBulk /> },
{ path: 'archived', element: <ArchivedAssets /> },
{ path: 'view/image/:assetId', element: <ViewImageAsset /> },
{ path: 'view/video/:assetId', element: <ViewVideoAsset /> },
+54
View File
@@ -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.`,
};
}