From c14b8d5a93fe42d4472c7fa15ffbb38584fc4250 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Tue, 21 Apr 2026 20:32:56 +0100 Subject: [PATCH] feat: time range select (#29) Co-authored-by: AlexHelo --- backend/src/app/api/mii/list/route.ts | 23 ++++- frontend/src/components/mii/list/index.tsx | 95 +++---------------- .../components/mii/list/time-range-select.tsx | 73 ++++++++++++++ shared/src/schemas.ts | 4 +- 4 files changed, 111 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/mii/list/time-range-select.tsx diff --git a/backend/src/app/api/mii/list/route.ts b/backend/src/app/api/mii/list/route.ts index 923e735..21b152d 100644 --- a/backend/src/app/api/mii/list/route.ts +++ b/backend/src/app/api/mii/list/route.ts @@ -9,7 +9,22 @@ export async function GET(request: NextRequest) { const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams)); if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 }); - const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, parentPage, userId } = parsed.data; + const { + q: query, + sort, + tags, + exclude, + platform, + gender, + makeup, + allowCopying, + quarantined, + page = 1, + limit = 24, + parentPage, + userId, + timeRange, + } = parsed.data; // My Likes page let miiIdsLiked: number[] | undefined = undefined; @@ -55,6 +70,12 @@ export async function GET(request: NextRequest) { ...(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 = { diff --git a/frontend/src/components/mii/list/index.tsx b/frontend/src/components/mii/list/index.tsx index d243c6f..0309362 100644 --- a/frontend/src/components/mii/list/index.tsx +++ b/frontend/src/components/mii/list/index.tsx @@ -9,7 +9,7 @@ import { Icon } from "@iconify/react"; import LikeButton from "../../like-button"; import { useStore } from "@nanostores/react"; import { session } from "../../../session"; -import Description from "../../description"; +import TimeRangeSelect from "./time-range-select"; interface ApiResponse { totalCount: number; @@ -19,14 +19,13 @@ interface ApiResponse { interface Props { userId?: number; - parentPage?: "likes" | "admin"; + parentPage?: "likes"; } export default function MiiList({ parentPage, userId }: Props) { const [searchParams] = useSearchParams(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - const [acceptingAll, setAcceptingAll] = useState(false); const [likedIds, setLikedIds] = useState>(new Set()); const $session = useStore(session); @@ -60,28 +59,6 @@ export default function MiiList({ parentPage, userId }: Props) { }); }, [searchParams, userId, parentPage, $session]); - async function handleAcceptAll() { - if (!data) return; - setAcceptingAll(true); - try { - await Promise.all( - data.miis.map((mii) => - fetch(`${import.meta.env.VITE_API_URL}/api/admin/accept-mii?id=${mii.id}`, { - method: "POST", - credentials: "include", - }), - ), - ); - const params = new URLSearchParams(searchParams.toString()); - if (userId) params.append("userId", userId.toString()); - if (parentPage) params.append("parentPage", parentPage); - const res = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/list?${params.toString()}`, { credentials: "include" }); - if (res.ok) setData(await res.json()); - } finally { - setAcceptingAll(false); - } - } - return ( <> {loading ? ( @@ -95,18 +72,9 @@ export default function MiiList({ parentPage, userId }: Props) {
- {parentPage === "admin" && data.miis.length > 0 && ( - - )} +
@@ -114,7 +82,7 @@ export default function MiiList({ parentPage, userId }: Props) { {data.miis.map((mii) => (
{mii.in_queue && (
@@ -123,29 +91,15 @@ export default function MiiList({ parentPage, userId }: Props) {
)} - {parentPage !== "admin" ? ( - - mii image - - ) : ( -
- {[ - `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`, - mii.platform === "THREE_DS" - ? `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=qr-code` - : `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=features`, - ...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=image${i}`), - ].map((src, i) => ( - mii image - ))} -
- )} + + mii image +
@@ -169,8 +123,6 @@ export default function MiiList({ parentPage, userId }: Props) { ))}
- {parentPage === "admin" && mii.description && } -
@@ -188,27 +140,6 @@ export default function MiiList({ parentPage, userId }: Props) {
)} - - {parentPage === "admin" && ( -
-
- -
- -
-
- - {new Date(mii.createdAt).toLocaleString("en-GB", { timeZone: "UTC" })} -
- )}
diff --git a/frontend/src/components/mii/list/time-range-select.tsx b/frontend/src/components/mii/list/time-range-select.tsx new file mode 100644 index 0000000..61233d1 --- /dev/null +++ b/frontend/src/components/mii/list/time-range-select.tsx @@ -0,0 +1,73 @@ +import { useTransition } from "react"; +import { useSelect } from "downshift"; +import { Icon } from "@iconify/react"; +import { useNavigate, useSearchParams } from "react-router"; + +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 navigate = useNavigate(); + 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(() => { + navigate(`?${params.toString()}`, { preventScrollReset: true }); + }); + }, + }); + + return ( +
+ + +
    + {isOpen && + items.map((item, index) => ( +
  • + {item.label} +
  • + ))} +
+
+ ); +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 483ef0b..1845405 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -39,6 +39,7 @@ export const idSchema = z.coerce.number({ error: "ID must be a number" }).int({ export const searchSchema = z.object({ q: querySchema.optional(), sort: z.enum(["likes", "newest", "oldest"], { error: "Sort must be either 'likes', 'newest', 'oldest'" }).default("newest"), + // todo: incorporate tagsSchema tags: z .string() .optional() @@ -62,7 +63,6 @@ export const searchSchema = z.object({ makeup: z.enum(["FULL", "PARTIAL", "NONE"], { error: "Makeup must be either 'FULL', 'PARTIAL', or 'NONE'" }).optional(), allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(), quarantined: z.coerce.boolean({ error: "Quarantined must be either true or false" }).optional(), - // todo: incorporate tagsSchema // Pages limit: z.coerce .number({ error: "Limit must be a number" }) @@ -74,6 +74,8 @@ export const searchSchema = z.object({ // Other parentPage: z.string().optional(), userId: idSchema.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