Refine image page, add about page

This commit is contained in:
2025-07-03 18:03:16 +02:00
parent f5edeb67d4
commit 6026416c5c
16 changed files with 301 additions and 167 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View File

@ -0,0 +1,58 @@
import { MailIcon, MessageCircleIcon, RadioIcon, WavesIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
export default function AboutPage() {
return (
<div className="max-w-3xl mx-auto px-4 py-12 flex flex-col gap-10">
<section className="text-center space-y-4">
<h1 className="text-4xl font-bold tracking-tight">About</h1>
<p className="text-muted-foreground text-lg">
Fellies is a network of services for a small group of fluffy furry and furry-friendly folks.<br />
We are here to support the furry and queer communities and provide a safe space for them to express themselves.<br />
This place is my own space to present all the nice artworks my (currently) three OCs including my main fursona Citali I've got from all the great and talented artists.
</p>
<p className="text-muted-foreground py-4">
This place is free of any AI or tracking functionality and is not intended to be used for any commercial purposes.<br />
The usage of any content on this page for AI training is strictly prohibited.
</p>
</section>
<section className="border rounded-lg p-6 shadow bg-muted/20 space-y-4">
<h2 className="text-xl font-semibold text-center">Connect with me</h2>
<div className="flex flex-wrap gap-4 justify-between">
<SocialLink icon={<MailIcon className="w-5 h-5" />} href="mailto:core@fellies.email" label="Email" />
<SocialLink icon={<MessageCircleIcon className="w-5 h-5" />} href="https://signal.me/#eu/ax5Sf75Na5g6c_Cir3Q9c0zv6TnoaQCKAo3dcUo15990aLTlbIBO1qbKqoB7WPuQ" label="Signal" />
<SocialLink icon={<WavesIcon className="w-5 h-5" />} href="https://matrix.to/#/@citali:steffo.dev" label="Matrix" />
<SocialLink icon={<RadioIcon className="w-5 h-5" />} href="https://fellies.social/@Citali" label="Fediverse" />
</div>
</section>
<section className="border rounded-lg p-6 shadow bg-muted/20 space-y-4">
<div className="py-6 flex justify-center">
<Image
src="/images/Intersex-inclusive_pride_flag.png"
alt="Inclusive Pride Flag"
width={500}
height={500}
className="rounded shadow"
/>
</div>
</section>
</div>
);
}
function SocialLink({ icon, href, label }: { icon: React.ReactNode, href: string, label: string }) {
return (
<Link
href={href}
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/30 hover:bg-muted/50 transition text-sm"
target="_blank"
rel="noopener noreferrer"
>
{icon}
{label}
</Link>
)
}

View File

@ -1,5 +1,5 @@
import ArtistImageGrid from "@/components/artists/ArtistImageGrid";
import ArtistInfoBox from "@/components/artists/ArtistInfoBox";
import ImageList from "@/components/lists/ImageList";
import prisma from "@/lib/prisma";
export default async function ArtistPage({ params }: { params: { artistSlug: string } }) {
@ -13,10 +13,12 @@ export default async function ArtistPage({ params }: { params: { artistSlug: str
images: {
include: {
variants: true,
album: { include: { gallery: true } }
}
album: { include: { gallery: true } },
colors: { include: { color: true } }
},
orderBy: [{ sortIndex: 'asc' }, { imageName: 'asc' }]
},
socials: { where: { isVisible: true }, orderBy: { isPrimary: "desc" } }
socials: { where: { isVisible: true }, orderBy: [{ sortIndex: 'asc' }, { isPrimary: "desc" }] }
}
})
@ -33,8 +35,7 @@ export default async function ArtistPage({ params }: { params: { artistSlug: str
</p>
</section>
<ArtistInfoBox artist={artist} />
<h2 className="text-2xl font-bold tracking-tight">Images</h2>
<ArtistImageGrid images={artist.images} />
<ImageList images={artist.images} />
</div>
);
}

View File

@ -1,4 +1,4 @@
import CategoryImageList from "@/components/categories/CategoryImageList";
import ImageList from "@/components/lists/ImageList";
import prisma from "@/lib/prisma";
export default async function CategoriesSinglePage({ params }: { params: { id: string } }) {
@ -10,48 +10,29 @@ export default async function CategoriesSinglePage({ params }: { params: { id: s
},
include: {
images: {
select: {
id: true,
imageName: true,
fileKey: true,
nsfw: true,
altText: true,
album: {
select: {
slug: true,
gallery: {
select: {
slug: true,
},
},
}
}
}
}
include: {
variants: true,
album: { include: { gallery: true } },
colors: { include: { color: true } }
},
orderBy: [{ sortIndex: 'asc' }, { imageName: 'asc' }]
},
}
});
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>
)
throw new Error("Category not found")
}
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 className="max-w-7xl mx-auto px-4 py-12 space-y-12 flex flex-col items-center">
<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>
<ImageList images={category.images} />
</div>
);
}

View File

@ -1,5 +1,5 @@
import ImageCard from "@/components/cards/ImageCard";
import ArtistInfoBox from "@/components/images/ArtistInfoBox";
import GlowingImageWithToggle from "@/components/images/GlowingImageWithToggle";
import ImageMetadataBox from "@/components/images/ImageMetadataBox";
import prisma from "@/lib/prisma";
@ -11,11 +11,11 @@ export default async function ImagePage({ params }: { params: { gallerySlug: str
id: imageId
},
include: {
artist: { include: { socials: true } },
artist: {
include: { socials: { where: { isVisible: true }, orderBy: [{ sortIndex: 'asc' }, { isPrimary: "desc" }] } }
},
colors: { include: { color: true } },
extractColors: { include: { extract: true } },
palettes: { include: { palette: { include: { items: true } } } },
album: true,
album: { include: { gallery: true } },
categories: true,
tags: true,
variants: true,
@ -26,82 +26,27 @@ export default async function ImagePage({ params }: { params: { gallerySlug: str
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}`}
nsfw={image.nsfw}
imageId={image.id}
/>
}
<section className="py-8 flex flex-col gap-4">
{image.artist && <ArtistInfoBox artist={image.artist} />}
<div className="border rounded-lg p-4 shadow bg-muted/30 w-full max-w-xl">
<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 className="flex flex-col items-center">
<div>
<h1 className="text-2xl font-bold mb-4 pb-8">{image.imageName}</h1>
</div>
)}
<ImageCard image={image} gallerySlug={image.album?.gallery?.slug} albumSlug={image.album?.slug} size="large" />
<section className="py-8 flex flex-col gap-4">
{image.artist && <ArtistInfoBox artist={image.artist} />}
<ImageMetadataBox
album={image.album}
categories={image.categories}
tags={image.tags}
creationDate={image.creationDate}
creationYear={image.creationYear}
creationMonth={image.creationMonth}
altText={image.altText}
description={image.description}
/>
</section>
</div>
</div >
);
}

View File

@ -1,4 +1,4 @@
import ImageList from "@/components/lists/ImageList";
import AlbumImageList from "@/components/lists/AlbumImageList";
import prisma from "@/lib/prisma";
export default async function GalleryPage({ params }: { params: { gallerySlug: string, albumSlug: string } }) {
@ -21,7 +21,10 @@ export default async function GalleryPage({ params }: { params: { gallerySlug: s
},
},
include: {
images: { include: { colors: { include: { color: true } } } },
images: {
include: { colors: { include: { color: true } } },
orderBy: [{ sortIndex: 'asc' }, { imageName: 'asc' }]
},
}
})
@ -35,7 +38,7 @@ export default async function GalleryPage({ params }: { params: { gallerySlug: s
{album.description}
</p>
</section>
<ImageList images={album.images} gallerySlug={gallerySlug} albumSlug={albumSlug} />
<AlbumImageList images={album.images} gallerySlug={gallerySlug} albumSlug={albumSlug} />
</>
)}
</div>

View File

@ -9,7 +9,11 @@ export default async function GalleryPage({ params }: { params: { gallerySlug: s
slug: gallerySlug
},
include: {
albums: { where: { images: { some: {} } }, include: { coverImage: { include: { colors: { include: { color: true } } } } } }
albums: {
where: { images: { some: {} } },
include: { coverImage: { include: { colors: { include: { color: true } } } } },
orderBy: { sortIndex: 'asc' }
}
}
})

View File

@ -0,0 +1,38 @@
import ImageList from "@/components/lists/ImageList";
import prisma from "@/lib/prisma";
export default async function TagsSinglePage({ params }: { params: { id: string } }) {
const { id } = await params;
const tag = await prisma.tag.findUnique({
where: {
id,
},
include: {
images: {
include: {
variants: true,
album: { include: { gallery: true } },
colors: { include: { color: true } }
},
orderBy: [{ sortIndex: 'asc' }, { imageName: 'asc' }]
},
}
});
if (!tag) {
throw new Error("Tag not found")
}
return (
<div className="max-w-7xl mx-auto px-4 py-12 space-y-12 flex flex-col items-center">
<section className="text-center space-y-4">
<h1 className="text-4xl font-bold tracking-tight">Tag: {tag.name}</h1>
<p className="text-lg text-muted-foreground">
{tag.description}
</p>
</section>
<ImageList images={tag.images} />
</div>
);
}

View File

@ -9,6 +9,7 @@ interface GlowingBorderWrapperProps {
className?: string
animate?: boolean
colors?: string[]
size?: "default" | "large"
}
export function GlowingBorderWrapper({
@ -16,6 +17,7 @@ export function GlowingBorderWrapper({
className,
animate = true,
colors = [],
size = "default"
}: GlowingBorderWrapperProps) {
const { theme } = useTheme()
@ -25,10 +27,16 @@ export function GlowingBorderWrapper({
? "rgba(255,255,255,0.2), rgba(255,255,255,0.05)"
: "rgba(0,0,0,0.1), rgba(0,0,0,0.03)"
const padding = size === "large" ? "p-[6px]" : "p-[2px]"
const roundedOuter = size === "large" ? "rounded-xl" : "rounded-lg"
const roundedInner = size === "large" ? "rounded-lg" : "rounded-md"
return (
<div
className={cn(
"relative rounded-lg p-[2px]",
"relative",
padding,
roundedOuter,
animate && "animate-glow",
className
)}
@ -36,7 +44,7 @@ export function GlowingBorderWrapper({
background: `linear-gradient(135deg, ${gradientColors})`,
}}
>
<div className="rounded-md overflow-hidden">{children}</div>
<div className={cn("overflow-hidden", roundedInner)}>{children}</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
"use client"
import { type Color, type Image, type ImageColor } from "@/generated/prisma"
import { ImageVariant, type Color, type Image, type ImageColor } from "@/generated/prisma"
import { useGlobalSettings } from "@/hooks/useGlobalSettings"
import clsx from "clsx"
import { EyeOffIcon } from "lucide-react"
@ -10,32 +10,55 @@ import { GlowingBorderWrapper } from "./GlowingBorderWrapper"
import { TagBadge } from "./TagBadge"
type ImageWithColors = Image & {
colors?: (ImageColor & { color: Color })[]
colors?: (ImageColor & { color: Color })[],
variants?: ImageVariant[]
}
type ImageCardProps = {
image: ImageWithColors,
gallerySlug?: string
albumSlug?: string
image: ImageWithColors;
gallerySlug?: string;
albumSlug?: string;
size?: "default" | "large";
disableLink?: boolean;
}
export default function ImageCard({ image, gallerySlug, albumSlug }: ImageCardProps) {
export default function ImageCard({
image,
gallerySlug,
albumSlug,
size = "default",
disableLink = false,
}: ImageCardProps) {
const { showNSFW, animateGlow } = useGlobalSettings()
const shouldBlur = image.nsfw && !showNSFW
const href = `/galleries/${gallerySlug}/${albumSlug}/${image.id}`
const isLarge = size === "large"
const href = isLarge
? `/raw/${image.id}`
: `/galleries/${gallerySlug}/${albumSlug}/${image.id}`
const borderColors =
image.colors?.map((c) => c.color?.hex).filter((hex): hex is string => Boolean(hex)) ?? []
const shouldBlur = image.nsfw && !showNSFW
const thumbnailSrc = `/api/image/thumbnails/${image.fileKey}.webp`
const resizedVariant = image.variants?.find((v) => v.type === "resized")
const resizedSrc = resizedVariant ? `/api/image/${resizedVariant.s3Key}` : undefined
const content = (
const imageSrc = isLarge && resizedSrc ? resizedSrc : thumbnailSrc
const width = isLarge && resizedVariant?.width ? resizedVariant.width : undefined
const height = isLarge && resizedVariant?.height ? resizedVariant.height : undefined
const imageElement = (
<div className="group rounded-lg border overflow-hidden hover:shadow-lg transition-shadow bg-background relative">
<div className="relative aspect-[4/3] w-full bg-muted items-center justify-center">
<div className="relative w-full bg-muted items-center justify-center"
style={isLarge && width && height ? { width, height } : { aspectRatio: "4 / 3" }}
>
<NextImage
src={`/api/image/thumbnails/${image.fileKey}.webp`}
src={imageSrc}
alt={image.imageName}
fill
fill={!width || !height}
width={width}
height={height}
className={clsx("object-cover transition duration-300", shouldBlur && "blur-md scale-105")}
/>
{image.nsfw && (
@ -49,19 +72,29 @@ export default function ImageCard({ image, gallerySlug, albumSlug }: ImageCardPr
</div>
)}
</div>
<div className="p-4">
<h2 className="text-lg font-semibold truncate text-center">{image.imageName}</h2>
</div>
{!isLarge && (
<div className="p-4">
<h2 className="text-lg font-semibold truncate text-center">{image.imageName}</h2>
</div>
)}
</div>
)
return (
return disableLink ? (
animateGlow && borderColors.length > 0 ? (
<GlowingBorderWrapper colors={borderColors} size={size}>{imageElement}</GlowingBorderWrapper>
) : (
imageElement
)
) : (
<Link href={href}>
{animateGlow && borderColors.length > 0 ? (
<GlowingBorderWrapper colors={borderColors}>{content}</GlowingBorderWrapper>
<GlowingBorderWrapper colors={borderColors} size={size}>{imageElement}</GlowingBorderWrapper>
) : (
content
imageElement
)}
</Link>
)
}

View File

@ -1,9 +1,7 @@
// 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 & {
@ -13,9 +11,8 @@ type ArtistWithItems = Artist & {
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">
<div className="flex items-center gap-3 mb-6">
<Link href={`/artists/${artist.slug}`} className="font-semibold text-xl hover:underline">
{artist.displayName}
</Link>
</div>
@ -34,9 +31,6 @@ export default function ArtistInfoBox({ artist }: { artist: ArtistWithItems }) {
>
<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>
)

View File

@ -1,23 +1,67 @@
// components/images/ImageMetadataBox.tsx
"use client"
import { Album, Category, Tag } from "@/generated/prisma"
import { FolderIcon, LayersIcon, TagIcon } from "lucide-react"
import { CalendarDaysIcon, FolderIcon, LayersIcon, QuoteIcon, TagIcon } from "lucide-react"
import Link from "next/link"
type Props = {
album: Album | null
categories: Category[]
tags: Tag[]
creationDate?: Date | null
creationYear?: number | null
creationMonth?: number | null
altText?: string | null
description?: string | null
}
export default function ImageMetadataBox({ album, categories, tags }: Props) {
export default function ImageMetadataBox({
album,
categories,
tags,
creationDate,
creationYear,
creationMonth,
altText,
description
}: Props) {
const creationLabel = creationDate
? new Date(creationDate).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: creationYear && creationMonth
? `${creationMonth.toString().padStart(2, "0")}/${creationYear}`
: null
return (
<div className="border rounded-lg p-4 shadow bg-muted/20 w-full max-w-xl space-y-3">
{album && (
{altText && (
<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">
<TagIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-[1px]" />
<span className="text-sm text-muted-foreground">{altText}</span>
</div>
)}
{description && (
<div className="flex items-center gap-2">
<QuoteIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-[1px]" />
<span className="text-sm text-muted-foreground">{description}</span>
</div>
)}
{creationLabel && (
<div className="flex items-center gap-2">
<CalendarDaysIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-[1px]" />
<span className="text-sm text-muted-foreground">{creationLabel}</span>
</div>
)}
{album && (
<div className="flex items-center gap-2 flex-wrap">
<FolderIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-[1px]" />
<Link href={`/galleries/${album.galleryId}/${album.slug}`} className="text-sm underline">
{album.name}
</Link>
</div>
@ -25,12 +69,12 @@ export default function ImageMetadataBox({ album, categories, tags }: Props) {
{categories.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<LayersIcon className="w-5 h-5 text-muted-foreground" />
<LayersIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-[1px]" />
{categories.map((cat) => (
<Link
key={cat.id}
href={`/categories/${cat.id}`}
className="text-sm text-muted-foreground hover:underline"
className="text-sm underline"
>
{cat.name}
</Link>
@ -40,12 +84,12 @@ export default function ImageMetadataBox({ album, categories, tags }: Props) {
{tags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<TagIcon className="w-5 h-5 text-muted-foreground" />
<TagIcon className="shrink-0 w-4 h-4 text-muted-foreground mt-[1px]" />
{tags.map((tag) => (
<Link
key={tag.id}
href={`/tags/${tag.id}`}
className="text-sm text-muted-foreground hover:underline"
className="text-sm underline"
>
{tag.name}
</Link>

View File

@ -0,0 +1,16 @@
import { Image } from "@/generated/prisma";
import ImageCard from "../cards/ImageCard";
export default function AlbumImageList({ images, gallerySlug, albumSlug }: { images: Image[], gallerySlug: string, albumSlug: string }) {
if (!images.length) return <p>There are no images here!</p>;
return (
<section>
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{images.map((image) => (
<ImageCard key={image.id} image={image} gallerySlug={gallerySlug} albumSlug={albumSlug} />
))}
</div>
</section>
);
}

View File

@ -12,6 +12,7 @@ export default async function GalleryList() {
},
},
},
orderBy: { sortIndex: 'asc' }
});
if (!galleries.length) return <p>There are no galleries here!</p>;

View File

@ -1,15 +1,23 @@
import { Image } from "@/generated/prisma";
import { Album, Gallery, Image } from "@/generated/prisma";
import ImageCard from "../cards/ImageCard";
export default function ImageList({ images, gallerySlug, albumSlug }: { images: Image[], gallerySlug: string, albumSlug: string }) {
type ImageWithItems = Image & {
album: Album & {
gallery: Gallery | null
} | null
}
export default function ImageList({ images }: { images: ImageWithItems[] }) {
if (!images.length) return <p>There are no images here!</p>;
return (
<section>
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{images.map((image) => (
<ImageCard key={image.id} image={image} gallerySlug={gallerySlug} albumSlug={albumSlug} />
))}
{images.map((image) => {
const gallerySlug = image.album?.gallery?.slug ? image.album.gallery.slug : ''
const albumSlug = image.album?.slug ? image.album.slug : ''
return (<ImageCard key={image.id} image={image} gallerySlug={gallerySlug} albumSlug={albumSlug} />)
})}
</div>
</section>
);