diff --git a/prisma/migrations/20251110183556_profile_descriptions/migration.sql b/prisma/migrations/20251110183556_profile_descriptions/migration.sql new file mode 100644 index 0000000..f039b27 --- /dev/null +++ b/prisma/migrations/20251110183556_profile_descriptions/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "description" VARCHAR(256); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ebd21f2..9be57ec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,7 @@ model User { email String @unique emailVerified DateTime? image String? + description String? @db.VarChar(256) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/api/auth/about-me/route.ts b/src/app/api/auth/about-me/route.ts new file mode 100644 index 0000000..b6f8e11 --- /dev/null +++ b/src/app/api/auth/about-me/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { profanity } from "@2toad/profanity"; +import z from "zod"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { RateLimit } from "@/lib/rate-limit"; + +export async function PATCH(request: NextRequest) { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const rateLimit = new RateLimit(request, 3); + const check = await rateLimit.handle(); + if (check) return check; + + const { description } = await request.json(); + if (!description) return rateLimit.sendResponse({ error: "New about me is required" }, 400); + + const validation = z.string().trim().max(256).safeParse(description); + if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400); + + try { + await prisma.user.update({ + where: { id: Number(session.user.id) }, + data: { description: profanity.censor(description) }, + }); + } catch (error) { + console.error("Failed to update description:", error); + return rateLimit.sendResponse({ error: "Failed to update description" }, 500); + } + + return rateLimit.sendResponse({ success: true }); +} diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index 3456f3c..86638b9 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -14,6 +14,7 @@ import DeleteMiiButton from "@/components/delete-mii"; import ShareMiiButton from "@/components/share-mii-button"; import ScanTutorialButton from "@/components/tutorial/scan"; import ProfilePicture from "@/components/profile-picture"; +import Description from "@/components/description"; interface Props { params: Promise<{ id: string }>; @@ -213,81 +214,7 @@ export default async function MiiPage({ params }: Props) { {/* Description */} - {mii.description && ( -

- {/* Adds fancy formatting when linking to other pages on the site */} - {(() => { - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://tomodachishare.com"; - - // Match both mii and profile links - const regex = new RegExp(`(${baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}/(?:mii|profile)/\\d+)`, "g"); - const parts = mii.description.split(regex); - - return parts.map(async (part, index) => { - const miiMatch = part.match(new RegExp(`^${baseUrl}/mii/(\\d+)$`)); - const profileMatch = part.match(new RegExp(`^${baseUrl}/profile/(\\d+)$`)); - - if (miiMatch) { - const id = Number(miiMatch[1]); - const linkedMii = await prisma.mii.findUnique({ - where: { - id, - }, - }); - - if (!linkedMii) return; - - return ( - - mii - {linkedMii.name} - - ); - } - - if (profileMatch) { - const id = Number(profileMatch[1]); - const linkedProfile = await prisma.user.findUnique({ - where: { - id, - }, - }); - - if (!linkedProfile) return; - - return ( - - - {linkedProfile.name} - - ); - } - - // Regular text - return {part}; - }); - })()} -

- )} + {mii.description && } {/* Buttons */} diff --git a/src/app/profile/settings/page.tsx b/src/app/profile/settings/page.tsx index 0ebb56b..509623f 100644 --- a/src/app/profile/settings/page.tsx +++ b/src/app/profile/settings/page.tsx @@ -2,6 +2,7 @@ import { Metadata } from "next"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; import ProfileSettings from "@/components/profile-settings"; import ProfileInformation from "@/components/profile-information"; @@ -20,10 +21,12 @@ export default async function ProfileSettingsPage() { if (!session) redirect("/login"); + const user = await prisma.user.findUnique({ where: { id: Number(session.user.id!) }, select: { description: true } }); + return (
- +
); } diff --git a/src/components/description.tsx b/src/components/description.tsx new file mode 100644 index 0000000..a55a268 --- /dev/null +++ b/src/components/description.tsx @@ -0,0 +1,83 @@ +import Image from "next/image"; +import Link from "next/link"; + +import { prisma } from "@/lib/prisma"; + +import ProfilePicture from "./profile-picture"; + +interface Props { + text: string; + className?: string; +} + +export default function Description({ text, className }: Props) { + return ( +

+ {/* Adds fancy formatting when linking to other pages on the site */} + {(() => { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://tomodachishare.com"; + + // Match both mii and profile links + const regex = new RegExp(`(${baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}/(?:mii|profile)/\\d+)`, "g"); + const parts = text.split(regex); + + return parts.map(async (part, index) => { + const miiMatch = part.match(new RegExp(`^${baseUrl}/mii/(\\d+)$`)); + const profileMatch = part.match(new RegExp(`^${baseUrl}/profile/(\\d+)$`)); + + if (miiMatch) { + const id = Number(miiMatch[1]); + const linkedMii = await prisma.mii.findUnique({ + where: { + id, + }, + }); + + if (!linkedMii) return; + + return ( + + mii + {linkedMii.name} + + ); + } + + if (profileMatch) { + const id = Number(profileMatch[1]); + const linkedProfile = await prisma.user.findUnique({ + where: { + id, + }, + }); + + if (!linkedProfile) return; + + return ( + + + {linkedProfile.name} + + ); + } + + // Regular text + return {part}; + }); + })()} +

+ ); +} diff --git a/src/components/dropzone.tsx b/src/components/dropzone.tsx index 53e619d..ff4611e 100644 --- a/src/components/dropzone.tsx +++ b/src/components/dropzone.tsx @@ -32,7 +32,7 @@ export default function Dropzone({ onDrop, options, children }: Props) { {...getRootProps()} onDragOver={() => setIsDraggingOver(true)} onDragLeave={() => setIsDraggingOver(false)} - className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none h-full transition-all duration-200 ${ + className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${ isDraggingOver && "scale-105 brightness-90 shadow-xl" }`} > diff --git a/src/components/profile-information.tsx b/src/components/profile-information.tsx index 6ed26e9..5b0befb 100644 --- a/src/components/profile-information.tsx +++ b/src/components/profile-information.tsx @@ -5,6 +5,7 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import ProfilePicture from "./profile-picture"; +import Description from "./description"; interface Props { userId?: number; @@ -48,7 +49,7 @@ export default async function ProfileInformation({ userId, page }: Props) {

@{user?.username}

-
+

Created:{" "} {user.createdAt.toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })} @@ -57,6 +58,8 @@ export default async function ProfileInformation({ userId, page }: Props) { Liked {likedMiis} Miis

+ + {user.description && }
diff --git a/src/components/profile-settings/index.tsx b/src/components/profile-settings/index.tsx index b171bf6..6e8ea62 100644 --- a/src/components/profile-settings/index.tsx +++ b/src/components/profile-settings/index.tsx @@ -9,18 +9,48 @@ import { displayNameSchema, usernameSchema } from "@/lib/schemas"; import ProfilePictureSettings from "./profile-picture"; import SubmitDialogButton from "./submit-dialog-button"; import DeleteAccount from "./delete-account"; +import z from "zod"; -export default function ProfileSettings() { +interface Props { + currentDescription: string | null | undefined; +} + +export default function ProfileSettings({ currentDescription }: Props) { const router = useRouter(); + const [description, setDescription] = useState(currentDescription); const [displayName, setDisplayName] = useState(""); const [username, setUsername] = useState(""); + const [descriptionChangeError, setDescriptionChangeError] = useState(undefined); const [displayNameChangeError, setDisplayNameChangeError] = useState(undefined); const [usernameChangeError, setUsernameChangeError] = useState(undefined); const usernameDate = dayjs().add(90, "days"); + const handleSubmitDescriptionChange = async (close: () => void) => { + const parsed = z.string().trim().max(256).safeParse(description); + if (!parsed.success) { + setDescriptionChangeError(parsed.error.issues[0].message); + return; + } + + const response = await fetch("/api/auth/about-me", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description }), + }); + + if (!response.ok) { + const { error } = await response.json(); + setDescriptionChangeError(error); + return; + } + + close(); + router.refresh(); + }; + const handleSubmitDisplayNameChange = async (close: () => void) => { const parsed = displayNameSchema.safeParse(displayName); if (!parsed.success) { @@ -84,17 +114,46 @@ export default function ProfileSettings() { {/* Profile Picture */} + {/* Description */} +
+
+ +

Write about yourself on your profile

+
+ +
+
+