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