Initial commit of Vellum, an event photo product for guest uploads, host moderation, and original-quality galleries.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "@/trpc/react";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function GuestGallery({ slug }: { slug: string }) {
|
||||
const gallery = api.event.gallery.useQuery(slug);
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
|
||||
if (gallery.isLoading) {
|
||||
return (
|
||||
<div className="columns-2 gap-3 sm:columns-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} className="mb-3 h-40 w-full break-inside-avoid" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const photos = gallery.data ?? [];
|
||||
const selected = photos.find((photo) => photo.id === active);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<Empty className="border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>No photos in the gallery yet</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Photos will appear here after the event people release the gallery.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="columns-2 gap-2 sm:columns-3 sm:gap-3">
|
||||
{photos.map((photo, index) =>
|
||||
photo.thumbUrl || photo.displayUrl ? (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
className="photo-rise mb-2 block w-full break-inside-avoid overflow-hidden rounded-xl focus-visible:ring-3 focus-visible:ring-ring/50 sm:mb-3"
|
||||
style={{ animationDelay: `${Math.min(index, 12) * 40}ms` }}
|
||||
onClick={() => setActive(photo.id)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photo.thumbUrl ?? photo.displayUrl ?? ""}
|
||||
alt={
|
||||
photo.contributorName
|
||||
? `Photo from ${photo.contributorName}`
|
||||
: "Event photo"
|
||||
}
|
||||
width={photo.width ?? 800}
|
||||
height={photo.height ?? 1000}
|
||||
loading="lazy"
|
||||
className="w-full transition-transform duration-300 ease-out hover:scale-[1.03] motion-reduce:transition-none motion-reduce:hover:scale-100"
|
||||
/>
|
||||
</button>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={Boolean(selected)} onOpenChange={(open) => !open && setActive(null)}>
|
||||
<DialogContent className="overflow-hidden border-none bg-background p-3 sm:max-w-3xl sm:p-4">
|
||||
<DialogHeader className="px-1">
|
||||
<DialogTitle>
|
||||
{selected?.contributorName ?? "Shared by a guest"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Full-size event photo
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selected?.displayUrl || selected?.thumbUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={selected.displayUrl ?? selected.thumbUrl ?? ""}
|
||||
alt=""
|
||||
width={selected.width ?? 1600}
|
||||
height={selected.height ?? 1200}
|
||||
className="max-h-[75dvh] w-full rounded-lg object-contain"
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user