mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
Compare commits
3 commits
5f45f205f4
...
cdd41c6566
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd41c6566 | ||
| 28bbaa52f1 | |||
|
|
8df69bcd79 |
11 changed files with 154 additions and 63 deletions
|
|
@ -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);
|
||||
|
|
@ -90,14 +90,16 @@ model Mii {
|
|||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
likedBy Like[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
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)])
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
71
src/components/mii/list/time-range-select.tsx
Normal file
71
src/components/mii/list/time-range-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue