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,23 @@
-- CreateTable
CREATE TABLE "ArtworkTimelapse" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"artworkId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"s3Key" TEXT NOT NULL,
"fileName" TEXT,
"mimeType" TEXT,
"sizeBytes" INTEGER,
CONSTRAINT "ArtworkTimelapse_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkTimelapse_artworkId_key" ON "ArtworkTimelapse"("artworkId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkTimelapse_s3Key_key" ON "ArtworkTimelapse"("s3Key");
-- AddForeignKey
ALTER TABLE "ArtworkTimelapse" ADD CONSTRAINT "ArtworkTimelapse_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -48,6 +48,7 @@ model Artwork {
gallery Gallery? @relation(fields: [galleryId], references: [id]) gallery Gallery? @relation(fields: [galleryId], references: [id])
metadata ArtworkMetadata? metadata ArtworkMetadata?
timelapse ArtworkTimelapse?
albums Album[] albums Album[]
categories ArtCategory[] categories ArtCategory[]
@ -195,6 +196,22 @@ model ArtworkMetadata {
artwork Artwork @relation(fields: [artworkId], references: [id]) artwork Artwork @relation(fields: [artworkId], references: [id])
} }
model ArtworkTimelapse {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkId String @unique
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
enabled Boolean @default(false)
s3Key String @unique
fileName String?
mimeType String?
sizeBytes Int?
}
model FileData { model FileData {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -9,6 +9,7 @@ export async function deleteArtwork(artworkId: string) {
where: { id: artworkId }, where: { id: artworkId },
include: { include: {
variants: true, variants: true,
timelapse: true,
colors: true, colors: true,
metadata: true, metadata: true,
tags: true, tags: true,
@ -32,6 +33,20 @@ export async function deleteArtwork(artworkId: string) {
} }
} }
// Delete timelapse S3 object (if present)
if (artwork.timelapse?.s3Key) {
try {
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: artwork.timelapse.s3Key,
})
);
} catch (err) {
console.warn(`Failed to delete timelapse S3 object: ${artwork.timelapse.s3Key}. ${err}`);
}
}
// Step 1: Delete join entries // Step 1: Delete join entries
await prisma.artworkColor.deleteMany({ where: { artworkId } }); await prisma.artworkColor.deleteMany({ where: { artworkId } });
@ -48,6 +63,9 @@ export async function deleteArtwork(artworkId: string) {
// Delete variants // Delete variants
await prisma.fileVariant.deleteMany({ where: { artworkId } }); await prisma.fileVariant.deleteMany({ where: { artworkId } });
// Delete timelapse DB row (relation also cascades, but be explicit)
await prisma.artworkTimelapse.deleteMany({ where: { artworkId } });
// Delete metadata // Delete metadata
await prisma.artworkMetadata.deleteMany({ where: { artworkId } }); await prisma.artworkMetadata.deleteMany({ where: { artworkId } });

View File

@ -16,7 +16,8 @@ export async function getSingleArtwork(id: string) {
colors: { include: { color: true } }, colors: { include: { color: true } },
// sortContexts: true, // sortContexts: true,
tags: true, tags: true,
variants: true variants: true,
timelapse: true
} }
}) })
} }

View File

@ -0,0 +1,136 @@
"use server";
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { revalidatePath } from "next/cache";
import { z } from "zod/v4";
import { v4 as uuidv4 } from "uuid";
const createUploadSchema = z.object({
artworkId: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
});
/**
* Creates a presigned PUT url so the client can upload large timelapse videos directly to S3
* (avoids Next.js body-size/proxy limits).
*/
export async function createArtworkTimelapseUpload(input: z.infer<typeof createUploadSchema>) {
const { artworkId, fileName, mimeType, sizeBytes } = createUploadSchema.parse(input);
const ext = fileName.includes(".") ? fileName.split(".").pop() : undefined;
const suffix = ext ? `.${ext}` : "";
// Keep previous uploads unique so caching/CDNs won't bite you.
const fileId = uuidv4();
const s3Key = `timelapse/${artworkId}/${fileId}${suffix}`;
const cmd = new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: s3Key,
ContentType: mimeType,
// If you want size enforcement at S3 level, you'd do that via policy; presigned PUT doesn't strictly enforce.
});
const uploadUrl = await getSignedUrl(s3, cmd, { expiresIn: 60 * 5 });
return { uploadUrl, s3Key, fileName, mimeType, sizeBytes };
}
const confirmSchema = z.object({
artworkId: z.string().min(1),
s3Key: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
});
/** Persist uploaded timelapse metadata in DB (upsert by artworkId). */
export async function confirmArtworkTimelapseUpload(input: z.infer<typeof confirmSchema>) {
const { artworkId, s3Key, fileName, mimeType, sizeBytes } = confirmSchema.parse(input);
// If an old timelapse exists, delete the old object so you don't leak storage.
const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } });
if (existing?.s3Key && existing.s3Key !== s3Key) {
try {
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: existing.s3Key,
})
);
} catch (err) {
// don't fail the request if cleanup fails
console.warn("Failed to delete previous timelapse object", existing.s3Key, err);
}
}
await prisma.artworkTimelapse.upsert({
where: { artworkId },
create: {
artworkId,
s3Key,
fileName,
mimeType,
sizeBytes,
enabled: true,
},
update: {
s3Key,
fileName,
mimeType,
sizeBytes,
},
});
revalidatePath(`/artworks/${artworkId}`);
return { ok: true };
}
const enabledSchema = z.object({
artworkId: z.string().min(1),
enabled: z.boolean(),
});
export async function setArtworkTimelapseEnabled(input: z.infer<typeof enabledSchema>) {
const { artworkId, enabled } = enabledSchema.parse(input);
await prisma.artworkTimelapse.update({
where: { artworkId },
data: { enabled },
});
revalidatePath(`/artworks/${artworkId}`);
return { ok: true };
}
const deleteSchema = z.object({
artworkId: z.string().min(1),
});
export async function deleteArtworkTimelapse(input: z.infer<typeof deleteSchema>) {
const { artworkId } = deleteSchema.parse(input);
const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } });
if (!existing) return { ok: true };
try {
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: existing.s3Key,
})
);
} catch (err) {
console.warn("Failed to delete timelapse object", existing.s3Key, err);
}
await prisma.artworkTimelapse.delete({ where: { artworkId } });
revalidatePath(`/artworks/${artworkId}`);
return { ok: true };
}

View File

@ -3,6 +3,7 @@ import { getCategoriesWithTags } from "@/actions/categories/getCategories";
import { getTags } from "@/actions/tags/getTags"; import { getTags } from "@/actions/tags/getTags";
import ArtworkColors from "@/components/artworks/single/ArtworkColors"; import ArtworkColors from "@/components/artworks/single/ArtworkColors";
import ArtworkDetails from "@/components/artworks/single/ArtworkDetails"; import ArtworkDetails from "@/components/artworks/single/ArtworkDetails";
import ArtworkTimelapse from "@/components/artworks/single/ArtworkTimelapse";
import ArtworkVariants from "@/components/artworks/single/ArtworkVariants"; import ArtworkVariants from "@/components/artworks/single/ArtworkVariants";
import DeleteArtworkButton from "@/components/artworks/single/DeleteArtworkButton"; import DeleteArtworkButton from "@/components/artworks/single/DeleteArtworkButton";
import EditArtworkForm from "@/components/artworks/single/EditArtworkForm"; import EditArtworkForm from "@/components/artworks/single/EditArtworkForm";
@ -31,6 +32,9 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
</div> </div>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div>
{item && <ArtworkTimelapse artworkId={item.id} timelapse={item.timelapse} />}
</div>
<div> <div>
{item && <ArtworkColors colors={item.colors} artworkId={item.id} />} {item && <ArtworkColors colors={item.colors} artworkId={item.id} />}
</div> </div>

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

View File

@ -1,4 +1,4 @@
import { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{
include: { include: {
@ -10,6 +10,7 @@ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{
colors: true; colors: true;
tags: true; tags: true;
variants: true; variants: true;
timelapse: true;
}; };
}>; }>;