Add timelapse upload and display to image edit page

This commit is contained in:
2026-01-30 22:18:24 +01:00
parent 95c44b7f8e
commit 3ce4c9acfc
8 changed files with 370 additions and 2 deletions

View 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>
);
}