Refactr a lot of things
This commit is contained in:
		
							
								
								
									
										32
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										32
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@ -24,7 +24,8 @@
 | 
			
		||||
        "react": "^19.0.0",
 | 
			
		||||
        "react-dom": "^19.0.0",
 | 
			
		||||
        "sonner": "^2.0.5",
 | 
			
		||||
        "tailwind-merge": "^3.3.1"
 | 
			
		||||
        "tailwind-merge": "^3.3.1",
 | 
			
		||||
        "zustand": "^5.0.6"
 | 
			
		||||
      },
 | 
			
		||||
      "devDependencies": {
 | 
			
		||||
        "@eslint/eslintrc": "^3",
 | 
			
		||||
@ -8988,6 +8989,35 @@
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "url": "https://github.com/sponsors/sindresorhus"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/zustand": {
 | 
			
		||||
      "version": "5.0.6",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz",
 | 
			
		||||
      "integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=12.20.0"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "@types/react": ">=18.0.0",
 | 
			
		||||
        "immer": ">=9.0.6",
 | 
			
		||||
        "react": ">=18.0.0",
 | 
			
		||||
        "use-sync-external-store": ">=1.2.0"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependenciesMeta": {
 | 
			
		||||
        "@types/react": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        },
 | 
			
		||||
        "immer": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        },
 | 
			
		||||
        "react": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        },
 | 
			
		||||
        "use-sync-external-store": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -25,7 +25,8 @@
 | 
			
		||||
    "react": "^19.0.0",
 | 
			
		||||
    "react-dom": "^19.0.0",
 | 
			
		||||
    "sonner": "^2.0.5",
 | 
			
		||||
    "tailwind-merge": "^3.3.1"
 | 
			
		||||
    "tailwind-merge": "^3.3.1",
 | 
			
		||||
    "zustand": "^5.0.6"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@eslint/eslintrc": "^3",
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import ImageList from "@/components/albums/ImageList";
 | 
			
		||||
import ImageList from "@/components/lists/ImageList";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export default async function GalleryPage({ params }: { params: { gallerySlug: string, albumSlug: string } }) {
 | 
			
		||||
@ -21,7 +21,7 @@ export default async function GalleryPage({ params }: { params: { gallerySlug: s
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    include: {
 | 
			
		||||
      images: true
 | 
			
		||||
      images: { include: { colors: { include: { color: true } } } },
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
@ -30,7 +30,7 @@ export default async function GalleryPage({ params }: { params: { gallerySlug: s
 | 
			
		||||
      {album && (
 | 
			
		||||
        <>
 | 
			
		||||
          <section className="text-center space-y-4">
 | 
			
		||||
            <h1 className="text-4xl font-bold tracking-tight">{album.name}</h1>
 | 
			
		||||
            <h1 className="text-4xl font-bold tracking-tight">Album: {album.name}</h1>
 | 
			
		||||
            <p className="text-lg text-muted-foreground">
 | 
			
		||||
              {album.description}
 | 
			
		||||
            </p>
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import AlbumList from "@/components/galleries/AlbumList";
 | 
			
		||||
import AlbumList from "@/components/lists/AlbumList";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export default async function GalleryPage({ params }: { params: { gallerySlug: string } }) {
 | 
			
		||||
@ -9,7 +9,7 @@ export default async function GalleryPage({ params }: { params: { gallerySlug: s
 | 
			
		||||
      slug: gallerySlug
 | 
			
		||||
    },
 | 
			
		||||
    include: {
 | 
			
		||||
      albums: { where: { images: { some: {} } }, include: { coverImage: true } }
 | 
			
		||||
      albums: { where: { images: { some: {} } }, include: { coverImage: { include: { colors: { include: { color: true } } } } } }
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@ export default async function GalleryPage({ params }: { params: { gallerySlug: s
 | 
			
		||||
      {gallery && (
 | 
			
		||||
        <>
 | 
			
		||||
          <section className="text-center space-y-4">
 | 
			
		||||
            <h1 className="text-4xl font-bold tracking-tight">{gallery.name}</h1>
 | 
			
		||||
            <h1 className="text-4xl font-bold tracking-tight">Gallery: {gallery.name}</h1>
 | 
			
		||||
            <p className="text-lg text-muted-foreground">
 | 
			
		||||
              {gallery.description}
 | 
			
		||||
            </p>
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import GalleryList from "@/components/home/GalleryList";
 | 
			
		||||
import GalleryList from "@/components/lists/GalleryList";
 | 
			
		||||
 | 
			
		||||
export default function HomePage() {
 | 
			
		||||
  return (
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										47
									
								
								src/components/cards/AlbumCard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/components/cards/AlbumCard.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,47 @@
 | 
			
		||||
import { Album, Image } from "@/generated/prisma";
 | 
			
		||||
import NextImage from "next/image";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  album: Album & {
 | 
			
		||||
    coverImage: Image | null;
 | 
			
		||||
  };
 | 
			
		||||
  gallerySlug: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function AlbumCard({ album, gallerySlug }: Props) {
 | 
			
		||||
  const href = `/galleries/${gallerySlug}/${album.slug}`;
 | 
			
		||||
  const cover = album.coverImage;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Link
 | 
			
		||||
      href={href}
 | 
			
		||||
      className="group relative overflow-hidden rounded-lg border bg-background shadow transition hover:shadow-lg"
 | 
			
		||||
    >
 | 
			
		||||
      <div className="aspect-[4/3] w-full relative">
 | 
			
		||||
        {cover ? (
 | 
			
		||||
          <NextImage
 | 
			
		||||
            src={`/api/image/thumbnails/${cover.fileKey}.webp`}
 | 
			
		||||
            alt={cover.imageName}
 | 
			
		||||
            fill
 | 
			
		||||
            className="object-cover"
 | 
			
		||||
          />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <div className="flex items-center justify-center h-full w-full bg-muted text-muted-foreground text-sm">
 | 
			
		||||
            No cover image
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {/* Overlay: Album label */}
 | 
			
		||||
        <div className="absolute top-1 left-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded z-10">
 | 
			
		||||
          Album
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {/* Bottom: Album name */}
 | 
			
		||||
        <div className="absolute bottom-0 left-0 w-full bg-black/50 text-white text-sm px-2 py-1 line-clamp-1 truncate">
 | 
			
		||||
          {album.name}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </Link>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										93
									
								
								src/components/cards/CoverCard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/components/cards/CoverCard.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,93 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { type Color, type Image, type ImageColor } from "@/generated/prisma"
 | 
			
		||||
import { useGlobalSettings } from "@/hooks/useGlobalSettings"
 | 
			
		||||
import clsx from "clsx"
 | 
			
		||||
import { BookOpenIcon, EyeOffIcon, ImagePlusIcon } from "lucide-react"
 | 
			
		||||
import NextImage from "next/image"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
import { GlowingBorderWrapper } from "./GlowingBorderWrapper"
 | 
			
		||||
import { TagBadge } from "./TagBadge"
 | 
			
		||||
 | 
			
		||||
type CoverCardProps = {
 | 
			
		||||
  item: {
 | 
			
		||||
    id: string
 | 
			
		||||
    name: string
 | 
			
		||||
    slug: string
 | 
			
		||||
    coverImage?: (Image & {
 | 
			
		||||
      colors?: (ImageColor & { color: Color })[]
 | 
			
		||||
    }) | null
 | 
			
		||||
    type: "album" | "gallery"
 | 
			
		||||
    gallerySlug?: string
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function CoverCard({ item }: CoverCardProps) {
 | 
			
		||||
  const { showNSFW, animateGlow } = useGlobalSettings()
 | 
			
		||||
  const { coverImage } = item
 | 
			
		||||
 | 
			
		||||
  const href =
 | 
			
		||||
    item.type === "album"
 | 
			
		||||
      ? `/galleries/${item.gallerySlug}/${item.slug}`
 | 
			
		||||
      : `/galleries/${item.slug}`
 | 
			
		||||
 | 
			
		||||
  const badgeText = item.type === "album" ? "Album" : "Gallery"
 | 
			
		||||
  const badgeIcon =
 | 
			
		||||
    item.type === "album" ? <BookOpenIcon size={12} className="mr-1" /> : <ImagePlusIcon size={12} className="mr-1" />
 | 
			
		||||
 | 
			
		||||
  const borderColors =
 | 
			
		||||
    coverImage?.colors?.map((c) => c.color?.hex).filter((hex): hex is string => Boolean(hex)) ?? []
 | 
			
		||||
 | 
			
		||||
  const shouldBlur = coverImage?.nsfw && !showNSFW
 | 
			
		||||
 | 
			
		||||
  const content = (
 | 
			
		||||
    <div className="group rounded-lg border overflow-hidden hover:shadow-lg transition-shadow bg-background relative">
 | 
			
		||||
      <div className="relative aspect-[4/3] w-full bg-muted items-center justify-center">
 | 
			
		||||
        {coverImage?.fileKey ? (
 | 
			
		||||
          <NextImage
 | 
			
		||||
            src={`/api/image/thumbnails/${coverImage.fileKey}.webp`}
 | 
			
		||||
            alt={coverImage.imageName}
 | 
			
		||||
            fill
 | 
			
		||||
            className={clsx(
 | 
			
		||||
              "object-cover transition duration-300",
 | 
			
		||||
              shouldBlur && "blur-md scale-105"
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
 | 
			
		||||
            No cover image
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        <div className="absolute top-1 left-1 z-10 flex gap-1">
 | 
			
		||||
          <TagBadge
 | 
			
		||||
            label={badgeText}
 | 
			
		||||
            variant="secondary"
 | 
			
		||||
            className="text-xs px-2 py-0.5 inline-flex items-center"
 | 
			
		||||
            icon={badgeIcon}
 | 
			
		||||
          />
 | 
			
		||||
          {coverImage?.nsfw && (
 | 
			
		||||
            <TagBadge
 | 
			
		||||
              label="NSFW"
 | 
			
		||||
              variant="destructive"
 | 
			
		||||
              icon={<EyeOffIcon size={12} />}
 | 
			
		||||
              className="text-xs px-2 py-0.5 inline-flex items-center"
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="p-4">
 | 
			
		||||
        <h2 className="text-lg font-semibold truncate text-center">{item.name}</h2>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Link href={href}>
 | 
			
		||||
      {coverImage && animateGlow && borderColors.length > 0 ? (
 | 
			
		||||
        <GlowingBorderWrapper colors={borderColors}>{content}</GlowingBorderWrapper>
 | 
			
		||||
      ) : (
 | 
			
		||||
        content
 | 
			
		||||
      )}
 | 
			
		||||
    </Link>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										42
									
								
								src/components/cards/GlowingBorderWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/components/cards/GlowingBorderWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
import { useTheme } from "next-themes"
 | 
			
		||||
import { ReactNode } from "react"
 | 
			
		||||
 | 
			
		||||
interface GlowingBorderWrapperProps {
 | 
			
		||||
  children: ReactNode
 | 
			
		||||
  className?: string
 | 
			
		||||
  animate?: boolean
 | 
			
		||||
  colors?: string[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function GlowingBorderWrapper({
 | 
			
		||||
  children,
 | 
			
		||||
  className,
 | 
			
		||||
  animate = true,
 | 
			
		||||
  colors = [],
 | 
			
		||||
}: GlowingBorderWrapperProps) {
 | 
			
		||||
  const { theme } = useTheme()
 | 
			
		||||
 | 
			
		||||
  const gradientColors = colors.length
 | 
			
		||||
    ? colors.join(", ")
 | 
			
		||||
    : theme === "dark"
 | 
			
		||||
      ? "rgba(255,255,255,0.2), rgba(255,255,255,0.05)"
 | 
			
		||||
      : "rgba(0,0,0,0.1), rgba(0,0,0,0.03)"
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "relative rounded-lg p-[2px]",
 | 
			
		||||
        animate && "animate-glow",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      style={{
 | 
			
		||||
        background: `linear-gradient(135deg, ${gradientColors})`,
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <div className="rounded-md overflow-hidden">{children}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										67
									
								
								src/components/cards/ImageCard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/components/cards/ImageCard.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,67 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { type Color, type Image, type ImageColor } from "@/generated/prisma"
 | 
			
		||||
import { useGlobalSettings } from "@/hooks/useGlobalSettings"
 | 
			
		||||
import clsx from "clsx"
 | 
			
		||||
import { EyeOffIcon } from "lucide-react"
 | 
			
		||||
import NextImage from "next/image"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
import { GlowingBorderWrapper } from "./GlowingBorderWrapper"
 | 
			
		||||
import { TagBadge } from "./TagBadge"
 | 
			
		||||
 | 
			
		||||
type ImageWithColors = Image & {
 | 
			
		||||
  colors?: (ImageColor & { color: Color })[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ImageCardProps = {
 | 
			
		||||
  image: ImageWithColors,
 | 
			
		||||
  gallerySlug?: string
 | 
			
		||||
  albumSlug?: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function ImageCard({ image, gallerySlug, albumSlug }: ImageCardProps) {
 | 
			
		||||
  const { showNSFW, animateGlow } = useGlobalSettings()
 | 
			
		||||
 | 
			
		||||
  const href = `/galleries/${gallerySlug}/${albumSlug}/${image.id}`
 | 
			
		||||
 | 
			
		||||
  const borderColors =
 | 
			
		||||
    image.colors?.map((c) => c.color?.hex).filter((hex): hex is string => Boolean(hex)) ?? []
 | 
			
		||||
 | 
			
		||||
  const shouldBlur = image.nsfw && !showNSFW
 | 
			
		||||
 | 
			
		||||
  const content = (
 | 
			
		||||
    <div className="group rounded-lg border overflow-hidden hover:shadow-lg transition-shadow bg-background relative">
 | 
			
		||||
      <div className="relative aspect-[4/3] w-full bg-muted items-center justify-center">
 | 
			
		||||
        <NextImage
 | 
			
		||||
          src={`/api/image/thumbnails/${image.fileKey}.webp`}
 | 
			
		||||
          alt={image.imageName}
 | 
			
		||||
          fill
 | 
			
		||||
          className={clsx("object-cover transition duration-300", shouldBlur && "blur-md scale-105")}
 | 
			
		||||
        />
 | 
			
		||||
        {image.nsfw && (
 | 
			
		||||
          <div className="absolute top-1 left-1 z-10 flex gap-1">
 | 
			
		||||
            <TagBadge
 | 
			
		||||
              label="NSFW"
 | 
			
		||||
              variant="destructive"
 | 
			
		||||
              icon={<EyeOffIcon size={12} />}
 | 
			
		||||
              className="text-xs px-2 py-0.5 inline-flex items-center"
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="p-4">
 | 
			
		||||
        <h2 className="text-lg font-semibold truncate text-center">{image.imageName}</h2>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Link href={href}>
 | 
			
		||||
      {animateGlow && borderColors.length > 0 ? (
 | 
			
		||||
        <GlowingBorderWrapper colors={borderColors}>{content}</GlowingBorderWrapper>
 | 
			
		||||
      ) : (
 | 
			
		||||
        content
 | 
			
		||||
      )}
 | 
			
		||||
    </Link>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										32
									
								
								src/components/cards/TagBadge.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/components/cards/TagBadge.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,32 @@
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
import { HTMLAttributes, ReactNode } from "react"
 | 
			
		||||
import { Badge } from "../ui/badge"
 | 
			
		||||
 | 
			
		||||
interface TagBadgeProps extends HTMLAttributes<HTMLDivElement> {
 | 
			
		||||
  label: string
 | 
			
		||||
  icon?: ReactNode
 | 
			
		||||
  variant?: "default" | "secondary" | "outline" | "destructive" | null
 | 
			
		||||
  hidden?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function TagBadge({
 | 
			
		||||
  label,
 | 
			
		||||
  icon,
 | 
			
		||||
  variant = "default",
 | 
			
		||||
  hidden = false,
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: TagBadgeProps) {
 | 
			
		||||
  if (hidden) return null
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Badge
 | 
			
		||||
      variant={variant}
 | 
			
		||||
      className={cn("inline-flex items-center text-xs px-2 py-0.5 rounded-md", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      {icon}
 | 
			
		||||
      {label}
 | 
			
		||||
    </Badge>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								src/components/global/AnimateToggle.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/components/global/AnimateToggle.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { useGlobalSettings } from "@/hooks/useGlobalSettings";
 | 
			
		||||
import { Sparkle, Sparkles } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
export default function AnimateToggle() {
 | 
			
		||||
  const { animateGlow, setAnimateGlow } = useGlobalSettings();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Button
 | 
			
		||||
      variant="outline"
 | 
			
		||||
      size="icon"
 | 
			
		||||
      onClick={() => setAnimateGlow(!animateGlow)}
 | 
			
		||||
      aria-label="Toggle glow animation"
 | 
			
		||||
    >
 | 
			
		||||
      {animateGlow ? <Sparkles className="w-5 h-5" /> : <Sparkle className="w-5 h-5 opacity-50" />}
 | 
			
		||||
    </Button>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -1,11 +1,17 @@
 | 
			
		||||
import AnimateToggle from "./AnimateToggle";
 | 
			
		||||
import ModeToggle from "./ModeToggle";
 | 
			
		||||
import NSFWToggle from "./NSFWToggle";
 | 
			
		||||
import TopNav from "./TopNav";
 | 
			
		||||
 | 
			
		||||
export default function Header() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex items-center justify-between">
 | 
			
		||||
      <TopNav />
 | 
			
		||||
      <ModeToggle />
 | 
			
		||||
      <div className="flex gap-4">
 | 
			
		||||
        <AnimateToggle />
 | 
			
		||||
        <NSFWToggle />
 | 
			
		||||
        <ModeToggle />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								src/components/global/NSFWToggle.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/components/global/NSFWToggle.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { useGlobalSettings } from "@/hooks/useGlobalSettings";
 | 
			
		||||
import { Eye, EyeOff } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
export default function NSFWToggle() {
 | 
			
		||||
  const { showNSFW, setShowNSFW } = useGlobalSettings();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Button
 | 
			
		||||
      variant="outline"
 | 
			
		||||
      size="icon"
 | 
			
		||||
      onClick={() => setShowNSFW(!showNSFW)}
 | 
			
		||||
      aria-label="Toggle NSFW content"
 | 
			
		||||
    >
 | 
			
		||||
      {showNSFW ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5 opacity-50" />}
 | 
			
		||||
    </Button>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -1,42 +0,0 @@
 | 
			
		||||
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={`/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 ? (
 | 
			
		||||
                  <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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										38
									
								
								src/components/lists/AlbumList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/components/lists/AlbumList.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,38 @@
 | 
			
		||||
import { Album, Color, Image, ImageColor } from "@/generated/prisma";
 | 
			
		||||
import CoverCard from "../cards/CoverCard";
 | 
			
		||||
 | 
			
		||||
type AlbumsWithCover = (Album & {
 | 
			
		||||
  coverImage: (Image & {
 | 
			
		||||
    colors?: (ImageColor & { color: Color })[];
 | 
			
		||||
  }) | null;
 | 
			
		||||
})[];
 | 
			
		||||
 | 
			
		||||
export default function AlbumList({
 | 
			
		||||
  albums,
 | 
			
		||||
  gallerySlug,
 | 
			
		||||
}: {
 | 
			
		||||
  albums: AlbumsWithCover;
 | 
			
		||||
  gallerySlug: string;
 | 
			
		||||
}) {
 | 
			
		||||
  if (!albums.length) return <p>There are no albums here!</p>;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <section>
 | 
			
		||||
      <div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
 | 
			
		||||
        {albums.map((album) => (
 | 
			
		||||
          <CoverCard
 | 
			
		||||
            key={album.id}
 | 
			
		||||
            item={{
 | 
			
		||||
              id: album.id,
 | 
			
		||||
              name: album.name,
 | 
			
		||||
              slug: album.slug,
 | 
			
		||||
              coverImage: album.coverImage ?? undefined,
 | 
			
		||||
              type: "album",
 | 
			
		||||
              gallerySlug,
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										37
									
								
								src/components/lists/GalleryList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/components/lists/GalleryList.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import CoverCard from "../cards/CoverCard";
 | 
			
		||||
 | 
			
		||||
export default async function GalleryList() {
 | 
			
		||||
  const galleries = await prisma.gallery.findMany({
 | 
			
		||||
    include: {
 | 
			
		||||
      coverImage: {
 | 
			
		||||
        include: {
 | 
			
		||||
          colors: {
 | 
			
		||||
            include: { color: true },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!galleries.length) return <p>There are no galleries here!</p>;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <section>
 | 
			
		||||
      <div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
 | 
			
		||||
        {galleries.map((gallery) => (
 | 
			
		||||
          <CoverCard
 | 
			
		||||
            key={gallery.id}
 | 
			
		||||
            item={{
 | 
			
		||||
              id: gallery.id,
 | 
			
		||||
              name: gallery.name,
 | 
			
		||||
              slug: gallery.slug,
 | 
			
		||||
              coverImage: gallery.coverImage ?? undefined,
 | 
			
		||||
              type: "gallery",
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										16
									
								
								src/components/lists/ImageList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/components/lists/ImageList.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
			
		||||
import { Image } from "@/generated/prisma";
 | 
			
		||||
import ImageCard from "../cards/ImageCard";
 | 
			
		||||
 | 
			
		||||
export default function ImageList({ images, gallerySlug, albumSlug }: { images: Image[], gallerySlug: string, albumSlug: string }) {
 | 
			
		||||
  if (!images.length) return <p>There are no images here!</p>;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <section>
 | 
			
		||||
      <div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
 | 
			
		||||
        {images.map((image) => (
 | 
			
		||||
          <ImageCard key={image.id} image={image} gallerySlug={gallerySlug} albumSlug={albumSlug} />
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										46
									
								
								src/components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,46 @@
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import { Slot } from "@radix-ui/react-slot"
 | 
			
		||||
import { cva, type VariantProps } from "class-variance-authority"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
const badgeVariants = cva(
 | 
			
		||||
  "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default:
 | 
			
		||||
          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
 | 
			
		||||
        secondary:
 | 
			
		||||
          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
 | 
			
		||||
        destructive:
 | 
			
		||||
          "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
 | 
			
		||||
        outline:
 | 
			
		||||
          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: "default",
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
function Badge({
 | 
			
		||||
  className,
 | 
			
		||||
  variant,
 | 
			
		||||
  asChild = false,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"span"> &
 | 
			
		||||
  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
 | 
			
		||||
  const Comp = asChild ? Slot : "span"
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Comp
 | 
			
		||||
      data-slot="badge"
 | 
			
		||||
      className={cn(badgeVariants({ variant }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Badge, badgeVariants }
 | 
			
		||||
							
								
								
									
										15
									
								
								src/hooks/useGlobalSettings.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/hooks/useGlobalSettings.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
			
		||||
import { create } from "zustand";
 | 
			
		||||
 | 
			
		||||
type GlobalSettings = {
 | 
			
		||||
  showNSFW: boolean;
 | 
			
		||||
  setShowNSFW: (val: boolean) => void;
 | 
			
		||||
  animateGlow: boolean;
 | 
			
		||||
  setAnimateGlow: (val: boolean) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useGlobalSettings = create<GlobalSettings>((set) => ({
 | 
			
		||||
  showNSFW: false,
 | 
			
		||||
  setShowNSFW: (val) => set({ showNSFW: val }),
 | 
			
		||||
  animateGlow: true,
 | 
			
		||||
  setAnimateGlow: (val) => set({ animateGlow: val }),
 | 
			
		||||
}));
 | 
			
		||||
		Reference in New Issue
	
	Block a user