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()}`);
});