Add artists page

This commit is contained in:
2025-06-29 14:22:26 +02:00
parent 1dba1cf093
commit 2e1161b50b
18 changed files with 321 additions and 94 deletions

View File

@ -59,6 +59,8 @@ model Artist {
displayName String
nickname String?
description String?
source String?
socials Social[]
images Image[]
@ -71,7 +73,8 @@ model Social {
handle String
platform String
isPrimary Boolean
isPrimary Boolean @default(false)
isVisible Boolean @default(true)
link String?
@ -136,11 +139,14 @@ model Image {
extractColors ImageExtractColor[]
palettes ImagePalette[]
variants ImageVariant[]
// pixels PixelSummary[]
// theme ThemeSeed[]
albumCover Album[] @relation("AlbumCoverImage")
galleryCover Gallery[] @relation("GalleryCoverImage")
categories Category[] @relation("ImageCategories")
// colors ImageColor[] @relation("ImageToImageColor")
tags Tag[] @relation("ImageTags")
// palettes ColorPalette[] @relation("ImagePalettes")
}
model ImageMetadata {
@ -214,6 +220,7 @@ model ColorPalette {
items ColorPaletteItem[]
images ImagePalette[]
// images Image[] @relation("ImagePalettes")
}
model ColorPaletteItem {
@ -243,6 +250,7 @@ model ExtractColor {
hue Float?
saturation Float?
// images Image[] @relation("ImageToExtractColor")
images ImageExtractColor[]
}
@ -262,6 +270,30 @@ model Color {
images ImageColor[]
}
// model ThemeSeed {
// id String @id @default(cuid())
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// imageId String
// seedHex String
// image Image @relation(fields: [imageId], references: [id])
// }
// model PixelSummary {
// id String @id @default(cuid())
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// imageId String
// channels Int
// height Int
// width Int
// image Image @relation(fields: [imageId], references: [id])
// }
model ImagePalette {
id String @id @default(cuid())
createdAt DateTime @default(now())

View File

@ -0,0 +1,40 @@
import ArtistImageGrid from "@/components/artists/ArtistImageGrid";
import ArtistInfoBox from "@/components/artists/ArtistInfoBox";
import prisma from "@/lib/prisma";
export default async function ArtistPage({ params }: { params: { artistSlug: string } }) {
const { artistSlug } = await params;
const artist = await prisma.artist.findUnique({
where: {
slug: artistSlug
},
include: {
images: {
include: {
variants: true,
album: { include: { gallery: true } }
}
},
socials: { where: { isVisible: true }, orderBy: { isPrimary: "desc" } }
}
})
if (!artist) {
throw new Error("Artist not found")
}
return (
<div className="max-w-7xl mx-auto px-4 py-12 space-y-12 flex flex-col items-center">
<section className="text-center space-y-4">
<h1 className="text-4xl font-bold tracking-tight">Artist: {artist.nickname ? artist.nickname : artist.displayName}</h1>
<p className="text-lg text-muted-foreground">
{artist.description ? artist.description : ""}
</p>
</section>
<ArtistInfoBox artist={artist} />
<h2 className="text-2xl font-bold tracking-tight">Images</h2>
<ArtistImageGrid images={artist.images} />
</div>
);
}

View File

@ -35,7 +35,6 @@ export default async function ImagePage({ params }: { params: { gallerySlug: str
<div>
<h1 className="text-2xl font-bold mb-4 pb-8">{image.imageName}</h1>
</div>
{resizedVariant &&
<GlowingImageWithToggle
alt={image.imageName}
@ -45,12 +44,11 @@ export default async function ImagePage({ params }: { params: { gallerySlug: str
nsfw={image.nsfw}
imageId={image.id}
/>
}
<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">
<div className="border rounded-lg p-4 shadow bg-muted/30 w-full max-w-xl">
<h2 className="text-xl font-semibold mb-2">Image Metadata</h2>
<p><strong>Name:</strong> {image.imageName}</p>

View File

@ -1,42 +1,13 @@
import Footer from "@/components/global/Footer";
import Header from "@/components/global/Header";
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";
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({
export default async function NormalLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="flex flex-col min-h-screen min-w-screen">
<header className="sticky top-0 z-50 h-14 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-2">
<Header />
@ -49,8 +20,5 @@ export default async function RootLayout({
</footer>
<Toaster />
</div>
</ThemeProvider>
</body>
</html>
);
}

View File

@ -1,42 +1,11 @@
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({
export default async function RawLayout({
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

@ -31,7 +31,7 @@ export default async function RawImagePage({ params }: { params: { imageId: stri
? { backgroundImage: `linear-gradient(to bottom, ${hexColors.join(", ")})` }
: { backgroundColor: "#0f0f0f" };
const targetHref = `/${image.album?.gallery?.slug}/${image.album?.slug}/${image.id}`;
const targetHref = `/galleries/${image.album?.gallery?.slug}/${image.album?.slug}/${image.id}`;
return (
<div

31
src/app/error.tsx Normal file
View File

@ -0,0 +1,31 @@
'use client' // Error boundaries must be Client Components
import { useEffect } from 'react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
return (
<div className="flex h-screen flex-col items-center justify-center bg-destructive/5 px-4 text-center">
<h1 className="text-3xl font-bold text-destructive">Something went wrong</h1>
<p className="mt-2 text-muted-foreground">
Please try refreshing the page or contact support if the problem persists.
</p>
<button
onClick={reset}
className="mt-6 rounded-lg bg-destructive px-4 py-2 text-white hover:bg-destructive/80"
>
Try Again
</button>
</div>
)
}

42
src/app/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>
);
}

10
src/app/loading.tsx Normal file
View File

@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="flex h-screen items-center justify-center bg-muted">
<div className="flex flex-col items-center space-y-4">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-muted-foreground text-sm">Loading content...</p>
</div>
</div>
);
}

18
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,18 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="flex h-screen flex-col items-center justify-center bg-background px-4 text-center">
<h1 className="text-4xl font-bold tracking-tight">Page Not Found</h1>
<p className="mt-2 text-muted-foreground">
Sorry, we couldn&apos;t find the page you were looking for.
</p>
<Link
href="/"
className="mt-6 rounded-xl bg-primary px-4 py-2 text-white hover:bg-primary/90"
>
Go back home
</Link>
</div>
);
}

View File

@ -10,7 +10,7 @@ export default function ImageList({ images, gallerySlug, albumSlug }: { images:
<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) => (
<Link href={`/${gallerySlug}/${albumSlug}/${img.id}`} key={img.id}>
<Link href={`/galleries/${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 ? (

View File

@ -0,0 +1,34 @@
"use client";
// import ImageCard from "@/components/image/ImageCard"; // You likely have this already
// import type { Album, Gallery, Image, ImageVariant } from "@prisma/client";
import { Album, Gallery, Image, ImageVariant } from "@/generated/prisma";
import { useState } from "react";
import ImageCard from "./ImageCard";
type Props = {
images: (Image & {
album: Album & { gallery: Gallery | null } | null;
variants: ImageVariant[];
})[];
};
export default function ArtistImageGrid({ images }: Props) {
const [showNSFW, setShowNSFW] = useState(false);
return (
<div className="space-y-4">
<button
onClick={() => setShowNSFW(!showNSFW)}
className="text-sm underline text-muted-foreground"
>
{showNSFW ? "Hide NSFW" : "Show NSFW"} content
</button>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{images.map((img) => (
<ImageCard key={img.id} image={img} showNSFW={showNSFW} />
))}
</div>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { Artist } from "@/generated/prisma";
import { getSocialIcon } from "@/utils/socialIconMap";
import { LinkIcon } from "lucide-react";
type Props = {
artist: Artist & {
socials: {
id: string;
handle: string;
platform: string;
link: string | null;
isPrimary: boolean;
}[];
};
};
export default function ArtistInfoBox({ artist }: Props) {
return (
<div className="border rounded-lg p-4 shadow bg-muted/30 w-full max-w-xl flex flex-col items-center text-center">
<h1 className="text-3xl font-bold">Socials / Links</h1>
<div className="mt-4 flex flex-col flex-wrap gap-4 items-center">
{artist.socials.map((social) => {
const Icon = getSocialIcon(social.platform) ?? LinkIcon;
return (
<div key={social.id} className="">
<a
href={social.link || "#"}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-primary"
target="_blank" rel="noopener noreferrer"
>
<Icon className="h-4 w-4" />
{social.platform}: {social.handle}
</a>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { Album, Gallery, Image, ImageVariant } from "@/generated/prisma";
import NextImage from "next/image";
import Link from "next/link";
type Props = {
image: Image & {
variants: ImageVariant[];
album: Album & { gallery: Gallery | null } | null;
};
showNSFW?: boolean;
};
export default function ImageCard({ image, showNSFW = false }: Props) {
const variant = image.variants.find(v => v.type === "thumbnail") || image.variants[0];
if (!variant) return null;
const href = image.album?.gallery && image.album
? `/galleries/${image.album.gallery.slug}/${image.album.slug}/${image.id}`
: `/image/${image.id}`;
const shouldBlur = image.nsfw && !showNSFW;
return (
<Link href={href} className="group relative overflow-hidden rounded-lg border bg-background shadow transition hover:shadow-lg">
<div className="aspect-[4/5] w-full relative">
<NextImage
src={`/api/image/${variant.s3Key}`}
alt={image.altText || image.imageName}
width={variant.width}
height={variant.height}
className={`object-cover h-full transition duration-200 ease-in-out ${shouldBlur ? "blur-md scale-105" : ""
}`}
/>
{image.nsfw && (
<div className="absolute top-1 left-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded z-10">
NSFW
</div>
)}
<div className="absolute bottom-0 left-0 w-full bg-black/50 text-white text-sm px-2 py-1 line-clamp-1 truncate">
{image.imageName}
</div>
</div>
</Link>
);
}

View File

@ -12,7 +12,7 @@ export default function AlbumList({ albums, gallerySlug }: { albums: AlbumsWithI
<h1 className="text-2xl font-bold mb-4">Albums</h1>
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{albums ? albums.map((album) => (
<Link href={`/${gallerySlug}/${album.slug}`} key={album.id}>
<Link href={`/galleries/${gallerySlug}/${album.slug}`} key={album.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">
{album.coverImage?.fileKey ? (

View File

@ -14,7 +14,7 @@ export default async function GalleryList() {
<h1 className="text-2xl font-bold mb-4">Galleries</h1>
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{galleries ? galleries.map((gallery) => (
<Link href={`/${gallery.slug}`} key={gallery.id}>
<Link href={`/galleries/${gallery.slug}`} key={gallery.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">
{gallery.coverImage?.fileKey ? (