Add timelapse upload and display to image edit page
This commit is contained in:
23
prisma/migrations/20260130204518_artwork_10/migration.sql
Normal file
23
prisma/migrations/20260130204518_artwork_10/migration.sql
Normal 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;
|
||||||
@ -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())
|
||||||
|
|||||||
@ -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 } });
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
136
src/actions/artworks/timelapse.ts
Normal file
136
src/actions/artworks/timelapse.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user