Add timelapse upload and display to image edit page
This commit is contained in:
168
src/components/artworks/single/ArtworkTimelapse.tsx
Normal file
168
src/components/artworks/single/ArtworkTimelapse.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
confirmArtworkTimelapseUpload,
|
||||
createArtworkTimelapseUpload,
|
||||
deleteArtworkTimelapse,
|
||||
setArtworkTimelapseEnabled,
|
||||
} from "@/actions/artworks/timelapse";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Timelapse = {
|
||||
enabled: boolean;
|
||||
s3Key: string;
|
||||
fileName: string | null;
|
||||
mimeType: string | null;
|
||||
sizeBytes: number | null;
|
||||
};
|
||||
|
||||
function fmtBytes(bytes: number) {
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`;
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export default function ArtworkTimelapse({
|
||||
artworkId,
|
||||
timelapse,
|
||||
}: {
|
||||
artworkId: string;
|
||||
timelapse: Timelapse | null;
|
||||
}) {
|
||||
const [isBusy, startTransition] = React.useTransition();
|
||||
|
||||
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
// 1) create presigned url
|
||||
const presign = await createArtworkTimelapseUpload({
|
||||
artworkId,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
sizeBytes: file.size,
|
||||
});
|
||||
|
||||
// 2) upload to S3 directly
|
||||
const putRes = await fetch(presign.uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": presign.mimeType,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!putRes.ok) {
|
||||
throw new Error(`Upload failed (${putRes.status})`);
|
||||
}
|
||||
|
||||
// 3) persist in DB
|
||||
await confirmArtworkTimelapseUpload({
|
||||
artworkId,
|
||||
s3Key: presign.s3Key,
|
||||
fileName: presign.fileName,
|
||||
mimeType: presign.mimeType,
|
||||
sizeBytes: presign.sizeBytes,
|
||||
});
|
||||
|
||||
toast.success("Timelapse uploaded");
|
||||
e.target.value = "";
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Upload failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onToggleEnabled(next: boolean) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await setArtworkTimelapseEnabled({ artworkId, enabled: next });
|
||||
toast.success("Timelapse updated");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Update failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteArtworkTimelapse({ artworkId });
|
||||
toast.success("Timelapse deleted");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Delete failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const src = timelapse?.s3Key ? `/api/image/${timelapse.s3Key}` : null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-medium">Timelapse</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Upload a timelapse video for this artwork (stored in S3).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="timelapse-enabled" className="text-sm">
|
||||
Display
|
||||
</Label>
|
||||
<Switch
|
||||
id="timelapse-enabled"
|
||||
disabled={!timelapse || isBusy}
|
||||
checked={timelapse?.enabled ?? false}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-sm">
|
||||
{timelapse ? (
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<span className="text-muted-foreground">File:</span>{" "}
|
||||
{timelapse.fileName ?? timelapse.s3Key}
|
||||
</div>
|
||||
{typeof timelapse.sizeBytes === "number" ? (
|
||||
<div className="text-muted-foreground">Size: {fmtBytes(timelapse.sizeBytes)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">No timelapse uploaded yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
onChange={onPickFile}
|
||||
disabled={isBusy}
|
||||
className="block text-sm"
|
||||
/>
|
||||
{timelapse ? (
|
||||
<Button variant="destructive" onClick={onDelete} disabled={isBusy}>
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{src ? (
|
||||
<video className="w-full rounded-md border" controls preload="metadata" src={src} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user