Add better image styles
This commit is contained in:
@ -201,6 +201,8 @@ model ImageVariant {
|
|||||||
sizeBytes Int?
|
sizeBytes Int?
|
||||||
|
|
||||||
image Image @relation(fields: [imageId], references: [id])
|
image Image @relation(fields: [imageId], references: [id])
|
||||||
|
|
||||||
|
@@unique([imageId, type])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ColorPalette {
|
model ColorPalette {
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
110
src/app/(normal)/[gallerySlug]/[albumSlug]/[imageId]/page.tsx
Normal file
110
src/app/(normal)/[gallerySlug]/[albumSlug]/[imageId]/page.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import ArtistInfoBox from "@/components/images/ArtistInfoBox";
|
||||||
|
import GlowingImageWithToggle from "@/components/images/GlowingImageWithToggle";
|
||||||
|
import ImageMetadataBox from "@/components/images/ImageMetadataBox";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
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 &&
|
||||||
|
<Link href={`/raw/${image.id}`} passHref>
|
||||||
|
<GlowingImageWithToggle
|
||||||
|
alt={image.imageName}
|
||||||
|
variant={resizedVariant}
|
||||||
|
colors={image.colors}
|
||||||
|
src={`/api/image/${resizedVariant.s3Key}`}
|
||||||
|
nsfw={image.nsfw}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
<section className="py-8 flex flex-col gap-4">
|
||||||
|
{image.artist && <ArtistInfoBox artist={image.artist} />}
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-6 space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Image Metadata</h2>
|
||||||
|
|
||||||
|
<p><strong>Name:</strong> {image.imageName}</p>
|
||||||
|
{image.altText && <p><strong>Alt Text:</strong> {image.altText}</p>}
|
||||||
|
{image.description && <p><strong>Description:</strong> {image.description}</p>}
|
||||||
|
{/* <p><strong>NSFW:</strong> {image.nsfw ? "Yes" : "No"}</p>
|
||||||
|
<p><strong>Upload Date:</strong> {new Date(image.uploadDate).toLocaleDateString()}</p>
|
||||||
|
{image.source && <p><strong>Source:</strong> {image.source}</p>}
|
||||||
|
{image.fileType && <p><strong>File Type:</strong> {image.fileType}</p>}
|
||||||
|
{image.fileSize && <p><strong>Size:</strong> {(image.fileSize / 1024).toFixed(1)} KB</p>}
|
||||||
|
{image.creationDate && (
|
||||||
|
<p><strong>Created:</strong> {new Date(image.creationDate).toLocaleDateString()}</p>
|
||||||
|
)}
|
||||||
|
{image.creationYear && image.creationMonth && (
|
||||||
|
<p><strong>Creation:</strong> {image.creationMonth}/{image.creationYear}</p>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* {image.metadata && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-lg font-medium mt-4">Metadata</h3>
|
||||||
|
<p><strong>Width:</strong> {image.metadata.width}px</p>
|
||||||
|
<p><strong>Height:</strong> {image.metadata.height}px</p>
|
||||||
|
<p><strong>Format:</strong> {image.metadata.format}</p>
|
||||||
|
<p><strong>Color Space:</strong> {image.metadata.space}</p>
|
||||||
|
<p><strong>Channels:</strong> {image.metadata.channels}</p>
|
||||||
|
{image.metadata.bitsPerSample && (
|
||||||
|
<p><strong>Bits Per Sample:</strong> {image.metadata.bitsPerSample}</p>
|
||||||
|
)}
|
||||||
|
{image.metadata.hasAlpha !== null && (
|
||||||
|
<p><strong>Has Alpha:</strong> {image.metadata.hasAlpha ? "Yes" : "No"}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* {image.stats && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-lg font-medium mt-4">Statistics</h3>
|
||||||
|
<p><strong>Entropy:</strong> {image.stats.entropy.toFixed(2)}</p>
|
||||||
|
<p><strong>Sharpness:</strong> {image.stats.sharpness.toFixed(2)}</p>
|
||||||
|
<p><strong>Dominant Color:</strong> rgb({image.stats.dominantR}, {image.stats.dominantG}, {image.stats.dominantB})</p>
|
||||||
|
<p><strong>Is Opaque:</strong> {image.stats.isOpaque ? "Yes" : "No"}</p>
|
||||||
|
</>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageMetadataBox
|
||||||
|
album={image.album}
|
||||||
|
categories={image.categories}
|
||||||
|
tags={image.tags}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
}
|
57
src/app/(normal)/categories/[id]/page.tsx
Normal file
57
src/app/(normal)/categories/[id]/page.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import CategoryImageList from "@/components/categories/CategoryImageList";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function CategoriesSinglePage({ params }: { params: { id: string } }) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const category = await prisma.category.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
images: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
imageName: true,
|
||||||
|
fileKey: true,
|
||||||
|
nsfw: true,
|
||||||
|
altText: true,
|
||||||
|
album: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
gallery: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-12 space-y-12">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight">Category not found</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-12 space-y-12">
|
||||||
|
{category && (
|
||||||
|
<>
|
||||||
|
<section className="text-center space-y-4">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight">Category: {category.name}</h1>
|
||||||
|
<p className="text-lg text-muted-foreground">
|
||||||
|
{category.description}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<CategoryImageList images={category.images} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -4,7 +4,7 @@ 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 { Toaster } from "sonner";
|
||||||
import "./globals.css";
|
import "../globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -21,7 +21,7 @@ export const metadata: Metadata = {
|
|||||||
description: "Generated by create next app",
|
description: "Generated by create next app",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
42
src/app/(raw)/layout.tsx
Normal file
42
src/app/(raw)/layout.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { ThemeProvider } from "@/components/global/ThemeProvider";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
51
src/app/(raw)/raw/[imageId]/page.tsx
Normal file
51
src/app/(raw)/raw/[imageId]/page.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// app/raw/[id]/page.tsx
|
||||||
|
import RawCloseButton from "@/components/raw/RawCloseButton";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import NextImage from "next/image";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function RawImagePage({ params }: { params: { imageId: string } }) {
|
||||||
|
const { imageId } = params;
|
||||||
|
|
||||||
|
const image = await prisma.image.findUnique({
|
||||||
|
where: { id: imageId },
|
||||||
|
include: {
|
||||||
|
album: { include: { gallery: true } },
|
||||||
|
variants: true,
|
||||||
|
palettes: {
|
||||||
|
include: { palette: { include: { items: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!image) return notFound();
|
||||||
|
|
||||||
|
const variant = image.variants.find(v => v.type === "watermarked");
|
||||||
|
if (!variant) return notFound();
|
||||||
|
|
||||||
|
const palette = image.palettes.find(p => p.type === "primary")?.palette;
|
||||||
|
const hexColors = palette?.items.map(item => item.hex).filter(Boolean) as string[];
|
||||||
|
|
||||||
|
const backgroundStyle =
|
||||||
|
hexColors && hexColors.length > 1
|
||||||
|
? { backgroundImage: `linear-gradient(to bottom, ${hexColors.join(", ")})` }
|
||||||
|
: { backgroundColor: "#0f0f0f" };
|
||||||
|
|
||||||
|
const targetHref = `/${image.album?.gallery?.slug}/${image.album?.slug}/${image.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style={backgroundStyle}
|
||||||
|
>
|
||||||
|
<RawCloseButton targetHref={targetHref} />
|
||||||
|
<NextImage
|
||||||
|
src={`/api/image/${variant.s3Key}`}
|
||||||
|
alt={image.altText || image.imageName}
|
||||||
|
width={variant.width}
|
||||||
|
height={variant.height}
|
||||||
|
className="object-contain rounded-lg max-h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,60 +0,0 @@
|
|||||||
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 >
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,4 +1,5 @@
|
|||||||
import { Image } from "@/generated/prisma";
|
import { Image } from "@/generated/prisma";
|
||||||
|
import clsx from "clsx";
|
||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@ -17,7 +18,10 @@ export default function ImageList({ images, gallerySlug, albumSlug }: { images:
|
|||||||
src={`/api/image/thumbnails/${img.fileKey}.webp`}
|
src={`/api/image/thumbnails/${img.fileKey}.webp`}
|
||||||
alt={img.imageName}
|
alt={img.imageName}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className={clsx(
|
||||||
|
" object-cover",
|
||||||
|
img.nsfw && "blur-md scale-105"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||||
|
48
src/components/categories/CategoryImageList.tsx
Normal file
48
src/components/categories/CategoryImageList.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Album, Gallery, Image } from "@/generated/prisma";
|
||||||
|
import NextImage from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type ImagesWithItems = (Pick<Image, "id" | "fileKey" | "imageName" | "altText" | "nsfw"> & {
|
||||||
|
album?: (Pick<Album, "slug"> & {
|
||||||
|
gallery?: Pick<Gallery, "slug"> | null
|
||||||
|
}) | null
|
||||||
|
})[]
|
||||||
|
|
||||||
|
export default function CategoryImageList({ images }: { images: ImagesWithItems }) {
|
||||||
|
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) => {
|
||||||
|
const gallerySlug = img.album?.gallery?.slug;
|
||||||
|
const albumSlug = img.album?.slug;
|
||||||
|
if (!gallerySlug || !albumSlug || !img.id) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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.altText || ""}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@ -14,8 +14,9 @@ type Props = {
|
|||||||
alt: string,
|
alt: string,
|
||||||
variant: ImageVariant,
|
variant: ImageVariant,
|
||||||
colors: Colors[],
|
colors: Colors[],
|
||||||
src: string
|
src: string,
|
||||||
className?: string
|
revealed?: boolean,
|
||||||
|
className?: string,
|
||||||
animate?: boolean
|
animate?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ export default function GlowingImageBorder({
|
|||||||
colors,
|
colors,
|
||||||
src,
|
src,
|
||||||
className,
|
className,
|
||||||
|
revealed = true,
|
||||||
animate = true,
|
animate = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
@ -37,11 +39,11 @@ export default function GlowingImageBorder({
|
|||||||
const getColor = (type: string) =>
|
const getColor = (type: string) =>
|
||||||
colors.find((c) => c.type === type)?.color.hex
|
colors.find((c) => c.type === type)?.color.hex
|
||||||
|
|
||||||
const vibrantLight = getColor("vibrant") || "#ff5ec4"
|
const vibrantLight = getColor("Vibrant") || "#ff5ec4"
|
||||||
const mutedLight = getColor("muted") || "#5ecaff"
|
const mutedLight = getColor("Muted") || "#5ecaff"
|
||||||
|
|
||||||
const darkVibrant = getColor("darkVibrant") || "#fc03a1"
|
const darkVibrant = getColor("DarkVibrant") || "#fc03a1"
|
||||||
const darkMuted = getColor("darkMuted") || "#035efc"
|
const darkMuted = getColor("DarkMuted") || "#035efc"
|
||||||
|
|
||||||
const vibrant = resolvedTheme === "dark" ? darkVibrant : vibrantLight
|
const vibrant = resolvedTheme === "dark" ? darkVibrant : vibrantLight
|
||||||
const muted = resolvedTheme === "dark" ? darkMuted : mutedLight
|
const muted = resolvedTheme === "dark" ? darkMuted : mutedLight
|
||||||
@ -62,13 +64,16 @@ export default function GlowingImageBorder({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="relative z-10 rounded-xl overflow-hidden">
|
<div className="relative z-10 rounded-xl overflow-hidden group">
|
||||||
<NextImage
|
<NextImage
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt || "Image"}
|
alt={alt || "Image"}
|
||||||
width={variant.width}
|
width={variant.width}
|
||||||
height={variant.height}
|
height={variant.height}
|
||||||
className="rounded-xl"
|
className={clsx(
|
||||||
|
"rounded-xl transition duration-300",
|
||||||
|
!revealed && "blur-md scale-105"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,10 +11,12 @@ type Props = {
|
|||||||
colors: (ImageColor & { color: Color })[];
|
colors: (ImageColor & { color: Color })[];
|
||||||
alt: string;
|
alt: string;
|
||||||
src: string;
|
src: string;
|
||||||
};
|
nsfw: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function GlowingImageWithToggle({ variant, colors, alt, src }: Props) {
|
export default function GlowingImageWithToggle({ variant, colors, alt, src, nsfw }: Props) {
|
||||||
const [animate, setAnimate] = useState(true);
|
const [animate, setAnimate] = useState(true);
|
||||||
|
const [revealed, setRevealed] = useState(!nsfw)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full max-w-fit">
|
<div className="relative w-full max-w-fit">
|
||||||
@ -24,6 +26,7 @@ export default function GlowingImageWithToggle({ variant, colors, alt, src }: Pr
|
|||||||
colors={colors}
|
colors={colors}
|
||||||
src={src}
|
src={src}
|
||||||
animate={animate}
|
animate={animate}
|
||||||
|
revealed={revealed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-4 pt-8">
|
<div className="flex flex-col items-center gap-4 pt-8">
|
||||||
@ -32,6 +35,15 @@ export default function GlowingImageWithToggle({ variant, colors, alt, src }: Pr
|
|||||||
<Label htmlFor="animate">Animate glow</Label>
|
<Label htmlFor="animate">Animate glow</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{nsfw && (
|
||||||
|
<div className="flex flex-col items-center gap-4 pt-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch id="animate" checked={revealed} onCheckedChange={setRevealed} />
|
||||||
|
<Label htmlFor="animate">Reveal NSFW</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
34
src/components/raw/RawCloseButton.tsx
Normal file
34
src/components/raw/RawCloseButton.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { X } from "lucide-react"; // react-lucide close icon
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
type RawCloseButtonProps = {
|
||||||
|
targetHref: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RawCloseButton({ targetHref }: RawCloseButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ESC key listener
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
router.push(targetHref);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleEsc);
|
||||||
|
return () => window.removeEventListener("keydown", handleEsc);
|
||||||
|
}, [targetHref, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(targetHref)}
|
||||||
|
className="absolute top-4 right-4 z-50 rounded-md bg-background/80 p-2 hover:bg-background/60 transition"
|
||||||
|
title="Close full view (ESC)"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user