mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user