diff --git a/apps/anipic/app/(ImagePages)/@modal/(.)i/[id]/ModalBackdrop.tsx b/apps/anipic/app/(ImagePages)/@modal/(.)i/[id]/ModalBackdrop.tsx
new file mode 100644
index 0000000..fd9aa43
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/@modal/(.)i/[id]/ModalBackdrop.tsx
@@ -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]);
+
+ // 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 (
+
+
e.stopPropagation()}
+ >
+ {/* Close button */}
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/@modal/(.)i/[id]/page.tsx b/apps/anipic/app/(ImagePages)/@modal/(.)i/[id]/page.tsx
new file mode 100644
index 0000000..3284a0c
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/@modal/(.)i/[id]/page.tsx
@@ -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 (
+
+ }>
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ {img.downloads.toLocaleString()} Download{img.downloads > 1 ? "s" : ""}
+
+ •
+
+ {img.likes.toLocaleString()} Like{img.likes > 1 ? "s" : ""}
+
+
+
+
+
+
+
+
+
+
+ Open full page ↗
+
+
+ {/* Tags */}
+ {img.tags.length > 0 && (
+
+
+ Tags
+
+
+ {img.tags.map((tag, i) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/@modal/default.tsx b/apps/anipic/app/(ImagePages)/@modal/default.tsx
new file mode 100644
index 0000000..5dbdb4d
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/@modal/default.tsx
@@ -0,0 +1,5 @@
+// Required by Next.js parallel routes.
+// Renders nothing when no modal is currently active.
+export default function ModalDefault() {
+ return null;
+}
\ No newline at end of file
diff --git a/apps/anipic/app/(ImagePages)/gallery/[cursor]/loading.tsx b/apps/anipic/app/(ImagePages)/gallery/[cursor]/loading.tsx
new file mode 100644
index 0000000..6f55bb4
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/gallery/[cursor]/loading.tsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/gallery/[cursor]/page.tsx b/apps/anipic/app/(ImagePages)/gallery/[cursor]/page.tsx
new file mode 100644
index 0000000..348dbc4
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/gallery/[cursor]/page.tsx
@@ -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;
+
+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 }));
+}
+
+export async function generateMetadata({ params }: Props): Promise {
+ 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 });
+
+ if (!images.length) return notFound();
+
+ return (
+ <>
+
+
Gallery
+
+ AI-generated anime art, wallpapers, and illustrations
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/gallery/loading.tsx b/apps/anipic/app/(ImagePages)/gallery/loading.tsx
new file mode 100644
index 0000000..c5bb0f1
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/gallery/loading.tsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/gallery/page.tsx b/apps/anipic/app/(ImagePages)/gallery/page.tsx
new file mode 100644
index 0000000..19f8f62
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/gallery/page.tsx
@@ -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;
+
+interface Props {
+ searchParams: Promise<{ q?: string; sort?: string; tags?: string }>;
+}
+
+export async function generateMetadata({ searchParams }: Props): Promise {
+ const { q, sort } = await searchParams;
+
+ const sortLabels: Record = {
+ 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 (
+ <>
+
+
+ {q ? (
+ <>
+ Results for “{q}”
+ >
+ ) : (
+ "Gallery"
+ )}
+
+ {tags.length > 0 && (
+
+ Filtered by:{" "}
+ {tags.map((t) => (
+
+ {t}
+
+ ))}
+
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/i/[id]/ImageClientAction.tsx b/apps/anipic/app/(ImagePages)/i/[id]/ImageClientAction.tsx
new file mode 100644
index 0000000..c32f023
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/i/[id]/ImageClientAction.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useState } from "react";
+import { AiOutlineLoading } from "react-icons/ai";
+import { SlCloudDownload } from "react-icons/sl";
+import { IoShareSocialOutline } from "react-icons/io5";
+import { toast } from "react-toastify";
+import { getDownloadUrl } from "./action";
+import { Button } from "@shared/components/ui/Button";
+import copyToClipboard from "@shared/utils/CopyToClipboard";
+
+// TODO: Implement like functionality with backend integration
+
+// export function LikeButton() {
+// const [liked, setLiked] = useState(false);
+
+// return (
+//
+// );
+// }
+
+export function DownloadButton({ id }: { id: string }) {
+ const [loading, setLoading] = useState(false);
+
+ const handleDownload = async () => {
+ if (loading) return;
+ setLoading(true);
+
+ try {
+ const res = await getDownloadUrl(id);
+ if (!res.success) throw new Error(res.message);
+
+ const anchor = document.createElement("a");
+ anchor.href = res.downloadUrl;
+ anchor.download = "";
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+
+ toast.success("Download started!");
+ } catch {
+ toast.error("Failed to start download. Please try again.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+export function ShareButton({ id, title }: { id: string; title: string }) {
+ const handleShare = async () => {
+ const url = `${window.location.origin}/i/${id}`;
+
+ // Use Web Share API if available (mobile)
+ if (typeof navigator.share === "function") {
+ try {
+ await navigator.share({ title, url });
+ return;
+ } catch {
+ // User cancelled or API failed — fall through to clipboard
+ }
+ }
+
+ // Clipboard fallback
+ copyToClipboard(url);
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/i/[id]/ZoomableImage.tsx b/apps/anipic/app/(ImagePages)/i/[id]/ZoomableImage.tsx
new file mode 100644
index 0000000..6b578f4
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/i/[id]/ZoomableImage.tsx
@@ -0,0 +1,147 @@
+
+"use client";
+
+import Image from "next/image";
+import { useState, useEffect, useCallback, useRef } from "react";
+import { IoClose, IoExpandOutline } from "react-icons/io5";
+
+interface ZoomableImageProps {
+ src: string;
+ alt: string;
+ width: number;
+ height: number;
+}
+
+export default function ZoomableImage({ src, alt, width, height }: ZoomableImageProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [scale, setScale] = useState(1);
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ const [isDragging, setIsDragging] = useState(false);
+ const dragStart = useRef({ x: 0, y: 0 });
+ const lastPosition = useRef({ x: 0, y: 0 });
+
+ const open = () => {
+ setIsOpen(true);
+ setScale(1);
+ setPosition({ x: 0, y: 0 });
+ };
+
+ const close = useCallback(() => {
+ setIsOpen(false);
+ setScale(1);
+ setPosition({ x: 0, y: 0 });
+ }, []);
+
+ // Escape key
+ useEffect(() => {
+ if (!isOpen) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") close();
+ };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [isOpen, close]);
+
+ // Lock body scroll
+ useEffect(() => {
+ if (!isOpen) return;
+ const prev = document.documentElement.style.overflow;
+ document.documentElement.style.overflow = "hidden";
+ return () => {
+ document.documentElement.style.overflow = prev;
+ };
+ }, [isOpen]);
+
+ // Wheel zoom
+ const handleWheel = useCallback((e: React.WheelEvent) => {
+ e.preventDefault();
+ setScale((prev) => Math.min(Math.max(prev - e.deltaY * 0.002, 0.5), 5));
+ }, []);
+
+ // Drag to pan
+ const handleMouseDown = (e: React.MouseEvent) => {
+ setIsDragging(true);
+ dragStart.current = {
+ x: e.clientX - lastPosition.current.x,
+ y: e.clientY - lastPosition.current.y,
+ };
+ };
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ if (!isDragging) return;
+ const x = e.clientX - dragStart.current.x;
+ const y = e.clientY - dragStart.current.y;
+ lastPosition.current = { x, y };
+ setPosition({ x, y });
+ };
+
+ const handleMouseUp = () => setIsDragging(false);
+ return (
+ <>
+
+
+ {isOpen && (
+
+
+
+
+ Scroll to zoom · Drag to pan · Press Esc to close
+
+
+
1 ? "grab" : "zoom-out",
+ transition: isDragging ? "none" : "transform 0.1s ease",
+ }}
+ onMouseDown={handleMouseDown}
+ onClick={(e) => {
+ // Only close if not dragging
+ if (e.target === e.currentTarget && scale <= 1) close();
+ }}
+ >
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+ {/* Backdrop click to close */}
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/i/[id]/_data.ts b/apps/anipic/app/(ImagePages)/i/[id]/_data.ts
new file mode 100644
index 0000000..fc57954
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/i/[id]/_data.ts
@@ -0,0 +1,47 @@
+import "server-only";
+
+import { cacheLife, cacheTag } from "next/cache";
+import getAniPicModel from "@/lib/db/models/AniPic";
+import { buildSeoDescription, buildSeoTitle } from "@/utils/seo/buildSeoUsingTags";
+import { BASE_FILTER } from "@/features/images/const";
+
+export interface ImageData {
+ id: string;
+ originalUrl: string;
+ displayUrl: string;
+ thumbnailUrl: string;
+ width: number;
+ height: number;
+ tags: string[];
+ title: string;
+ description: string;
+ downloads: number;
+ likes: number;
+ createdAt: string; // ISO string for client rendering
+}
+
+export async function getImage(id: string): Promise {
+ "use cache";
+ cacheLife("days");
+ cacheTag(`image:${id}`);
+
+ const AniPic = await getAniPicModel();
+ const img = await AniPic.findOne({ _id: id, ...BASE_FILTER }).lean();
+
+ if (!img) return null;
+
+ return {
+ id: img._id.toString(),
+ originalUrl: img.originalUrl,
+ displayUrl: img.displayUrl,
+ thumbnailUrl: img.thumbnailUrl,
+ width: img.width ?? 1920,
+ height: img.height ?? 1080,
+ tags: img.tags,
+ title: buildSeoTitle(img.tags),
+ description: buildSeoDescription(img.tags),
+ downloads: img.downloads ?? 0,
+ likes: img.likes ?? 0,
+ createdAt: img.createdAt.toISOString(),
+ };
+}
diff --git a/apps/anipic/app/(ImagePages)/i/[id]/action.ts b/apps/anipic/app/(ImagePages)/i/[id]/action.ts
new file mode 100644
index 0000000..9408d1b
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/i/[id]/action.ts
@@ -0,0 +1,44 @@
+"use server";
+
+import getAniPicModel from "@/lib/db/models/AniPic";
+import { createDownloadToken } from "@/utils/createDownloadToken";
+
+const DOWNLOAD_BASE = process.env.DOWNLOAD_BASE;
+
+if (!DOWNLOAD_BASE) {
+ throw new Error("DOWNLOAD_BASE environment variable is not defined");
+}
+
+export type DownloadResult =
+ | { success: true; downloadUrl: string }
+ | { success: false; message: string };
+
+export async function getDownloadUrl(id: string): Promise {
+ try {
+ const AniPic = await getAniPicModel();
+
+ const img = await AniPic.findOneAndUpdate(
+ { _id: id, approved: true },
+ { $inc: { downloads: 1 } },
+ { new: true },
+ );
+
+ if (!img) return { success: false, message: "Image not found or not approved" };
+
+ const title = `AniPic — ${img.tags.slice(0, 3).join(", ")}`;
+
+ const token = createDownloadToken({
+ u: img.originalUrl,
+ w: img.width ?? 4000,
+ h: img.height ?? 4000,
+ t: title,
+ });
+
+ const downloadUrl = new URL(`?token=${token}`, DOWNLOAD_BASE).toString();
+
+ return { success: true, downloadUrl };
+ } catch (err) {
+ console.error("[getDownloadUrl]", err);
+ return { success: false, message: "An unexpected error occurred. Please try again." };
+ }
+}
diff --git a/apps/anipic/app/(ImagePages)/i/[id]/loading.tsx b/apps/anipic/app/(ImagePages)/i/[id]/loading.tsx
new file mode 100644
index 0000000..345bece
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/i/[id]/loading.tsx
@@ -0,0 +1,5 @@
+import { ImageDetailSkeleton } from "@/components/skeletons/ImageDetailSkeleton";
+
+export default function ImagePageLoading() {
+ return ;
+}
diff --git a/apps/anipic/app/(ImagePages)/i/[id]/page.tsx b/apps/anipic/app/(ImagePages)/i/[id]/page.tsx
new file mode 100644
index 0000000..c5886b2
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/i/[id]/page.tsx
@@ -0,0 +1,139 @@
+import { notFound } from "next/navigation";
+import type { Metadata } from "next";
+import Link from "next/link";
+import { Suspense } from "react";
+
+import { getImage } from "./_data";
+import { DownloadButton, ShareButton } from "./ImageClientAction";
+import ZoomableImage from "./ZoomableImage";
+import { capitalize } from "@/utils/capitalize";
+import { loadImages } from "@/features/images/loadImages";
+import MasonryImageGrid from "@/components/MasonryImageGrid";
+
+interface Props {
+ params: Promise<{ id: string }>;
+}
+
+export async function generateMetadata({ params }: Props): Promise {
+ const { id } = await params;
+ const img = await getImage(id);
+
+ if (!img) return { title: "Image Not Found", robots: { index: false, follow: false } };
+
+ const canonical = `/i/${img.id}`;
+
+ return {
+ title: img.title,
+ description: img.description,
+ keywords: img.tags,
+ alternates: { canonical },
+ openGraph: {
+ url: canonical,
+ siteName: "AniPic",
+ type: "article",
+ images: [{ url: img.thumbnailUrl, width: img.width, height: img.height, alt: img.title }],
+ },
+ twitter: { card: "summary_large_image" },
+ };
+}
+
+async function RelatedPhotos({ skipId, tags }: { skipId: string; tags: string[] }) {
+ const { images, hasMore, nextCursor } = await loadImages({ tags, skipId });
+
+ return (
+
+ );
+}
+
+export default async function ImagePage({ params }: Props) {
+ const { id } = await params;
+ const img = await getImage(id);
+
+ if (!img) return notFound();
+
+ const aspectRatio = img.height / img.width;
+ const uploadDate = new Date(img.createdAt).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+
+ return (
+ <>
+ {img.title}
+ {img.description}
+
+
+
+
+
+
+
+ Click the image to zoom — scroll or pinch to adjust
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/i/[sno]/ImageClientAction.tsx b/apps/anipic/app/(ImagePages)/i/[sno]/ImageClientAction.tsx
deleted file mode 100644
index 228b73f..0000000
--- a/apps/anipic/app/(ImagePages)/i/[sno]/ImageClientAction.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-"use client";
-
-import { IconButton } from "@shared/components/ui/Button";
-import { useState } from "react";
-import { AiOutlineLoading } from "react-icons/ai";
-import { FaRegHeart } from "react-icons/fa6";
-import { SlCloudDownload } from "react-icons/sl";
-import { toast } from "react-toastify";
-import getDownloadUrl from "./action";
-
-export function LikeButton() {
- return (
-
-
-
-
-
- );
-}
-
-export function DownloadButton({ sno }: { sno: number }) {
- const [loading, setLoading] = useState(false);
-
- const handleDownload = async () => {
- if (loading) return;
-
- try {
- setLoading(true);
-
- const res = await getDownloadUrl(sno);
-
- if (!res?.success || !res.downloadUrl) {
- throw new Error("Invalid response");
- }
-
- const link = document.createElement("a");
- link.href = res.downloadUrl;
- link.download = "";
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
-
- toast.success("Download started");
- } catch {
- toast.error("Failed to download");
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
- );
-}
diff --git a/apps/anipic/app/(ImagePages)/i/[sno]/action.ts b/apps/anipic/app/(ImagePages)/i/[sno]/action.ts
deleted file mode 100644
index d1846cc..0000000
--- a/apps/anipic/app/(ImagePages)/i/[sno]/action.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-"use server";
-
-import getAniPicModel from "@/lib/db/models/AniPic";
-import { createDownloadToken } from "@/utils/createDownloadToken";
-
-const DOWNLOAD_BASE = process.env.DOWNLOAD_BASE;
-
-if (!DOWNLOAD_BASE) {
- throw new Error("DOWNLOAD_BASE is not defined");
-}
-
-export default async function getDownloadUrl(sno: number) {
- try {
- const AniPic = await getAniPicModel();
-
- const img = await AniPic.findOneAndUpdate(
- { sno, approved: true },
- { $inc: { downloads: 1 } },
- { new: true }
- );
-
- if (!img) return { success: false, message: "Image not found or not approved" };
-
- const title = `AniPic image of ${img.tags[0]}, ${img.tags[1]}, ${img.tags[2]}`;
-
- const downloadToken = createDownloadToken({
- u: img.originalUrl,
- w: img.width || 4000,
- h: img.height || 4000,
- t: title,
- });
-
- const downloadUrl = new URL(`?token=${downloadToken}`, DOWNLOAD_BASE).toString();
-
- return { success: true, message: "Update success", downloadUrl };
- } catch (error) {
- console.error("Error occurred in getDownloadUrl:", error);
-
- return { success: false, message: "An unexpected error occurred. Please try again." };
- }
-}
diff --git a/apps/anipic/app/(ImagePages)/i/[sno]/loading.tsx b/apps/anipic/app/(ImagePages)/i/[sno]/loading.tsx
deleted file mode 100644
index cbb6a51..0000000
--- a/apps/anipic/app/(ImagePages)/i/[sno]/loading.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import ImageContent from "@shared/components/loader/ImageContent";
-
-export default function Loading() {
- return (
-
-
-
- );
-}
diff --git a/apps/anipic/app/(ImagePages)/i/[sno]/page.tsx b/apps/anipic/app/(ImagePages)/i/[sno]/page.tsx
deleted file mode 100644
index 8cfcbb6..0000000
--- a/apps/anipic/app/(ImagePages)/i/[sno]/page.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { notFound } from "next/navigation";
-import type { Metadata } from "next";
-import getAniPicModel from "@/lib/db/models/AniPic";
-import { cacheLife, cacheTag } from "next/cache";
-import Image from "next/image";
-import { DownloadButton } from "./ImageClientAction";
-import { buildSeoDescription, buildSeoTitle } from "@/utils/seo/buildSeoUsingTags";
-import { capitalize } from "@/utils/capitalize";
-
-interface Props { params: Promise<{ sno: string }> }
-
-async function getImage(sno: number) {
- "use cache";
- cacheLife("days");
- cacheTag(`image:${sno}`);
-
- const AniPic = await getAniPicModel();
- const imgObj = await AniPic.findOne({ sno, approved: true }).lean();
-
- if (!imgObj) return null;
-
- const img = {
- sno: imgObj.sno,
- originalUrl: imgObj.originalUrl,
- displayUrl: imgObj.displayUrl,
- thumbnailUrl: imgObj.thumbnailUrl,
- width: imgObj.width,
- height: imgObj.height,
- tags: imgObj.tags,
- title: buildSeoTitle(imgObj.tags),
- description: buildSeoDescription(imgObj.tags),
- downloads: imgObj.downloads,
- };
-
- return img;
-}
-
-export async function generateMetadata({ params }: Props): Promise {
- const { sno } = await params; // ✅ unwrap
- const img = await getImage(Number(sno));
-
- if (!img) {
- return { title: "Image Not Found", robots: { index: false, follow: false } };
- }
-
- const canonical = `/i/${img.sno}`;
-
- return {
- title: img.title,
- description: img.description,
- keywords: img.tags,
-
- alternates: { canonical },
-
- openGraph: {
- title: img.title,
- description: img.description,
- url: canonical,
- siteName: "AniPic",
- type: "article",
- images: [
- {
- url: img.thumbnailUrl,
- width: img.width,
- height: img.height,
- alt: img.title,
- },
- ],
- },
-
- twitter: {
- card: "summary_large_image",
- title: img.title,
- description: img.description,
- images: [img.displayUrl],
- },
- };
-}
-
-export default async function ImagePageInner({ params }: Props) {
- const { sno } = await params;
- const img = await getImage(Number(sno));
- if (!img) return notFound();
-
- return (
-
- {/* Hidden Title & Desc */}
- {img.title}
- {img.description}
-
- {/* Image Section */}
-
-
-
-
- {/* Right Panel */}
-
- {/* Action Buttons */}
-
-
-
-
- {/* Tags Section */}
- {img.tags.length > 0 && (
-
-
Tags
-
-
- {img.tags.map((tag, i) => (
-
- {capitalize(tag)}
-
- ))}
-
-
- )}
-
-
- );
-}
-
-// function TotalDownloads({ sno }: { sno: number }) {
-// return 0;
-// }
diff --git a/apps/anipic/app/(ImagePages)/layout.tsx b/apps/anipic/app/(ImagePages)/layout.tsx
new file mode 100644
index 0000000..aa51f4b
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/layout.tsx
@@ -0,0 +1,13 @@
+interface ImagePagesLayoutProps {
+ children: React.ReactNode;
+ modal: React.ReactNode;
+}
+
+export default function ImagePagesLayout({ children, modal }: ImagePagesLayoutProps) {
+ return (
+ <>
+ {children}
+ {modal}
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/loading.tsx b/apps/anipic/app/(ImagePages)/loading.tsx
new file mode 100644
index 0000000..77f2a97
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/loading.tsx
@@ -0,0 +1,5 @@
+import HomePageSkeleton from "@/components/skeletons/HomePageSkeleton";
+
+export default function HomeLoading() {
+ return ;
+}
diff --git a/apps/anipic/app/(ImagePages)/page.tsx b/apps/anipic/app/(ImagePages)/page.tsx
new file mode 100644
index 0000000..d0e3bee
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/page.tsx
@@ -0,0 +1,241 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import {
+ IoSparklesOutline,
+ IoSearchOutline,
+ IoImagesOutline,
+ IoCloudDownloadOutline,
+ IoFlashOutline,
+} from "react-icons/io5";
+import { capitalize } from "@/utils/capitalize";
+import { loadLandingData } from "@/features/images/loadLandingPageData";
+import { Button } from "@shared/components/ui/Button";
+import { Input } from "@shared/components/ui/Input";
+import { LandingPageMasonryGrid } from "@/components/MasonryImageGrid";
+
+export const metadata: Metadata = {
+ title: "AniPic — AI Wallpapers & Art",
+ description:
+ "Discover thousands of stunning AI-generated images, wallpapers, and digital art on AniPic. Free to browse and download.",
+ alternates: { canonical: "/" },
+};
+
+function HeroSection() {
+ return (
+
+
+
+ AI-Powered Images
+
+
+
+ Discover{" "}
+
+ Images
+
+
+ Like Never Before
+
+
+
+ Browse thousands of high-quality AI-generated wallpapers, illustrations, and digital art —
+ all free to download.
+
+
+
+
+
+
+ Explore Gallery →
+
+
+ Trending Now ↗
+
+
+
+ {/* Scroll indicator */}
+
+
+ );
+}
+
+function FeaturesSection() {
+ const features = [
+ {
+ icon: IoSparklesOutline,
+ title: "AI Generated",
+ desc: "Every image crafted by cutting-edge AI for stunning aesthetics.",
+ gradient: "from-rose-500/20 to-pink-500/20",
+ border: "border-rose-500/20",
+ iconColor: "text-rose-400",
+ },
+ {
+ icon: IoImagesOutline,
+ title: "High Resolution",
+ desc: "Up to 4K wallpapers and illustrations perfect for any screen or device.",
+ gradient: "from-violet-500/20 to-purple-500/20",
+ border: "border-violet-500/20",
+ iconColor: "text-violet-400",
+ },
+ {
+ icon: IoCloudDownloadOutline,
+ title: "Free Downloads",
+ desc: "Download any image instantly. No account required.",
+ gradient: "from-blue-500/20 to-cyan-500/20",
+ border: "border-blue-500/20",
+ iconColor: "text-blue-400",
+ },
+ {
+ icon: IoFlashOutline,
+ title: "Smart Search",
+ desc: "Find exactly what you want with tag-based search, filters, and sorting.",
+ gradient: "from-amber-500/20 to-orange-500/20",
+ border: "border-amber-500/20",
+ iconColor: "text-amber-400",
+ },
+ ];
+
+ return (
+
+
+ {features.map((f) => (
+
+
+
+
+
{f.title}
+
+ {f.desc}
+
+
+ ))}
+
+
+ );
+}
+
+function SectionHeader({
+ title,
+ subtitle,
+ href,
+}: {
+ title: string;
+ subtitle?: string;
+ href: string;
+}) {
+ return (
+
+
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+
+ View all →
+
+
+ );
+}
+
+function TagCloudSection({ tags }: { tags: { tag: string; count: number }[] }) {
+ return (
+
+
+
+ {tags.map(({ tag, count }) => (
+
+ ))}
+
+
+ );
+}
+
+function Divider() {
+ return (
+
+ );
+}
+
+export default async function HomePage() {
+ const { popularTags, popularPhotos, mostDownloaded, recentPhotos } = await loadLandingData();
+
+ return (
+ <>
+
+
+
+
+ {/* Trending Now */}
+
+
+
+
+ {/* Most Downloaded */}
+
+
+
+
+ {/* Recent Uploads */}
+
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/page/[page]/page.tsx b/apps/anipic/app/(ImagePages)/page/[page]/page.tsx
deleted file mode 100644
index 8b51f8f..0000000
--- a/apps/anipic/app/(ImagePages)/page/[page]/page.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import ImageGrid, { type SafeImages } from "@/components/imageGrid";
-import { IMAGE_LIMIT_PER_PAGE } from "@/utils/const";
-import getAniPicModel from "@/lib/db/models/AniPic";
-import type { Metadata } from "next";
-import { cacheLife, cacheTag } from "next/cache";
-import { notFound } from "next/navigation";
-import { buildSeoTitle } from "@/utils/seo/buildSeoUsingTags";
-import { Button } from "@shared/components/ui/Button";
-
-interface Params {
- params: Promise<{ page: string }>;
-}
-
-/* --------------------------- Static Params --------------------------- */
-export async function generateStaticParams() {
- const AniPic = await getAniPicModel();
- const total = await AniPic.countDocuments({ approved: true });
- const totalPages = Math.ceil(total / IMAGE_LIMIT_PER_PAGE);
- const length = Math.min(totalPages, 50);
-
- return Array.from({ length }, (_, i) => ({
- page: (i + 1).toString(),
- }));
-}
-
-/* --------------------------- Dynamic Metadata --------------------------- */
-export async function generateMetadata({ params }: Params): Promise {
- const pageNum = Math.max(parseInt((await params).page), 1);
- const title = `Browse AI Generated Images - Page ${pageNum}`;
-
- const description = `Explore high-quality AI generated images, wallpapers, and digital art on AniPic - Page ${pageNum} of our curated collection.`;
-
- return {
- title,
- description,
- alternates: { canonical: `/page/${pageNum}` },
- };
-}
-
-/* --------------------------- Page Component --------------------------- */
-
-export default async function AniPicPage({ params }: Params) {
- "use cache";
- cacheLife("max");
- cacheTag("anipicImagePages");
-
- const { page } = await params;
- const pageNum = Math.max(parseInt(page), 1);
-
- cacheTag(`anipicImagePage:${pageNum}`);
-
- const AniPic = await getAniPicModel();
-
- const [images, total] = await Promise.all([
- AniPic.find({ approved: true })
- .sort({ createdAt: -1 })
- .skip(pageNum * IMAGE_LIMIT_PER_PAGE)
- .limit(IMAGE_LIMIT_PER_PAGE)
- .lean(),
- AniPic.countDocuments({ approved: true }),
- ]);
-
- const totalPages = Math.ceil(total / IMAGE_LIMIT_PER_PAGE);
-
- if (pageNum > totalPages - 1) {
- return notFound();
- }
-
- const safeImages: SafeImages[] = images.map((img) => ({
- sno: img.sno,
- thumbnailUrl: img.thumbnailUrl,
- width: img.width,
- height: img.height,
- title: buildSeoTitle(img.tags),
- }));
-
- return (
-
-
-
- {/* Pagination controls */}
-
- {/* Previous Button */}
-
-
- {/* Next Button */}
- {pageNum + 1 < totalPages && }
-
-
- );
-}
diff --git a/apps/anipic/app/(ImagePages)/tag/[tag]/[cursor]/loading.tsx b/apps/anipic/app/(ImagePages)/tag/[tag]/[cursor]/loading.tsx
new file mode 100644
index 0000000..c5bb0f1
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/tag/[tag]/[cursor]/loading.tsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/tag/[tag]/[cursor]/page.tsx b/apps/anipic/app/(ImagePages)/tag/[tag]/[cursor]/page.tsx
new file mode 100644
index 0000000..0eff904
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/tag/[tag]/[cursor]/page.tsx
@@ -0,0 +1,79 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import MasonryImageGrid from "@/components/MasonryImageGrid";
+import { getAllTags, loadImages } from "@/features/images/loadImages";
+import { generateTagCursors } from "@/features/images/generateCursors";
+import { capitalize } from "@/utils/capitalize";
+import { cacheLife, cacheTag } from "next/cache";
+import FilterBar from "@/components/FilterBar";
+
+interface Props {
+ params: Promise<{ tag: string; cursor: string }>;
+}
+
+export async function generateStaticParams() {
+ "use cache";
+ cacheLife("days");
+ cacheTag("anipicCursors");
+
+ const topTags = await getAllTags("count");
+
+ const allParams: { tag: string; cursor: string }[] = [];
+
+ await Promise.all(
+ topTags.map(async (tag) => {
+ const cursors = await generateTagCursors(tag); // no limit — all pages
+ for (const cursor of cursors) {
+ allParams.push({ tag: encodeURIComponent(tag), cursor });
+ }
+ }),
+ );
+
+ if (allParams.length === 0) return [{ tag: "__empty__", cursor: "__empty__" }]; // dummy page to avoid build error when DB is empty
+
+ return allParams;
+}
+
+export async function generateMetadata({ params }: Props): Promise {
+ const { tag } = await params;
+ const decoded = decodeURIComponent(tag);
+ return {
+ title: `#${capitalize(decoded)} AI Anime Art · AniPic`,
+ description: `Browse AI-generated anime images tagged "${decoded}" on AniPic.`,
+ alternates: { canonical: `/tag/${tag}` },
+ robots: { index: true, follow: true },
+ };
+}
+
+export default async function TagCursorPage({ params }: Props) {
+ const { tag, cursor } = await params;
+ if (tag === "__empty__" || cursor === "__empty__") return notFound(); // handle dummy page for empty DB
+
+ const decoded = decodeURIComponent(tag);
+
+ const { images, hasMore, nextCursor } = await loadImages({
+ cursor,
+ tags: [decoded],
+ });
+
+ if (!images.length) return notFound();
+
+ return (
+ <>
+
+
+ #
+ {capitalize(decoded)}
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/tag/[tag]/loading.tsx b/apps/anipic/app/(ImagePages)/tag/[tag]/loading.tsx
new file mode 100644
index 0000000..c5bb0f1
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/tag/[tag]/loading.tsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/apps/anipic/app/(ImagePages)/tag/[tag]/page.tsx b/apps/anipic/app/(ImagePages)/tag/[tag]/page.tsx
new file mode 100644
index 0000000..a33c383
--- /dev/null
+++ b/apps/anipic/app/(ImagePages)/tag/[tag]/page.tsx
@@ -0,0 +1,49 @@
+import type { Metadata } from "next";
+import MasonryImageGrid from "@/components/MasonryImageGrid";
+import { loadImages } from "@/features/images/loadImages";
+import { capitalize } from "@/utils/capitalize";
+import FilterBar from "@/components/FilterBar";
+
+interface Props {
+ params: Promise<{ tag: string }>;
+}
+
+export async function generateMetadata({ params }: Props): Promise {
+ const { tag } = await params;
+ const decoded = decodeURIComponent(tag);
+
+ return {
+ title: `#${capitalize(decoded)} AI Art · AniPic`,
+ description: `Browse AI-generated images tagged "${decoded}" on AniPic.`,
+ alternates: { canonical: `/tag/${tag}` },
+ };
+}
+
+export default async function TagFirstPage({ params }: Props) {
+ const { tag } = await params;
+ const decoded = decodeURIComponent(tag);
+
+ const { images, total, hasMore, nextCursor } = await loadImages({
+ tags: [decoded],
+ });
+
+ return (
+ <>
+
+
+ #
+ {capitalize(decoded)}
+
+
{total.toLocaleString()} images
+
+
+
+
+ >
+ );
+}
diff --git a/apps/anipic/app/api/images/route.ts b/apps/anipic/app/api/images/route.ts
new file mode 100644
index 0000000..b86eba8
--- /dev/null
+++ b/apps/anipic/app/api/images/route.ts
@@ -0,0 +1,30 @@
+import { loadImages } from "@/features/images/loadImages";
+import { ImageApiRequestBodySchema } from "@/features/images/schemas";
+import { type NextRequest, NextResponse } from "next/server";
+
+export async function GET(req: NextRequest) {
+ const { searchParams } = req.nextUrl;
+
+ const parseData = ImageApiRequestBodySchema.safeParse({
+ cursor: searchParams.get("cursor") ?? undefined,
+ tags: searchParams.get("tags") ?? undefined,
+ q: searchParams.get("q") ?? undefined,
+ sort: searchParams.get("sort") ?? undefined,
+ skipId: searchParams.get("skipId") ?? undefined,
+ });
+
+ if (!parseData.success) {
+ return NextResponse.json({ error: "Invalid query parameters" }, { status: 400 });
+ }
+
+ const { cursor, tags, q, sort, skipId } = parseData.data;
+
+ try {
+ const { images, hasMore, nextCursor } = await loadImages({ cursor, tags, q, sort, skipId });
+
+ return NextResponse.json({ images, hasMore, nextCursor });
+ } catch (err) {
+ console.error("[GET /api/images]", err);
+ return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
+ }
+}
diff --git a/apps/anipic/app/page.tsx b/apps/anipic/app/page.tsx
deleted file mode 100644
index 16c52ca..0000000
--- a/apps/anipic/app/page.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { Metadata } from "next";
-import ImageGrid, { type SafeImages } from "@/components/imageGrid";
-import { IMAGE_LIMIT_PER_PAGE } from "@/utils/const";
-import getAniPicModel from "@/lib/db/models/AniPic";
-import { cacheLife, cacheTag } from "next/cache";
-import { buildSeoTitle } from "@/utils/seo/buildSeoUsingTags";
-import { Button } from "@shared/components/ui/Button";
-
-export const metadata: Metadata = { alternates: { canonical: "/" } };
-
-export default async function HomePage() {
- "use cache";
- cacheLife("max");
- cacheTag("anipicImagePages");
- cacheTag("anipicImagePage:0");
-
- const AniPic = await getAniPicModel();
-
- const images = await AniPic.find({ approved: true })
- .sort({ createdAt: -1 })
- .limit(IMAGE_LIMIT_PER_PAGE)
- .lean();
-
- const safeImages: SafeImages[] = images.map((img) => ({
- sno: img.sno,
- thumbnailUrl: img.thumbnailUrl,
- width: img.width,
- height: img.height,
- title: buildSeoTitle(img.tags),
- }));
-
- return (
-
-
-
- {/* Pagination controls */}
-
-
-
-
- );
-}
diff --git a/apps/anipic/app/sitemap.ts b/apps/anipic/app/sitemap.ts
index 9768c55..e7db4e3 100644
--- a/apps/anipic/app/sitemap.ts
+++ b/apps/anipic/app/sitemap.ts
@@ -1,39 +1,40 @@
-import getAniPicModel from "@/lib/db/models/AniPic";
-import { IMAGE_LIMIT_PER_PAGE } from "@/utils/const";
import type { MetadataRoute } from "next";
-import { cacheLife, cacheTag } from "next/cache";
+import { getGallerySitemapUrls, getAllTagsSitemapUrls } from "@/features/images/generateCursors";
+import { cacheLife } from "next/cache";
-const baseUrl = process.env.BASE_URL ?? "https://anipic.anixlab.in";
+const BASE_URL = process.env.BASE_URL ?? "https://anipic.anixlab.in";
export default async function sitemap(): Promise {
"use cache";
- cacheLife("max");
- cacheTag("anipicImagePages");
+ cacheLife("days");
- const AniPic = await getAniPicModel();
+ const now = new Date();
- // Count total approved images
- const total = await AniPic.countDocuments({ approved: true });
- const totalPages = Math.ceil(total / IMAGE_LIMIT_PER_PAGE);
+ const staticPages: MetadataRoute.Sitemap = [
+ { url: `${BASE_URL}/`, lastModified: now, changeFrequency: "daily", priority: 1.0 },
+ { url: `${BASE_URL}/gallery`, lastModified: now, changeFrequency: "hourly", priority: 0.9 },
+ ];
- const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
+ const [galleryCursorUrls, tagUrls] = await Promise.all([
+ getGallerySitemapUrls(),
+ getAllTagsSitemapUrls(),
+ ]);
- // Generate individual pagination page URLs
- const imagePages = Array.from({ length: totalPages - 1 }, (_, i) => ({
- url: `${normalizedBaseUrl}/page/${i + 1}`,
- lastModified: new Date(),
+ const galleryCursorPages: MetadataRoute.Sitemap = galleryCursorUrls.slice(1).map((path) => ({
+ url: `${BASE_URL}${path}`,
+ lastModified: now,
changeFrequency: "daily" as const,
- priority: 0.8,
+ priority: 0.7,
}));
- // Include homepage at the top
- return [
- {
- url: normalizedBaseUrl,
- lastModified: new Date(),
- changeFrequency: "daily",
- priority: 1,
- },
- ...imagePages,
- ];
+ const tagPages: MetadataRoute.Sitemap = tagUrls.map((path) => ({
+ url: `${BASE_URL}${path}`,
+ lastModified: now,
+ changeFrequency: "daily" as const,
+ priority: path.split("/").length === 3 ? 0.8 : 0.6,
+ // /tag/foo → 3 segments → first page (0.8)
+ // /tag/foo/xyz → 4 segments → cursor page (0.6)
+ }));
+
+ return [...staticPages, ...galleryCursorPages, ...tagPages];
}
diff --git a/apps/anipic/app/upload/actions.ts b/apps/anipic/app/upload/actions.ts
index 73c6074..cf9659a 100644
--- a/apps/anipic/app/upload/actions.ts
+++ b/apps/anipic/app/upload/actions.ts
@@ -6,8 +6,6 @@ import { headers } from "next/headers";
import { z } from "zod";
import { updateTag } from "next/cache";
-/* ---------------- ZOD SCHEMA ---------------- */
-
const uploadImageSchema = z.object({
originalUrl: z.url(),
displayUrl: z.url(),
@@ -33,8 +31,6 @@ export type UploadImageState =
| { success: false; error: string }
| { success: true; message: string };
-/* ---------------- SERVER ACTION ---------------- */
-
export async function uploadImageAction(
_prevState: UploadImageState,
formData: FormData,
@@ -54,7 +50,6 @@ export async function uploadImageAction(
return { success: false, error: "Unauthorized" };
}
- /* -------- Convert FormData → Object -------- */
const raw = Object.fromEntries(formData.entries());
const parsed = uploadImageSchema.safeParse(raw);
@@ -69,13 +64,7 @@ export async function uploadImageAction(
const AniPic = await getAniPicModel();
- /* -------- Generate serial number -------- */
- const last = await AniPic.findOne().sort({ sno: -1 }).lean();
- const sno = last ? last.sno + 1 : 1;
-
await AniPic.create({
- sno,
-
originalUrl,
displayUrl,
thumbnailUrl,
diff --git a/apps/anipic/components/FilterBar.tsx b/apps/anipic/components/FilterBar.tsx
new file mode 100644
index 0000000..b2ce266
--- /dev/null
+++ b/apps/anipic/components/FilterBar.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import type { SortOption } from "@/features/images/schemas";
+import { Button } from "@shared/components/ui/Button";
+import { Input } from "@shared/components/ui/Input";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useState } from "react";
+import { IoClose, IoSearchOutline } from "react-icons/io5";
+
+const SORT_OPTIONS: { label: string; value: SortOption }[] = [
+ { label: "Latest", value: "latest" },
+ { label: "Popular", value: "popular" },
+ { label: "Most Viewed", value: "views" },
+ { label: "Most Downloaded", value: "downloads" },
+];
+
+export default function FilterBar({
+ currentSort = "latest",
+ currentQ,
+}: {
+ currentSort?: SortOption;
+ currentQ?: string;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const [search, setSearch] = useState(currentQ ?? "");
+
+ const updateParams = useCallback(
+ (updates: Record) => {
+ const params = new URLSearchParams(searchParams.toString());
+ for (const [key, val] of Object.entries(updates)) {
+ if (val) params.set(key, val);
+ else params.delete(key);
+ }
+ void router.push(`${pathname}?${params.toString()}`);
+ },
+ [router, pathname, searchParams],
+ );
+
+ const handleSearchSubmit = (e: React.SubmitEvent) => {
+ e.preventDefault();
+ updateParams({ q: search || undefined });
+ };
+
+ return (
+
+ {/* Sort tabs */}
+
+ {SORT_OPTIONS.map(({ label, value }) => (
+
+ ))}
+
+
+ {/* Search */}
+
+
+ );
+}
diff --git a/apps/anipic/components/ImageCard.tsx b/apps/anipic/components/ImageCard.tsx
new file mode 100644
index 0000000..8b9bba8
--- /dev/null
+++ b/apps/anipic/components/ImageCard.tsx
@@ -0,0 +1,33 @@
+import Image from "next/image";
+import Link from "next/link";
+import type { ImageItem } from "../features/images/types";
+
+export const ImageCard: React.FC<{ data: ImageItem }> = ({ data: item }) => {
+ const aspectRatio = (item.height ?? 512) / (item.width ?? 512);
+
+ return (
+
+ );
+};
diff --git a/apps/anipic/components/MasonryImageGrid.tsx b/apps/anipic/components/MasonryImageGrid.tsx
new file mode 100644
index 0000000..0610a7f
--- /dev/null
+++ b/apps/anipic/components/MasonryImageGrid.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { useColumnCount } from "@/hooks/useColumnCount";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "react-toastify";
+import { VirtuosoMasonry } from "@virtuoso.dev/masonry";
+import { Button } from "@shared/components/ui/Button";
+import { ImageCard } from "./ImageCard";
+import type { ImageItem } from "@/features/images/types";
+import { ImageApiResponseSchema, type SortOption } from "@/features/images/schemas";
+
+interface MasonryImageGridProps {
+ initialImages: ImageItem[];
+ initialHasMore: boolean;
+ initialNextCursor: string;
+ tags?: string[];
+ q?: string;
+ sort?: SortOption;
+ skipId?: string;
+}
+
+export default function MasonryImageGrid({
+ initialImages,
+ initialHasMore,
+ initialNextCursor,
+ tags = [],
+ q,
+ sort = "latest",
+ skipId,
+}: MasonryImageGridProps) {
+ const [images, setImages] = useState(initialImages);
+ const [nextCursor, setNextCursor] = useState(initialNextCursor);
+ const [hasMore, setHasMore] = useState(initialHasMore);
+ const [loading, setLoading] = useState(false);
+ const [errorLoading, setErrorLoading] = useState(false);
+
+ const sentinelRef = useRef(null);
+ const columnCount = useColumnCount();
+
+ const loadMore = useCallback(async () => {
+ if (loading || !hasMore || !nextCursor) return;
+
+ setLoading(true);
+ setErrorLoading(false);
+ try {
+ const params = new URLSearchParams({ cursor: nextCursor });
+ if (tags?.length) params.set("tags", tags.join(","));
+ if (q) params.set("q", q);
+ if (sort && sort !== "latest") params.set("sort", sort);
+ if (skipId) params.set("skipId", skipId);
+
+ const res = await fetch(`/api/images?${params.toString()}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+
+ const data = ImageApiResponseSchema.parse(await res.json());
+ if ("error" in data) throw new Error(data.error);
+
+ setImages((prev) => [...prev, ...data.images]);
+ setNextCursor(data.nextCursor ?? "");
+ setHasMore(data.hasMore);
+ } catch (err) {
+ console.error("[MasonryImageGrid] loadMore failed:", err);
+ setErrorLoading(true);
+ toast.error("Failed to load more images. Please try again.");
+ } finally {
+ setLoading(false);
+ }
+ }, [loading, hasMore, nextCursor, tags, q, sort, skipId]);
+
+ // Intersection observer for infinite scroll
+ useEffect(() => {
+ const sentinel = sentinelRef.current;
+ if (!sentinel) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting) void loadMore();
+ },
+ { rootMargin: "400px" },
+ );
+
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, [loadMore]);
+
+ // Reset when external filters change
+ useEffect(() => {
+ setImages(initialImages);
+ setNextCursor(initialNextCursor);
+ setHasMore(initialHasMore);
+ }, [initialImages, initialHasMore, initialNextCursor, tags, q, sort, skipId]);
+
+ return (
+ <>
+
+
+ {errorLoading ? (
+
+
Failed to load more images.
+
+
+ ) : (
+
+ {loading && (
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {!hasMore && !loading && images.length > 0 && (
+ You've reached the end ✨
+ )}
+
+ {!loading && images.length === 0 && (
+
+
No images found
+
Try a different search or filter.
+
+ )}
+ >
+ );
+}
+
+export function LandingPageMasonryGrid({ images }: { images: ImageItem[] }) {
+ const columnCount = useColumnCount();
+
+ return (
+
+ );
+}
diff --git a/apps/anipic/components/imageGrid.tsx b/apps/anipic/components/imageGrid.tsx
deleted file mode 100644
index 4a9fa3f..0000000
--- a/apps/anipic/components/imageGrid.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import Image from "next/image";
-import Link from "next/link";
-
-export interface SafeImages {
- sno: number;
- thumbnailUrl: string;
- width: number;
- height: number;
- title: string;
-}
-
-function reorder(items: T[]) {
- const half = Math.ceil(items.length / 2);
- const result: T[] = [];
-
- for (let i = 0; i < half; i++) {
- const a = items[i];
- const b = items[i + half];
- if (a !== undefined) result.push(a);
- if (b !== undefined) result.push(b);
- }
-
- return result;
-}
-
-export default function ImageGrid({ images }: { images: SafeImages[] }) {
- const orderedImages = reorder(images);
-
- return (
-
- {orderedImages.map(({ sno, thumbnailUrl, width, height, title }, index) => (
-
-
-
- {/* Hidden tags for seo */}
-
{title}
-
- ))}
-
- );
-}
diff --git a/apps/anipic/components/skeletons/FilterBarSkeleton.tsx b/apps/anipic/components/skeletons/FilterBarSkeleton.tsx
new file mode 100644
index 0000000..1eadc3e
--- /dev/null
+++ b/apps/anipic/components/skeletons/FilterBarSkeleton.tsx
@@ -0,0 +1,14 @@
+import { Shimmer } from "./Shimmer";
+
+export function FilterBarSkeleton() {
+ return (
+
+ {/* Sort tabs */}
+
+
+ {/* Search */}
+
+
+
+ );
+}
diff --git a/apps/anipic/components/skeletons/GalleryHeaderSkeleton.tsx b/apps/anipic/components/skeletons/GalleryHeaderSkeleton.tsx
new file mode 100644
index 0000000..ab27b1c
--- /dev/null
+++ b/apps/anipic/components/skeletons/GalleryHeaderSkeleton.tsx
@@ -0,0 +1,12 @@
+import { Shimmer } from "./Shimmer";
+
+export function GalleryHeaderSkeleton() {
+ return (
+
+ );
+}
diff --git a/apps/anipic/components/skeletons/HomePageSkeleton.tsx b/apps/anipic/components/skeletons/HomePageSkeleton.tsx
new file mode 100644
index 0000000..6901268
--- /dev/null
+++ b/apps/anipic/components/skeletons/HomePageSkeleton.tsx
@@ -0,0 +1,69 @@
+import { MasonrySkeleton } from "./MasonrySkeleton";
+import { Shimmer } from "./Shimmer";
+
+export default function HomePageSkeleton() {
+ return (
+ <>
+ {/* Hero */}
+
+ {/* Badge */}
+
+
+ {/* Headline */}
+
+
+
+
+
+
+ {/* Subtitle */}
+
+
+
+
+
+ {/* Search bar */}
+
+
+ {/* CTA buttons */}
+
+
+
+
+
+
+ {/* Features */}
+
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+ {/* Tags section */}
+
+
+
+
+
+
+ {Array.from({ length: 18 }, (_, i) => (
+
+ ))}
+
+
+
+ {/* Photo strip × 3 */}
+ {[0, 1, 2].map((section) => (
+
+ ))}
+ >
+ );
+}
diff --git a/apps/anipic/components/skeletons/ImageDetailSkeleton.tsx b/apps/anipic/components/skeletons/ImageDetailSkeleton.tsx
new file mode 100644
index 0000000..6c16b6c
--- /dev/null
+++ b/apps/anipic/components/skeletons/ImageDetailSkeleton.tsx
@@ -0,0 +1,42 @@
+import { MasonrySkeleton } from "./MasonrySkeleton";
+import { Shimmer } from "./Shimmer";
+
+export function ImageDetailSkeleton() {
+ return (
+
+
+ {/* Main image */}
+
+
+ {/* Sidebar */}
+
+ {/* Action buttons */}
+
+
+
+
+
+
+ {/* Meta card */}
+
+
+ {/* Tags label */}
+
+
+ {/* Tag chips */}
+
+ {[72, 96, 56, 80, 64, 88, 52, 76].map((w, i) => (
+
+ ))}
+
+
+
+
+ {/* Related section */}
+
+
+
+
+
+ );
+}
diff --git a/apps/anipic/components/skeletons/MasonrySkeleton.tsx b/apps/anipic/components/skeletons/MasonrySkeleton.tsx
new file mode 100644
index 0000000..f0a1798
--- /dev/null
+++ b/apps/anipic/components/skeletons/MasonrySkeleton.tsx
@@ -0,0 +1,63 @@
+import { Shimmer } from "./Shimmer";
+
+const COLUMN_PATTERNS: number[][] = [
+ [1.42, 0.75, 1.2, 1.6, 0.8, 1.35, 1.0], // col 0
+ [0.8, 1.55, 1.1, 0.7, 1.45, 0.9, 1.3], // col 1
+ [1.3, 0.85, 1.5, 1.0, 0.72, 1.4, 1.15], // col 2
+ [0.7, 1.4, 0.9, 1.6, 1.1, 0.8, 1.35], // col 3
+ [1.5, 0.75, 1.2, 0.9, 1.55, 1.0, 0.85], // col 4
+ [0.85, 1.45, 1.0, 1.3, 0.8, 1.5, 1.1], // col 5
+];
+
+interface MasonrySkeletonProps {
+ rowsPerColumn?: number;
+}
+
+export function MasonrySkeleton({ rowsPerColumn = 5 }: MasonrySkeletonProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function ColumnGrid({
+ cols,
+ rowsPerColumn,
+ className,
+}: {
+ cols: number;
+ rowsPerColumn: number;
+ className: string;
+}) {
+ return (
+
+ {Array.from({ length: cols }, (_, colIdx) => (
+
+ {Array.from({ length: rowsPerColumn }, (_, rowIdx) => {
+ const pattern = COLUMN_PATTERNS[colIdx % COLUMN_PATTERNS.length]!;
+ const ratio = pattern[rowIdx % pattern.length]!;
+ return (
+
+ );
+ })}
+
+ ))}
+
+ );
+}
diff --git a/apps/anipic/components/skeletons/Shimmer.module.css b/apps/anipic/components/skeletons/Shimmer.module.css
new file mode 100644
index 0000000..95dcd09
--- /dev/null
+++ b/apps/anipic/components/skeletons/Shimmer.module.css
@@ -0,0 +1,10 @@
+@keyframes shimmer {
+ 100% {
+ transform: translateX(200%);
+ }
+}
+
+/* shimmer animation class */
+.skeletonShimmer {
+ animation: shimmer 1.6s infinite;
+}
diff --git a/apps/anipic/components/skeletons/Shimmer.tsx b/apps/anipic/components/skeletons/Shimmer.tsx
new file mode 100644
index 0000000..6f71a9c
--- /dev/null
+++ b/apps/anipic/components/skeletons/Shimmer.tsx
@@ -0,0 +1,13 @@
+import styles from "./Shimmer.module.css";
+
+type ShimmerProps = Omit, "children">;
+export function Shimmer({ className = "", ...props }: ShimmerProps) {
+ return (
+
+ {/* Sweep animation */}
+
+
+ );
+}
diff --git a/apps/anipic/features/images/const.ts b/apps/anipic/features/images/const.ts
new file mode 100644
index 0000000..e06e112
--- /dev/null
+++ b/apps/anipic/features/images/const.ts
@@ -0,0 +1,5 @@
+export const IMAGE_LIMIT_PER_LOAD = 50;
+export const LANDING_PAGE_IMAGE_LIMIT = 12;
+export const LANDING_PAGE_TAG_LIMIT = 20;
+
+export const BASE_FILTER = { approved: true, isDeleted: false, dmcaFlag: false };
diff --git a/apps/anipic/features/images/generateCursors.ts b/apps/anipic/features/images/generateCursors.ts
new file mode 100644
index 0000000..187172f
--- /dev/null
+++ b/apps/anipic/features/images/generateCursors.ts
@@ -0,0 +1,69 @@
+import { cacheLife, cacheTag } from "next/cache";
+import getAniPicModel from "@/lib/db/models/AniPic";
+import { BASE_FILTER, IMAGE_LIMIT_PER_LOAD } from "./const";
+import { encodeCursor } from "./utils";
+import { getAllTags } from "./loadImages";
+
+async function fetchBoundaryIds(filter: Record): Promise {
+ const AniPic = await getAniPicModel();
+
+ const rows = await AniPic.aggregate<{ _id: { toString(): string } }>([
+ { $match: filter },
+ // assign a 1-based row number to each document in _id-desc order
+ {
+ $setWindowFields: {
+ sortBy: { _id: -1 },
+ output: { _rowNum: { $documentNumber: {} } },
+ },
+ },
+ // keep only the rows that sit exactly at a page boundary
+ { $match: { $expr: { $eq: [{ $mod: ["$_rowNum", IMAGE_LIMIT_PER_LOAD] }, 0] } } },
+ // return only the _id
+ { $project: { _id: 1 } },
+ ]);
+
+ return rows.map((r) => r._id.toString());
+}
+
+export async function generateGalleryCursors(): Promise {
+ "use cache";
+ cacheLife("days");
+ cacheTag("anipicCursors");
+ cacheTag("anipicImagePages");
+
+ const ids = await fetchBoundaryIds(BASE_FILTER);
+ return ids.map((id) => encodeCursor({ id }));
+}
+
+export async function getGallerySitemapUrls(): Promise {
+ const cursors = await generateGalleryCursors();
+ return ["/gallery", ...cursors.map((c) => `/gallery/${c}`)];
+}
+
+export async function generateTagCursors(tag: string): Promise {
+ "use cache";
+ cacheLife("days");
+ cacheTag("anipicCursors");
+ cacheTag(`anipicTag:${tag}`);
+
+ const ids = await fetchBoundaryIds({ ...BASE_FILTER, tags: tag });
+ return ids.map((id) => encodeCursor({ id }));
+}
+
+async function getTagSitemapUrls(tag: string): Promise {
+ const cursors = await generateTagCursors(tag);
+ const encoded = encodeURIComponent(tag);
+ return [`/tag/${encoded}`, ...cursors.map((c) => `/tag/${encoded}/${c}`)];
+}
+
+export async function getAllTagsSitemapUrls(): Promise {
+ "use cache";
+ cacheLife("days");
+ cacheTag("anipicCursors");
+ cacheTag("anipicImagePages");
+
+ const tags = await getAllTags();
+ const urlArrays = await Promise.all(tags.map((tag) => getTagSitemapUrls(tag)));
+
+ return urlArrays.flat();
+}
diff --git a/apps/anipic/features/images/loadImages.ts b/apps/anipic/features/images/loadImages.ts
new file mode 100644
index 0000000..11a62ba
--- /dev/null
+++ b/apps/anipic/features/images/loadImages.ts
@@ -0,0 +1,175 @@
+import { cacheLife, cacheTag } from "next/cache";
+import { Types } from "mongoose";
+
+import getAniPicModel from "@/lib/db/models/AniPic";
+import { buildSeoTitle } from "@/utils/seo/buildSeoUsingTags";
+
+import type { CursorPayload, ImageItem } from "./types";
+import type { ImageApiRequestBody } from "./schemas";
+import { BASE_FILTER, IMAGE_LIMIT_PER_LOAD } from "./const";
+import { decodeCursor, encodeCursor } from "./utils";
+
+type ImageSort = NonNullable;
+
+interface LoadImagesOptions {
+ cursor?: string | null;
+ tags?: string[];
+ q?: string;
+ sort?: ImageSort;
+ skipId?: string; // For related images, to exclude the current image
+}
+
+function buildBaseFilter(tags: string[], q?: string) {
+ const and: Record[] = [BASE_FILTER];
+
+ if (tags.length > 0) and.push({ tags: { $all: tags } });
+
+ if (q?.trim()) and.push({ tags: { $regex: q.trim(), $options: "i" } });
+
+ return { $and: and };
+}
+
+function buildCursorCondition(sort: ImageSort, decodedCursor: CursorPayload) {
+ const objectId = new Types.ObjectId(decodedCursor.id);
+
+ switch (sort) {
+ case "popular":
+ return {
+ $or: [
+ { likes: { $lt: decodedCursor.value ?? 0 } },
+ { likes: decodedCursor.value ?? 0, _id: { $lt: objectId } },
+ ],
+ };
+
+ case "views":
+ return {
+ $or: [
+ { views: { $lt: decodedCursor.value ?? 0 } },
+ { views: decodedCursor.value ?? 0, _id: { $lt: objectId } },
+ ],
+ };
+
+ case "downloads":
+ return {
+ $or: [
+ { downloads: { $lt: decodedCursor.value ?? 0 } },
+ { downloads: decodedCursor.value ?? 0, _id: { $lt: objectId } },
+ ],
+ };
+
+ case "latest":
+ default:
+ return { _id: { $lt: objectId } };
+ }
+}
+
+export async function loadImages({
+ cursor,
+ tags = [],
+ q,
+ sort = "latest",
+ skipId,
+}: LoadImagesOptions = {}) {
+ "use cache";
+ cacheLife("max");
+ cacheTag("anipicImagePages");
+
+ const AniPic = await getAniPicModel();
+ const decodedCursor = decodeCursor(cursor);
+
+ const baseFilter = buildBaseFilter(tags, q);
+
+ if (skipId) {
+ const objectId = new Types.ObjectId(skipId);
+ baseFilter.$and.push({ _id: { $ne: objectId } });
+ }
+
+ const total = await AniPic.countDocuments(baseFilter);
+
+ const sortQuery = ((): Record => {
+ switch (sort) {
+ case "popular":
+ return { likes: -1, _id: -1 };
+ case "views":
+ return { views: -1, _id: -1 };
+ case "downloads":
+ return { downloads: -1, _id: -1 };
+ case "latest":
+ default:
+ return { _id: -1 };
+ }
+ })();
+
+ const queryFilter = decodedCursor
+ ? { $and: [...baseFilter.$and, buildCursorCondition(sort, decodedCursor)] }
+ : baseFilter;
+
+ const raw = await AniPic.find(queryFilter)
+ .sort(sortQuery)
+ .limit(IMAGE_LIMIT_PER_LOAD + 1)
+ .select({
+ _id: 1,
+ thumbnailUrl: 1,
+ width: 1,
+ height: 1,
+ tags: 1,
+ likes: 1,
+ views: 1,
+ downloads: 1,
+ })
+ .lean();
+
+ const hasMore = raw.length > IMAGE_LIMIT_PER_LOAD;
+ const sliced = hasMore ? raw.slice(0, IMAGE_LIMIT_PER_LOAD) : raw;
+
+ const images: ImageItem[] = sliced.map((img) => ({
+ id: img._id.toString(),
+ thumbnailUrl: img.thumbnailUrl,
+ width: img.width ?? 512,
+ height: img.height ?? 512,
+ title: buildSeoTitle(img.tags),
+ }));
+
+ const last = sliced[sliced.length - 1];
+ let nextCursor: string | null = null;
+
+ if (last) {
+ switch (sort) {
+ case "popular":
+ nextCursor = encodeCursor({ id: last._id.toString(), value: last.likes ?? 0 });
+ break;
+
+ case "views":
+ nextCursor = encodeCursor({ id: last._id.toString(), value: last.views ?? 0 });
+ break;
+
+ case "downloads":
+ nextCursor = encodeCursor({ id: last._id.toString(), value: last.downloads ?? 0 });
+ break;
+
+ case "latest":
+ default:
+ nextCursor = encodeCursor({ id: last._id.toString() });
+ break;
+ }
+ }
+
+ return { images, total, hasMore, nextCursor };
+}
+
+export async function getAllTags(shortBy: "name" | "count" = "name"): Promise {
+ "use cache";
+ cacheLife("days");
+ cacheTag("anipicTags");
+
+ const AniPic = await getAniPicModel();
+
+ const tagAgg = await AniPic.aggregate<{ _id: string; count: number }>([
+ { $match: BASE_FILTER },
+ { $unwind: "$tags" },
+ { $group: { _id: "$tags", count: { $sum: 1 } } },
+ { $sort: shortBy === "count" ? { count: -1 } : { _id: 1 } },
+ ]);
+
+ return tagAgg.map(({ _id }) => _id);
+}
diff --git a/apps/anipic/features/images/loadLandingPageData.ts b/apps/anipic/features/images/loadLandingPageData.ts
new file mode 100644
index 0000000..4e0b4d7
--- /dev/null
+++ b/apps/anipic/features/images/loadLandingPageData.ts
@@ -0,0 +1,74 @@
+import { cacheLife, cacheTag } from "next/cache";
+import getAniPicModel from "@/lib/db/models/AniPic";
+import { buildSeoTitle } from "@/utils/seo/buildSeoUsingTags";
+import type { ImageItem } from "./types";
+import type { Types } from "mongoose";
+import { BASE_FILTER, LANDING_PAGE_IMAGE_LIMIT, LANDING_PAGE_TAG_LIMIT } from "./const";
+
+const IMAGE_SELECT = { _id: 1, thumbnailUrl: 1, width: 1, height: 1, tags: 1 };
+
+interface ToImageItemInput {
+ _id: Types.ObjectId;
+ thumbnailUrl: string;
+ width?: number;
+ height?: number;
+ tags: string[];
+}
+
+function toImageItem(img: ToImageItemInput) {
+ return {
+ id: img._id.toString(),
+ thumbnailUrl: img.thumbnailUrl,
+ width: img.width ?? 512,
+ height: img.height ?? 512,
+ title: buildSeoTitle(img.tags),
+ } satisfies ImageItem;
+}
+
+export async function loadLandingData() {
+ "use cache";
+ cacheLife("hours");
+ cacheTag("anipicLanding");
+ cacheTag("anipicImagePages");
+
+ const AniPic = await getAniPicModel();
+
+ const [tagAgg, popularRaw, downloadedRaw, recentRaw] = await Promise.all([
+ // Top tags by document count
+ AniPic.aggregate<{ _id: string; count: number }>([
+ { $match: BASE_FILTER },
+ { $unwind: "$tags" },
+ { $group: { _id: "$tags", count: { $sum: 1 } } },
+ { $sort: { count: -1 } },
+ { $limit: LANDING_PAGE_TAG_LIMIT },
+ ]),
+
+ // Top by likes
+ AniPic.find(BASE_FILTER)
+ .sort({ likes: -1, _id: -1 })
+ .limit(LANDING_PAGE_IMAGE_LIMIT)
+ .select(IMAGE_SELECT)
+ .lean(),
+
+ // Top by downloads
+ AniPic.find(BASE_FILTER)
+ .sort({ downloads: -1, _id: -1 })
+ .limit(LANDING_PAGE_IMAGE_LIMIT)
+ .select(IMAGE_SELECT)
+ .lean(),
+
+ // Latest
+ AniPic.find(BASE_FILTER)
+ .sort({ _id: -1 })
+ .limit(LANDING_PAGE_IMAGE_LIMIT)
+ .select(IMAGE_SELECT)
+ .lean(),
+ ]);
+
+ return {
+ popularTags: tagAgg.map(({ _id, count }) => ({ tag: _id, count })),
+ popularPhotos: popularRaw.map(toImageItem),
+ mostDownloaded: downloadedRaw.map(toImageItem),
+ recentPhotos: recentRaw.map(toImageItem),
+ };
+}
diff --git a/apps/anipic/features/images/schemas.ts b/apps/anipic/features/images/schemas.ts
new file mode 100644
index 0000000..916c315
--- /dev/null
+++ b/apps/anipic/features/images/schemas.ts
@@ -0,0 +1,33 @@
+import z from "zod";
+
+export const ImageApiResponseSchema = z.union([
+ z.object({
+ images: z.array(
+ z.object({
+ id: z.string(),
+ thumbnailUrl: z.string(),
+ title: z.string(),
+ width: z.number(),
+ height: z.number(),
+ }),
+ ),
+ nextCursor: z.string().nullable(),
+ hasMore: z.boolean(),
+ }),
+ z.object({ error: z.string() }),
+]);
+
+export const ImageApiRequestBodySchema = z.object({
+ cursor: z.string().optional(),
+ tags: z
+ .string()
+ .transform((val) => (val ? val.split(",").filter(Boolean) : []))
+ .optional(),
+ q: z.string().optional(),
+ sort: z.enum(["latest", "popular", "views", "downloads"]).optional(),
+ skipId: z.string().optional(), // For related images, to exclude the current image
+});
+
+export type ImageApiResponse = z.infer;
+export type ImageApiRequestBody = z.infer;
+export type SortOption = NonNullable;
\ No newline at end of file
diff --git a/apps/anipic/features/images/types.ts b/apps/anipic/features/images/types.ts
new file mode 100644
index 0000000..91d5dd7
--- /dev/null
+++ b/apps/anipic/features/images/types.ts
@@ -0,0 +1,13 @@
+export interface ImageItem {
+ id: string;
+ thumbnailUrl: string;
+ width: number;
+ height: number;
+ title: string;
+}
+
+
+export interface CursorPayload {
+ id: string;
+ value?: number;
+}
diff --git a/apps/anipic/features/images/utils.ts b/apps/anipic/features/images/utils.ts
new file mode 100644
index 0000000..b330505
--- /dev/null
+++ b/apps/anipic/features/images/utils.ts
@@ -0,0 +1,15 @@
+import type { CursorPayload } from "./types";
+
+export function encodeCursor(data: CursorPayload) {
+ return Buffer.from(JSON.stringify(data)).toString("base64url");
+}
+
+export function decodeCursor(cursor?: string | null) {
+ if (!cursor) return null;
+
+ try {
+ return JSON.parse(Buffer.from(cursor, "base64url").toString()) as CursorPayload;
+ } catch {
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/apps/anipic/hooks/useColumnCount.ts b/apps/anipic/hooks/useColumnCount.ts
new file mode 100644
index 0000000..283de77
--- /dev/null
+++ b/apps/anipic/hooks/useColumnCount.ts
@@ -0,0 +1,19 @@
+import { useEffect, useMemo, useState } from "react";
+
+export function useColumnCount(): number {
+ const [width, setWidth] = useState(typeof window !== "undefined" ? window.innerWidth : 768);
+
+ useEffect(() => {
+ const handleResize = () => setWidth(window.innerWidth);
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ return useMemo(() => {
+ if (width <= 375) return 1;
+ if (width <= 640) return 2;
+ if (width < 768) return 3;
+ if (width < 1024) return 4;
+ return 5;
+ }, [width]);
+}
diff --git a/apps/anipic/lib/db/models/AniPic.ts b/apps/anipic/lib/db/models/AniPic.ts
index 44e343a..63a6eb9 100644
--- a/apps/anipic/lib/db/models/AniPic.ts
+++ b/apps/anipic/lib/db/models/AniPic.ts
@@ -1,9 +1,11 @@
import "server-only";
-import { Schema, type Model, type Connection } from "mongoose";
+
+import { Schema, type Model, type Connection, type Types } from "mongoose";
+
import connectToAniPicDb from "../connections/aniPicDb";
export interface AniPic {
- sno: number;
+ _id: Types.ObjectId;
originalUrl: string;
displayUrl: string;
@@ -31,32 +33,35 @@ export interface AniPic {
const aniPicSchema = new Schema(
{
- sno: { type: Number, required: true, unique: true, index: true },
originalUrl: { type: String, required: true, unique: true },
displayUrl: { type: String, required: true },
thumbnailUrl: { type: String, required: true },
- uploadedBy: { type: String, required: true, ref: "User", index: true },
- approved: { type: Boolean, default: false, index: true },
+ uploadedBy: { type: String, required: true, ref: "User" },
+ approved: { type: Boolean, default: false },
- tags: { type: [String], default: [], index: true },
+ tags: { type: [String], default: [] },
- width: Number,
- height: Number,
+ width: { type: Number, required: true },
+ height: { type: Number, required: true },
downloads: { type: Number, default: 0 },
views: { type: Number, default: 0 },
likes: { type: Number, default: 0 },
- isDeleted: { type: Boolean, default: false, index: true },
- dmcaFlag: { type: Boolean, default: false, index: true },
+ isDeleted: { type: Boolean, default: false },
+ dmcaFlag: { type: Boolean, default: false },
dmcaReason: { type: String },
},
{ timestamps: true },
);
-// Index for sorting and searching efficiently
-aniPicSchema.index({ createdAt: -1 });
+// Cursor + filter indexes
+aniPicSchema.index({ approved: 1, isDeleted: 1, dmcaFlag: 1, _id: -1 });
+aniPicSchema.index({ approved: 1, isDeleted: 1, dmcaFlag: 1, likes: -1, _id: -1 });
+aniPicSchema.index({ approved: 1, isDeleted: 1, dmcaFlag: 1, views: -1, _id: -1 });
+aniPicSchema.index({ approved: 1, isDeleted: 1, dmcaFlag: 1, downloads: -1, _id: -1 });
+aniPicSchema.index({ tags: 1, approved: 1, isDeleted: 1, dmcaFlag: 1, _id: -1 });
let cachedModel: Model | null = null;
diff --git a/apps/anipic/package.json b/apps/anipic/package.json
index 5b7eeec..12efd7d 100644
--- a/apps/anipic/package.json
+++ b/apps/anipic/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"@repo/shared": "workspace:*",
+ "@virtuoso.dev/masonry": "^1.4.3",
"next": "^16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4"
diff --git a/apps/anipic/utils/const.ts b/apps/anipic/utils/const.ts
deleted file mode 100644
index ed209cb..0000000
--- a/apps/anipic/utils/const.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const IMAGE_LIMIT_PER_PAGE = 10;
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 314550c..99983fc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -132,6 +132,9 @@ importers:
'@repo/shared':
specifier: workspace:*
version: link:../../shared
+ '@virtuoso.dev/masonry':
+ specifier: ^1.4.3
+ version: 1.4.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next:
specifier: ^16.1.6
version: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1533,6 +1536,18 @@ packages:
resolution: {integrity: sha512-heiJGj2qt5qTv6yiShH9f6KRAoZGj+lz61GQ+lBRL4lhvUmKI9A51KYlQTnsUd9ymdFlKHBlvmPeG+yGz2Qsbg==}
engines: {node: '>=16.14'}
+ '@virtuoso.dev/gurx@1.2.3':
+ resolution: {integrity: sha512-zqIzVOQgJGEbrG+hvNiNHdrtM2G/9zBVgBSRUAW2yT8iUOouSHAsPIxpCU/xsAYEwicXURGhU6cxzgBPVjXL+g==}
+ peerDependencies:
+ react: '>= 16 || >= 17 || >= 18 || >= 19'
+ react-dom: '>= 16 || >= 17 || >= 18 || >= 19'
+
+ '@virtuoso.dev/masonry@1.4.3':
+ resolution: {integrity: sha512-3LxsW2TxlWn061NWZFSi8FhLVK0bwNfGZNPMMsKBg5uxhgVUo8j7XaEuOMgY9UO/EuIFMijAyHDc3DO3A5+KCA==}
+ peerDependencies:
+ react: '>= 18 || >= 19'
+ react-dom: '>= 18 || >= 19'
+
'@wojtekmaj/date-utils@2.0.2':
resolution: {integrity: sha512-Do66mSlSNifFFuo3l9gNKfRMSFi26CRuQMsDJuuKO/ekrDWuTTtE4ZQxoFCUOG+NgxnpSeBq/k5TY8ZseEzLpA==}
@@ -5081,6 +5096,17 @@ snapshots:
throttleit: 2.1.0
undici: 5.29.0
+ '@virtuoso.dev/gurx@1.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ '@virtuoso.dev/masonry@1.4.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@virtuoso.dev/gurx': 1.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
'@wojtekmaj/date-utils@2.0.2': {}
abstract-logging@2.0.1: {}