Basic layout finished
This commit is contained in:
		
							
								
								
									
										2164
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2164
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -9,9 +9,14 @@
 | 
			
		||||
    "lint": "next lint"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@aws-sdk/client-s3": "^3.839.0",
 | 
			
		||||
    "@aws-sdk/s3-request-presigner": "^3.839.0",
 | 
			
		||||
    "@prisma/client": "^6.10.1",
 | 
			
		||||
    "@radix-ui/react-dropdown-menu": "^2.1.15",
 | 
			
		||||
    "@radix-ui/react-label": "^2.1.7",
 | 
			
		||||
    "@radix-ui/react-navigation-menu": "^1.2.13",
 | 
			
		||||
    "@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
    "@radix-ui/react-switch": "^1.2.5",
 | 
			
		||||
    "class-variance-authority": "^0.7.1",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
    "lucide-react": "^0.525.0",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										31
									
								
								src/app/(galleries)/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/app/(galleries)/layout.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,31 @@
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										60
									
								
								src/app/[gallerySlug]/[albumSlug]/[imageId]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/app/[gallerySlug]/[albumSlug]/[imageId]/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,60 @@
 | 
			
		||||
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 >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/app/[gallerySlug]/[albumSlug]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/app/[gallerySlug]/[albumSlug]/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,43 @@
 | 
			
		||||
import ImageList from "@/components/albums/ImageList";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export default async function GalleryPage({ params }: { params: { gallerySlug: string, albumSlug: string } }) {
 | 
			
		||||
  const { gallerySlug, albumSlug } = await params;
 | 
			
		||||
 | 
			
		||||
  const gallery = await prisma.gallery.findUnique({
 | 
			
		||||
    where: { slug: gallerySlug },
 | 
			
		||||
    select: { id: true },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!gallery) {
 | 
			
		||||
    throw new Error("Gallery not found");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const album = await prisma.album.findUnique({
 | 
			
		||||
    where: {
 | 
			
		||||
      galleryId_slug: {
 | 
			
		||||
        galleryId: gallery.id,
 | 
			
		||||
        slug: albumSlug,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    include: {
 | 
			
		||||
      images: true
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="max-w-7xl mx-auto px-4 py-12 space-y-12">
 | 
			
		||||
      {album && (
 | 
			
		||||
        <>
 | 
			
		||||
          <section className="text-center space-y-4">
 | 
			
		||||
            <h1 className="text-4xl font-bold tracking-tight">{album.name}</h1>
 | 
			
		||||
            <p className="text-lg text-muted-foreground">
 | 
			
		||||
              {album.description}
 | 
			
		||||
            </p>
 | 
			
		||||
          </section>
 | 
			
		||||
          <ImageList images={album.images} gallerySlug={gallerySlug} albumSlug={albumSlug} />
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										31
									
								
								src/app/[gallerySlug]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/app/[gallerySlug]/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,31 @@
 | 
			
		||||
import AlbumList from "@/components/galleries/AlbumList";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export default async function GalleryPage({ params }: { params: { gallerySlug: string } }) {
 | 
			
		||||
  const { gallerySlug } = await params;
 | 
			
		||||
 | 
			
		||||
  const gallery = await prisma.gallery.findUnique({
 | 
			
		||||
    where: {
 | 
			
		||||
      slug: gallerySlug
 | 
			
		||||
    },
 | 
			
		||||
    include: {
 | 
			
		||||
      albums: { where: { images: { some: {} } }, include: { coverImage: true } }
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="max-w-7xl mx-auto px-4 py-12 space-y-12">
 | 
			
		||||
      {gallery && (
 | 
			
		||||
        <>
 | 
			
		||||
          <section className="text-center space-y-4">
 | 
			
		||||
            <h1 className="text-4xl font-bold tracking-tight">{gallery.name}</h1>
 | 
			
		||||
            <p className="text-lg text-muted-foreground">
 | 
			
		||||
              {gallery.description}
 | 
			
		||||
            </p>
 | 
			
		||||
          </section>
 | 
			
		||||
          <AlbumList albums={gallery.albums} gallerySlug={gallerySlug} />
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										33
									
								
								src/app/api/image/[...key]/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/app/api/image/[...key]/route.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,33 @@
 | 
			
		||||
import { s3 } from "@/lib/s3";
 | 
			
		||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
 | 
			
		||||
 | 
			
		||||
export async function GET(req: Request, { params }: { params: { key: string[] } }) {
 | 
			
		||||
  const { key } = await params;
 | 
			
		||||
  const s3Key = key.join("/"); 
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const command = new GetObjectCommand({
 | 
			
		||||
      Bucket: "felliesartapp",
 | 
			
		||||
      Key: s3Key,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const response = await s3.send(command);
 | 
			
		||||
 | 
			
		||||
    if (!response.Body) {
 | 
			
		||||
      return new Response("No body", { status: 500 });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const contentType = response.ContentType ?? "application/octet-stream";
 | 
			
		||||
 | 
			
		||||
    return new Response(response.Body as ReadableStream, {
 | 
			
		||||
      headers: {
 | 
			
		||||
        "Content-Type": contentType,
 | 
			
		||||
        "Cache-Control": "public, max-age=3600",
 | 
			
		||||
        "Content-Disposition": "inline", // use 'attachment' to force download
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    console.log(err)
 | 
			
		||||
    return new Response("Image not found", { status: 404 });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -241,3 +241,52 @@ body {
 | 
			
		||||
    @apply bg-background text-foreground;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* 
 | 
			
		||||
@keyframes glow {
 | 
			
		||||
  0% {
 | 
			
		||||
    transform: rotate(0deg);
 | 
			
		||||
  }
 | 
			
		||||
  100% {
 | 
			
		||||
    transform: rotate(360deg);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.animate-glow {
 | 
			
		||||
  animation: glow 12s linear infinite; 
 | 
			
		||||
} 
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
@keyframes pulse-border {
 | 
			
		||||
  0% {
 | 
			
		||||
    background-position: 0% 50%;
 | 
			
		||||
  }
 | 
			
		||||
  50% {
 | 
			
		||||
    background-position: 100% 50%;
 | 
			
		||||
  }
 | 
			
		||||
  100% {
 | 
			
		||||
    background-position: 0% 50%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.glow-border::before,
 | 
			
		||||
.static-glow-border::before {
 | 
			
		||||
  content: "";
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  inset: 0;
 | 
			
		||||
  z-index: 0;
 | 
			
		||||
  padding: 8px;
 | 
			
		||||
  border-radius: inherit;
 | 
			
		||||
  background: linear-gradient(270deg, var(--vibrant), var(--muted), var(--vibrant));
 | 
			
		||||
  background-size: 400% 400%;
 | 
			
		||||
  filter: blur(16px);
 | 
			
		||||
  opacity: 0.8;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.glow-border::before {
 | 
			
		||||
  animation: pulse-border 15s ease infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.static-glow-border::before {
 | 
			
		||||
  background-position: center;
 | 
			
		||||
}
 | 
			
		||||
@ -1,5 +1,9 @@
 | 
			
		||||
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({
 | 
			
		||||
@ -23,11 +27,29 @@ export default function RootLayout({
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
}>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <html lang="en">
 | 
			
		||||
    <html lang="en" suppressHydrationWarning>
 | 
			
		||||
      <body
 | 
			
		||||
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
        <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>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,15 @@
 | 
			
		||||
import GalleryList from "@/components/home/GalleryList";
 | 
			
		||||
 | 
			
		||||
export default function Home() {
 | 
			
		||||
export default function HomePage() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      APP HOME
 | 
			
		||||
    <div className="max-w-7xl mx-auto px-4 py-12 space-y-12">
 | 
			
		||||
      <section className="text-center space-y-4">
 | 
			
		||||
        <h1 className="text-4xl font-bold tracking-tight">Welcome to Fellies Art</h1>
 | 
			
		||||
        <p className="text-lg text-muted-foreground">
 | 
			
		||||
          Some descriptive text blabla
 | 
			
		||||
        </p>
 | 
			
		||||
      </section>
 | 
			
		||||
      <GalleryList />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										37
									
								
								src/components/albums/ImageList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/components/albums/ImageList.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
			
		||||
import { Image } from "@/generated/prisma";
 | 
			
		||||
import NextImage from "next/image";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export default function ImageList({ images, gallerySlug, albumSlug }: { images: Image[], gallerySlug: string, albumSlug: string }) {
 | 
			
		||||
 | 
			
		||||
  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) => (
 | 
			
		||||
          <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.imageName}
 | 
			
		||||
                    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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								src/components/galleries/AlbumList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/components/galleries/AlbumList.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
import { Album, Image } from "@/generated/prisma";
 | 
			
		||||
import NextImage from "next/image";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
type AlbumsWithItems = Album & {
 | 
			
		||||
  coverImage: Image | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function AlbumList({ albums, gallerySlug }: { albums: AlbumsWithItems[], gallerySlug: string }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <section>
 | 
			
		||||
      <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}>
 | 
			
		||||
            <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 ? (
 | 
			
		||||
                  <NextImage
 | 
			
		||||
                    src={`/api/image/thumbnails/${album.coverImage.fileKey}.webp`}
 | 
			
		||||
                    alt={album.coverImage.imageName}
 | 
			
		||||
                    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">{album.name}</h2>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </Link>
 | 
			
		||||
        )) : "There are no albums here!"}
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								src/components/global/Breadcrumbs.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/components/global/Breadcrumbs.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,30 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { ChevronRight, HomeIcon } from "lucide-react";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export function Breadcrumbs({
 | 
			
		||||
  items,
 | 
			
		||||
}: {
 | 
			
		||||
  items: { name: string; href?: string }[];
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <nav className="flex items-center text-sm mb-4 text-muted-foreground">
 | 
			
		||||
      <Link href="/" className="flex items-center gap-1 hover:text-foreground">
 | 
			
		||||
        <HomeIcon size={16} />
 | 
			
		||||
      </Link>
 | 
			
		||||
      {items.map((item, idx) => (
 | 
			
		||||
        <span key={idx} className="flex items-center">
 | 
			
		||||
          <ChevronRight size={16} className="mx-1" />
 | 
			
		||||
          {item.href ? (
 | 
			
		||||
            <Link href={item.href} className="hover:text-foreground">
 | 
			
		||||
              {item.name}
 | 
			
		||||
            </Link>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <span className="text-foreground">{item.name}</span>
 | 
			
		||||
          )}
 | 
			
		||||
        </span>
 | 
			
		||||
      ))}
 | 
			
		||||
    </nav>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										5
									
								
								src/components/global/Footer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/components/global/Footer.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
export default function Footer() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div>Footer</div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								src/components/global/Header.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/components/global/Header.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
			
		||||
import ModeToggle from "./ModeToggle";
 | 
			
		||||
import TopNav from "./TopNav";
 | 
			
		||||
 | 
			
		||||
export default function Header() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex items-center justify-between">
 | 
			
		||||
      <TopNav />
 | 
			
		||||
      <ModeToggle />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										39
									
								
								src/components/global/ModeToggle.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/components/global/ModeToggle.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { Moon, Sun } from "lucide-react"
 | 
			
		||||
import { useTheme } from "next-themes"
 | 
			
		||||
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import {
 | 
			
		||||
  DropdownMenu,
 | 
			
		||||
  DropdownMenuContent,
 | 
			
		||||
  DropdownMenuItem,
 | 
			
		||||
  DropdownMenuTrigger,
 | 
			
		||||
} from "@/components/ui/dropdown-menu"
 | 
			
		||||
 | 
			
		||||
export default function ModeToggle() {
 | 
			
		||||
  const { setTheme } = useTheme()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenu>
 | 
			
		||||
      <DropdownMenuTrigger asChild>
 | 
			
		||||
        <Button variant="outline" size="icon">
 | 
			
		||||
          <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
 | 
			
		||||
          <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
 | 
			
		||||
          <span className="sr-only">Toggle theme</span>
 | 
			
		||||
        </Button>
 | 
			
		||||
      </DropdownMenuTrigger>
 | 
			
		||||
      <DropdownMenuContent align="end">
 | 
			
		||||
        <DropdownMenuItem onClick={() => setTheme("light")}>
 | 
			
		||||
          Light
 | 
			
		||||
        </DropdownMenuItem>
 | 
			
		||||
        <DropdownMenuItem onClick={() => setTheme("dark")}>
 | 
			
		||||
          Dark
 | 
			
		||||
        </DropdownMenuItem>
 | 
			
		||||
        <DropdownMenuItem onClick={() => setTheme("system")}>
 | 
			
		||||
          System
 | 
			
		||||
        </DropdownMenuItem>
 | 
			
		||||
      </DropdownMenuContent>
 | 
			
		||||
    </DropdownMenu>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								src/components/global/ThemeProvider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/components/global/ThemeProvider.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
 | 
			
		||||
export function ThemeProvider({
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof NextThemesProvider>) {
 | 
			
		||||
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								src/components/global/Toaster.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/components/global/Toaster.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,25 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { useTheme } from "next-themes"
 | 
			
		||||
import { Toaster as Sonner, ToasterProps } from "sonner"
 | 
			
		||||
 | 
			
		||||
const Toaster = ({ ...props }: ToasterProps) => {
 | 
			
		||||
  const { theme = "system" } = useTheme()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Sonner
 | 
			
		||||
      theme={theme as ToasterProps["theme"]}
 | 
			
		||||
      className="toaster group"
 | 
			
		||||
      style={
 | 
			
		||||
        {
 | 
			
		||||
          "--normal-bg": "var(--popover)",
 | 
			
		||||
          "--normal-text": "var(--popover-foreground)",
 | 
			
		||||
          "--normal-border": "var(--border)",
 | 
			
		||||
        } as React.CSSProperties
 | 
			
		||||
      }
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Toaster }
 | 
			
		||||
							
								
								
									
										23
									
								
								src/components/global/TopNav.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/components/global/TopNav.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,23 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { NavigationMenu, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export default function TopNav() {
 | 
			
		||||
  return (
 | 
			
		||||
    <NavigationMenu viewport={false}>
 | 
			
		||||
      <NavigationMenuList>
 | 
			
		||||
        <NavigationMenuItem>
 | 
			
		||||
          <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
 | 
			
		||||
            <Link href="/">Home</Link>
 | 
			
		||||
          </NavigationMenuLink>
 | 
			
		||||
        </NavigationMenuItem>
 | 
			
		||||
        <NavigationMenuItem>
 | 
			
		||||
          <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
 | 
			
		||||
            <Link href="/about">About</Link>
 | 
			
		||||
          </NavigationMenuLink>
 | 
			
		||||
        </NavigationMenuItem>
 | 
			
		||||
      </NavigationMenuList>
 | 
			
		||||
    </NavigationMenu>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										42
									
								
								src/components/home/GalleryList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/components/home/GalleryList.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import Image from "next/image";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export default async function GalleryList() {
 | 
			
		||||
  const galleries = await prisma.gallery.findMany({
 | 
			
		||||
    include: {
 | 
			
		||||
      coverImage: true
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <section>
 | 
			
		||||
      <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}>
 | 
			
		||||
            <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 ? (
 | 
			
		||||
                  <Image
 | 
			
		||||
                    src={`/api/image/thumbnails/${gallery.coverImage.fileKey}.webp`}
 | 
			
		||||
                    alt={gallery.coverImage.imageName}
 | 
			
		||||
                    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">{gallery.name}</h2>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </Link>
 | 
			
		||||
        )) : "There are no galleries here!"}
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										48
									
								
								src/components/images/ArtistInfoBox.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/components/images/ArtistInfoBox.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,48 @@
 | 
			
		||||
// components/images/ArtistInfoBox.tsx
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { Artist, Social } from "@/generated/prisma"
 | 
			
		||||
import { getSocialIcon } from "@/utils/socialIconMap"
 | 
			
		||||
import { UserIcon } from "lucide-react"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
 | 
			
		||||
type ArtistWithItems = Artist & {
 | 
			
		||||
  socials: Social[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function ArtistInfoBox({ artist }: { artist: ArtistWithItems }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="border rounded-lg p-4 shadow bg-muted/30 w-full max-w-xl">
 | 
			
		||||
      <div className="flex items-center gap-3 mb-2">
 | 
			
		||||
        <UserIcon className="w-5 h-5 text-muted-foreground" />
 | 
			
		||||
        <Link href={`/artists/${artist.slug}`} className="font-semibold text-lg hover:underline">
 | 
			
		||||
          {artist.displayName}
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {artist.socials?.length > 0 && (
 | 
			
		||||
        <div className="flex flex-col gap-3 flex-wrap mt-2">
 | 
			
		||||
          {artist.socials.map((social) => {
 | 
			
		||||
            const Icon = getSocialIcon(social.platform)
 | 
			
		||||
            return (
 | 
			
		||||
              <div key={social.id}>
 | 
			
		||||
                <a
 | 
			
		||||
                  href={social.link || ""}
 | 
			
		||||
                  target="_blank"
 | 
			
		||||
                  rel="noopener noreferrer"
 | 
			
		||||
                  className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary hover:underline"
 | 
			
		||||
                >
 | 
			
		||||
                  <Icon className="w-4 h-4" />
 | 
			
		||||
                  {social.platform}: {social.handle}
 | 
			
		||||
                  {social.isPrimary && (
 | 
			
		||||
                    <span className="text-xs text-primary font-semibold ml-1">(main)</span>
 | 
			
		||||
                  )}
 | 
			
		||||
                </a>
 | 
			
		||||
              </div>
 | 
			
		||||
            )
 | 
			
		||||
          })}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										76
									
								
								src/components/images/GlowingImageBorder.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								src/components/images/GlowingImageBorder.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,76 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { Color, ImageColor, ImageVariant } from "@/generated/prisma"
 | 
			
		||||
import clsx from "clsx"
 | 
			
		||||
import { useTheme } from "next-themes"
 | 
			
		||||
import NextImage from "next/image"
 | 
			
		||||
import { useEffect, useState } from "react"
 | 
			
		||||
 | 
			
		||||
type Colors = ImageColor & {
 | 
			
		||||
  color: Color
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  alt: string,
 | 
			
		||||
  variant: ImageVariant,
 | 
			
		||||
  colors: Colors[],
 | 
			
		||||
  src: string
 | 
			
		||||
  className?: string
 | 
			
		||||
  animate?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function GlowingImageBorder({
 | 
			
		||||
  alt,
 | 
			
		||||
  variant,
 | 
			
		||||
  colors,
 | 
			
		||||
  src,
 | 
			
		||||
  className,
 | 
			
		||||
  animate = true,
 | 
			
		||||
}: Props) {
 | 
			
		||||
  const { resolvedTheme } = useTheme()
 | 
			
		||||
  const [mounted, setMounted] = useState(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setMounted(true);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const getColor = (type: string) =>
 | 
			
		||||
    colors.find((c) => c.type === type)?.color.hex
 | 
			
		||||
 | 
			
		||||
  const vibrantLight = getColor("vibrant") || "#ff5ec4"
 | 
			
		||||
  const mutedLight = getColor("muted") || "#5ecaff"
 | 
			
		||||
 | 
			
		||||
  const darkVibrant = getColor("darkVibrant") || "#fc03a1"
 | 
			
		||||
  const darkMuted = getColor("darkMuted") || "#035efc"
 | 
			
		||||
 | 
			
		||||
  const vibrant = resolvedTheme === "dark" ? darkVibrant : vibrantLight
 | 
			
		||||
  const muted = resolvedTheme === "dark" ? darkMuted : mutedLight
 | 
			
		||||
 | 
			
		||||
  if (!mounted) return null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        "relative inline-block rounded-xl overflow-hidden p-[12px]",
 | 
			
		||||
        animate ? "glow-border" : "static-glow-border",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      style={
 | 
			
		||||
        {
 | 
			
		||||
          "--vibrant": vibrant,
 | 
			
		||||
          "--muted": muted,
 | 
			
		||||
        } as React.CSSProperties
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <div className="relative z-10 rounded-xl overflow-hidden">
 | 
			
		||||
        <NextImage
 | 
			
		||||
          src={src}
 | 
			
		||||
          alt={alt || "Image"}
 | 
			
		||||
          width={variant.width}
 | 
			
		||||
          height={variant.height}
 | 
			
		||||
          className="rounded-xl"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										37
									
								
								src/components/images/GlowingImageWithToggle.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/components/images/GlowingImageWithToggle.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { Color, ImageColor, ImageVariant } from "@/generated/prisma";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { Label } from "../ui/label";
 | 
			
		||||
import { Switch } from "../ui/switch";
 | 
			
		||||
import GlowingImageBorder from "./GlowingImageBorder";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  variant: ImageVariant;
 | 
			
		||||
  colors: (ImageColor & { color: Color })[];
 | 
			
		||||
  alt: string;
 | 
			
		||||
  src: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function GlowingImageWithToggle({ variant, colors, alt, src }: Props) {
 | 
			
		||||
  const [animate, setAnimate] = useState(true);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="relative w-full max-w-fit">
 | 
			
		||||
      <GlowingImageBorder
 | 
			
		||||
        alt={alt}
 | 
			
		||||
        variant={variant}
 | 
			
		||||
        colors={colors}
 | 
			
		||||
        src={src}
 | 
			
		||||
        animate={animate}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <div className="flex flex-col items-center gap-4 pt-8">
 | 
			
		||||
        <div className="flex items-center gap-2">
 | 
			
		||||
          <Switch id="animate" checked={animate} onCheckedChange={setAnimate} />
 | 
			
		||||
          <Label htmlFor="animate">Animate glow</Label>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										100
									
								
								src/components/images/ImageInfoPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								src/components/images/ImageInfoPanel.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,100 @@
 | 
			
		||||
import { Image as ImageType } from "@/generated/prisma";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
// import { SocialIcon } from "react-social-icons";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  image: ImageType & {
 | 
			
		||||
    artist?: {
 | 
			
		||||
      id: string;
 | 
			
		||||
      slug: string;
 | 
			
		||||
      displayName: string;
 | 
			
		||||
      socials?: { type: string; handle: string; link: string }[];
 | 
			
		||||
    };
 | 
			
		||||
    categories?: { id: string; name: string }[];
 | 
			
		||||
    tags?: { id: string; name: string }[];
 | 
			
		||||
    album?: { id: string; name: string; slug: string };
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function ImageInfoPanel({ image }: Props) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="w-full max-w-2xl mt-8 border border-border rounded-lg p-6 shadow-sm bg-background">
 | 
			
		||||
      {/* Creator */}
 | 
			
		||||
      {image.artist && (
 | 
			
		||||
        <div className="mb-4">
 | 
			
		||||
          <h2 className="text-lg font-semibold mb-1">Creator</h2>
 | 
			
		||||
          <Link
 | 
			
		||||
            href={`/artists/${image.artist.slug}`}
 | 
			
		||||
            className="text-primary hover:underline font-medium"
 | 
			
		||||
          >
 | 
			
		||||
            {image.artist.displayName}
 | 
			
		||||
          </Link>
 | 
			
		||||
 | 
			
		||||
          {image.artist.socials?.length > 0 && (
 | 
			
		||||
            <div className="flex gap-2 mt-2">
 | 
			
		||||
              {image.artist.socials.map((social, i) => (
 | 
			
		||||
                <SocialIcon
 | 
			
		||||
                  key={i}
 | 
			
		||||
                  url={social.link}
 | 
			
		||||
                  target="_blank"
 | 
			
		||||
                  rel="noopener noreferrer"
 | 
			
		||||
                  style={{ height: 28, width: 28 }}
 | 
			
		||||
                  title={social.type}
 | 
			
		||||
                />
 | 
			
		||||
              ))}
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {/* Album */}
 | 
			
		||||
      {image.album && (
 | 
			
		||||
        <div className="mb-4">
 | 
			
		||||
          <h2 className="text-lg font-semibold mb-1">Album</h2>
 | 
			
		||||
          <Link
 | 
			
		||||
            href={`/galleries/${image.album.slug}`}
 | 
			
		||||
            className="text-primary hover:underline"
 | 
			
		||||
          >
 | 
			
		||||
            {image.album.name}
 | 
			
		||||
          </Link>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {/* Categories */}
 | 
			
		||||
      {image.categories?.length > 0 && (
 | 
			
		||||
        <div className="mb-4">
 | 
			
		||||
          <h2 className="text-lg font-semibold mb-1">Categories</h2>
 | 
			
		||||
          <div className="flex flex-wrap gap-2">
 | 
			
		||||
            {image.categories.map((cat) => (
 | 
			
		||||
              <Link
 | 
			
		||||
                key={cat.id}
 | 
			
		||||
                href={`/categories/${cat.id}`}
 | 
			
		||||
                className="bg-muted text-sm px-2 py-1 rounded hover:bg-accent"
 | 
			
		||||
              >
 | 
			
		||||
                {cat.name}
 | 
			
		||||
              </Link>
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {/* Tags */}
 | 
			
		||||
      {image.tags?.length > 0 && (
 | 
			
		||||
        <div>
 | 
			
		||||
          <h2 className="text-lg font-semibold mb-1">Tags</h2>
 | 
			
		||||
          <div className="flex flex-wrap gap-2">
 | 
			
		||||
            {image.tags.map((tag) => (
 | 
			
		||||
              <Link
 | 
			
		||||
                key={tag.id}
 | 
			
		||||
                href={`/tags/${tag.id}`}
 | 
			
		||||
                className="bg-muted text-sm px-2 py-1 rounded hover:bg-accent"
 | 
			
		||||
              >
 | 
			
		||||
                #{tag.name}
 | 
			
		||||
              </Link>
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										57
									
								
								src/components/images/ImageMetadataBox.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/components/images/ImageMetadataBox.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,57 @@
 | 
			
		||||
// components/images/ImageMetadataBox.tsx
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { Album, Category, Tag } from "@/generated/prisma"
 | 
			
		||||
import { FolderIcon, LayersIcon, TagIcon } from "lucide-react"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  album: Album | null
 | 
			
		||||
  categories: Category[]
 | 
			
		||||
  tags: Tag[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function ImageMetadataBox({ album, categories, tags }: Props) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="border rounded-lg p-4 shadow bg-muted/20 w-full max-w-xl space-y-3">
 | 
			
		||||
      {album && (
 | 
			
		||||
        <div className="flex items-center gap-2">
 | 
			
		||||
          <FolderIcon className="w-5 h-5 text-muted-foreground" />
 | 
			
		||||
          <Link href={`/galleries/${album.galleryId}/${album.slug}`} className="hover:underline">
 | 
			
		||||
            {album.name}
 | 
			
		||||
          </Link>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {categories.length > 0 && (
 | 
			
		||||
        <div className="flex items-center gap-2 flex-wrap">
 | 
			
		||||
          <LayersIcon className="w-5 h-5 text-muted-foreground" />
 | 
			
		||||
          {categories.map((cat) => (
 | 
			
		||||
            <Link
 | 
			
		||||
              key={cat.id}
 | 
			
		||||
              href={`/categories/${cat.id}`}
 | 
			
		||||
              className="text-sm text-muted-foreground hover:underline"
 | 
			
		||||
            >
 | 
			
		||||
              {cat.name}
 | 
			
		||||
            </Link>
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {tags.length > 0 && (
 | 
			
		||||
        <div className="flex items-center gap-2 flex-wrap">
 | 
			
		||||
          <TagIcon className="w-5 h-5 text-muted-foreground" />
 | 
			
		||||
          {tags.map((tag) => (
 | 
			
		||||
            <Link
 | 
			
		||||
              key={tag.id}
 | 
			
		||||
              href={`/tags/${tag.id}`}
 | 
			
		||||
              className="text-sm text-muted-foreground hover:underline"
 | 
			
		||||
            >
 | 
			
		||||
              {tag.name}
 | 
			
		||||
            </Link>
 | 
			
		||||
          ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										257
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,257 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
 | 
			
		||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function DropdownMenu({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
 | 
			
		||||
  return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuPortal({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuTrigger({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Trigger
 | 
			
		||||
      data-slot="dropdown-menu-trigger"
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuContent({
 | 
			
		||||
  className,
 | 
			
		||||
  sideOffset = 4,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Portal>
 | 
			
		||||
      <DropdownMenuPrimitive.Content
 | 
			
		||||
        data-slot="dropdown-menu-content"
 | 
			
		||||
        sideOffset={sideOffset}
 | 
			
		||||
        className={cn(
 | 
			
		||||
          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
 | 
			
		||||
          className
 | 
			
		||||
        )}
 | 
			
		||||
        {...props}
 | 
			
		||||
      />
 | 
			
		||||
    </DropdownMenuPrimitive.Portal>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuGroup({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuItem({
 | 
			
		||||
  className,
 | 
			
		||||
  inset,
 | 
			
		||||
  variant = "default",
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
 | 
			
		||||
  inset?: boolean
 | 
			
		||||
  variant?: "default" | "destructive"
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Item
 | 
			
		||||
      data-slot="dropdown-menu-item"
 | 
			
		||||
      data-inset={inset}
 | 
			
		||||
      data-variant={variant}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuCheckboxItem({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  checked,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.CheckboxItem
 | 
			
		||||
      data-slot="dropdown-menu-checkbox-item"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      checked={checked}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
 | 
			
		||||
        <DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
          <CheckIcon className="size-4" />
 | 
			
		||||
        </DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
      </span>
 | 
			
		||||
      {children}
 | 
			
		||||
    </DropdownMenuPrimitive.CheckboxItem>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuRadioGroup({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.RadioGroup
 | 
			
		||||
      data-slot="dropdown-menu-radio-group"
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuRadioItem({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.RadioItem
 | 
			
		||||
      data-slot="dropdown-menu-radio-item"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
 | 
			
		||||
        <DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
          <CircleIcon className="size-2 fill-current" />
 | 
			
		||||
        </DropdownMenuPrimitive.ItemIndicator>
 | 
			
		||||
      </span>
 | 
			
		||||
      {children}
 | 
			
		||||
    </DropdownMenuPrimitive.RadioItem>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuLabel({
 | 
			
		||||
  className,
 | 
			
		||||
  inset,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
 | 
			
		||||
  inset?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Label
 | 
			
		||||
      data-slot="dropdown-menu-label"
 | 
			
		||||
      data-inset={inset}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSeparator({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.Separator
 | 
			
		||||
      data-slot="dropdown-menu-separator"
 | 
			
		||||
      className={cn("bg-border -mx-1 my-1 h-px", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuShortcut({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"span">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <span
 | 
			
		||||
      data-slot="dropdown-menu-shortcut"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "text-muted-foreground ml-auto text-xs tracking-widest",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSub({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
 | 
			
		||||
  return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSubTrigger({
 | 
			
		||||
  className,
 | 
			
		||||
  inset,
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
 | 
			
		||||
  inset?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.SubTrigger
 | 
			
		||||
      data-slot="dropdown-menu-sub-trigger"
 | 
			
		||||
      data-inset={inset}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
      <ChevronRightIcon className="ml-auto size-4" />
 | 
			
		||||
    </DropdownMenuPrimitive.SubTrigger>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DropdownMenuSubContent({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenuPrimitive.SubContent
 | 
			
		||||
      data-slot="dropdown-menu-sub-content"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  DropdownMenu,
 | 
			
		||||
  DropdownMenuPortal,
 | 
			
		||||
  DropdownMenuTrigger,
 | 
			
		||||
  DropdownMenuContent,
 | 
			
		||||
  DropdownMenuGroup,
 | 
			
		||||
  DropdownMenuLabel,
 | 
			
		||||
  DropdownMenuItem,
 | 
			
		||||
  DropdownMenuCheckboxItem,
 | 
			
		||||
  DropdownMenuRadioGroup,
 | 
			
		||||
  DropdownMenuRadioItem,
 | 
			
		||||
  DropdownMenuSeparator,
 | 
			
		||||
  DropdownMenuShortcut,
 | 
			
		||||
  DropdownMenuSub,
 | 
			
		||||
  DropdownMenuSubTrigger,
 | 
			
		||||
  DropdownMenuSubContent,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								src/components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as LabelPrimitive from "@radix-ui/react-label"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function Label({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <LabelPrimitive.Root
 | 
			
		||||
      data-slot="label"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Label }
 | 
			
		||||
							
								
								
									
										31
									
								
								src/components/ui/switch.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/components/ui/switch.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,31 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function Switch({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SwitchPrimitive.Root
 | 
			
		||||
      data-slot="switch"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <SwitchPrimitive.Thumb
 | 
			
		||||
        data-slot="switch-thumb"
 | 
			
		||||
        className={cn(
 | 
			
		||||
          "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
 | 
			
		||||
        )}
 | 
			
		||||
      />
 | 
			
		||||
    </SwitchPrimitive.Root>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Switch }
 | 
			
		||||
							
								
								
									
										14
									
								
								src/lib/prisma.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/lib/prisma.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
// import { PrismaClient } from '@/types/prisma'
 | 
			
		||||
// import { withAccelerate } from '@prisma/extension-accelerate'
 | 
			
		||||
 | 
			
		||||
import { PrismaClient } from "@/generated/prisma"
 | 
			
		||||
 | 
			
		||||
const globalForPrisma = global as unknown as { 
 | 
			
		||||
    prisma: PrismaClient
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const prisma = globalForPrisma.prisma || new PrismaClient()
 | 
			
		||||
 | 
			
		||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
 | 
			
		||||
 | 
			
		||||
export default prisma
 | 
			
		||||
							
								
								
									
										21
									
								
								src/lib/s3.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/lib/s3.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
 | 
			
		||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 | 
			
		||||
 | 
			
		||||
export const s3 = new S3Client({
 | 
			
		||||
  region: "us-east-1",
 | 
			
		||||
  endpoint: "http://10.0.20.11:9010",
 | 
			
		||||
  forcePathStyle: true,
 | 
			
		||||
  credentials: {
 | 
			
		||||
    accessKeyId: "fellies",
 | 
			
		||||
    secretAccessKey: "XCJ7spqxWZhVn8tkYnfVBFbz2cRKYxPAfeQeIdPRp1",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export async function getSignedImageUrl(key: string, expiresInSec = 3600) {
 | 
			
		||||
  const command = new GetObjectCommand({
 | 
			
		||||
    Bucket: "felliesartapp",
 | 
			
		||||
    Key: key,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return getSignedUrl(s3, command, { expiresIn: expiresInSec });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										56
									
								
								src/utils/generateBreadcrumbs.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/utils/generateBreadcrumbs.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,56 @@
 | 
			
		||||
// src/utils/generateBreadcrumbsFromPath.ts
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export async function generateBreadcrumbsFromPath(segments: string[]) {
 | 
			
		||||
  const items: { name: string; href: string }[] = [];
 | 
			
		||||
 | 
			
		||||
  let galleryId: string | null = null;
 | 
			
		||||
  let accumulatedPath = "";
 | 
			
		||||
 | 
			
		||||
  for (const segment of segments) {
 | 
			
		||||
    accumulatedPath += `/${segment}`;
 | 
			
		||||
 | 
			
		||||
    // Try match gallery
 | 
			
		||||
    const gallery = await prisma.gallery.findUnique({ where: { slug: segment } });
 | 
			
		||||
    if (gallery) {
 | 
			
		||||
      items.push({ name: gallery.name, href: accumulatedPath });
 | 
			
		||||
      galleryId = gallery.id;
 | 
			
		||||
      continue;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Try match album using compound key
 | 
			
		||||
    if (galleryId) {
 | 
			
		||||
      const album = await prisma.album.findUnique({
 | 
			
		||||
        where: {
 | 
			
		||||
          galleryId_slug: {
 | 
			
		||||
            galleryId,
 | 
			
		||||
            slug: segment,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      if (album) {
 | 
			
		||||
        items.push({ name: album.name, href: accumulatedPath });
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Try match artist
 | 
			
		||||
    const artist = await prisma.artist.findUnique({ where: { slug: segment } });
 | 
			
		||||
    if (artist) {
 | 
			
		||||
      items.push({ name: artist.displayName, href: accumulatedPath });
 | 
			
		||||
      continue;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Try match image
 | 
			
		||||
    const image = await prisma.image.findUnique({ where: { id: segment } });
 | 
			
		||||
    if (image) {
 | 
			
		||||
      items.push({ name: image.imageName, href: accumulatedPath });
 | 
			
		||||
      continue;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Fallback: unknown segment
 | 
			
		||||
    items.push({ name: segment, href: accumulatedPath });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return items;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								src/utils/socialIconMap.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/utils/socialIconMap.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
// utils/socialIconMap.ts
 | 
			
		||||
import {
 | 
			
		||||
  GithubIcon,
 | 
			
		||||
  GlobeIcon,
 | 
			
		||||
  InstagramIcon,
 | 
			
		||||
  LinkIcon,
 | 
			
		||||
  MailIcon,
 | 
			
		||||
  MessageCircleIcon,
 | 
			
		||||
  SendIcon,
 | 
			
		||||
  TwitterIcon,
 | 
			
		||||
  UserIcon,
 | 
			
		||||
  YoutubeIcon,
 | 
			
		||||
} from "lucide-react"
 | 
			
		||||
 | 
			
		||||
export function getSocialIcon(type: string) {
 | 
			
		||||
  switch (type.toLowerCase()) {
 | 
			
		||||
    case "website":
 | 
			
		||||
    case "linktree":
 | 
			
		||||
      return GlobeIcon
 | 
			
		||||
    case "twitter":
 | 
			
		||||
      return TwitterIcon
 | 
			
		||||
    case "instagram":
 | 
			
		||||
      return InstagramIcon
 | 
			
		||||
    case "youtube":
 | 
			
		||||
      return YoutubeIcon
 | 
			
		||||
    case "email":
 | 
			
		||||
      return MailIcon
 | 
			
		||||
    case "github":
 | 
			
		||||
      return GithubIcon
 | 
			
		||||
    case "telegram":
 | 
			
		||||
      return SendIcon
 | 
			
		||||
    case "mastodon":
 | 
			
		||||
    case "fediverse":
 | 
			
		||||
      return MessageCircleIcon
 | 
			
		||||
    case "furaffinity":
 | 
			
		||||
      return UserIcon
 | 
			
		||||
    default:
 | 
			
		||||
      return LinkIcon
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user