diff --git a/package.json b/package.json index a226af1..febf054 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "tomodachi-share", "version": "0.1.0", "private": true, - "packageManager": "pnpm@10.11.0", + "packageManager": "pnpm@10.13.1", "scripts": { "dev": "next dev --turbopack", "build": "next build", @@ -33,6 +33,7 @@ "react-dropzone": "^14.3.8", "react-webcam": "^7.2.0", "satori": "^0.15.2", + "seedrandom": "^3.0.5", "sharp": "^0.34.3", "sjcl-with-all": "1.0.8", "swr": "^2.3.4", @@ -46,6 +47,7 @@ "@types/node": "^24.0.13", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@types/seedrandom": "^3.0.8", "@types/sjcl": "^1.0.34", "eslint": "^9.31.0", "eslint-config-next": "15.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 390b290..feb1a75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: satori: specifier: ^0.15.2 version: 0.15.2 + seedrandom: + specifier: ^3.0.5 + version: 3.0.5 sharp: specifier: ^0.34.3 version: 0.34.3 @@ -105,6 +108,9 @@ importers: '@types/react-dom': specifier: ^19.1.6 version: 19.1.6(@types/react@19.1.8) + '@types/seedrandom': + specifier: ^3.0.8 + version: 3.0.8 '@types/sjcl': specifier: ^1.0.34 version: 1.0.34 @@ -926,6 +932,9 @@ packages: '@types/react@19.1.8': resolution: {integrity: sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==} + '@types/seedrandom@3.0.8': + resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==} + '@types/sjcl@1.0.34': resolution: {integrity: sha512-bQHEeK5DTQRunIfQeUMgtpPsNNCcZyQ9MJuAfW1I7iN0LDunTc78Fu17STbLMd7KiEY/g2zHVApippa70h6HoQ==} @@ -2380,6 +2389,9 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + seedrandom@3.0.5: + resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3360,6 +3372,8 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/seedrandom@3.0.8': {} + '@types/sjcl@1.0.34': {} '@types/use-sync-external-store@0.0.6': {} @@ -5005,6 +5019,8 @@ snapshots: scheduler@0.26.0: {} + seedrandom@3.0.5: {} + semver@6.3.1: {} semver@7.7.2: {} diff --git a/src/components/mii-list/index.tsx b/src/components/mii-list/index.tsx index 5d8d61f..30210d4 100644 --- a/src/components/mii-list/index.tsx +++ b/src/components/mii-list/index.tsx @@ -4,6 +4,8 @@ import { MiiGender, Prisma } from "@prisma/client"; import { Icon } from "@iconify/react"; import { z } from "zod"; +import seedrandom from "seedrandom"; + import { querySchema } from "@/lib/schemas"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; @@ -24,7 +26,7 @@ interface Props { const searchSchema = z.object({ q: querySchema.optional(), - sort: z.enum(["newest", "likes", "oldest"], { message: "Sort must be either 'newest', 'likes', or 'oldest'" }).default("newest"), + sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"), tags: z .string() .optional() @@ -48,6 +50,8 @@ const searchSchema = z.object({ .int({ error: "Page must be an integer" }) .min(1, { error: "Page must be at least 1" }) .optional(), + // Random sort + seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(), }); export default async function MiiList({ searchParams, userId, inLikesPage }: Props) { @@ -56,7 +60,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro const parsed = searchSchema.safeParse(searchParams); if (!parsed.success) return

{parsed.error.issues[0].message}

; - const { q: query, sort, tags, gender, page = 1, limit = 24 } = parsed.data; + const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data; // My Likes page let miiIdsLiked: number[] | undefined = undefined; @@ -84,18 +88,6 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro ...(userId && { userId }), }; - // Sorting by likes, newest, or oldest - let orderBy: Prisma.MiiOrderByWithRelationInput[]; - - if (sort === "likes") { - orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }]; - } else if (sort === "oldest") { - orderBy = [{ createdAt: "asc" }, { name: "asc" }]; - } else { - // default to newest - orderBy = [{ createdAt: "desc" }, { name: "asc" }]; - } - const select: Prisma.MiiSelect = { id: true, // Don't show when userId is specified @@ -126,11 +118,61 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro const skip = (page - 1) * limit; - const [totalCount, filteredCount, list] = await Promise.all([ - prisma.mii.count({ where: { ...where, userId } }), - prisma.mii.count({ where, skip, take: limit }), - prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }), - ]); + let totalCount: number; + let filteredCount: number; + let list: Prisma.MiiGetPayload<{ select: typeof select }>[]; + + if (sort === "random") { + // Use seed for consistent random results + const randomSeed = seed || Math.floor(Math.random() * 1_000_000_000); + + // Get all IDs that match the where conditions + const matchingIds = await prisma.mii.findMany({ + where, + select: { id: true }, + }); + + totalCount = matchingIds.length; + filteredCount = Math.min(matchingIds.length, limit); + + if (matchingIds.length === 0) return; + + const rng = seedrandom(randomSeed.toString()); + + // Randomize all IDs using the Durstenfeld algorithm + for (let i = matchingIds.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]]; + } + + // Convert to number[] array + const selectedIds = matchingIds.slice(0, limit).map((i) => i.id); + + list = await prisma.mii.findMany({ + where: { + id: { in: selectedIds }, + }, + select, + }); + } else { + // Sorting by likes, newest, or oldest + let orderBy: Prisma.MiiOrderByWithRelationInput[]; + + if (sort === "likes") { + orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }]; + } else if (sort === "oldest") { + orderBy = [{ createdAt: "asc" }, { name: "asc" }]; + } else { + // default to newest + orderBy = [{ createdAt: "desc" }, { name: "asc" }]; + } + + [totalCount, filteredCount, list] = await Promise.all([ + prisma.mii.count({ where: { ...where, userId } }), + prisma.mii.count({ where, skip, take: limit }), + prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }), + ]); + } const lastPage = Math.ceil(totalCount / limit); const miis = list.map(({ _count, likedBy, ...rest }) => ({ diff --git a/src/components/mii-list/sort-select.tsx b/src/components/mii-list/sort-select.tsx index 828dd0c..f000e7e 100644 --- a/src/components/mii-list/sort-select.tsx +++ b/src/components/mii-list/sort-select.tsx @@ -5,9 +5,9 @@ import { useTransition } from "react"; import { useSelect } from "downshift"; import { Icon } from "@iconify/react"; -type Sort = "newest" | "likes" | "oldest"; +type Sort = "likes" | "newest" | "oldest" | "random"; -const items = ["newest", "likes", "oldest"]; +const items = ["likes", "newest", "oldest", "random"]; export default function SortSelect() { const router = useRouter(); @@ -25,6 +25,10 @@ export default function SortSelect() { const params = new URLSearchParams(searchParams); params.set("sort", selectedItem); + if (selectedItem == "random") { + params.set("seed", Math.floor(Math.random() * 1_000_000_000).toString()); + } + startTransition(() => { router.push(`?${params.toString()}`); });