// modules/admin/pages/assets/AddAsset.jsx import { useState, useRef } from "react"; 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 { toast } from "sonner"; import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react"; import { useAssets } from "@/contexts/AdminAssetsContext"; import { useUploadQueue } from "@/contexts/UploadQueueContext"; import { useAuth } from "@/contexts/AuthContext"; import { MAX_ASSET_FILE_SIZE_SINGLE, MAX_ASSET_FILE_SIZE_SINGLE_LABEL } from "@/utils/assetUpload.util"; import { formatFileSize } from "@/utils/format.util"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; // ─── Derive file_type from MIME type ────────────────────────────────────────── function resolveFileType(mimeType = "") { if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("audio/")) return "audio"; if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document"; return "document"; } // ─── Schema ─────────────────────────────────────────────────────────────────── const schema = z.object({ is_public: z.enum(["true", "false"]), storage_provider: z.enum(["chibisafe", "local", "s3"]), }); // ─── File type icon ─────────────────────────────────────────────────────────── function FileTypeIcon({ mimeType = "" }) { if (mimeType.startsWith("video/")) return ; if (mimeType.startsWith("image/")) return ; if (mimeType.startsWith("audio/")) return ; return ; } // ─── Drop zone ──────────────────────────────────────────────────────────────── function DropZone({ label, accept, file, onFile, onClear, error }) { const inputRef = useRef(null); return (
e.preventDefault()} onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onFile(f); }} onClick={() => !file && inputRef.current?.click()} className={[ "rounded-lg border-2 border-dashed transition-colors cursor-pointer", error ? "border-destructive/60 bg-destructive/5" : "", file ? "border-border bg-muted/30 cursor-default" : "", !file && !error ? "border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20" : "", ].join(" ")} > { if (e.target.files[0]) onFile(e.target.files[0]); }} /> {file ? (

{file.name}

{formatFileSize(file.size)} · {file.type}

) : (

Drag & drop or browse to upload
{label}

)}
); } // ─── Field error ────────────────────────────────────────────────────────────── function FieldError({ message }) { if (!message) return null; return

{message}

; } // ─── Page ───────────────────────────────────────────────────────────────────── export default function AddAsset() { const navigate = useNavigate(); const { fetchAssets } = useAssets(); const { addBatch } = useUploadQueue(); const { user } = useAuth(); const fileRef = useRef(null); const thumbnailRef = useRef(null); const [thumbKey, setThumbKey] = useState(0); const { control, handleSubmit, setValue, watch, setError, clearErrors, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema), defaultValues: { is_public: "false", storage_provider: "s3", // ← changed from "chibisafe" }, }); const file = watch("_file"); const isVideo = file?.type?.startsWith("video/"); const isAudio = file?.type?.startsWith("audio/"); // setValue("_file", ...) doesn't mark isDirty (no shouldDirty), so a // picked-but-unsubmitted file wouldn't otherwise be caught by the guard. const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || !!file); // ── Auto-derive file_type from MIME ─────────────────────────────────────── const fileType = file ? resolveFileType(file.type) : null; const setFile = (f) => { if (f.size > MAX_ASSET_FILE_SIZE_SINGLE) { setError("_file", { message: `File exceeds the ${MAX_ASSET_FILE_SIZE_SINGLE_LABEL} size limit.` }); return; } fileRef.current = f; setValue("_file", f); clearErrors("_file"); }; const setThumbnail = (f) => { thumbnailRef.current = f; setValue("_thumbnail", f); clearErrors("_thumbnail"); }; // Fire-and-forget: the job goes on the same global UploadQueueContext the // Bulk flow uses, so it keeps uploading in the background no matter where // you navigate to next — a single sonner toast.promise() tracks it // through loading -> success/error instead of a dedicated widget. const onSubmit = (data) => { if (!fileRef.current) { setError("_file", { message: "A file is required." }); return; } const fileName = fileRef.current.name; const uploadPromise = new Promise((resolve, reject) => { addBatch( [{ file: fileRef.current, thumbnail: thumbnailRef.current ?? undefined }], { is_public: data.is_public === "true", storage_provider: data.storage_provider, createdBy: user?.user_id, }, { source: "single", onSettled: ([result]) => { fetchAssets({ force: true }); if (result?.status === "uploaded") resolve(result); else reject(new Error(result?.error || "Upload failed.")); }, } ); }); toast.promise(uploadPromise, { loading: `Uploading ${fileName}…`, success: (result) => `${result.name} uploaded successfully.`, error: (err) => err.message || "Upload failed.", }); bypassOnce(); navigate("/admin/assets"); }; return (
{/* ── Header ── */}

Add File

Upload a new file to the file library.

{/* ── File ── */}
{ fileRef.current = null; setValue("_file", null); clearErrors("_file"); }} error={errors._file?.message} /> Uploading multiple files? Use Bulk Upload
{/* ── Thumbnail (optional for both video and audio) ── */} {(isVideo || isAudio) && (
{ thumbnailRef.current = null; setValue("_thumbnail", null); clearErrors("_thumbnail"); setThumbKey((k) => k + 1); }} error={errors._thumbnail?.message} />
)} {/* ── File Type · Access · Storage (one row) ── */}
{/* File Type — auto-derived, disabled */}

Detected from file

{/* Access */}
( )} />
{/* Storage Provider — TEMPORARY: locked to S3 (see zrok tunnel note above; revert after VPS migration) */}
{/* ── Actions ── */}
{unsavedChangesDialog}
); }