diff --git a/package.json b/package.json index 63a6a2a..38ed58e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@prisma/client": "^6.6.0", "@trafficlunar/asmcrypto.js": "^1.0.2", "bit-buffer": "^0.2.5", + "dayjs": "^1.11.13", "downshift": "^9.0.9", "embla-carousel-react": "^8.6.0", "jsqr": "^1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fb8ae8..2d436b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: bit-buffer: specifier: ^0.2.5 version: 0.2.5 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 downshift: specifier: ^9.0.9 version: 9.0.9(react@19.1.0) @@ -1117,6 +1120,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -3138,6 +3144,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.13: {} + debug@3.2.7: dependencies: ms: 2.1.3 diff --git a/src/app/api/auth/display-name/route.ts b/src/app/api/auth/display-name/route.ts new file mode 100644 index 0000000..f65a23e --- /dev/null +++ b/src/app/api/auth/display-name/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { displayNameSchema } from "@/lib/schemas"; + +export async function PATCH(request: NextRequest) { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { displayName } = await request.json(); + if (!displayName) return NextResponse.json({ error: "New display name is required" }, { status: 400 }); + + const validation = displayNameSchema.safeParse(displayName); + if (!validation.success) return NextResponse.json({ error: validation.error.errors[0].message }, { status: 400 }); + + try { + await prisma.user.update({ + where: { email: session.user?.email ?? undefined }, + data: { name: displayName }, + }); + } catch (error) { + console.error("Failed to update display name:", error); + return NextResponse.json({ error: "Failed to update display name" }, { status: 500 }); + } + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/auth/username/route.ts b/src/app/api/auth/username/route.ts index 24ca704..ff106f6 100644 --- a/src/app/api/auth/username/route.ts +++ b/src/app/api/auth/username/route.ts @@ -1,21 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; - -const usernameSchema = z - .string() - .min(3, "Username must be at least 3 characters long") - .max(20, "Username cannot be more than 20 characters long") - .regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"); +import { usernameSchema } from "@/lib/schemas"; export async function PATCH(request: NextRequest) { const session = await auth(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { username } = await request.json(); - if (!username) return NextResponse.json({ error: "Username is required" }, { status: 400 }); + if (!username) return NextResponse.json({ error: "New username is required" }, { status: 400 }); const validation = usernameSchema.safeParse(username); if (!validation.success) return NextResponse.json({ error: validation.error.errors[0].message }, { status: 400 }); diff --git a/src/app/components/delete-mii.tsx b/src/app/components/delete-mii.tsx index 5ddb2c5..bd2f49e 100644 --- a/src/app/components/delete-mii.tsx +++ b/src/app/components/delete-mii.tsx @@ -29,7 +29,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes }: Props) { } close(); - window.location.reload(); // I would use router.refresh() here but the API data fetching breaks + window.location.reload(); // I would use router.refresh() here but the Mii list doesn't update }; const close = () => { diff --git a/src/app/components/profile-settings/index.tsx b/src/app/components/profile-settings/index.tsx index 771fa4e..272ce04 100644 --- a/src/app/components/profile-settings/index.tsx +++ b/src/app/components/profile-settings/index.tsx @@ -1,23 +1,76 @@ "use client"; +import { useRouter } from "next/navigation"; + import { useState } from "react"; import SubmitDialogButton from "./submit-dialog-button"; import DeleteAccount from "./delete-account"; -interface Props { - name: string | null | undefined; - username: string | null | undefined; -} +import { displayNameSchema, usernameSchema } from "@/lib/schemas"; +import dayjs from "dayjs"; -export default function ProfileSettings({ name, username }: Props) { - const [displayName, setDisplayName] = useState(name ?? ""); - const [usernameState, setUsernameState] = useState(username ?? ""); +export default function ProfileSettings() { + const router = useRouter(); + + const [displayName, setDisplayName] = useState(""); + const [username, setUsername] = useState(""); + + const [displayNameChangeError, setDisplayNameChangeError] = useState(undefined); + const [usernameChangeError, setUsernameChangeError] = useState(undefined); + + const usernameDate = dayjs().add(90, "days"); + + const handleSubmitDisplayNameChange = async (close: () => void) => { + const parsed = displayNameSchema.safeParse(displayName); + if (!parsed.success) { + setDisplayNameChangeError(parsed.error.errors[0].message); + return; + } + + const response = await fetch("/api/auth/display-name", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName }), + }); + + 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.errors[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); + return; + } + + close(); + router.refresh(); + }; return (

Profile Settings

-

Update your account info, username, and preferences.

+

Update your account info, and username.

{/* Separator */} @@ -37,15 +90,22 @@ export default function ProfileSettings({ name, username }: Props) {
- setDisplayName(e.target.value)} /> + setDisplayName(e.target.value)} + /> {}} + error={displayNameChangeError} + onSubmit={handleSubmitDisplayNameChange} >

New display name:

-

"{name}"

+

'{displayName}'

@@ -65,19 +125,26 @@ export default function ProfileSettings({ name, username }: Props) { setUsernameState(e.target.value)} + placeholder="Type here..." + value={username} + onChange={(e) => setUsername(e.target.value)} /> @ {}} + error={usernameChangeError} + onSubmit={handleSubmitUsernameChange} > +

+ After submitting, you can change it again on{" "} + {usernameDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}. +

+

New username:

-

"@{usernameState}"

+

'@{username}'

diff --git a/src/app/components/profile-settings/submit-dialog-button.tsx b/src/app/components/profile-settings/submit-dialog-button.tsx index d7c193a..31c6b72 100644 --- a/src/app/components/profile-settings/submit-dialog-button.tsx +++ b/src/app/components/profile-settings/submit-dialog-button.tsx @@ -7,7 +7,7 @@ import { Icon } from "@iconify/react"; interface Props { title: string; description: string; - onSubmit: () => void; + onSubmit: (close: () => void) => void; error?: string; children?: React.ReactNode; } @@ -17,9 +17,7 @@ export default function SubmitDialogButton({ title, description, onSubmit, error const [isVisible, setIsVisible] = useState(false); const submit = () => { - onSubmit(); - close(); - window.location.reload(); // I would use router.refresh() here but the API data fetching breaks + onSubmit(close); }; const close = () => { diff --git a/src/app/components/username-form.tsx b/src/app/components/username-form.tsx index cdab7c4..e860da1 100644 --- a/src/app/components/username-form.tsx +++ b/src/app/components/username-form.tsx @@ -2,6 +2,7 @@ import { FormEvent, useState } from "react"; import { redirect } from "next/navigation"; +import { usernameSchema } from "@/lib/schemas"; export default function UsernameForm() { const [username, setUsername] = useState(""); @@ -10,6 +11,9 @@ export default function UsernameForm() { const handleSubmit = async (event: FormEvent) => { event.preventDefault(); + const parsed = usernameSchema.safeParse(username); + if (!parsed.success) setError(parsed.error.errors[0].message); + const response = await fetch("/api/auth/username", { method: "PATCH", headers: { "Content-Type": "application/json" }, diff --git a/src/app/profile/settings/page.tsx b/src/app/profile/settings/page.tsx index 3bbf3a0..e883368 100644 --- a/src/app/profile/settings/page.tsx +++ b/src/app/profile/settings/page.tsx @@ -46,7 +46,7 @@ export default async function ProfileSettingsPage() { - + ); } diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 469f0c6..2c0bda8 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -1,14 +1,5 @@ import { z } from "zod"; -export const nameSchema = z - .string() - .trim() - .min(2, { message: "Name must be at least 2 characters long" }) - .max(64, { message: "Name cannot be more than 64 characters long" }) - .regex(/^[a-zA-Z0-9-_. ']+$/, { - message: "Name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.", - }); - export const querySchema = z .string() .trim() @@ -18,6 +9,16 @@ export const querySchema = z message: "Search query can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.", }); +// Miis +export const nameSchema = z + .string() + .trim() + .min(2, { message: "Name must be at least 2 characters long" }) + .max(64, { message: "Name cannot be more than 64 characters long" }) + .regex(/^[a-zA-Z0-9-_. ']+$/, { + message: "Name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.", + }); + export const tagsSchema = z .array( z @@ -30,3 +31,20 @@ export const tagsSchema = z ) .min(1, { message: "There must be at least 1 tag" }) .max(8, { message: "There cannot be more than 8 tags" }); + +// Account Info +export const usernameSchema = z + .string() + .trim() + .min(3, "Username must be at least 3 characters long") + .max(20, "Username cannot be more than 20 characters long") + .regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"); + +export const displayNameSchema = z + .string() + .trim() + .min(2, { message: "Display name must be at least 2 characters long" }) + .max(64, { message: "Display name cannot be more than 64 characters long" }) + .regex(/^[a-zA-Z0-9-_. ']+$/, { + message: "Display name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.", + });