Refactr a lot of things

This commit is contained in:
2025-07-01 22:19:48 +02:00
parent 2e1161b50b
commit 51c85b93de
19 changed files with 520 additions and 52 deletions

32
package-lock.json generated
View File

@ -24,7 +24,8 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1",
"zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@ -8988,6 +8989,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "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
}
}
} }
} }
} }

View File

@ -25,7 +25,8 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1",
"zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

View File

@ -1,4 +1,4 @@
import ImageList from "@/components/albums/ImageList"; import ImageList from "@/components/lists/ImageList";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
export default async function GalleryPage({ params }: { params: { gallerySlug: string, albumSlug: string } }) { 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: { include: {
images: true images: { include: { colors: { include: { color: true } } } },
} }
}) })
@ -30,7 +30,7 @@ export default async function GalleryPage({ params }: { params: { gallerySlug: s
{album && ( {album && (
<> <>
<section className="text-center space-y-4"> <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"> <p className="text-lg text-muted-foreground">
{album.description} {album.description}
</p> </p>

View File

@ -1,4 +1,4 @@
import AlbumList from "@/components/galleries/AlbumList"; import AlbumList from "@/components/lists/AlbumList";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
export default async function GalleryPage({ params }: { params: { gallerySlug: string } }) { export default async function GalleryPage({ params }: { params: { gallerySlug: string } }) {
@ -9,7 +9,7 @@ export default async function GalleryPage({ params }: { params: { gallerySlug: s
slug: gallerySlug slug: gallerySlug
}, },
include: { 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 && ( {gallery && (
<> <>
<section className="text-center space-y-4"> <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"> <p className="text-lg text-muted-foreground">
{gallery.description} {gallery.description}
</p> </p>

View File

@ -1,4 +1,4 @@
import GalleryList from "@/components/home/GalleryList"; import GalleryList from "@/components/lists/GalleryList";
export default function HomePage() { export default function HomePage() {
return ( return (

View 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>
);
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
);
}

View File

@ -1,11 +1,17 @@
import AnimateToggle from "./AnimateToggle";
import ModeToggle from "./ModeToggle"; import ModeToggle from "./ModeToggle";
import NSFWToggle from "./NSFWToggle";
import TopNav from "./TopNav"; import TopNav from "./TopNav";
export default function Header() { export default function Header() {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<TopNav /> <TopNav />
<div className="flex gap-4">
<AnimateToggle />
<NSFWToggle />
<ModeToggle /> <ModeToggle />
</div> </div>
</div>
); );
} }

View 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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 }

View 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 }),
}));