// 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 { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react"; import { useAssets } from "@/contexts/AdminAssetsContext"; 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 { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Spinner } from "@/components/ui/spinner"; import { Progress } from "@/components/ui/progress"; 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({ display_name: z.string().min(1, "Display name is required."), description: z.string().optional(), 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 { uploadAsset, loading } = useAssets(); const { user } = useAuth(); const fileRef = useRef(null); const thumbnailRef = useRef(null); const [thumbKey, setThumbKey] = useState(0); const [progress, setProgress] = useState(null); // { phase: 'uploading'|'processing'|'done'|'error', pct } | null const { register, control, handleSubmit, setValue, watch, setError, clearErrors, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema), defaultValues: { display_name: "", description: "", 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); if (!watch("display_name")) setValue("display_name", f.name); clearErrors("_file"); }; const setThumbnail = (f) => { thumbnailRef.current = f; setValue("_thumbnail", f); clearErrors("_thumbnail"); }; const onSubmit = async (data) => { let hasFileError = false; if (!fileRef.current) { setError("_file", { message: "A file is required." }); hasFileError = true; } if (hasFileError) return; setProgress({ phase: "uploading", pct: 0 }); const result = await uploadAsset({ file: fileRef.current, thumbnail: thumbnailRef.current ?? undefined, display_name: data.display_name, description: data.description ?? "", file_type: fileType, is_public: data.is_public === "true", storage_provider: data.storage_provider, createdBy: user?.user_id, onProgress: setProgress, }); setProgress(null); if (result) { bypassOnce(); navigate("/admin/assets"); } }; const progressLabel = { uploading: "Uploading…", processing: "Processing…", done: "Done.", error: "Upload failed.", }[progress?.phase]; return (
{/* ── Header ── */}

Add Asset

Upload a new file to the asset library.

{/* ── File ── */}
{ fileRef.current = null; setValue("_file", null); setValue("display_name", ""); 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} />
)} {/* ── Display Name ── */}
{/* ── Description ── */}