Add artists page
This commit is contained in:
@ -58,7 +58,9 @@ model Artist {
|
||||
slug String @unique
|
||||
displayName String
|
||||
|
||||
nickname 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[]
|
||||
|
||||
albumCover Album[] @relation("AlbumCoverImage")
|
||||
galleryCover Gallery[] @relation("GalleryCoverImage")
|
||||
categories Category[] @relation("ImageCategories")
|
||||
tags Tag[] @relation("ImageTags")
|
||||
// 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())
|
||||
|
40
src/app/(normal)/artists/[artistSlug]/page.tsx
Normal file
40
src/app/(normal)/artists/[artistSlug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
@ -1,56 +1,24 @@
|
||||
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 />
|
||||
</header>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="mt-auto px-4 py-2 h-14 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 ">
|
||||
<Footer />
|
||||
</footer>
|
||||
<Toaster />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
<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 />
|
||||
</header>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="mt-auto px-4 py-2 h-14 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 ">
|
||||
<Footer />
|
||||
</footer>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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
31
src/app/error.tsx
Normal 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
42
src/app/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>
|
||||
);
|
||||
}
|
10
src/app/loading.tsx
Normal file
10
src/app/loading.tsx
Normal 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
18
src/app/not-found.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
@ -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 ? (
|
||||
|
34
src/components/artists/ArtistImageGrid.tsx
Normal file
34
src/components/artists/ArtistImageGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
src/components/artists/ArtistInfoBox.tsx
Normal file
40
src/components/artists/ArtistInfoBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
45
src/components/artists/ImageCard.tsx
Normal file
45
src/components/artists/ImageCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 ? (
|
||||
|
@ -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 ? (
|
||||
|
Reference in New Issue
Block a user