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