This commit is contained in:
rgrgogu
2026-05-14 12:59:31 +08:00
parent 6e98ff0b5d
commit dcf5f43fd5
38 changed files with 2174 additions and 382 deletions
@@ -0,0 +1,345 @@
// 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 { useAuth } from "@/contexts/AuthContext";
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>;
}
function formatBytes(bytes) {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
// ─── Thumbnail Drop Zone ──────────────────────────────────────────────────────
function ThumbnailDropZone({ currentUrl, newFile, onFile, onClear, error }) {
console.log(currentUrl)
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;
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;
navigate(-1);
};
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">Asset 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(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Edit Asset</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">{formatBytes(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 : asset.file_url}
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 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>
{/* ── 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(-1)}
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>
</div>
);
}