diff --git a/prisma/migrations/20260130204518_artwork_10/migration.sql b/prisma/migrations/20260130204518_artwork_10/migration.sql new file mode 100644 index 0000000..5ca117f --- /dev/null +++ b/prisma/migrations/20260130204518_artwork_10/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9b87d63..57a7609 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,6 +48,7 @@ model Artwork { gallery Gallery? @relation(fields: [galleryId], references: [id]) metadata ArtworkMetadata? + timelapse ArtworkTimelapse? albums Album[] categories ArtCategory[] @@ -195,6 +196,22 @@ model ArtworkMetadata { 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 { id String @id @default(cuid()) createdAt DateTime @default(now()) diff --git a/src/actions/artworks/deleteArtwork.ts b/src/actions/artworks/deleteArtwork.ts index 560cfc5..7858b6b 100644 --- a/src/actions/artworks/deleteArtwork.ts +++ b/src/actions/artworks/deleteArtwork.ts @@ -9,6 +9,7 @@ export async function deleteArtwork(artworkId: string) { where: { id: artworkId }, include: { variants: true, + timelapse: true, colors: true, metadata: 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 await prisma.artworkColor.deleteMany({ where: { artworkId } }); @@ -48,6 +63,9 @@ export async function deleteArtwork(artworkId: string) { // Delete variants await prisma.fileVariant.deleteMany({ where: { artworkId } }); + // Delete timelapse DB row (relation also cascades, but be explicit) + await prisma.artworkTimelapse.deleteMany({ where: { artworkId } }); + // Delete metadata await prisma.artworkMetadata.deleteMany({ where: { artworkId } }); diff --git a/src/actions/artworks/getArtworks.ts b/src/actions/artworks/getArtworks.ts index 5fda70a..48eff3e 100644 --- a/src/actions/artworks/getArtworks.ts +++ b/src/actions/artworks/getArtworks.ts @@ -16,7 +16,8 @@ export async function getSingleArtwork(id: string) { colors: { include: { color: true } }, // sortContexts: true, tags: true, - variants: true + variants: true, + timelapse: true } }) } \ No newline at end of file diff --git a/src/actions/artworks/timelapse.ts b/src/actions/artworks/timelapse.ts new file mode 100644 index 0000000..f1205ad --- /dev/null +++ b/src/actions/artworks/timelapse.ts @@ -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) { + 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) { + 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) { + 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) { + 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 }; +} diff --git a/src/app/(admin)/artworks/[id]/page.tsx b/src/app/(admin)/artworks/[id]/page.tsx index 16a5ef0..d41693d 100644 --- a/src/app/(admin)/artworks/[id]/page.tsx +++ b/src/app/(admin)/artworks/[id]/page.tsx @@ -3,6 +3,7 @@ import { getCategoriesWithTags } from "@/actions/categories/getCategories"; import { getTags } from "@/actions/tags/getTags"; import ArtworkColors from "@/components/artworks/single/ArtworkColors"; import ArtworkDetails from "@/components/artworks/single/ArtworkDetails"; +import ArtworkTimelapse from "@/components/artworks/single/ArtworkTimelapse"; import ArtworkVariants from "@/components/artworks/single/ArtworkVariants"; import DeleteArtworkButton from "@/components/artworks/single/DeleteArtworkButton"; import EditArtworkForm from "@/components/artworks/single/EditArtworkForm"; @@ -31,6 +32,9 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
+
+ {item && } +
{item && }
diff --git a/src/components/artworks/single/ArtworkTimelapse.tsx b/src/components/artworks/single/ArtworkTimelapse.tsx new file mode 100644 index 0000000..c4e152d --- /dev/null +++ b/src/components/artworks/single/ArtworkTimelapse.tsx @@ -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) { + 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 ( +
+
+
+
Timelapse
+
+ Upload a timelapse video for this artwork (stored in S3). +
+
+ +
+ + +
+
+ +
+
+ {timelapse ? ( +
+
+ File:{" "} + {timelapse.fileName ?? timelapse.s3Key} +
+ {typeof timelapse.sizeBytes === "number" ? ( +
Size: {fmtBytes(timelapse.sizeBytes)}
+ ) : null} +
+ ) : ( +
No timelapse uploaded yet.
+ )} +
+ +
+ + {timelapse ? ( + + ) : null} +
+
+ + {src ? ( +
+ ); +} diff --git a/src/types/Artwork.ts b/src/types/Artwork.ts index 78e3e94..16b9dde 100644 --- a/src/types/Artwork.ts +++ b/src/types/Artwork.ts @@ -1,4 +1,4 @@ -import { Prisma } from "@/generated/prisma/client"; +import type { Prisma } from "@/generated/prisma/client"; export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{ include: { @@ -10,6 +10,7 @@ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{ colors: true; tags: true; variants: true; + timelapse: true; }; }>;