mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
224 lines
11 KiB
React
224 lines
11 KiB
React
// 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 { formatFileSize } from "@/utils/format.util";
|
|
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" />;
|
|
}
|
|
|
|
// ─── 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">{formatFileSize(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: allJobs, addBatch, retryJob, removeJob, clearFinished } = useUploadQueue();
|
|
const { user } = useAuth();
|
|
|
|
// Add File (single) shares this same queue so its upload survives
|
|
// navigation too — but it's a separate flow the user never asked to
|
|
// batch, so it shouldn't appear here looking like part of a bulk run.
|
|
const jobs = allJobs.filter((j) => j.source === "bulk");
|
|
|
|
const [isPublic, setIsPublic] = useState("false");
|
|
// TEMPORARY: locked to S3 (see zrok tunnel note; revert after VPS migration)
|
|
const storageProvider = "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 }),
|
|
source: "bulk",
|
|
});
|
|
};
|
|
|
|
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("/admin/assets")}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Uploading Files</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 file 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>
|
|
{/* TEMPORARY: locked to S3 (see zrok tunnel note; revert after VPS migration) */}
|
|
<Select value={storageProvider} disabled>
|
|
<SelectTrigger className="disabled:opacity-60 disabled:cursor-not-allowed"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<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("bulk")}>
|
|
<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("/admin/assets")}>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|