mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
345 lines
16 KiB
React
345 lines
16 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 { 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 <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 { 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 (
|
|
<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 File</h1>
|
|
<p className="text-sm text-muted-foreground">Upload a new file to the file 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);
|
|
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>
|
|
)}
|
|
|
|
{/* ── 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>
|
|
|
|
{/* ── Actions ── */}
|
|
<div className="flex justify-end gap-3">
|
|
<Button type="button" variant="outline" onClick={() => navigate("/admin/assets")}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit">
|
|
Upload Asset
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
|
|
{unsavedChangesDialog}
|
|
</div>
|
|
);
|
|
} |