Files
starr-philproperties/src/modules/admin/pages/assets/AddAsset.jsx
T

389 lines
17 KiB
React

// 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 <FileVideo className="h-10 w-10 text-blue-400" />;
if (mimeType.startsWith("image/")) return <Image className="h-10 w-10 text-green-400" />;
if (mimeType.startsWith("audio/")) return <FileAudio className="h-10 w-10 text-purple-400" />;
return <FileText className="h-10 w-10 text-orange-400" />;
}
// ─── Drop zone ────────────────────────────────────────────────────────────────
function DropZone({ label, accept, file, onFile, onClear, error }) {
const inputRef = useRef(null);
return (
<div
onDragOver={(e) => 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(" ")}
>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => { if (e.target.files[0]) onFile(e.target.files[0]); }}
/>
{file ? (
<div className="flex items-center gap-3 p-4">
<FileTypeIcon mimeType={file.type} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} · {file.type}
</p>
</div>
<Button
type="button" variant="ghost" size="icon"
onClick={(e) => { e.stopPropagation(); onClear(); }}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<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">{label}</span>
</p>
</div>
)}
</div>
);
}
// ─── Field error ──────────────────────────────────────────────────────────────
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
// ─── 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 (
<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">Add Asset</h1>
<p className="text-sm text-muted-foreground">Upload a new file to the asset library.</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-5">
{/* ── File ── */}
<div className="space-y-1.5">
<Label>File <span className="text-destructive">*</span></Label>
<DropZone
label="Images, videos, audio, documents"
accept="image/*,video/*,audio/*,application/*,text/*"
file={fileRef.current}
onFile={setFile}
onClear={() => {
fileRef.current = null;
setValue("_file", null);
setValue("display_name", "");
clearErrors("_file");
}}
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 (optional for both video and audio) ── */}
{(isVideo || isAudio) && (
<div className="space-y-1.5">
<Label>
Thumbnail{" "}
<span className="text-muted-foreground text-xs">
(optional — {isVideo ? "video preview" : "album / cover art"})
</span>
</Label>
<DropZone
key={thumbKey}
label="JPEG, PNG"
accept="image/*"
file={thumbnailRef.current}
onFile={setThumbnail}
onClear={() => {
thumbnailRef.current = null;
setValue("_thumbnail", null);
clearErrors("_thumbnail");
setThumbKey((k) => k + 1);
}}
error={errors._thumbnail?.message}
/>
<FieldError message={errors._thumbnail?.message} />
</div>
)}
{/* ── Display Name ── */}
<div className="space-y-1.5">
<Label htmlFor="display_name">
Display Name <span className="text-destructive">*</span>
</Label>
<Input
id="display_name"
placeholder="Friendly name for this asset"
{...register("display_name")}
/>
<FieldError message={errors.display_name?.message} />
</div>
{/* ── Description ── */}
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Optional description"
rows={3}
{...register("description")}
/>
</div>
{/* ── File Type · Access · Storage (one row) ── */}
<div className="grid grid-cols-3 gap-4">
{/* File Type — auto-derived, disabled */}
<div className="space-y-1.5">
<Label>File Type</Label>
<Select value={fileType ?? ""} disabled>
<SelectTrigger className="disabled:opacity-60 disabled:cursor-not-allowed">
<SelectValue placeholder="Auto-detected" />
</SelectTrigger>
<SelectContent>
<SelectItem value="avatar">Avatar</SelectItem>
<SelectItem value="image">Image</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="document">Document</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Detected from file</p>
</div>
{/* Access */}
<div className="space-y-1.5">
<Label>Access</Label>
<Controller
name="is_public"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="false">Private</SelectItem>
<SelectItem value="true">Public</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
{/* Storage Provider — TEMPORARY: locked to S3 (see zrok tunnel note above; revert after VPS migration) */}
<div className="space-y-1.5">
<Label>Storage</Label>
<Select value="s3" disabled>
<SelectTrigger className="disabled:opacity-60 disabled:cursor-not-allowed"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="s3">S3</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* ── Upload progress (real — see AdminAssetsContext.uploadAsset) ── */}
{progress && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{progressLabel}</span>
<span>{progress.pct ?? 0}%</span>
</div>
<Progress value={progress.pct ?? 0} />
</div>
)}
{/* ── Actions ── */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate("/admin/assets")}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Upload Asset
</Button>
</div>
</form>
{unsavedChangesDialog}
</div>
);
}