Merge branch 'main' into feat/living-the-dream-manually

This commit is contained in:
trafficlunar 2026-03-25 16:41:24 +00:00
commit 22911804c0
56 changed files with 1166 additions and 1669 deletions

View file

@ -14,7 +14,6 @@ import PunishmentDeletionDialog from "./punishment-deletion-dialog";
interface ApiResponse {
success: boolean;
name: string;
username: string;
image: string;
createdAt: string;
punishments: Prisma.PunishmentGetPayload<{
@ -115,7 +114,7 @@ export default function Punishments() {
<ProfilePicture src={user.image} width={96} height={96} className="rounded-full border-2 border-orange-400" />
<div className="p-2 flex flex-col">
<p className="text-xl font-bold">{user.name}</p>
<p className="text-black/60 text-sm font-medium">@{user.username}</p>
<p className="text-black/60 text-sm font-medium">@{user.name}</p>
<p className="text-sm mt-auto">
<span className="font-medium">Created:</span>{" "}
{new Date(user.createdAt).toLocaleString("en-GB", {

View file

@ -63,7 +63,7 @@ export default function Description({ text, className }: Props) {
href={`/profile/${id}`}
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs"
>
<ProfilePicture src={linkedProfile.image || "/guest.webp"} width={24} height={24} className="bg-white rounded-lg border-r border-orange-400" />
<ProfilePicture src={linkedProfile.image || "/guest.png"} width={24} height={24} className="bg-white rounded-lg border-r border-orange-400" />
{linkedProfile.name}
</Link>
);

View file

@ -22,7 +22,7 @@ export default function Dropzone({ onDrop, options, children }: Props) {
onDrop: handleDrop,
maxFiles: 3,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".webp", ".heic"],
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"],
},
...options,
});

View file

@ -7,7 +7,7 @@ export default function LoginButtons() {
return (
<div className="flex flex-col items-center gap-2">
<button
onClick={() => signIn("discord", { redirectTo: "/create-username" })}
onClick={() => signIn("discord", { redirectTo: "/" })}
aria-label="Login with Discord"
className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
>
@ -15,7 +15,7 @@ export default function LoginButtons() {
Login with Discord
</button>
<button
onClick={() => signIn("github", { redirectTo: "/create-username" })}
onClick={() => signIn("github", { redirectTo: "/" })}
aria-label="Login with GitHub"
className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
>

View file

@ -1,3 +1,4 @@
import { headers } from "next/headers";
import Link from "next/link";
import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
@ -25,7 +26,6 @@ interface Props {
export default async function MiiList({ searchParams, userId, inLikesPage }: Props) {
const session = await auth();
const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
@ -34,7 +34,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
if (inLikesPage && session?.user.id) {
if (inLikesPage && session?.user?.id) {
const likedMiis = await prisma.like.findMany({
where: { userId: Number(session.user.id) },
select: { miiId: true },
@ -69,7 +69,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
user: {
select: {
id: true,
username: true,
name: true,
},
},
}),
@ -219,11 +219,11 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
{!userId && (
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
@{mii.user?.username}
@{mii.user?.name}
</Link>
)}
{userId && Number(session?.user.id) == userId && (
{userId && Number(session?.user?.id) == userId && (
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
<Link href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
<Icon icon="mdi:pencil" />

View file

@ -15,7 +15,7 @@ interface Props {
export default async function ProfileInformation({ userId, page }: Props) {
const session = await auth();
const id = userId ? userId : Number(session?.user.id);
const id = userId ? userId : Number(session?.user?.id);
const user = await prisma.user.findUnique({ where: { id } });
const likedMiis = await prisma.like.count({ where: { userId: id } });
@ -23,14 +23,14 @@ export default async function ProfileInformation({ userId, page }: Props) {
const isAdmin = id === Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID);
const isContributor = process.env.NEXT_PUBLIC_CONTRIBUTORS_USER_IDS?.split(",").includes(id.toString());
const isOwnProfile = Number(session?.user.id) === id;
const isOwnProfile = Number(session?.user?.id) === id;
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex gap-4 mb-2 max-md:flex-col">
<div className="flex w-full gap-4 overflow-x-scroll">
{/* Profile picture */}
<Link href={`/profile/${user.id}`} className="size-28 aspect-square">
<ProfilePicture src={user.image ?? "/guest.webp"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" />
<ProfilePicture src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" />
</Link>
{/* User information */}
<div className="flex flex-col w-full relative py-3">
@ -47,7 +47,7 @@ export default async function ProfileInformation({ userId, page }: Props) {
</div>
)}
</div>
<h2 className="text-black/60 text-sm font-semibold wrap-break-word">@{user?.username}</h2>
<h2 className="text-black/60 text-sm font-semibold wrap-break-word">ID: {user?.id}</h2>
<div className="mt-3 text-sm flex gap-8">
<h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>

View file

@ -7,15 +7,15 @@ export default async function ProfileOverview() {
return (
<li title="Your profile">
<Link href={`/profile/${session?.user.id}`} aria-label="Go to profile" className="pill button gap-2! p-0! h-full max-w-64" data-tooltip="Your Profile">
<Link href={`/profile/${session?.user?.id}`} aria-label="Go to profile" className="pill button gap-2! p-0! h-full max-w-64" data-tooltip="Your Profile">
<Image
src={session?.user?.image ?? "/guest.webp"}
src={session?.user?.image ?? "/guest.png"}
alt="profile picture"
width={40}
height={40}
className="rounded-full aspect-square object-cover h-full bg-white outline-2 outline-orange-400"
/>
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.user?.username ?? "unknown"}</span>
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.user?.name ?? "unknown"}</span>
</Link>
</li>
);

View file

@ -7,5 +7,5 @@ export default function ProfilePicture(props: Partial<ImageProps>) {
const { src, ...rest } = props;
const [imgSrc, setImgSrc] = useState(src);
return <Image width={128} height={128} {...rest} src={imgSrc || "/guest.webp"} alt={"profile picture"} onError={() => setImgSrc("/guest.webp")} />;
return <Image width={128} height={128} {...rest} src={imgSrc || "/guest.png"} alt={"profile picture"} onError={() => setImgSrc("/guest.png")} />;
}

View file

@ -2,9 +2,8 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import dayjs from "dayjs";
import { displayNameSchema, usernameSchema } from "@/lib/schemas";
import { userNameSchema } from "@/lib/schemas";
import ProfilePictureSettings from "./profile-picture";
import SubmitDialogButton from "./submit-dialog-button";
@ -19,14 +18,10 @@ export default function ProfileSettings({ currentDescription }: Props) {
const router = useRouter();
const [description, setDescription] = useState(currentDescription);
const [displayName, setDisplayName] = useState("");
const [username, setUsername] = useState("");
const [name, setName] = useState("");
const [descriptionChangeError, setDescriptionChangeError] = useState<string | undefined>(undefined);
const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined);
const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined);
const usernameDate = dayjs().add(90, "days");
const [nameChangeError, setNameChangeError] = useState<string | undefined>(undefined);
const handleSubmitDescriptionChange = async (close: () => void) => {
const parsed = z.string().trim().max(256).safeParse(description);
@ -51,45 +46,22 @@ export default function ProfileSettings({ currentDescription }: Props) {
router.refresh();
};
const handleSubmitDisplayNameChange = async (close: () => void) => {
const parsed = displayNameSchema.safeParse(displayName);
const handleSubmitNameChange = async (close: () => void) => {
const parsed = userNameSchema.safeParse(name);
if (!parsed.success) {
setDisplayNameChangeError(parsed.error.issues[0].message);
setNameChangeError(parsed.error.issues[0].message);
return;
}
const response = await fetch("/api/auth/display-name", {
const response = await fetch("/api/auth/name", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName }),
body: JSON.stringify({ name }),
});
if (!response.ok) {
const { error } = await response.json();
setDisplayNameChangeError(error);
return;
}
close();
router.refresh();
};
const handleSubmitUsernameChange = async (close: () => void) => {
const parsed = usernameSchema.safeParse(username);
if (!parsed.success) {
setUsernameChangeError(parsed.error.issues[0].message);
return;
}
const response = await fetch("/api/auth/username", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
if (!response.ok) {
const { error } = await response.json();
setUsernameChangeError(error);
setNameChangeError(error);
return;
}
@ -101,7 +73,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4">
<div>
<h2 className="text-2xl font-bold">Profile Settings</h2>
<p className="text-sm text-zinc-500">Update your account info, and username.</p>
<p className="text-sm text-zinc-500">Update your profile picture, description, name, etc.</p>
</div>
{/* Separator */}
@ -146,58 +118,21 @@ export default function ProfileSettings({ currentDescription }: Props) {
{/* Change Name */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<label className="font-semibold">Change Display Name</label>
<p className="text-sm text-zinc-500">This is a display name shown on your profile feel free to change it anytime</p>
<label className="font-semibold">Change Name</label>
<p className="text-sm text-zinc-500">This is your name shown on your profile and miis feel free to change it anytime</p>
</div>
<div className="flex justify-end gap-1 h-min col-span-2">
<input type="text" className="pill input flex-1" placeholder="Type here..." value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
<input type="text" className="pill input flex-1" placeholder="Type here..." value={name} onChange={(e) => setName(e.target.value)} />
<SubmitDialogButton
title="Confirm Display Name Change"
description="Are you sure? This will only be visible on your profile. You can change it again later."
error={displayNameChangeError}
onSubmit={handleSubmitDisplayNameChange}
title="Confirm Name Change"
description="Are you sure? You can change it again later."
error={nameChangeError}
onSubmit={handleSubmitNameChange}
>
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1">
<p className="font-semibold">New display name:</p>
<p className="indent-4">&apos;{displayName}&apos;</p>
</div>
</SubmitDialogButton>
</div>
</div>
{/* Change Username */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<label className="font-semibold">Change Username</label>
<p className="text-sm text-zinc-500">Your unique tag on the site. Can only be changed once every 90 days</p>
</div>
<div className="flex justify-end gap-1 col-span-2">
<div className="relative flex-1">
<input
type="text"
className="pill input w-full indent-4"
placeholder="Type here..."
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<span className="absolute top-1/2 -translate-y-1/2 left-4 select-none">@</span>
</div>
<SubmitDialogButton
title="Confirm Username Change"
description="Are you sure? Your username is your unique indentifier and can only be changed every 90 days."
error={usernameChangeError}
onSubmit={handleSubmitUsernameChange}
>
<p className="text-sm text-zinc-500 mt-2">
After submitting, you can change it again on{" "}
{usernameDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}.
</p>
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1">
<p className="font-semibold">New username:</p>
<p className="indent-4">&apos;@{username}&apos;</p>
<p className="font-semibold">New name:</p>
<p className="indent-4">&apos;{name}&apos;</p>
</div>
</SubmitDialogButton>
</div>

View file

@ -59,7 +59,7 @@ export default function ProfilePictureSettings() {
</p>
<Image
src={newPicture ? URL.createObjectURL(newPicture) : "/guest.webp"}
src={newPicture ? URL.createObjectURL(newPicture) : "/guest.png"}
alt="new profile picture"
width={96}
height={96}
@ -93,7 +93,7 @@ export default function ProfilePictureSettings() {
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center">
<p className="font-semibold mb-2">New profile picture:</p>
<Image
src={newPicture ? URL.createObjectURL(newPicture) : "/guest.webp"}
src={newPicture ? URL.createObjectURL(newPicture) : "/guest.png"}
alt="new profile picture"
width={128}
height={128}

View file

@ -43,11 +43,8 @@ export default function ReportUserForm({ user }: Props) {
<hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex p-4 gap-4">
<ProfilePicture src={user.image ?? "/guest.webp"} width={96} height={96} className="aspect-square rounded-full border-2 border-orange-400" />
<div className="flex flex-col justify-center">
<p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
<p className="text-sm font-bold overflow-hidden text-ellipsis">@{user.username}</p>
</div>
<ProfilePicture src={user.image ?? "/guest.png"} width={96} height={96} className="aspect-square rounded-full border-2 border-orange-400" />
<p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
</div>
<div className="w-full grid grid-cols-3 items-center">

View file

@ -90,7 +90,7 @@ export default function EditForm({ mii, likes }: Props) {
const response = await fetch(path);
const blob = await response.blob();
return Object.assign(new File([blob], `image${index}.webp`, { type: "image/webp" }), { path });
return Object.assign(new File([blob], `image${index}.png`, { type: "image/png" }), { path });
}),
);

View file

@ -1,46 +0,0 @@
"use client";
import { useState } from "react";
import { redirect } from "next/navigation";
import { usernameSchema } from "@/lib/schemas";
import SubmitButton from "./submit-button";
export default function UsernameForm() {
const [username, setUsername] = useState("");
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const parsed = usernameSchema.safeParse(username);
if (!parsed.success) setError(parsed.error.issues[0].message);
const response = await fetch("/api/auth/username", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
if (!response.ok) {
const { error } = await response.json();
setError(error);
return;
}
redirect("/");
};
return (
<form className="flex flex-col items-center">
<input
type="text"
placeholder="Type your username..."
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="pill input w-96 mb-2"
/>
<SubmitButton onClick={handleSubmit} />
{error && <p className="text-red-400 font-semibold mt-4">Error: {error}</p>}
</form>
);
}