mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 06:34:15 +00:00
feat: usernames
also change userId to number
This commit is contained in:
parent
9344f2f315
commit
9d35d93d9e
15 changed files with 200 additions and 78 deletions
38
src/app/api/auth/username/route.ts
Normal file
38
src/app/api/auth/username/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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");
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
return Response.json({ username: session.user.username });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { username } = await request.json();
|
||||
if (!username) return Response.json({ error: "Username is required" }, { status: 400 });
|
||||
|
||||
const validation = usernameSchema.safeParse(username);
|
||||
if (!validation.success) return Response.json({ error: validation.error.errors[0].message }, { status: 400 });
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { username } });
|
||||
if (existingUser) return Response.json({ error: "Username is already taken" }, { status: 400 });
|
||||
|
||||
await prisma.user.update({
|
||||
where: { email: session.user?.email ?? undefined },
|
||||
data: { username },
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ export default function LikeButton({ likes, isLoggedIn }: Props) {
|
|||
};
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className="flex items-center gap-2 text-xl text-red-400 mt-1 cursor-pointer">
|
||||
<button onClick={onClick} className="flex items-center gap-2 text-xl text-red-400 cursor-pointer">
|
||||
<Icon icon={isLiked ? "icon-park-solid:like" : "icon-park-outline:like"} />
|
||||
<span>{likesState}</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,17 @@ import { signIn } from "next-auth/react";
|
|||
export default function LoginButtons() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 mt-8">
|
||||
<button onClick={() => signIn("discord")} className="pill button gap-2 !px-3 !bg-indigo-400 !border-indigo-500 hover:!bg-indigo-500">
|
||||
<button
|
||||
onClick={() => signIn("discord", { redirectTo: "/create-username" })}
|
||||
className="pill button gap-2 !px-3 !bg-indigo-400 !border-indigo-500 hover:!bg-indigo-500"
|
||||
>
|
||||
<Icon icon="ic:baseline-discord" fontSize={32} />
|
||||
Login with Discord
|
||||
</button>
|
||||
<button onClick={() => signIn("github")} className="pill button gap-2 !px-3 !bg-zinc-700 !border-zinc-800 hover:!bg-zinc-800 text-white">
|
||||
<button
|
||||
onClick={() => signIn("github", { redirectTo: "/create-username" })}
|
||||
className="pill button gap-2 !px-3 !bg-zinc-700 !border-zinc-800 hover:!bg-zinc-800 text-white"
|
||||
>
|
||||
<Icon icon="mdi:github" fontSize={32} />
|
||||
Login with GitHub
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export default async function ProfileOverview() {
|
|||
<li title="Your profile">
|
||||
<button className="pill button !gap-2 !p-0 h-full max-w-64">
|
||||
<img src={session?.user?.image ?? "/missing.webp"} alt="profile picture" className="rounded-full h-full outline-2 outline-orange-400" />
|
||||
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.user?.name}</span>
|
||||
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.user?.username ?? "unknown"}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
45
src/app/components/username-form.tsx
Normal file
45
src/app/components/username-form.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function UsernameForm() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
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 onSubmit={handleSubmit} className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type your username..."
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="pill !bg-orange-200 outline-0 focus:ring-[3px] ring-orange-400/50 transition w-96 mt-8 mb-2"
|
||||
/>
|
||||
|
||||
<button type="submit" className="pill button w-min">
|
||||
Submit
|
||||
</button>
|
||||
{error && <p className="text-red-400 font-semibold mt-4">Error: {error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
22
src/app/create-username/page.tsx
Normal file
22
src/app/create-username/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { auth } from "@/lib/auth";
|
||||
|
||||
import UsernameForm from "../components/username-form";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function CreateUsernamePage() {
|
||||
const session = await auth();
|
||||
|
||||
// If the user is not logged in or already has a username, redirect
|
||||
if (!session || session?.user.username) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-medium text-center">Welcome to TomodachiShare!</h1>
|
||||
<h2 className="text-lg text-center">Please create a username</h2>
|
||||
|
||||
<UsernameForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { auth as middleware } from "@/lib/auth";
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
|
|
@ -36,8 +37,19 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
|||
const miis = await prisma.mii.findMany({
|
||||
where: where,
|
||||
orderBy,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (session?.user && !session.user.username) {
|
||||
redirect("/create-username");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
|
|
@ -74,20 +86,24 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
|||
{miis.map((mii) => (
|
||||
<div
|
||||
key={mii.id}
|
||||
className="bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600"
|
||||
className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3 transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600"
|
||||
>
|
||||
<img src="https://placehold.co/600x400" alt="mii" className="rounded-xl" />
|
||||
<div className="p-4">
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
<h3 className="font-bold text-2xl overflow-hidden text-ellipsis line-clamp-2" title={mii.name}>
|
||||
{mii.name}
|
||||
</h3>
|
||||
<div id="tags" className="flex gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
|
||||
<div id="tags" className="flex gap-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
|
||||
{mii.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<LikeButton likes={mii.likes} isLoggedIn={session?.user != null} />
|
||||
<div className="mt-auto grid grid-cols-2 items-center">
|
||||
<LikeButton likes={mii.likes} isLoggedIn={session?.user != null} />
|
||||
|
||||
<span className="text-sm text-right text-ellipsis">@{mii.user?.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
async session({ session, user }) {
|
||||
if (user) {
|
||||
session.user.id = user.id;
|
||||
session.user.username = user.username;
|
||||
session.user.email = user.email;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
14
src/types.d.ts
vendored
Normal file
14
src/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { DefaultSession, User } from "next-auth";
|
||||
import { User as PrismaUser } from "@prisma/client";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
username?: string;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User {
|
||||
username?: string;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue