Add timelapse to single image

This commit is contained in:
2026-01-30 22:17:27 +01:00
parent 2222d24863
commit 2402891e9d
3 changed files with 116 additions and 3 deletions

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())
@ -353,14 +370,14 @@ model CommissionTypeCustomInput {
model CommissionRequest { model CommissionRequest {
id String @id @default(cuid()) id String @id @default(cuid())
index Int @default(autoincrement())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sortIndex Int @default(0)
customerName String customerName String
customerEmail String customerEmail String
message String message String
status String @default("NEW") // NEW | REVIEWING | ACCEPTED | REJECTED | SPAM status String @default("NEW")
customerSocials String? customerSocials String?
ipAddress String? ipAddress String?
@ -422,6 +439,11 @@ model User {
sessions Session[] sessions Session[]
accounts Account[] accounts Account[]
role String @default("user")
banned Boolean? @default(false)
banReason String?
banExpires DateTime?
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
} }
@ -437,6 +459,8 @@ model Session {
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
impersonatedBy String?
@@unique([token]) @@unique([token])
@@index([userId]) @@index([userId])
@@map("session") @@map("session")

View File

@ -1,7 +1,10 @@
import ArtworkMetaCard from "@/components/artworks/ArtworkMetaCard"; import ArtworkMetaCard from "@/components/artworks/ArtworkMetaCard";
import ArtworkTimelapseViewer from "@/components/artworks/ArtworkTimelapseViewer";
import { ContextBackButton } from "@/components/artworks/ContextBackButton"; import { ContextBackButton } from "@/components/artworks/ContextBackButton";
import { Button } from "@/components/ui/button";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PlayCircle } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@ -19,7 +22,8 @@ export default async function SingleArtworkPage({ params }: { params: { id: stri
categories: true, categories: true,
colors: { include: { color: true } }, colors: { include: { color: true } },
tags: true, tags: true,
variants: true variants: true,
timelapse: true,
} }
}) })
@ -62,6 +66,20 @@ export default async function SingleArtworkPage({ params }: { params: { id: stri
</Link> </Link>
</div> </div>
</div> </div>
{artwork.timelapse?.enabled ? (
<div className="flex justify-center">
<ArtworkTimelapseViewer
timelapse={artwork.timelapse}
artworkName={artwork.name}
trigger={
<Button size="lg" className="gap-2">
<PlayCircle className="h-5 w-5" />
Watch timelapse
</Button>
}
/>
</div>
) : null}
<div <div
className="rounded-lg" className="rounded-lg"
style={{ style={{

View File

@ -0,0 +1,71 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import * as React from "react";
type Timelapse = {
s3Key: string;
fileName: string | null;
mimeType: string | null;
sizeBytes: number | null;
};
export default function ArtworkTimelapseViewer({
timelapse,
artworkName,
trigger,
}: {
timelapse: Timelapse;
artworkName?: string | null;
trigger: React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
// IMPORTANT:
// This assumes your existing `/api/image/[...key]` can stream arbitrary S3 keys.
// If your route expects a different format, adjust this in one place.
const src = `/api/image/${encodeURI(timelapse.s3Key)}`;
// Minimal empty captions track (satisfies jsx-a11y/media-has-caption)
const emptyVtt = "data:text/vtt;charset=utf-8,WEBVTT%0A%0A";
const title = artworkName ? `Timelapse — ${artworkName}` : "Timelapse";
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{/* Only render video when open (prevents unnecessary network / CPU). */}
{open ? (
<div className="space-y-2">
<video
className="w-full rounded-md border bg-black"
controls
preload="metadata"
playsInline
>
<source src={src} type={timelapse.mimeType ?? "video/mp4"} />
<track kind="captions" src={emptyVtt} srcLang="en" label="Captions" default />
Your browser does not support the video tag.
</video>
<div className="text-xs text-muted-foreground">
{timelapse.fileName ? timelapse.fileName : timelapse.s3Key}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}