Add better image styles
This commit is contained in:
@ -201,6 +201,8 @@ model ImageVariant {
|
||||
sizeBytes Int?
|
||||
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
|
||||
@@unique([imageId, type])
|
||||
}
|
||||
|
||||
model ColorPalette {
|
||||
|
@ -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);
|
||||
}
|
110
src/app/(normal)/[gallerySlug]/[albumSlug]/[imageId]/page.tsx
Normal file
110
src/app/(normal)/[gallerySlug]/[albumSlug]/[imageId]/page.tsx
Normal 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 >
|
||||
);
|
||||
}
|
57
src/app/(normal)/categories/[id]/page.tsx
Normal file
57
src/app/(normal)/categories/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -4,7 +4,7 @@ import { ThemeProvider } from "@/components/global/ThemeProvider";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -21,7 +21,7 @@ export const metadata: Metadata = {
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
42
src/app/(raw)/layout.tsx
Normal file
42
src/app/(raw)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
51
src/app/(raw)/raw/[imageId]/page.tsx
Normal file
51
src/app/(raw)/raw/[imageId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 >
|
||||
);
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { Image } from "@/generated/prisma";
|
||||
import clsx from "clsx";
|
||||
import NextImage from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
@ -17,7 +18,10 @@ export default function ImageList({ images, gallerySlug, albumSlug }: { images:
|
||||
src={`/api/image/thumbnails/${img.fileKey}.webp`}
|
||||
alt={img.imageName}
|
||||
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">
|
||||
|
48
src/components/categories/CategoryImageList.tsx
Normal file
48
src/components/categories/CategoryImageList.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -14,8 +14,9 @@ type Props = {
|
||||
alt: string,
|
||||
variant: ImageVariant,
|
||||
colors: Colors[],
|
||||
src: string
|
||||
className?: string
|
||||
src: string,
|
||||
revealed?: boolean,
|
||||
className?: string,
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
@ -25,6 +26,7 @@ export default function GlowingImageBorder({
|
||||
colors,
|
||||
src,
|
||||
className,
|
||||
revealed = true,
|
||||
animate = true,
|
||||
}: Props) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
@ -37,11 +39,11 @@ export default function GlowingImageBorder({
|
||||
const getColor = (type: string) =>
|
||||
colors.find((c) => c.type === type)?.color.hex
|
||||
|
||||
const vibrantLight = getColor("vibrant") || "#ff5ec4"
|
||||
const mutedLight = getColor("muted") || "#5ecaff"
|
||||
const vibrantLight = getColor("Vibrant") || "#ff5ec4"
|
||||
const mutedLight = getColor("Muted") || "#5ecaff"
|
||||
|
||||
const darkVibrant = getColor("darkVibrant") || "#fc03a1"
|
||||
const darkMuted = getColor("darkMuted") || "#035efc"
|
||||
const darkVibrant = getColor("DarkVibrant") || "#fc03a1"
|
||||
const darkMuted = getColor("DarkMuted") || "#035efc"
|
||||
|
||||
const vibrant = resolvedTheme === "dark" ? darkVibrant : vibrantLight
|
||||
const muted = resolvedTheme === "dark" ? darkMuted : mutedLight
|
||||
@ -62,13 +64,16 @@ export default function GlowingImageBorder({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="relative z-10 rounded-xl overflow-hidden">
|
||||
<div className="relative z-10 rounded-xl overflow-hidden group">
|
||||
<NextImage
|
||||
src={src}
|
||||
alt={alt || "Image"}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="rounded-xl"
|
||||
className={clsx(
|
||||
"rounded-xl transition duration-300",
|
||||
!revealed && "blur-md scale-105"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -11,10 +11,12 @@ type Props = {
|
||||
colors: (ImageColor & { color: Color })[];
|
||||
alt: 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 [revealed, setRevealed] = useState(!nsfw)
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-fit">
|
||||
@ -24,6 +26,7 @@ export default function GlowingImageWithToggle({ variant, colors, alt, src }: Pr
|
||||
colors={colors}
|
||||
src={src}
|
||||
animate={animate}
|
||||
revealed={revealed}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
34
src/components/raw/RawCloseButton.tsx
Normal file
34
src/components/raw/RawCloseButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user