Compare commits

...

3 commits

Author SHA1 Message Date
aa631095fa fix: parity with vite-split
idk what to use
2026-04-22 11:45:54 +01:00
5235efdd2e
Merge pull request #30 from AlexHelo/feat/time-range-filter
feat: add time range filter for Mii discovery
2026-04-22 11:35:23 +01:00
AlexHelo
8df69bcd79 feat: add time range filter for Mii discovery 2026-04-16 18:00:49 -06:00
12 changed files with 121 additions and 138 deletions

View file

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

View file

@ -0,0 +1,25 @@
/*
Warnings:
- You are about to drop the column `punishmentId` on the `miis` table. All the data in the column will be lost.
- You are about to drop the column `notes` on the `punishments` table. All the data in the column will be lost.
- You are about to drop the column `reasons` on the `punishments` table. All the data in the column will be lost.
- You are about to drop the `mii_punishments` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `reason` to the `punishments` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "mii_punishments" DROP CONSTRAINT "mii_punishments_miiId_fkey";
-- DropForeignKey
ALTER TABLE "mii_punishments" DROP CONSTRAINT "mii_punishments_punishmentId_fkey";
-- AlterTable
ALTER TABLE "miis" DROP COLUMN "punishmentId";
-- AlterTable
ALTER TABLE "punishments" RENAME COLUMN "notes" TO "reason";
ALTER TABLE "punishments" DROP COLUMN "reasons";
-- DropTable
DROP TABLE "mii_punishments";

View file

@ -92,9 +92,6 @@ model Mii {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
likeCount Int @default(0) likeCount Int @default(0)
punishmentId Int?
punishments MiiPunishment[]
likedBy Like[] likedBy Like[]
@@index([tags], type: Gin) @@index([tags], type: Gin)
@ -142,28 +139,13 @@ model Report {
@@map("reports") @@map("reports")
} }
model MiiPunishment {
punishmentId Int
miiId Int
reason String
punishment Punishment @relation(fields: [punishmentId], references: [id], onDelete: Cascade)
mii Mii @relation(fields: [miiId], references: [id], onDelete: Cascade)
@@id([punishmentId, miiId])
@@map("mii_punishments")
}
model Punishment { model Punishment {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
type PunishmentType type PunishmentType
returned Boolean @default(false) returned Boolean @default(false)
notes String reason String
reasons String[]
violatingMiis MiiPunishment[]
expiresAt DateTime? expiresAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View file

@ -43,15 +43,6 @@ export default async function AdminPage({ searchParams }: Props) {
</div> </div>
<BannerForm /> <BannerForm />
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
<span>Control Center</span>
<hr className="grow border-zinc-300" />
</div>
<ControlCenter />
<RegenerateImagesButton /> <RegenerateImagesButton />
{/* Separator */} {/* Separator */}

View file

@ -29,16 +29,7 @@ export async function GET(request: NextRequest) {
id: true, id: true,
type: true, type: true,
returned: true, returned: true,
notes: true,
reasons: true,
violatingMiis: {
select: {
miiId: true,
reason: true, reason: true,
},
},
expiresAt: true, expiresAt: true,
createdAt: true, createdAt: true,
}, },

View file

@ -14,16 +14,7 @@ const punishSchema = z.object({
.number({ error: "Duration (days) must be a number" }) .number({ error: "Duration (days) must be a number" })
.int({ error: "Duration (days) must be an integer" }) .int({ error: "Duration (days) must be an integer" })
.positive({ error: "Duration (days) must be valid" }), .positive({ error: "Duration (days) must be valid" }),
notes: z.string(),
reasons: z.array(z.string()).optional(),
miiReasons: z
.array(
z.object({
id: z.number({ error: "Mii ID must be a number" }).int({ error: "Mii ID must be an integer" }).positive({ error: "Mii ID must be valid" }),
reason: z.string(), reason: z.string(),
}),
)
.optional(),
}); });
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -42,7 +33,7 @@ export async function POST(request: NextRequest) {
const parsed = punishSchema.safeParse(body); const parsed = punishSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const { type, duration, notes, reasons, miiReasons } = parsed.data; const { type, duration, reason } = parsed.data;
const expiresAt = type === "TEMP_EXILE" ? dayjs().add(duration, "days").toDate() : null; const expiresAt = type === "TEMP_EXILE" ? dayjs().add(duration, "days").toDate() : null;
@ -51,14 +42,7 @@ export async function POST(request: NextRequest) {
userId, userId,
type: type as PunishmentType, type: type as PunishmentType,
expiresAt, expiresAt,
notes, reason,
reasons: reasons?.length !== 0 ? reasons : [],
violatingMiis: {
create: miiReasons?.map((mii) => ({
miiId: mii.id,
reason: mii.reason,
})),
},
}, },
}); });

View file

@ -17,17 +17,6 @@ export async function DELETE(request: NextRequest) {
userId: Number(session.user?.id), userId: Number(session.user?.id),
returned: false, returned: false,
}, },
include: {
violatingMiis: {
include: {
mii: {
select: {
name: true,
},
},
},
},
},
}); });
if (!activePunishment) return rateLimit.sendResponse({ error: "You have no active punishments!" }, 404); if (!activePunishment) return rateLimit.sendResponse({ error: "You have no active punishments!" }, 404);

View file

@ -29,17 +29,6 @@ export default async function ExiledPage() {
userId: Number(session?.user.id), userId: Number(session?.user.id),
returned: false, returned: false,
}, },
include: {
violatingMiis: {
include: {
mii: {
select: {
name: true,
},
},
},
},
},
}); });
if (!activePunishment) redirect("/"); if (!activePunishment) redirect("/");
@ -74,36 +63,9 @@ export default async function ExiledPage() {
</p> </p>
<p className="mt-1"> <p className="mt-1">
<span className="font-bold">Note:</span> {activePunishment.notes} <span className="font-bold">Reason:</span> {activePunishment.reason}
</p> </p>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4">
<hr className="grow border-zinc-300" />
<span>Violating Items</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col gap-2 p-4">
{activePunishment.reasons.map((index, reason) => (
<div key={index} className="bg-orange-100 rounded-xl border-2 border-orange-400 p-4">
<p>
<span className="font-bold">Reason:</span> {reason}
</p>
</div>
))}
{activePunishment.violatingMiis.map((mii) => (
<div key={mii.miiId} className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<Image src={`/mii/${mii.miiId}/image?type=mii`} alt="mii image" width={96} height={96} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.mii.name}</p>
<p className="text-sm">
<span className="font-bold">Reason:</span> {mii.reason}
</p>
</div>
</div>
))}
</div>
<hr className="border-zinc-300 mt-2 mb-4" /> <hr className="border-zinc-300 mt-2 mb-4" />
{activePunishment.type !== "PERM_EXILE" ? ( {activePunishment.type !== "PERM_EXILE" ? (

View file

@ -5,7 +5,7 @@
import { useState } from "react"; import { useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Prisma, PunishmentType } from "@prisma/client"; import { Prisma, Punishment, PunishmentType } from "@prisma/client";
import ProfilePicture from "../profile-picture"; import ProfilePicture from "../profile-picture";
import SubmitButton from "../submit-button"; import SubmitButton from "../submit-button";
@ -16,11 +16,7 @@ interface ApiResponse {
name: string; name: string;
image: string; image: string;
createdAt: string; createdAt: string;
punishments: Prisma.PunishmentGetPayload<{ punishments: Punishment[];
include: {
violatingMiis: true;
};
}>[];
} }
interface MiiList { interface MiiList {
@ -170,7 +166,7 @@ export default function Punishments() {
</div> </div>
</div> </div>
<p className="text-sm text-zinc-600"> <p className="text-sm text-zinc-600">
<strong>Notes:</strong> {punishment.notes} <strong>Reason:</strong> {punishment.reason}
</p> </p>
{punishment.type !== "WARNING" && ( {punishment.type !== "WARNING" && (
<p className="text-sm text-zinc-600"> <p className="text-sm text-zinc-600">
@ -185,24 +181,6 @@ export default function Punishments() {
<strong>Returned:</strong> {JSON.stringify(punishment.returned)} <strong>Returned:</strong> {JSON.stringify(punishment.returned)}
</p> </p>
)} )}
<p className="text-sm text-zinc-600">
<strong>Reasons:</strong>
</p>
<ul className="ml-8 list-disc text-sm text-zinc-600">
{punishment.reasons.map((reason, index) => (
<li key={index}>{reason}</li>
))}
</ul>
<p className="text-sm text-zinc-600">
<strong>Mii Reasons:</strong>
</p>
<ul className="ml-8 list-disc text-sm text-zinc-600">
{punishment.violatingMiis.map((mii) => (
<li key={mii.miiId}>
{mii.miiId}: {mii.reason}
</li>
))}
</ul>
</div> </div>
))} ))}
</> </>

View file

@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import SortSelect from "./sort-select"; import SortSelect from "./sort-select";
import TimeRangeSelect from "./time-range-select";
import Pagination from "./pagination"; import Pagination from "./pagination";
import FilterMenu from "./filter-menu"; import FilterMenu from "./filter-menu";
import MiiGrid from "./mii-grid"; import MiiGrid from "./mii-grid";
@ -20,7 +21,7 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
const parsed = searchSchema.safeParse(searchParams); const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>; 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 // My Likes page
let miiIdsLiked: number[] | undefined = undefined; let miiIdsLiked: number[] | undefined = undefined;
@ -66,6 +67,14 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
...(makeup && { makeup: { equals: makeup } }), ...(makeup && { makeup: { equals: makeup } }),
// Quarantined // Quarantined
...(!quarantined && !userId && { quarantined: false }), ...(!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 = { const select: Prisma.MiiSelect = {
@ -140,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"> <div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
<FilterMenu /> <FilterMenu />
<SortSelect /> <SortSelect />
<TimeRangeSelect />
</div> </div>
</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" }) .max(100, { error: "Limit cannot be more than 100" })
.optional(), .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(), 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 export const userNameSchema = z