mirror of
https://github.com/UiO-Gaming/name-100-uio-gamers.git
synced 2026-11-05 13:56:53 +01:00
feat: add discord login support
This commit is contained in:
14 files changed
+456
-136
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
+8
-1
@@ -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;
|
||||||
Generated
+163
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user