Basic layout finished

This commit is contained in:
2025-06-28 22:47:00 +02:00
parent 26b034f6f0
commit ee79f75668
32 changed files with 3474 additions and 5 deletions

2164
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,9 +9,14 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.839.0",
"@aws-sdk/s3-request-presigner": "^3.839.0",
"@prisma/client": "^6.10.1", "@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-navigation-menu": "^1.2.13",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",

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

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

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

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

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

View File

@ -241,3 +241,52 @@ body {
@apply bg-background text-foreground; @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;
}

View File

@ -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 type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@ -23,11 +27,29 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} 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> </body>
</html> </html>
); );

View File

@ -1,8 +1,15 @@
import GalleryList from "@/components/home/GalleryList";
export default function Home() { export default function HomePage() {
return ( return (
<div> <div className="max-w-7xl mx-auto px-4 py-12 space-y-12">
APP HOME <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> </div>
); );
} }

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

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

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

View File

@ -0,0 +1,5 @@
export default function Footer() {
return (
<div>Footer</div>
);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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