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.title} +
+

+ + {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 ( + <> +
+ {alt} + +
+
+ +
+
+
+ + {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 */} + {alt} +
+ + {/* 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 ( +
+

Related Art

+ +
+ ); +} + +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 */} -
- {img.title} -
- - {/* 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 */} +
+ Scroll +
+
+
+ ); +} + +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 */} +
+
+ + setSearch(e.target.value)} + placeholder="Search tags..." + className="w-full pl-8 pr-8 py-2.5" + /> + {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 ( +
+ +
+ {item.title} +
+

+ {item.title} +

+
+
+ +
+ ); +}; 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) => ( - - {title} - - {/* 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: {}