Compare commits

...

3 commits

Author SHA1 Message Date
Alex Helo
cdd41c6566
Merge 8df69bcd79 into 28bbaa52f1 2026-04-19 18:50:52 +01:00
28bbaa52f1 fix: better like count 2026-04-19 18:47:11 +01:00
AlexHelo
8df69bcd79 feat: add time range filter for Mii discovery 2026-04-16 18:00:49 -06:00
11 changed files with 154 additions and 63 deletions

View file

@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "likeCount" INTEGER NOT NULL DEFAULT 0;
-- CreateIndex
CREATE INDEX "miis_likeCount_idx" ON "miis"("likeCount" DESC);
UPDATE miis SET like_count = (SELECT COUNT(*) FROM likes WHERE likes."miiId" = miis.id);

View file

@ -91,13 +91,15 @@ model Mii {
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
likedBy Like[]
likeCount Int @default(0)
punishmentId Int?
punishments MiiPunishment[]
likedBy Like[]
@@index([tags], type: Gin)
@@index([createdAt])
@@index([likeCount(sort: Desc)])
@@index([quarantined, createdAt(sort: Desc)])
@@index([platform, createdAt(sort: Desc)])
@@index([userId, createdAt(sort: Desc)])

View file

@ -5,55 +5,57 @@ import { prisma } from "@/lib/prisma";
import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
// const session = await auth();
// if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// const rateLimit = new RateLimit(request, 100, "/api/mii/like");
// const check = await rateLimit.handle();
// if (check) return check;
const rateLimit = new RateLimit(request, 100, "/api/mii/like");
const check = await rateLimit.handle();
if (check) return check;
// const { id: slugId } = await params;
// const parsed = idSchema.safeParse(slugId);
// if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
// const miiId = parsed.data;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
// const result = await prisma.$transaction(async (tx) => {
// const existingLike = await tx.like.findUnique({
// where: {
// userId_miiId: {
// userId: Number(session.user?.id),
// miiId,
// },
// },
// });
const result = await prisma.$transaction(async (tx) => {
const existingLike = await tx.like.findUnique({
where: {
userId_miiId: {
userId: Number(session.user?.id),
miiId,
},
},
});
// if (existingLike) {
// // Remove the like if it exists
// await tx.like.delete({
// where: {
// userId_miiId: {
// userId: Number(session.user?.id),
// miiId,
// },
// },
// });
// } else {
// // Add a like if it doesn't exist
// await tx.like.create({
// data: {
// userId: Number(session.user?.id),
// miiId,
// },
// });
// }
if (existingLike) {
await tx.like.delete({
where: {
userId_miiId: {
userId: Number(session.user?.id),
miiId,
},
},
});
await tx.mii.update({
where: { id: miiId },
data: { likeCount: { decrement: 1 } },
});
} else {
await tx.like.create({
data: {
userId: Number(session.user?.id),
miiId,
},
});
await tx.mii.update({
where: { id: miiId },
data: { likeCount: { increment: 1 } },
});
}
// const likeCount = await tx.like.count({
// where: { miiId },
// });
return { liked: !existingLike };
});
// return { liked: !existingLike, count: likeCount };
// });
return NextResponse.json({ success: false });
return rateLimit.sendResponse({ success: true, liked: result.liked });
}

View file

@ -46,5 +46,5 @@ export default async function MiiPage({ params }: Props) {
// Check ownership
if (!mii || (Number(session?.user?.id) !== mii.userId && Number(session?.user?.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) redirect("/404");
return <EditForm mii={mii} likes={mii._count.likedBy} />;
return <EditForm mii={mii} likes={mii.likeCount} />;
}

View file

@ -51,13 +51,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii.likeCount} likes.`,
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
creator: name,
openGraph: {
type: "article",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii.likeCount} likes.`,
images: [
{
url: metadataImageUrl,
@ -70,7 +70,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
twitter: {
card: "summary_large_image",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.name} on TomodachiShare with ${mii.likeCount} likes.`,
images: [
{
url: metadataImageUrl,
@ -306,7 +306,7 @@ export default async function MiiPage({ params }: Props) {
{/* Submission name */}
<h1 className="text-4xl font-extrabold wrap-break-word whitespace-break-spaces text-amber-700 flex-1 min-w-0">{mii.name}</h1>
{/* Like button */}
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={false} big />
<LikeButton likes={mii.likeCount ?? 0} miiId={mii.id} isLiked={false} big />
</div>
{/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">

View file

@ -41,7 +41,7 @@ export default async function ReportMiiPage({ params }: Props) {
return (
<div className="flex justify-center w-full">
<ReportMiiForm mii={mii} likes={mii._count.likedBy} />
<ReportMiiForm mii={mii} likes={mii.likeCount} />
</div>
);
}

View file

@ -31,7 +31,7 @@ export default function AuthorButtons({ mii }: Props) {
<Icon icon="mdi:pencil" />
<span>Edit</span>
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likeCount ?? 0} inMiiPage />
</>
);
}

View file

@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import SortSelect from "./sort-select";
import TimeRangeSelect from "./time-range-select";
import Pagination from "./pagination";
import FilterMenu from "./filter-menu";
import MiiGrid from "./mii-grid";
@ -20,7 +21,7 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24 } = parsed.data;
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, timeRange } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
@ -66,6 +67,14 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
...(makeup && { makeup: { equals: makeup } }),
// Quarantined
...(!quarantined && !userId && { quarantined: false }),
// Time range
...(timeRange && {
createdAt: {
gte: new Date(
Date.now() - { day: 86400000, week: 604800000, month: 2592000000, year: 31536000000 }[timeRange],
),
},
}),
};
const select: Prisma.MiiSelect = {
@ -89,6 +98,7 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
allowedCopying: true,
quarantined: true,
in_queue: true,
likeCount: true,
// Mii liked check
...(session?.user?.id && {
likedBy: {
@ -96,10 +106,6 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
select: { userId: true },
},
}),
// Like count
_count: {
select: { likedBy: true },
},
};
const skip = (page - 1) * limit;
@ -111,7 +117,7 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
let orderBy: Prisma.MiiOrderByWithRelationInput[];
if (sort === "likes") {
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
orderBy = [{ likeCount: "desc" }, { name: "asc" }];
} else if (sort === "oldest") {
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
} else {
@ -143,6 +149,7 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
<FilterMenu />
<SortSelect />
<TimeRangeSelect />
</div>
</div>

View file

@ -74,7 +74,7 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
</div>
<div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={likedIds.has(mii.id)} abbreviate />
<LikeButton likes={mii.likeCount} miiId={mii.id} isLiked={likedIds.has(mii.id)} abbreviate />
{!userId && (
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
@ -87,7 +87,7 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
<Link href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
<Icon icon="mdi:pencil" />
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} />
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likeCount} />
</div>
)}
@ -105,7 +105,7 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
<Icon icon="material-symbols:check-rounded" />
</button>
<div className="text-zinc-400 hover:text-red-500 transition-colors p-1 bg-white rounded-md shadow-sm border border-zinc-200 hover:border-red-500 flex items-center justify-center">
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} />
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likeCount} />
</div>
</div>

View file

@ -0,0 +1,71 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useSelect } from "downshift";
import { Icon } from "@iconify/react";
type TimeRange = "day" | "week" | "month" | "year";
const items: { value: TimeRange | "all"; label: string }[] = [
{ value: "all", label: "all time" },
{ value: "day", label: "today" },
{ value: "week", label: "this week" },
{ value: "month", label: "this month" },
{ value: "year", label: "this year" },
];
export default function TimeRangeSelect() {
const router = useRouter();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const currentRange = (searchParams.get("timeRange") as TimeRange) || "all";
const currentItem = items.find((i) => i.value === currentRange) || items[0];
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
items,
selectedItem: currentItem,
itemToString: (item) => item?.label || "",
onSelectedItemChange: ({ selectedItem }) => {
if (!selectedItem) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (selectedItem.value === "all") {
params.delete("timeRange");
} else {
params.set("timeRange", selectedItem.value);
}
startTransition(() => {
router.push(`?${params.toString()}`, { scroll: false });
});
},
});
return (
<div className="relative w-fit">
<button type="button" {...getToggleButtonProps()} aria-label="Time range dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
<Icon icon="mdi:clock-outline" className="size-5" />
{selectedItem?.label || "all time"}
<Icon icon="tabler:chevron-down" className="ml-1 size-5" />
</button>
<ul
{...getMenuProps()}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isOpen ? "block" : "hidden"
}`}
>
{isOpen &&
items.map((item, index) => (
<li key={item.value} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
{item.label}
</li>
))}
</ul>
</div>
);
}

View file

@ -72,6 +72,8 @@ export const searchSchema = z.object({
.max(100, { error: "Limit cannot be more than 100" })
.optional(),
page: z.coerce.number({ error: "Page must be a number" }).int({ error: "Page must be an integer" }).min(1, { error: "Page must be at least 1" }).optional(),
// Time range filter
timeRange: z.enum(["day", "week", "month", "year"], { error: "Time range must be either 'day', 'week', 'month', or 'year'" }).optional(),
});
export const userNameSchema = z