Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/anipic/app/(ImagePages)/@modal/(.)i/[id]/ModalBackdrop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { IoClose } from "react-icons/io5";

export default function ModalBackdrop({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [isClosing, setIsClosing] = useState(false);

// back after 300ms (to allow modal close animation to play)
const close = () => {
setIsClosing(true);
setTimeout(() => {
setIsClosing(false);
router.back();
}, 300);
};

// Close on Escape key
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [close]);
Comment on lines +11 to +27

// Prevent scroll while modal is open
useEffect(() => {
const html = document.documentElement;
const previousHtmlOverflow = html.style.overflow;

html.style.overflow = "hidden";

return () => {
html.style.overflow = previousHtmlOverflow;
};
}, []);

return (
<div
className="fixed inset-0 z-99 flex items-center justify-center bg-black/75 backdrop-blur-sm p-4"
onClick={close}
>
<div
className={`relative w-full max-w-5xl max-h-[90dvh] overflow-y-auto rounded-3xl bg-white dark:bg-neutral-950 shadow-2xl ${isClosing ? "opacity-0 scale-0 pointer-events-none" : "opacity-100 scale-100"} transition-all duration-300`}
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
aria-label="Close"
onClick={close}
className="absolute top-3 right-3 z-10 w-8 h-8 flex items-center justify-center rounded-full bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20 transition-colors text-sm font-bold cursor-pointer"
>
<IoClose />
</button>

{children}
</div>
</div>
);
}
93 changes: 93 additions & 0 deletions apps/anipic/app/(ImagePages)/@modal/(.)i/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { notFound } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";

import { getImage } from "@/app/(ImagePages)/i/[id]/_data";
import { DownloadButton, ShareButton } from "@/app/(ImagePages)/i/[id]/ImageClientAction";
import { capitalize } from "@/utils/capitalize";
import ModalBackdrop from "./ModalBackdrop";
import ImagePageLoading from "@/app/(ImagePages)/i/[id]/loading";
import { Button } from "@shared/components/ui/Button";

interface Props {
params: Promise<{ id: string }>;
}

export default function ImageModal({ params }: Props) {
return (
<ModalBackdrop>
<Suspense fallback={<ImagePageLoading />}>
<ImagePreview params={params} />
</Suspense>
</ModalBackdrop>
);
}

async function ImagePreview({ params }: Props) {
const { id } = await params;
const img = await getImage(id);

if (!img) return notFound();

const aspectRatio = img.height / img.width;

return (
<div className="flex flex-col gap-2 md:flex-row overflow-hidden rounded-3xl">
<div className="flex-1 bg-neutral-900 flex items-center justify-center min-h-52">
<Image
src={img.displayUrl}
unoptimized
alt={img.title}
width={720}
height={Math.round(720 * aspectRatio)}
className="w-full max-h-[60vh] object-contain"
priority
/>
</div>
<p className="flex gap-5 justify-center text-xs text-neutral-600">
<span className="flex-1 text-right">
{img.downloads.toLocaleString()} Download{img.downloads > 1 ? "s" : ""}
</span>
<span className="flex-1">
{img.likes.toLocaleString()} Like{img.likes > 1 ? "s" : ""}
</span>
</p>

<div className="md:w-64 shrink-0 p-6 flex flex-col gap-5 border-l border-neutral-800">
<div className="flex flex-wrap gap-2 mt-4 md:mt-0">
<DownloadButton id={img.id} />
<ShareButton id={img.id} title={img.title} />
</div>

<Link
href={`/i/${img.id}`}
className="text-xs text-neutral-500 hover:text-rose-400 underline underline-offset-2 transition-colors w-fit"
>
Open full page ↗
</Link>

{/* Tags */}
{img.tags.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-neutral-600 dark:text-neutral-400 mb-3">
Tags
</p>
<div className="flex flex-wrap gap-1.5">
{img.tags.map((tag, i) => (
<Button
key={`${tag.replace(/\s+/g, "-")}-${i}`}
href={`/tag/${encodeURIComponent(tag)}`}
className="py-0 m-0 rounded-full border border-theme-600 bg-theme-500/10 text-neutral-600 dark:text-neutral-400"
>
{capitalize(tag)}
</Button>
))}
</div>
</div>
)}
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions apps/anipic/app/(ImagePages)/@modal/default.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Required by Next.js parallel routes.
// Renders nothing when no modal is currently active.
export default function ModalDefault() {
return null;
}
13 changes: 13 additions & 0 deletions apps/anipic/app/(ImagePages)/gallery/[cursor]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { FilterBarSkeleton } from "@/components/skeletons/FilterBarSkeleton";
import { GalleryHeaderSkeleton } from "@/components/skeletons/GalleryHeaderSkeleton";
import { MasonrySkeleton } from "@/components/skeletons/MasonrySkeleton";

export default function GalleryCursorLoading() {
return (
<>
<GalleryHeaderSkeleton />
<FilterBarSkeleton />
<MasonrySkeleton rowsPerColumn={6} />
</>
);
}
66 changes: 66 additions & 0 deletions apps/anipic/app/(ImagePages)/gallery/[cursor]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import MasonryImageGrid from "@/components/MasonryImageGrid";
import { loadImages } from "@/features/images/loadImages";
import { generateGalleryCursors } from "@/features/images/generateCursors";
import type { ImageApiRequestBody } from "@/features/images/schemas";
import FilterBar from "@/components/FilterBar";

type SortOption = NonNullable<ImageApiRequestBody["sort"]>;

interface Props {
params: Promise<{ cursor: string }>;
searchParams: Promise<{ sort?: string; q?: string; tags?: string }>;
}

export async function generateStaticParams() {
const cursors = await generateGalleryCursors(); // no limit — all pages

if (cursors.length === 0) return [{ cursor: "__empty__" }]; // dummy page to avoid build error when DB is empty
return cursors.map((cursor) => ({ cursor }));
Comment on lines +11 to +20
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { cursor } = await params;
return {
title: "Browse AI Anime Art · AniPic Gallery",
description: "Explore AI-generated anime wallpapers, illustrations, and digital art on AniPic.",
alternates: { canonical: `/gallery/${cursor}` },
robots: { index: true, follow: true },
};
}

export default async function GalleryCursorPage({ params, searchParams }: Props) {
const { cursor } = await params;
if (cursor === "__empty__") return notFound(); // handle dummy page for empty DB

const { sort: rawSort, q, tags: rawTags } = await searchParams;

const sort = (rawSort as SortOption | undefined) ?? "latest";
const tags = rawTags ? rawTags.split(",").filter(Boolean) : [];

const { images, hasMore, nextCursor } = await loadImages({ cursor, sort, tags, q });

Comment on lines +37 to +43
if (!images.length) return notFound();

return (
<>
<div className="pt-10 pb-2">
<h1 className="text-3xl font-black text-white tracking-tight">Gallery</h1>
<p className="text-neutral-500 text-sm mt-1">
AI-generated anime art, wallpapers, and illustrations
</p>
</div>

<FilterBar currentSort={sort} currentQ={q} />
<MasonryImageGrid
initialImages={images}
initialHasMore={hasMore}
initialNextCursor={nextCursor ?? ""}
sort={sort}
tags={tags}
q={q}
/>
</>
);
}
13 changes: 13 additions & 0 deletions apps/anipic/app/(ImagePages)/gallery/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { FilterBarSkeleton } from "@/components/skeletons/FilterBarSkeleton";
import { GalleryHeaderSkeleton } from "@/components/skeletons/GalleryHeaderSkeleton";
import { MasonrySkeleton } from "@/components/skeletons/MasonrySkeleton";

export default function GalleryLoading() {
return (
<>
<GalleryHeaderSkeleton />
<FilterBarSkeleton />
<MasonrySkeleton rowsPerColumn={6} />
</>
);
}
78 changes: 78 additions & 0 deletions apps/anipic/app/(ImagePages)/gallery/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Metadata } from "next";
import MasonryImageGrid from "@/components/MasonryImageGrid";
import { loadImages } from "@/features/images/loadImages";
import type { ImageApiRequestBody } from "@/features/images/schemas";
import FilterBar from "@/components/FilterBar";

type SortOption = NonNullable<ImageApiRequestBody["sort"]>;

interface Props {
searchParams: Promise<{ q?: string; sort?: string; tags?: string }>;
}

export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
const { q, sort } = await searchParams;

const sortLabels: Record<string, string> = {
popular: "Trending",
downloads: "Most Downloaded",
views: "Most Viewed",
latest: "Latest",
};

const sortLabel = sort ? (sortLabels[sort] ?? "Latest") : "Latest";
const title = q ? `Search "${q}" — AniPic AI Art` : `${sortLabel} AI Art — AniPic Gallery`;
const description = q
? `Browse AI-generated art matching "${q}" on AniPic.`
: `Explore ${sortLabel.toLowerCase()} AI-generated wallpapers and digital art on AniPic.`;

return { title, description, alternates: { canonical: "/gallery" } };
}

export default async function GalleryPage({ searchParams }: Props) {
const { q, sort: rawSort, tags: rawTags } = await searchParams;

const sort = (rawSort as SortOption | undefined) ?? "latest";
const tags = rawTags ? rawTags.split(",").filter(Boolean) : [];

const { images, hasMore, nextCursor } = await loadImages({ sort, tags, q });

return (
<>
<div className="px-4 pb-2">
<h1 className="text-3xl tracking-tight">
{q ? (
<>
Results for <span className="text-theme-400">&ldquo;{q}&rdquo;</span>
</>
) : (
"Gallery"
)}
</h1>
{tags.length > 0 && (
<p className="text-neutral-500 text-sm mt-1">
Filtered by:{" "}
{tags.map((t) => (
<span
key={t}
className="inline-block px-2 py-0.5 rounded-full bg-neutral-800 text-neutral-300 text-xs mr-1"
>
{t}
</span>
))}
</p>
)}
</div>

<FilterBar currentSort={sort} currentQ={q} />
<MasonryImageGrid
initialImages={images}
initialHasMore={hasMore}
initialNextCursor={nextCursor ?? ""}
sort={sort}
tags={tags}
q={q}
/>
</>
);
}
Loading