Add better image styles

This commit is contained in:
2025-06-29 01:56:19 +02:00
parent ee79f75668
commit ac3b19a1f2
16 changed files with 378 additions and 104 deletions

View File

@ -201,6 +201,8 @@ model ImageVariant {
sizeBytes Int? sizeBytes Int?
image Image @relation(fields: [imageId], references: [id]) image Image @relation(fields: [imageId], references: [id])
@@unique([imageId, type])
} }
model ColorPalette { model ColorPalette {

View File

@ -1,31 +0,0 @@
import { Breadcrumbs } from "@/components/global/Breadcrumbs";
import { generateBreadcrumbsFromPath } from "@/utils/generateBreadcrumbs";
import { notFound } from "next/navigation";
import { ReactNode } from "react";
export default async function SubLayout({
children
}: {
children: ReactNode
}) {
const segments = getSegmentsFromUrl();
const breadcrumbs = await generateBreadcrumbsFromPath(segments);
if (!breadcrumbs) return notFound();
return (
<div className="px-4 py-6">
<Breadcrumbs items={breadcrumbs} />
{children}
</div>
);
}
function getSegmentsFromUrl(): string[] {
const path = decodeURIComponent(
// remove leading slash
(typeof window !== "undefined" ? window.location.pathname : "")
.replace(/^\//, "")
);
return path.split("/").filter(Boolean);
}

View File

@ -0,0 +1,110 @@
import ArtistInfoBox from "@/components/images/ArtistInfoBox";
import GlowingImageWithToggle from "@/components/images/GlowingImageWithToggle";
import ImageMetadataBox from "@/components/images/ImageMetadataBox";
import prisma from "@/lib/prisma";
import Link from "next/link";
export default async function ImagePage({ params }: { params: { gallerySlug: string, albumSlug: string, imageId: string } }) {
const { imageId } = await params;
const image = await prisma.image.findUnique({
where: {
id: imageId
},
include: {
artist: { include: { socials: true } },
colors: { include: { color: true } },
extractColors: { include: { extract: true } },
palettes: { include: { palette: { include: { items: true } } } },
album: true,
categories: true,
tags: true,
variants: true,
metadata: true,
stats: true
}
})
if (!image) return <div>Image not found</div>
const resizedVariant = image.variants.find(v => v.type === "resized");
return (
<div>
{image && (
<div className="flex flex-col items-center">
<div>
<h1 className="text-2xl font-bold mb-4 pb-8">{image.imageName}</h1>
</div>
{resizedVariant &&
<Link href={`/raw/${image.id}`} passHref>
<GlowingImageWithToggle
alt={image.imageName}
variant={resizedVariant}
colors={image.colors}
src={`/api/image/${resizedVariant.s3Key}`}
nsfw={image.nsfw}
/>
</Link>
}
<section className="py-8 flex flex-col gap-4">
{image.artist && <ArtistInfoBox artist={image.artist} />}
<div className="rounded-lg border p-6 space-y-4">
<h2 className="text-xl font-semibold mb-2">Image Metadata</h2>
<p><strong>Name:</strong> {image.imageName}</p>
{image.altText && <p><strong>Alt Text:</strong> {image.altText}</p>}
{image.description && <p><strong>Description:</strong> {image.description}</p>}
{/* <p><strong>NSFW:</strong> {image.nsfw ? "Yes" : "No"}</p>
<p><strong>Upload Date:</strong> {new Date(image.uploadDate).toLocaleDateString()}</p>
{image.source && <p><strong>Source:</strong> {image.source}</p>}
{image.fileType && <p><strong>File Type:</strong> {image.fileType}</p>}
{image.fileSize && <p><strong>Size:</strong> {(image.fileSize / 1024).toFixed(1)} KB</p>}
{image.creationDate && (
<p><strong>Created:</strong> {new Date(image.creationDate).toLocaleDateString()}</p>
)}
{image.creationYear && image.creationMonth && (
<p><strong>Creation:</strong> {image.creationMonth}/{image.creationYear}</p>
)} */}
{/* {image.metadata && (
<>
<h3 className="text-lg font-medium mt-4">Metadata</h3>
<p><strong>Width:</strong> {image.metadata.width}px</p>
<p><strong>Height:</strong> {image.metadata.height}px</p>
<p><strong>Format:</strong> {image.metadata.format}</p>
<p><strong>Color Space:</strong> {image.metadata.space}</p>
<p><strong>Channels:</strong> {image.metadata.channels}</p>
{image.metadata.bitsPerSample && (
<p><strong>Bits Per Sample:</strong> {image.metadata.bitsPerSample}</p>
)}
{image.metadata.hasAlpha !== null && (
<p><strong>Has Alpha:</strong> {image.metadata.hasAlpha ? "Yes" : "No"}</p>
)}
</>
)} */}
{/* {image.stats && (
<>
<h3 className="text-lg font-medium mt-4">Statistics</h3>
<p><strong>Entropy:</strong> {image.stats.entropy.toFixed(2)}</p>
<p><strong>Sharpness:</strong> {image.stats.sharpness.toFixed(2)}</p>
<p><strong>Dominant Color:</strong> rgb({image.stats.dominantR}, {image.stats.dominantG}, {image.stats.dominantB})</p>
<p><strong>Is Opaque:</strong> {image.stats.isOpaque ? "Yes" : "No"}</p>
</>
)} */}
</div>
<ImageMetadataBox
album={image.album}
categories={image.categories}
tags={image.tags}
/>
</section>
</div>
)}
</div >
);
}

View File

@ -0,0 +1,57 @@
import CategoryImageList from "@/components/categories/CategoryImageList";
import prisma from "@/lib/prisma";
export default async function CategoriesSinglePage({ params }: { params: { id: string } }) {
const { id } = await params;
const category = await prisma.category.findUnique({
where: {
id,
},
include: {
images: {
select: {
id: true,
imageName: true,
fileKey: true,
nsfw: true,
altText: true,
album: {
select: {
slug: true,
gallery: {
select: {
slug: true,
},
},
}
}
}
}
}
});
if (!category) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 space-y-12">
<h1 className="text-4xl font-bold tracking-tight">Category not found</h1>
</div>
)
}
return (
<div className="max-w-7xl mx-auto px-4 py-12 space-y-12">
{category && (
<>
<section className="text-center space-y-4">
<h1 className="text-4xl font-bold tracking-tight">Category: {category.name}</h1>
<p className="text-lg text-muted-foreground">
{category.description}
</p>
</section>
<CategoryImageList images={category.images} />
</>
)}
</div>
);
}

View File

@ -4,7 +4,7 @@ import { ThemeProvider } from "@/components/global/ThemeProvider";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import "./globals.css"; import "../globals.css";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -21,7 +21,7 @@ export const metadata: Metadata = {
description: "Generated by create next app", description: "Generated by create next app",
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;

42
src/app/(raw)/layout.tsx Normal file
View File

@ -0,0 +1,42 @@
import { ThemeProvider } from "@/components/global/ThemeProvider";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "../globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,51 @@
// app/raw/[id]/page.tsx
import RawCloseButton from "@/components/raw/RawCloseButton";
import prisma from "@/lib/prisma";
import NextImage from "next/image";
import { notFound } from "next/navigation";
export default async function RawImagePage({ params }: { params: { imageId: string } }) {
const { imageId } = params;
const image = await prisma.image.findUnique({
where: { id: imageId },
include: {
album: { include: { gallery: true } },
variants: true,
palettes: {
include: { palette: { include: { items: true } } },
},
},
});
if (!image) return notFound();
const variant = image.variants.find(v => v.type === "watermarked");
if (!variant) return notFound();
const palette = image.palettes.find(p => p.type === "primary")?.palette;
const hexColors = palette?.items.map(item => item.hex).filter(Boolean) as string[];
const backgroundStyle =
hexColors && hexColors.length > 1
? { backgroundImage: `linear-gradient(to bottom, ${hexColors.join(", ")})` }
: { backgroundColor: "#0f0f0f" };
const targetHref = `/${image.album?.gallery?.slug}/${image.album?.slug}/${image.id}`;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={backgroundStyle}
>
<RawCloseButton targetHref={targetHref} />
<NextImage
src={`/api/image/${variant.s3Key}`}
alt={image.altText || image.imageName}
width={variant.width}
height={variant.height}
className="object-contain rounded-lg max-h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)]"
/>
</div>
);
}

View File

@ -1,60 +0,0 @@
import ArtistInfoBox from "@/components/images/ArtistInfoBox";
import GlowingImageWithToggle from "@/components/images/GlowingImageWithToggle";
import ImageMetadataBox from "@/components/images/ImageMetadataBox";
import prisma from "@/lib/prisma";
export default async function ImagePage({ params }: { params: { gallerySlug: string, albumSlug: string, imageId: string } }) {
const { imageId } = await params;
const image = await prisma.image.findUnique({
where: {
id: imageId
},
include: {
artist: { include: { socials: true } },
colors: { include: { color: true } },
extractColors: { include: { extract: true } },
palettes: { include: { palette: { include: { items: true } } } },
album: true,
categories: true,
tags: true,
variants: true,
metadata: true,
stats: true
}
})
if (!image) return <div>Image not found</div>
const resizedVariant = image.variants.find(v => v.type === "resized");
return (
<div>
{image && (
<div className="flex flex-col items-center">
<div>
<h1 className="text-2xl font-bold mb-4 pb-8">{image.imageName}</h1>
</div>
{resizedVariant &&
<GlowingImageWithToggle
alt={image.imageName}
variant={resizedVariant}
colors={image.colors}
src={`/api/image/${resizedVariant.s3Key}`}
/>
}
<div className="py-8 flex flex-col gap-4">
{image.artist && <ArtistInfoBox artist={image.artist} />}
<ImageMetadataBox
album={image.album}
categories={image.categories}
tags={image.tags}
/>
</div>
</div>
)}
</div >
);
}

View File

@ -1,4 +1,5 @@
import { Image } from "@/generated/prisma"; import { Image } from "@/generated/prisma";
import clsx from "clsx";
import NextImage from "next/image"; import NextImage from "next/image";
import Link from "next/link"; import Link from "next/link";
@ -17,7 +18,10 @@ export default function ImageList({ images, gallerySlug, albumSlug }: { images:
src={`/api/image/thumbnails/${img.fileKey}.webp`} src={`/api/image/thumbnails/${img.fileKey}.webp`}
alt={img.imageName} alt={img.imageName}
fill fill
className="object-cover" className={clsx(
" object-cover",
img.nsfw && "blur-md scale-105"
)}
/> />
) : ( ) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm"> <div className="flex items-center justify-center h-full text-muted-foreground text-sm">

View File

@ -0,0 +1,48 @@
import { Album, Gallery, Image } from "@/generated/prisma";
import NextImage from "next/image";
import Link from "next/link";
type ImagesWithItems = (Pick<Image, "id" | "fileKey" | "imageName" | "altText" | "nsfw"> & {
album?: (Pick<Album, "slug"> & {
gallery?: Pick<Gallery, "slug"> | null
}) | null
})[]
export default function CategoryImageList({ images }: { images: ImagesWithItems }) {
return (
<section>
<h1 className="text-2xl font-bold mb-4">Images</h1>
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{images ? images.map((img) => {
const gallerySlug = img.album?.gallery?.slug;
const albumSlug = img.album?.slug;
if (!gallerySlug || !albumSlug || !img.id) return null;
return (
<Link href={`/${gallerySlug}/${albumSlug}/${img.id}`} key={img.id}>
<div className="group rounded-lg border overflow-hidden hover:shadow-lg transition-shadow bg-background">
<div className="relative aspect-[4/3] w-full bg-muted items-center justify-center">
{img.fileKey ? (
<NextImage
src={`/api/image/thumbnails/${img.fileKey}.webp`}
alt={img.altText || ""}
fill
className="object-cover"
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No cover image
</div>
)}
</div>
<div className="p-4">
<h2 className="text-lg font-semibold truncate text-center">{img.imageName}</h2>
</div>
</div>
</Link>
)
}) : "There are no images here!"}
</div>
</section>
);
}

View File

@ -14,8 +14,9 @@ type Props = {
alt: string, alt: string,
variant: ImageVariant, variant: ImageVariant,
colors: Colors[], colors: Colors[],
src: string src: string,
className?: string revealed?: boolean,
className?: string,
animate?: boolean animate?: boolean
} }
@ -25,6 +26,7 @@ export default function GlowingImageBorder({
colors, colors,
src, src,
className, className,
revealed = true,
animate = true, animate = true,
}: Props) { }: Props) {
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
@ -37,11 +39,11 @@ export default function GlowingImageBorder({
const getColor = (type: string) => const getColor = (type: string) =>
colors.find((c) => c.type === type)?.color.hex colors.find((c) => c.type === type)?.color.hex
const vibrantLight = getColor("vibrant") || "#ff5ec4" const vibrantLight = getColor("Vibrant") || "#ff5ec4"
const mutedLight = getColor("muted") || "#5ecaff" const mutedLight = getColor("Muted") || "#5ecaff"
const darkVibrant = getColor("darkVibrant") || "#fc03a1" const darkVibrant = getColor("DarkVibrant") || "#fc03a1"
const darkMuted = getColor("darkMuted") || "#035efc" const darkMuted = getColor("DarkMuted") || "#035efc"
const vibrant = resolvedTheme === "dark" ? darkVibrant : vibrantLight const vibrant = resolvedTheme === "dark" ? darkVibrant : vibrantLight
const muted = resolvedTheme === "dark" ? darkMuted : mutedLight const muted = resolvedTheme === "dark" ? darkMuted : mutedLight
@ -62,13 +64,16 @@ export default function GlowingImageBorder({
} as React.CSSProperties } as React.CSSProperties
} }
> >
<div className="relative z-10 rounded-xl overflow-hidden"> <div className="relative z-10 rounded-xl overflow-hidden group">
<NextImage <NextImage
src={src} src={src}
alt={alt || "Image"} alt={alt || "Image"}
width={variant.width} width={variant.width}
height={variant.height} height={variant.height}
className="rounded-xl" className={clsx(
"rounded-xl transition duration-300",
!revealed && "blur-md scale-105"
)}
/> />
</div> </div>
</div> </div>

View File

@ -11,10 +11,12 @@ type Props = {
colors: (ImageColor & { color: Color })[]; colors: (ImageColor & { color: Color })[];
alt: string; alt: string;
src: string; src: string;
}; nsfw: boolean;
}
export default function GlowingImageWithToggle({ variant, colors, alt, src }: Props) { export default function GlowingImageWithToggle({ variant, colors, alt, src, nsfw }: Props) {
const [animate, setAnimate] = useState(true); const [animate, setAnimate] = useState(true);
const [revealed, setRevealed] = useState(!nsfw)
return ( return (
<div className="relative w-full max-w-fit"> <div className="relative w-full max-w-fit">
@ -24,6 +26,7 @@ export default function GlowingImageWithToggle({ variant, colors, alt, src }: Pr
colors={colors} colors={colors}
src={src} src={src}
animate={animate} animate={animate}
revealed={revealed}
/> />
<div className="flex flex-col items-center gap-4 pt-8"> <div className="flex flex-col items-center gap-4 pt-8">
@ -32,6 +35,15 @@ export default function GlowingImageWithToggle({ variant, colors, alt, src }: Pr
<Label htmlFor="animate">Animate glow</Label> <Label htmlFor="animate">Animate glow</Label>
</div> </div>
</div> </div>
{nsfw && (
<div className="flex flex-col items-center gap-4 pt-8">
<div className="flex items-center gap-2">
<Switch id="animate" checked={revealed} onCheckedChange={setRevealed} />
<Label htmlFor="animate">Reveal NSFW</Label>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,34 @@
"use client";
import { X } from "lucide-react"; // react-lucide close icon
import { useRouter } from "next/navigation";
import { useEffect } from "react";
type RawCloseButtonProps = {
targetHref: string;
};
export default function RawCloseButton({ targetHref }: RawCloseButtonProps) {
const router = useRouter();
// ESC key listener
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
router.push(targetHref);
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [targetHref, router]);
return (
<button
onClick={() => router.push(targetHref)}
className="absolute top-4 right-4 z-50 rounded-md bg-background/80 p-2 hover:bg-background/60 transition"
title="Close full view (ESC)"
>
<X className="w-6 h-6" />
</button>
);
}