feat: add discord login support

This commit is contained in:
leander committed 2025-06-22 00:42:25 +02:00
1 parent 5570fc8eb0
commit 290b359c4f
14 files changed
+456 -136

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
NEXTAUTH_SECRET=
+8 -1
View File
@@ -2,7 +2,14 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
/* config options here */ images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.discordapp.com",
},
],
},
}; };
export default nextConfig; export default nextConfig;
+163
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.4", "@headlessui/react": "^2.2.4",
"next": "15.3.4", "next": "15.3.4",
"next-auth": "^4.24.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
@@ -151,6 +152,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@date-vir/duration": { "node_modules/@date-vir/duration": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/@date-vir/duration/-/duration-7.3.1.tgz", "resolved": "https://registry.npmjs.org/@date-vir/duration/-/duration-7.3.1.tgz",
@@ -1159,6 +1169,15 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@react-aria/focus": { "node_modules/@react-aria/focus": {
"version": "3.20.5", "version": "3.20.5",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.5.tgz", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.5.tgz",
@@ -2722,6 +2741,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4489,6 +4517,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4878,6 +4915,24 @@
"loose-envify": "cli.js" "loose-envify": "cli.js"
} }
}, },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lru-cache/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/luxon": { "node_modules/luxon": {
"version": "3.6.1", "version": "3.6.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
@@ -5096,6 +5151,38 @@
} }
} }
}, },
"node_modules/next-auth": {
"version": "4.24.11",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
"integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
"cookie": "^0.7.0",
"jose": "^4.15.5",
"oauth": "^0.9.15",
"openid-client": "^5.4.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"@auth/core": "0.34.2",
"next": "^12.2.5 || ^13 || ^14 || ^15",
"nodemailer": "^6.6.5",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@auth/core": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -5124,6 +5211,12 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5134,6 +5227,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5247,6 +5349,30 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oidc-token-hash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5413,6 +5539,28 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/preact": {
"version": "10.26.9",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz",
"integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5565,6 +5713,12 @@
} }
} }
}, },
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -6630,6 +6784,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.4", "@headlessui/react": "^2.2.4",
"next": "15.3.4", "next": "15.3.4",
"next-auth": "^4.24.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
+32
View File
@@ -0,0 +1,32 @@
import type { NextAuthOptions } from "next-auth";
import NextAuth from "next-auth";
import DiscordProvider, { type DiscordProfile } from "next-auth/providers/discord";
export const authOptions: NextAuthOptions = {
providers: [
DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID as string,
clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
profile(profile: DiscordProfile) {
if (profile.avatar === null) {
const defaultAvatarNumber = parseInt(profile.discriminator) % 5;
profile.image_url = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNumber}.png`;
} else {
const format = profile.avatar.startsWith("a_") ? "gif" : "png";
profile.image_url = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
}
return {
id: profile.id,
name: profile.global_name ?? profile.username,
email: profile.email,
image: profile.image_url,
};
},
}),
],
secret: process.env.NEXTAUTH_SECRET,
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+6
View File
@@ -1,8 +1,14 @@
import { authOptions } from "@/api/auth/[...nextauth]/route";
import type { Match } from "@/types"; import type { Match } from "@/types";
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { findNorwegianPerson } from "./dataProvider"; import { findNorwegianPerson } from "./dataProvider";
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const { input } = await request.json(); const { input } = await request.json();
const match: Match = await findNorwegianPerson(input); const match: Match = await findNorwegianPerson(input);
return NextResponse.json(match); return NextResponse.json(match);
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { GOAL_AMOUNT } from "@/consts";
import { useLanguage } from "@/LanguageContext";
import { translations } from "@/translations";
import type { Match } from "@/types";
import type { Session } from "next-auth";
import Image from "next/image";
import React, { useMemo, useRef, useState } from "react";
import Footer from "./Footer";
import GuessesList from "./GuessesList";
import GuessForm from "./GuessForm";
import Header from "./Header";
import HowToPlay from "./HowToPlay";
import HowToPlayButton from "./HowToPlayButton";
import LanguageMenu from "./LanguageMenu";
import LogoutButton from "./LogoutButton";
import RestartButton from "./RestartButton";
import Score from "./Score";
import Timer from "./Timer";
export default function HomeClient({ session }: { session: Session }) {
const [input, setInput] = useState("");
const [matches, setMatches] = useState<Match[]>([]);
const [timer, setTimer] = useState(0);
const [timerStarted, setTimerStarted] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const [howToPlayOpen, setHowToPlayOpen] = useState(false);
const [repeatMessage, setRepeatMessage] = useState("");
const { language } = useLanguage();
const t = translations[language];
const correctMatchesCount = useMemo(() => matches.filter((m) => m.correct).length, [matches]);
const startTimer = () => {
if (!timerStarted) {
setTimerStarted(true);
timerRef.current = setInterval(() => {
setTimer((prev) => prev + 1);
}, 1000);
}
};
const stopTimer = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
React.useEffect(() => {
return () => stopTimer();
}, []);
// Stop timer when 100 correct guesses are made
React.useEffect(() => {
if (correctMatchesCount === GOAL_AMOUNT) {
stopTimer();
}
}, [correctMatchesCount]);
const handleRestart = () => {
stopTimer();
setTimer(0);
setTimerStarted(false);
setMatches([]);
setInput("");
setRepeatMessage("");
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput) return;
if (matches.some((m) => m.input.toLowerCase() === trimmedInput.toLowerCase())) {
setRepeatMessage(t.alreadySubmitted);
return;
}
setRepeatMessage("");
const res = await fetch("/api/guess", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: trimmedInput }),
});
if (res.ok) {
const data = await res.json();
if (data.name && matches.some((m) => m.name && m.name.toLowerCase() === data.name.toLowerCase())) {
setRepeatMessage(t.alreadySubmitted);
setInput("");
return;
}
setMatches((prev) => [...prev, { input: trimmedInput, ...data }]);
if (data.correct && !timerStarted) {
startTimer();
}
}
setInput("");
};
return (
<div className="grid min-h-dvh grid-rows-[auto,1fr,auto]">
<div className="absolute top-4 right-4 left-4 z-50 flex flex-wrap items-center justify-between gap-4">
{session.user && (
<div className="flex items-center gap-4">
<LogoutButton />
<div className="flex items-center gap-2">
<Image
src={session.user.image!}
alt={session.user.name!}
width={40}
height={40}
className="rounded-full"
/>
<span className="font-semibold">{session.user.name}</span>
</div>
</div>
)}
<div className="flex items-center gap-2">
<LanguageMenu />
<HowToPlayButton onClick={() => setHowToPlayOpen(true)} ariaLabel={t.howToPlayTitle} />
</div>
</div>
<Header />
<main className="row-start-2 flex w-full flex-1 flex-col items-center gap-6 px-4 pt-28 pb-12 sm:pt-12">
{timerStarted && (
<div className="flex gap-4">
<RestartButton onClick={handleRestart} />
<Timer timer={timer} />
</div>
)}
<GuessForm
input={input}
onInputChange={(e) => {
setInput(e.target.value);
setRepeatMessage("");
}}
onSubmit={handleSubmit}
disabled={correctMatchesCount === GOAL_AMOUNT}
/>
<Score matchesCount={correctMatchesCount} />
{repeatMessage && <div className="mb-2 font-semibold text-red-500">{repeatMessage}</div>}
{matches.length > 0 && (
<>
<h2 className="mt-4 text-2xl">{t.submittedAnswers}</h2>
<GuessesList matches={matches} />
</>
)}
</main>
<Footer className="self-end" />
<HowToPlay open={howToPlayOpen} onClose={() => setHowToPlayOpen(false)} />
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { signIn } from "next-auth/react";
import { FaDiscord } from "react-icons/fa";
export default function LoginButton({ text }: { text: string }) {
return (
<button
onClick={() => signIn("discord")}
className="flex items-center gap-2 rounded-md bg-[#5865F2] px-4 py-2 text-white"
>
<FaDiscord />
{text}
</button>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useLanguage } from "@/LanguageContext";
import { translations } from "@/translations";
import { useState } from "react";
import HowToPlay from "./HowToPlay";
import HowToPlayButton from "./HowToPlayButton";
import LanguageMenu from "./LanguageMenu";
import LoginButton from "./LoginButton";
export default function LoginClient() {
const { language } = useLanguage();
const t = translations[language];
const [howToPlayOpen, setHowToPlayOpen] = useState(false);
return (
<>
<div className="absolute top-4 right-4 z-50 flex items-center gap-2">
<LanguageMenu />
<HowToPlayButton onClick={() => setHowToPlayOpen(true)} ariaLabel={t.howToPlayTitle} />
</div>
<div className="flex h-screen flex-col items-center justify-center">
<h1 className="mb-4 text-2xl font-bold">{t.appTitle}</h1>
<p className="mb-8">{t.loginPrompt}</p>
<LoginButton text={t.loginWithDiscord} />
</div>
<HowToPlay open={howToPlayOpen} onClose={() => setHowToPlayOpen(false)} />
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { signOut } from "next-auth/react";
import { CiLogout } from "react-icons/ci";
export default function LogoutButton() {
return (
<button onClick={() => signOut()} className="rounded-lg bg-red-500 p-2 text-white" aria-label="Logout">
<CiLogout size={24} />
</button>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { SessionProvider } from "next-auth/react";
import type { ReactNode } from "react";
export default function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
+4 -1
View File
@@ -1,3 +1,4 @@
import Providers from "@/components/Providers";
import "@/globals.css"; import "@/globals.css";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -13,7 +14,9 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body>
<Providers>{children}</Providers>
</body>
</html> </html>
); );
} }
+16 -134
View File
@@ -1,141 +1,23 @@
"use client"; import { authOptions } from "@/api/auth/[...nextauth]/route";
import { LanguageProvider } from "@/LanguageContext";
import { getServerSession } from "next-auth";
import HomeClient from "./components/HomeClient";
import LoginClient from "./components/LoginClient";
import { GOAL_AMOUNT } from "@/consts"; export default async function HomePageWithLanguage() {
import { LanguageProvider, useLanguage } from "@/LanguageContext"; const session = await getServerSession(authOptions);
import { translations } from "@/translations";
import React, { useMemo, useRef, useState } from "react"; if (!session) {
import Footer from "./components/Footer"; return (
import GuessesList from "./components/GuessesList"; <LanguageProvider>
import GuessForm from "./components/GuessForm"; <LoginClient />
import Header from "./components/Header"; </LanguageProvider>
import HowToPlay from "./components/HowToPlay"; );
import HowToPlayButton from "./components/HowToPlayButton"; }
import LanguageMenu from "./components/LanguageMenu";
import RestartButton from "./components/RestartButton";
import Score from "./components/Score";
import Timer from "./components/Timer";
import type { Match } from "./types";
export default function HomePageWithLanguage() {
return ( return (
<LanguageProvider> <LanguageProvider>
<Home /> <HomeClient session={session} />
</LanguageProvider> </LanguageProvider>
); );
} }
function Home() {
const [input, setInput] = useState("");
const [matches, setMatches] = useState<Match[]>([]);
const [timer, setTimer] = useState(0);
const [timerStarted, setTimerStarted] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const [howToPlayOpen, setHowToPlayOpen] = useState(false);
const [repeatMessage, setRepeatMessage] = useState("");
const { language } = useLanguage();
const t = translations[language];
const correctMatchesCount = useMemo(() => matches.filter((m) => m.correct).length, [matches]);
const startTimer = () => {
if (!timerStarted) {
setTimerStarted(true);
timerRef.current = setInterval(() => {
setTimer((prev) => prev + 1);
}, 1000);
}
};
const stopTimer = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
React.useEffect(() => {
return () => stopTimer();
}, []);
// Stop timer when 100 correct guesses are made
React.useEffect(() => {
if (correctMatchesCount === GOAL_AMOUNT) {
stopTimer();
}
}, [correctMatchesCount]);
const handleRestart = () => {
stopTimer();
setTimer(0);
setTimerStarted(false);
setMatches([]);
setInput("");
setRepeatMessage("");
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput) return;
if (matches.some((m) => m.input.toLowerCase() === trimmedInput.toLowerCase())) {
setRepeatMessage(t.alreadySubmitted);
return;
}
setRepeatMessage("");
const res = await fetch("/api/guess", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: trimmedInput }),
});
if (res.ok) {
const data = await res.json();
if (data.name && matches.some((m) => m.name && m.name.toLowerCase() === data.name.toLowerCase())) {
setRepeatMessage(t.alreadySubmitted);
setInput("");
return;
}
setMatches((prev) => [...prev, { input: trimmedInput, ...data }]);
if (data.correct && !timerStarted) {
startTimer();
}
}
setInput("");
};
return (
<div className="grid min-h-dvh grid-rows-[auto,1fr,auto] pt-15">
<div className="absolute top-4 right-4 z-50 flex items-center gap-2">
<LanguageMenu />
<HowToPlayButton onClick={() => setHowToPlayOpen(true)} ariaLabel={t.howToPlayTitle} />
</div>
<Header />
<main className="row-start-2 flex w-full flex-1 flex-col items-center gap-6 px-4 pb-12">
{timerStarted && (
<div className="flex gap-4">
<RestartButton onClick={handleRestart} />
<Timer timer={timer} />
</div>
)}
<GuessForm
input={input}
onInputChange={(e) => {
setInput(e.target.value);
setRepeatMessage("");
}}
onSubmit={handleSubmit}
disabled={correctMatchesCount === GOAL_AMOUNT}
/>
<Score matchesCount={correctMatchesCount} />
{repeatMessage && <div className="mb-2 font-semibold text-red-500">{repeatMessage}</div>}
{matches.length > 0 && (
<>
<h2 className="mt-4 text-2xl">{t.submittedAnswers}</h2>
<GuessesList matches={matches} />
</>
)}
</main>
<Footer className="self-end" />
<HowToPlay open={howToPlayOpen} onClose={() => setHowToPlayOpen(false)} />
</div>
);
}
+4
View File
@@ -13,6 +13,8 @@ export const translations = {
madeBy: "Made by", madeBy: "Made by",
sourceCode: "Source code", sourceCode: "Source code",
subject: "UiO Gamers", subject: "UiO Gamers",
loginPrompt: "Please log in to play",
loginWithDiscord: "Login with Discord",
}, },
no: { no: {
submittedAnswers: "Dine svar", submittedAnswers: "Dine svar",
@@ -28,5 +30,7 @@ export const translations = {
madeBy: "Laget av", madeBy: "Laget av",
sourceCode: "Kildekode", sourceCode: "Kildekode",
subject: "UiO-Gamere", subject: "UiO-Gamere",
loginPrompt: "Vennligst logg inn for å spille",
loginWithDiscord: "Logg inn med Discord",
}, },
}; };