From 290b359c4f1743fb9b5dc44ddc7cc7a34067f985 Mon Sep 17 00:00:00 2001 From: Leander Furumo Date: Sun, 22 Jun 2025 00:42:25 +0200 Subject: [PATCH] feat: add discord login support --- env.example | 3 + next.config.ts | 9 +- package-lock.json | 163 ++++++++++++++++++++++++ package.json | 1 + src/app/api/auth/[...nextauth]/route.ts | 32 +++++ src/app/api/guess/route.ts | 6 + src/app/components/HomeClient.tsx | 153 ++++++++++++++++++++++ src/app/components/LoginButton.tsx | 16 +++ src/app/components/LoginClient.tsx | 30 +++++ src/app/components/LogoutButton.tsx | 12 ++ src/app/components/Providers.tsx | 8 ++ src/app/layout.tsx | 5 +- src/app/page.tsx | 150 +++------------------- src/app/translations.ts | 4 + 14 files changed, 456 insertions(+), 136 deletions(-) create mode 100644 env.example create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/components/HomeClient.tsx create mode 100644 src/app/components/LoginButton.tsx create mode 100644 src/app/components/LoginClient.tsx create mode 100644 src/app/components/LogoutButton.tsx create mode 100644 src/app/components/Providers.tsx diff --git a/env.example b/env.example new file mode 100644 index 0000000..e676a10 --- /dev/null +++ b/env.example @@ -0,0 +1,3 @@ +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +NEXTAUTH_SECRET= \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index ad52782..7d1aa39 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", - /* config options here */ + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "cdn.discordapp.com", + }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 1e0510e..cd997f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@headlessui/react": "^2.2.4", "next": "15.3.4", + "next-auth": "^4.24.11", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", @@ -151,6 +152,15 @@ "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": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/@date-vir/duration/-/duration-7.3.1.tgz", @@ -1159,6 +1169,15 @@ "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": { "version": "3.20.5", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.5.tgz", @@ -2722,6 +2741,15 @@ "dev": true, "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4489,6 +4517,15 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4878,6 +4915,24 @@ "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": { "version": "3.6.1", "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": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5124,6 +5211,12 @@ "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5134,6 +5227,15 @@ "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": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5247,6 +5349,30 @@ "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": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5413,6 +5539,28 @@ "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": { "version": "1.2.1", "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": { "version": "15.8.1", "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" } }, + "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 45caba5..f877990 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@headlessui/react": "^2.2.4", "next": "15.3.4", + "next-auth": "^4.24.11", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..99d4a81 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/src/app/api/guess/route.ts b/src/app/api/guess/route.ts index d3012b4..81cc435 100644 --- a/src/app/api/guess/route.ts +++ b/src/app/api/guess/route.ts @@ -1,8 +1,14 @@ +import { authOptions } from "@/api/auth/[...nextauth]/route"; import type { Match } from "@/types"; +import { getServerSession } from "next-auth/next"; import { NextResponse } from "next/server"; import { findNorwegianPerson } from "./dataProvider"; 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 match: Match = await findNorwegianPerson(input); return NextResponse.json(match); diff --git a/src/app/components/HomeClient.tsx b/src/app/components/HomeClient.tsx new file mode 100644 index 0000000..0ab6bd2 --- /dev/null +++ b/src/app/components/HomeClient.tsx @@ -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([]); + const [timer, setTimer] = useState(0); + const [timerStarted, setTimerStarted] = useState(false); + const timerRef = useRef(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 ( +
+
+ {session.user && ( +
+ +
+ {session.user.name!} + {session.user.name} +
+
+ )} +
+ + setHowToPlayOpen(true)} ariaLabel={t.howToPlayTitle} /> +
+
+
+
+ {timerStarted && ( +
+ + +
+ )} + { + setInput(e.target.value); + setRepeatMessage(""); + }} + onSubmit={handleSubmit} + disabled={correctMatchesCount === GOAL_AMOUNT} + /> + + {repeatMessage &&
{repeatMessage}
} + {matches.length > 0 && ( + <> +

{t.submittedAnswers}

+ + + )} +
+
+ setHowToPlayOpen(false)} /> +
+ ); +} diff --git a/src/app/components/LoginButton.tsx b/src/app/components/LoginButton.tsx new file mode 100644 index 0000000..563ca52 --- /dev/null +++ b/src/app/components/LoginButton.tsx @@ -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 ( + + ); +} diff --git a/src/app/components/LoginClient.tsx b/src/app/components/LoginClient.tsx new file mode 100644 index 0000000..1d39234 --- /dev/null +++ b/src/app/components/LoginClient.tsx @@ -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 ( + <> +
+ + setHowToPlayOpen(true)} ariaLabel={t.howToPlayTitle} /> +
+
+

{t.appTitle}

+

{t.loginPrompt}

+ +
+ setHowToPlayOpen(false)} /> + + ); +} diff --git a/src/app/components/LogoutButton.tsx b/src/app/components/LogoutButton.tsx new file mode 100644 index 0000000..8f5963f --- /dev/null +++ b/src/app/components/LogoutButton.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { signOut } from "next-auth/react"; +import { CiLogout } from "react-icons/ci"; + +export default function LogoutButton() { + return ( + + ); +} diff --git a/src/app/components/Providers.tsx b/src/app/components/Providers.tsx new file mode 100644 index 0000000..3502f27 --- /dev/null +++ b/src/app/components/Providers.tsx @@ -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 {children}; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b848dab..938a18e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,4 @@ +import Providers from "@/components/Providers"; import "@/globals.css"; import type { Metadata } from "next"; @@ -13,7 +14,9 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 8964cf5..7e123f6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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"; -import { LanguageProvider, useLanguage } from "@/LanguageContext"; -import { translations } from "@/translations"; -import React, { useMemo, useRef, useState } from "react"; -import Footer from "./components/Footer"; -import GuessesList from "./components/GuessesList"; -import GuessForm from "./components/GuessForm"; -import Header from "./components/Header"; -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 async function HomePageWithLanguage() { + const session = await getServerSession(authOptions); + + if (!session) { + return ( + + + + ); + } -export default function HomePageWithLanguage() { return ( - + ); } - -function Home() { - const [input, setInput] = useState(""); - const [matches, setMatches] = useState([]); - const [timer, setTimer] = useState(0); - const [timerStarted, setTimerStarted] = useState(false); - const timerRef = useRef(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 ( -
-
- - setHowToPlayOpen(true)} ariaLabel={t.howToPlayTitle} /> -
-
-
- {timerStarted && ( -
- - -
- )} - { - setInput(e.target.value); - setRepeatMessage(""); - }} - onSubmit={handleSubmit} - disabled={correctMatchesCount === GOAL_AMOUNT} - /> - - {repeatMessage &&
{repeatMessage}
} - {matches.length > 0 && ( - <> -

{t.submittedAnswers}

- - - )} -
-
- ); -} diff --git a/src/app/translations.ts b/src/app/translations.ts index 058d846..80807e6 100644 --- a/src/app/translations.ts +++ b/src/app/translations.ts @@ -13,6 +13,8 @@ export const translations = { madeBy: "Made by", sourceCode: "Source code", subject: "UiO Gamers", + loginPrompt: "Please log in to play", + loginWithDiscord: "Login with Discord", }, no: { submittedAnswers: "Dine svar", @@ -28,5 +30,7 @@ export const translations = { madeBy: "Laget av", sourceCode: "Kildekode", subject: "UiO-Gamere", + loginPrompt: "Vennligst logg inn for å spille", + loginWithDiscord: "Logg inn med Discord", }, };