mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
351 lines
15 KiB
React
351 lines
15 KiB
React
// modules/admin/pages/assets/EditAsset.jsx
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { useForm, Controller } from "react-hook-form";
|
|
import { z } from "zod";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from "lucide-react";
|
|
|
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
|
import { formatFileSize } from "@/utils/format.util";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
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 { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
|
|
// ─── 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"]),
|
|
});
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
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" />;
|
|
return <FileText className="h-10 w-10 text-orange-400" />;
|
|
}
|
|
|
|
function FieldError({ message }) {
|
|
if (!message) return null;
|
|
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
|
}
|
|
|
|
|
|
// ─── Thumbnail Drop Zone ──────────────────────────────────────────────────────
|
|
|
|
function ThumbnailDropZone({ currentUrl, newFile, onFile, onClear, error }) {
|
|
const inputRef = useRef(null);
|
|
|
|
const preview = newFile
|
|
? URL.createObjectURL(newFile)
|
|
: currentUrl ?? null;
|
|
|
|
return (
|
|
<div
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onFile(f); }}
|
|
onClick={() => !newFile && inputRef.current?.click()}
|
|
className={[
|
|
"relative rounded-lg border-2 border-dashed transition-colors overflow-hidden",
|
|
error ? "border-destructive/60 bg-destructive/5" : "",
|
|
newFile ? "border-border cursor-default" : "",
|
|
!newFile && !error ? "border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 cursor-pointer" : "",
|
|
].join(" ")}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => { if (e.target.files[0]) onFile(e.target.files[0]); }}
|
|
/>
|
|
|
|
{preview ? (
|
|
<div className="relative">
|
|
<img
|
|
src={preview}
|
|
alt="Thumbnail preview"
|
|
className="w-full object-contain"
|
|
/>
|
|
{/* overlay actions */}
|
|
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
|
Replace
|
|
</Button>
|
|
{newFile && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
|
>
|
|
<X className="h-3.5 w-3.5 mr-1.5" />
|
|
Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{newFile && (
|
|
<Badge className="absolute top-2 right-2 bg-primary text-primary-foreground text-xs">
|
|
New
|
|
</Badge>
|
|
)}
|
|
</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">JPEG, PNG</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function EditAsset() {
|
|
const navigate = useNavigate();
|
|
const { assetId } = useParams();
|
|
const { fetchAsset, updateAsset, loading } = useAssets();
|
|
const { user } = useAuth();
|
|
|
|
const thumbnailRef = useRef(null);
|
|
const [thumbKey, setThumbKey] = useState(0);
|
|
const [initializing, setInitializing] = useState(true);
|
|
const [asset, setAsset] = useState(null); // ← local, always fresh
|
|
|
|
const {
|
|
register,
|
|
control,
|
|
handleSubmit,
|
|
reset,
|
|
formState: { errors, isDirty },
|
|
} = useForm({
|
|
resolver: zodResolver(schema),
|
|
defaultValues: {
|
|
display_name: "",
|
|
description: "",
|
|
is_public: "false",
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
const res = await fetchAsset(assetId);
|
|
const fetched = res?.data?.data ?? null;
|
|
if (!fetched) return;
|
|
setAsset(fetched);
|
|
reset({
|
|
display_name: fetched.display_name ?? "",
|
|
description: fetched.description ?? "",
|
|
is_public: fetched.is_public ? "true" : "false",
|
|
});
|
|
setInitializing(false);
|
|
})();
|
|
}, [assetId]);
|
|
|
|
const isVideo = asset?.file_type === "video";
|
|
const hasThumbnailChange = !!thumbnailRef.current;
|
|
|
|
// asset.file_url is redacted to null for S3-stored assets (see
|
|
// redactS3Url in assets.controller.js) — resolve the real preview src
|
|
// the same way ViewImageAsset.jsx does instead of reading it raw.
|
|
const { src: previewSrc } = useAssetPreviewSrc(asset, { scope: "admin" });
|
|
|
|
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || hasThumbnailChange);
|
|
|
|
const onSubmit = async (data) => {
|
|
const result = await updateAsset(
|
|
assetId,
|
|
{
|
|
display_name: data.display_name,
|
|
description: data.description ?? "",
|
|
is_public: data.is_public === "true",
|
|
updatedBy: user?.user_id,
|
|
...(isVideo && hasThumbnailChange ? { is_thumbnail: true } : {}),
|
|
},
|
|
hasThumbnailChange ? thumbnailRef.current : null,
|
|
);
|
|
|
|
if (!result) return;
|
|
bypassOnce();
|
|
navigate("/admin/assets");
|
|
};
|
|
|
|
if (initializing) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<Spinner className="h-6 w-6" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!asset) {
|
|
return (
|
|
<div className="max-w-2xl mx-auto px-4 py-6">
|
|
<p className="text-sm text-muted-foreground">File not found.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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">Edit File</h1>
|
|
<p className="text-sm text-muted-foreground truncate max-w-sm">
|
|
{asset.original_name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
|
|
|
{/* ── Current file info (read-only) ── */}
|
|
<div className="rounded-lg border bg-muted/30 p-4">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
|
File Info
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<FileTypeIcon mimeType={asset.mime_type} />
|
|
<div className="flex-1 min-w-0 space-y-1">
|
|
<p className="text-sm font-medium truncate">{asset.original_name}</p>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Badge variant="secondary" className="text-xs">{asset.file_type}</Badge>
|
|
<Badge variant="outline" className="text-xs">{asset.extension?.toUpperCase()}</Badge>
|
|
<span className="text-xs text-muted-foreground">{formatFileSize(asset.file_size) ?? "—"}</span>
|
|
{asset.resolution && (
|
|
<span className="text-xs text-muted-foreground">{asset.resolution}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Editable fields ── */}
|
|
<div className="rounded-lg border bg-card p-6 space-y-5">
|
|
|
|
{/* ── Thumbnail (video or image) ── */}
|
|
{(isVideo || asset.file_type === "image" || asset.file_type === "avatar") && (
|
|
<div className="space-y-1.5">
|
|
<Label>
|
|
Thumbnail
|
|
{isVideo && <span className="text-muted-foreground text-xs ml-1">(optional replacement)</span>}
|
|
</Label>
|
|
<ThumbnailDropZone
|
|
key={thumbKey}
|
|
currentUrl={asset.file_type === "video" ? asset.thumbnail_url : previewSrc}
|
|
newFile={thumbnailRef.current}
|
|
onFile={(f) => {
|
|
thumbnailRef.current = f;
|
|
setThumbKey((k) => k + 1);
|
|
}}
|
|
onClear={() => {
|
|
thumbnailRef.current = null;
|
|
setThumbKey((k) => k + 1);
|
|
}}
|
|
error={null}
|
|
/>
|
|
</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 file"
|
|
{...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>
|
|
|
|
{/* ── Access ── */}
|
|
<div className="space-y-1.5 max-w-[200px]">
|
|
<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>
|
|
|
|
</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 || (!isDirty && !hasThumbnailChange)}
|
|
>
|
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
|
Save Changes
|
|
</Button>
|
|
</div>
|
|
|
|
</form>
|
|
|
|
{unsavedChangesDialog}
|
|
</div>
|
|
);
|
|
} |