77 Commits
Author SHA1 Message Date
leanderandClaude Opus 5.5 9a8076813b ci: deploy through the restricted uiogaming-deploy script
The old workflow ran docker compose in the repo folder, which failed because the containers belong to the server's top-level compose project. The deploy key can now only run /usr/local/bin/uiogaming-deploy (see UiO-Gaming/drift), so the workflow just passes the service name. Also adds manual runs and prevents overlapping deploys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:47:58 +01:00
leander d9efb8c51f Merge pull request #56 from UiO-Gaming/release-please--branches--main--components--uiogaming.no
chore(main): release uiogaming.no 1.0.1
2026-11-02 22:19:45 +01:00
github-actions[bot] a89c2f609d chore(main): release uiogaming.no 1.0.1 2026-11-02 21:18:29 +00:00
leander 15982ed804 Merge branch 'slop/sloppy-fixes'
# Conflicts:
#	README.md
2026-11-02 22:15:02 +01:00
leanderandClaude Opus 5.5 d20de4b455 ci: add release-please for automated releases
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:56:49 +01:00
leander a09b7ae871 elaborate on note about webhook 2026-11-01 22:24:20 +01:00
leanderandClaude Opus 5.5 1a18096cf6 Apply Sanity crop and hotspot to images
Fetch the full image objects instead of only the asset URL so the URL
builder can apply the crop and hotspot set in the Studio, crop the Open
Graph image around the hotspot, and add sizes to full-width blog images.

Closes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:42:21 +01:00
leanderandClaude Opus 5.5 f647dd869d Update contributing guide for the bilingual site and new checks
Text shown to users goes in both message files instead of being
Norwegian only. Document lint and CI, and make npm run format cover
the same files as the CI check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:05:50 +01:00
leander 28346d6907 fix typos 2026-10-30 23:03:09 +01:00
leanderandClaude Opus 5.5 b60f8a92e1 Add sitemap.xml and robots.txt
List the home page, blog posts and events in both languages with
hreflang alternates, and point crawlers to the sitemap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:01:49 +01:00
leanderandClaude Opus 5.5 2e54e32ea6 Add canonical URLs, hreflang alternates and article metadata
Set metadataBase so relative metadata URLs resolve to the real domain,
link the Norwegian and English versions of each page, and mark blog
posts as articles with their publish date and author.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:56 +01:00
leanderandClaude Opus 5.5 115afd4275 Make the Back button aware of client-side navigation
document.referrer only changes on full page loads, so the button sent
visitors to the home page instead of back after navigating within the
site. Use the Navigation API's canGoBack, which only considers this
site's history entries, and keep the referrer check as a fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:57:28 +01:00
leanderandClaude Opus 5.5 67641c5719 Don't open mailto: and internal links in a new tab
target="_blank" on mailto: links leaves an empty tab behind. In blog
posts, only links to other sites now open in a new tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:56:08 +01:00
leanderandClaude Opus 5.5 70ba50c515 Hide decorative flags from screen readers in the language selector
The flags had titles that repeated the button text. Also mark each
language name with its own lang so it's pronounced correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:54:18 +01:00
leanderandClaude Opus 5.5 633237f262 Make the "Other" dropdown accessible
Expose its state with aria-expanded, close it on outside clicks,
Escape and when focus leaves, restore the focus outline, and move the
inline margins into CSS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:53:03 +01:00
leanderandClaude Opus 5.5 eab0e0bbab Use a span instead of a paragraph inside the Back button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:51:56 +01:00
leanderandClaude Opus 5.5 7822daa557 Add CI workflow that checks pull requests
Run formatting, lint, type checking and a production build on every
pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:51:03 +01:00
leanderandClaude Opus 5.5 773d21b709 Shorten deploy downtime and limit workflow permissions
Build the new image before replacing the running container instead of
stopping the site first, prune old images, prevent overlapping deploys,
and drop the unused pull-requests permission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:40:21 +01:00
leanderandClaude Opus 5.5 b8e352ba22 Use a multi-stage Docker build with Next's standalone output
Install with npm ci, ship only the standalone server and its traced
dependencies, and run as a non-root user. Shrinks the image from
1.78 GB to 438 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:39:14 +01:00
leanderandClaude Opus 5.5 703ab5a32c Only send translations used by client components to the browser
Server components read their translations on the server, but every
namespace was serialized into each page for the client provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:30:41 +01:00
leanderandClaude Opus 5.5 edf0eed3dc Lazy-load the MazeMap iframe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:28:03 +01:00
leanderandClaude Opus 5.5 1d506a7b4c Fix deprecated Prettier option and make the repo pass prettier --check
jsxBracketSameLine was replaced by bracketSameLine in Prettier 3.
Ignore tsconfig.json, since Next rewrites it in its own format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:27:37 +01:00
leanderandClaude Opus 5.5 aca183a4a2 Use the @/ import alias consistently
Drop the @components and @lib aliases, which only a few imports used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:26:06 +01:00
leanderandClaude Opus 5.5 3a0ad44e06 Define icons, theme color and viewport once
Icons were set in both the layout metadata and every page's metadata,
and theme-color was output twice. Inherit icons from the layout and
export the viewport from the layout instead of from each page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:25:05 +01:00
leanderandClaude Opus 5.5 0eb47846e7 Simplify Umami analytics into one server component
The config was split between lib/analyticsConfig and a client component
that repeated the production check and default URL, and checked window
during render. Umami's data-domains already restricts tracking to our
domains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:23:06 +01:00
leanderandClaude Opus 5.5 5c612d995d Share event date handling and stop depending on the server timezone
Event dates are stored as Oslo wall-clock strings, but were parsed in
the server's local timezone, so times were only correct because the
container sets TZ=Europe/Oslo. Move date parsing and formatting into
lib/dates, interpret those strings as Oslo time, and also accept ISO
timestamps in case the Studio writes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:20:46 +01:00
leanderandClaude Opus 5.5 35c175c1ad Remove unused translation key and query field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:18:02 +01:00
leanderandClaude Opus 5.5 19cda1b817 Remove accidentally added install package
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:16:24 +01:00
leanderandClaude Opus 5.5 7c344a2c74 Rename middleware.ts to proxy.ts
The middleware file convention is deprecated in Next 16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:15:24 +01:00
leanderandClaude Opus 5.5 5b135ce712 Define locales once with next-intl's routing config
Locales were listed separately in the middleware, the request config
and several type casts. Use defineRouting and createNavigation, type
the Locale globally, and let the language selector use the locale-aware
router so it keeps the query string and hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:13:57 +01:00
leander b84781ebe5 remove unused i18n request config 2026-10-30 22:10:42 +01:00
leanderandClaude Opus 5.5 aca584791e Update Node to 26 and dependencies to their latest versions
- Node 20 is end-of-life; use the current LTS in the Docker image
- Major updates: @sanity/client 8, @sanity/image-url 2,
  @portabletext/react 8, eslint-config-next 16
- Unpin React and update to 19.3
- ESLint stays on 9 and TypeScript on 6.0, since eslint-config-next's
  plugins and typescript-eslint don't support newer majors yet
- Use the native flat config from eslint-config-next 16
- Use useSyncExternalStore for WebGL detection to satisfy the new
  react-hooks lint rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:01:56 +01:00
leanderandClaude Opus 5.5 a1729494b4 Only render safe link URLs from blog content
Blog links come from the CMS, so a javascript: URL would run code when
clicked. Allow only http(s), mailto and relative links, and render
anything else as plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:51:53 +01:00
leanderandClaude Opus 5.5 d04afb3e0c Use real links in the "Other" dropdown
The items were buttons calling window.open() without noopener, which
let the opened page access window.opener. Links also support
middle-click and show the URL on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:50:23 +01:00
leanderandClaude Opus 5.5 c997b74d01 Verify Sanity webhook signatures in the revalidate route
The secret was passed in the query string, which ends up in access logs
and doesn't protect the request body. Verify the signed header with
@sanity/webhook instead, and return 400 for invalid JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:48:49 +01:00
leanderandClaude Opus 5.5 c5c366884a Fetch only upcoming events from Sanity
Filter and limit in GROQ instead of fetching every event and filtering
in JavaScript. Event dates are stored as Oslo local time strings, so
compare them against the current Oslo time in the same format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:46:06 +01:00
leanderandClaude Opus 5.5 b05ecf757d Handle events without a description
Event cards crashed on a missing description, and the event metadata
included "null" for missing fields. Truncate previews by grapheme so
emoji aren't split, and show descriptions of exactly 70 characters in
full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:41:07 +01:00
leanderandClaude Opus 5.5 1f82345a2f Keep .env and local build artifacts out of the Docker image
Without a .dockerignore, COPY . . baked the .env file (including the
webhook secret) into the image and overwrote the container's
node_modules with the host's. Pass public build-time values as build
args and provide the rest at runtime through env_file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:38:49 +01:00
leander d03278ef0d replace deprecated next lint with eslint 2026-10-30 21:30:12 +01:00
leanderandClaude Opus 5.5 da3d26d16b Render pages statically using next/root-params
getLocale() read request headers, which made every route dynamic and the
page-level revalidate a no-op. Make [locale]/layout.tsx the root layout
so the locale can be read through next/root-params, set <html lang>, add
generateStaticParams for locales and slugs, and pass the locale
explicitly to metadata translations.

The middleware now matches all non-file paths so URLs without a locale
are redirected instead of hitting the root layout's notFound().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:28:32 +01:00
leanderandClaude Opus 5.5 a8b31a5379 Expire cache tags immediately on webhook revalidation
The "max" profile serves stale content once more while revalidating, so
the first visit after publishing showed the old version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:18:07 +01:00
leander 30da54c78e use next cache instead of sanity's cache 2026-10-30 21:16:08 +01:00
leander 13463d4eeb fetch board member data on data change 2026-10-30 21:13:58 +01:00
leander d44fbfa9d7 fix 404 page i18n handling 2026-10-30 21:11:53 +01:00
leander 52b8f2b7b7 remove redundant layout tags 2026-10-30 21:03:53 +01:00
leander 38f1ba69b4 return 404 on no data for blogposts/events 2026-10-30 21:02:32 +01:00
leander 39ad3947fd run npm audit fix 2026-10-30 15:49:10 +01:00
leander 14c1403e6d fix some vulns 2026-10-22 19:04:06 +02:00
leander e238eed619 clarify webhook api deployment 2026-09-10 02:17:26 +02:00
leander 08bc43942a fix vulns 2026-08-29 16:47:38 +02:00
leander b0e343a390 Merge pull request #55 from UiO-Gaming/dependabot/npm_and_yarn/multi-5a59400ff4
Bump brace-expansion
2026-08-28 21:08:07 +02:00
leander c7d646362b Merge pull request #54 from UiO-Gaming/dependabot/npm_and_yarn/next-15.5.24
Bump next from 15.5.18 to 15.5.24
2026-08-28 21:07:32 +02:00
dependabot[bot] 2183175de6 Bump brace-expansion
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 1.1.14 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.14...v1.1.18)

Updates `brace-expansion` from 2.1.0 to 2.1.4
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.14...v1.1.18)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 2.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 19:05:43 +00:00
dependabot[bot] bf6f282155 Bump next from 15.5.18 to 15.5.24
Bumps [next](https://github.com/vercel/next.js) from 15.5.18 to 15.5.24.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.18...v15.5.24)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.24
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 19:04:30 +00:00
leander 8480327a4c Merge pull request #53 from UiO-Gaming/dependabot/npm_and_yarn/next-15.5.18
Bump next from 15.5.15 to 15.5.18
2026-05-14 22:35:19 +08:00
dependabot[bot] 30abf637ac Bump next from 15.5.15 to 15.5.18
Bumps [next](https://github.com/vercel/next.js) from 15.5.15 to 15.5.18.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.15...v15.5.18)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-14 13:45:01 +00:00
leander 93b2dc4d4e fix vulns 2026-04-14 04:25:01 +02:00
leander 183e519a19 fix CVE-2026-27903 2026-03-23 15:14:15 +01:00
leander 1c02352aad add section ids
allow linkink to certain parts of the homepage
2026-03-09 21:03:48 +01:00
leander abcc39ce6d fix GHSA-h25m-26qc-wcjf 2026-02-14 15:41:52 +01:00
leander e9cff6ab84 another day, another vuln to fix 2026-01-19 02:08:14 +01:00
leander 6cc19ea4fb LET ME ENJOY MY VACATION IN PEACE STUPID FUCKING FRAMEWORK 2025-12-13 03:28:28 +01:00
leander 743df3691c Merge pull request #52 from UiO-Gaming/dependabot/npm_and_yarn/multi-79dce8efd9
Bump glob and npm
2025-12-13 02:24:34 +00:00
dependabot[bot] afa4e5580c Bump glob and npm
Removes [glob](https://github.com/isaacs/node-glob). It's no longer used after updating ancestor dependency [npm](https://github.com/npm/cli). These dependencies need to be updated together.


Removes `glob`

Updates `npm` from 11.5.2 to 11.7.0
- [Release notes](https://github.com/npm/cli/releases)
- [Changelog](https://github.com/npm/cli/blob/latest/CHANGELOG.md)
- [Commits](https://github.com/npm/cli/compare/v11.5.2...v11.7.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 
  dependency-type: indirect
- dependency-name: npm
  dependency-version: 11.7.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-13 02:24:04 +00:00
leander 8a7ecf223d everyday I wake up to a new js-something vuln and I'm sick and tired of it
let me vacation in peacegit add .git add .
2025-12-09 12:58:09 +01:00
leander 3c6c55afd9 use padding instead of margin for event page spacing 2025-09-16 04:20:03 +02:00
leander 38d015fb20 add left margin to back button 2025-09-16 04:16:29 +02:00
leander d871eee968 add larger top and bottom margin to event pages 2025-09-16 04:13:30 +02:00
leander 2591eacd14 Merge pull request #51 from UiO-Gaming/fix/translation-rewrite
fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple fuck apple
2025-09-16 04:09:47 +02:00
leander 017f5154b1 Rewrite translation logic
Rerendering translations apparently caused issues with safari because
apple fucking sucks. Thus we had to rewrite the logic to use
prefix-based translations. This is the attempt at this. It might be a
mega commit but it's how it ought to be...
2025-09-16 03:31:11 +02:00
leander 1434528e4f add timezone to docker image 2025-08-24 19:14:18 +02:00
leander 0e283cc794 add pc room image alt text translations 2025-08-21 04:27:34 +02:00
leander e04422acb4 change phrasing of office description 2025-08-21 04:23:45 +02:00
leander 30b49ee8c9 add timezone to next-intl request middleware 2025-08-21 04:01:03 +02:00
leander 267bce6422 new 3d office + pc room image 2025-08-21 04:01:03 +02:00
leander 540ce583c9 add mazemap iframe with office directions 2025-08-11 14:44:58 +02:00
leander 48ff006c13 add restart clause to docker compose 2025-08-07 20:27:16 +02:00
62 changed files with 3138 additions and 3961 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# Installed and built inside the image
node_modules
.next
# Secrets are passed in by docker compose, never baked into the image
.env*
.git
.github
Dockerfile
docker-compose.yml
*.md
npm-debug.log*
+40
View File
@@ -0,0 +1,40 @@
name: CI
on:
pull_request:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
# Public values from env.example. The build prerenders pages with data
# from Sanity, so it needs to know which project to read from
env:
NEXT_PUBLIC_SANITY_PROJECT_ID: mmqlu667
NEXT_PUBLIC_SANITY_DATASET: production
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 26
cache: npm
- run: npm ci
- name: Check formatting
run: npx prettier --check .
- name: Lint
run: npm run lint
# Generates the route and next/root-params types tsc relies on
- name: Type check
run: npx next typegen && npx tsc --noEmit
- name: Build
run: npm run build
+12 -8
View File
@@ -4,25 +4,29 @@ on:
push:
branches:
- main
workflow_dispatch:
# Never run two deploys of this repo at the same time
concurrency: deploy
jobs:
run_pull:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Set up SSH
run: |
install -m 600 -D /dev/null ~/.ssh/id_ed25519
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 # This should be redundant but seems to do the trick.
ssh-keyscan -H ${{ vars.SSH_HOST }} > ~/.ssh/known_hosts
- name: Connect, pull, build and deploy
run: ssh ${{ secrets.SSH_USER }}@${{ vars.SSH_HOST }} "cd ${{ secrets.WORK_DIR }} && git checkout ${{ vars.MAIN_BRANCH }} && git pull && docker compose down && docker compose up -d --build && exit"
# The deploy key can only run /usr/local/bin/uiogaming-deploy on the server. It takes the
# service name from the top-level docker-compose.yml (see drift/driftsdokumentasjon.md).
- name: Deploy
run: ssh ${{ secrets.SSH_USER }}@${{ vars.SSH_HOST }} uiogaming.no
- name: Clean up
if: always()
run: rm -rf ~/.ssh
permissions:
contents: read
pull-requests: write
+19
View File
@@ -0,0 +1,19 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+1
View File
@@ -2,3 +2,4 @@
package.json
package-lock.json
public
tsconfig.json
+1 -1
View File
@@ -7,6 +7,6 @@
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"bracketSameLine": false,
"arrowParens": "avoid"
}
+3
View File
@@ -0,0 +1,3 @@
{
".": "1.0.1"
}
+9
View File
@@ -0,0 +1,9 @@
# Changelog
## [1.0.1](https://github.com/UiO-Gaming/uiogaming.no/compare/uiogaming.no-1.0.0...uiogaming.no-1.0.1) (2026-11-02)
### Bug Fixes
* filter function not working in firefox ([52e8087](https://github.com/UiO-Gaming/uiogaming.no/commit/52e8087229153d8465a9b257c1da5778b997e20a))
* photo credit text placement ([95f75d9](https://github.com/UiO-Gaming/uiogaming.no/commit/95f75d9bb1022407915da2eba5e9245e8b9e2ee4))
+9 -2
View File
@@ -1,9 +1,16 @@
# Huskeliste for bidrag
- Sørg for å formatere koden med Prettier
- Sørg for å formatere koden med Prettier (`npm run format`)
- Sjekk at koden ikke har lint-feil (`npm run lint`)
- Sjekk om nettsiden bygges uten problemer (`npm run build`)
- Formatering, linting, typesjekk og bygg kjøres også automatisk på alle pull requests
- Commit messages samt issues skal helst være skrevet på engelsk
- Kode skal skrives på engelsk. Det vil si variabelnavn osv. Tekst som vises til bruker skal være på norsk
- Kode skal skrives på engelsk. Det vil si variabelnavn osv.
- Nettsiden finnes på både norsk og engelsk. Tekst som vises til bruker skal ikke skrives rett i komponentene, men legges inn i både [messages/no.json](messages/no.json) og [messages/en.json](messages/en.json)
+36 -5
View File
@@ -1,15 +1,46 @@
FROM node:20-alpine
FROM node:26-alpine AS deps
WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
RUN npm install
COPY package.json package-lock.json ./
RUN npm ci
FROM node:26-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_* values are inlined at build time, and pages are prerendered
# with data from Sanity during the build
ARG NEXT_PUBLIC_SANITY_PROJECT_ID
ARG NEXT_PUBLIC_SANITY_DATASET
ARG NEXT_PUBLIC_UMAMI_SRC
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
RUN npm run build
# Only the standalone server and its traced dependencies end up in the final
# image, not the full node_modules or the source
FROM node:26-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=9000
ENV HOSTNAME=0.0.0.0
# The server writes regenerated pages to .next, so the files must be owned by
# the user it runs as
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
USER node
EXPOSE 9000
CMD ["npm", "run", "start"]
CMD ["node", "server.js"]
+14 -3
View File
@@ -41,11 +41,22 @@ npm run dev
### Webhook API
Om du vil at innhold skal oppdatere seg når innholdet endres i backend må du kjøre dette APIet. Dette gjelder bare om du kjører en produksjonsversjon av nettsiden
Om du vil at innhold skal oppdatere seg når innholdet endres i backend må du kjøre dette APIet. Det kjører automatisk når du starter applikasjonen.
Du må spesifisere URL for webhook i Sanity sitt dashbord. URLen dette webhooket skal peke på er `https://uiogaming.no/api/revalidate` med _POST_ forespørselstype.
MEN
Du kan lese mer om det [her](https://www.sanity.io/docs/webhooks).
Du må sette opp et webhook i [Sanity sitt dashbord](https://www.sanity.io/manage) under _API → Webhooks_:
- **URL:** `https://uiogaming.no/api/revalidate`
- **HTTP method:** POST
- **Projection:** Må minst inneholde `_type` og `slug`, f.eks `{_type, slug}`
- **Secret:** Samme verdi som `SANITY_REVALIDATE_SECRET` i `.env`
Sanity bruker _Secret_ til å signere hver forespørsel med headeren `sanity-webhook-signature`, og APIet avviser forespørsler uten gyldig signatur med `401`. Hemmeligheten skal altså ligge i _Secret_-feltet, ikke i URLen.
Webhooket oppdaterer blogginnlegg, arrangementer og forfattere med en gang. Annet innhold oppdateres først når cachen går ut (opptil en time).
Du kan lese mer om webhooks [her](https://www.sanity.io/docs/webhooks).
## Vil du bidra?
+12 -1
View File
@@ -1,6 +1,17 @@
services:
uiogaming.no:
container_name: uiogaming.no
build: .
build:
context: .
# Values are read from .env
args:
NEXT_PUBLIC_SANITY_PROJECT_ID: ${NEXT_PUBLIC_SANITY_PROJECT_ID}
NEXT_PUBLIC_SANITY_DATASET: ${NEXT_PUBLIC_SANITY_DATASET}
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
env_file: .env
ports:
- 9000:9000
environment:
- TZ=Europe/Oslo
restart: unless-stopped
+6 -14
View File
@@ -1,16 +1,8 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import { defineConfig } from "eslint/config"
import nextVitals from "eslint-config-next/core-web-vitals"
import nextTs from "eslint-config-next/typescript"
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// eslint-config-next already ignores .next, out, build and next-env.d.ts
const eslintConfig = defineConfig([...nextVitals, ...nextTs])
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
export default eslintConfig
+9 -4
View File
@@ -6,9 +6,13 @@
"complaint": "Complaint",
"documents": "Documents"
},
"metaTags": {
"time": "Time",
"location": "Location",
"description": "A student organisation that aims to create an inclusive environment around gaming and e-sports for the students at UiO"
},
"buttons": {
"back": "back",
"flagImageAlt": "Norwegian flag"
"back": "back"
},
"404": {
"title": "404: Page not found",
@@ -34,11 +38,12 @@
"office": {
"title": "The Office",
"text": "We have an office at Chateau Neuf where you can use our gear and hang out with other members! We've got all the latest and greatest consoles, 6 high-end PCs as well as a plethora of board games.",
"tip": "Tip: You can look around the office by clicking and dragging the image below 👀"
"tip": "Tip: You can look around the office by clicking and dragging the image below 👀",
"pcRoomImageAlt": "PC-room with 6 gaming setups"
},
"boardMembers": {
"title": "The Board",
"imageAlt": "Potrait of board member",
"imageAlt": "Portrait of board member",
"noContact": "No contact info"
},
"contact": {
+9 -4
View File
@@ -6,9 +6,13 @@
"complaint": "Klage",
"documents": "Dokumenter"
},
"metaTags": {
"time": "Tid",
"location": "Sted",
"description": "En hobbyforening som ønsker å skape et sosialt miljø rundt gaming og e-sport på UiO!"
},
"buttons": {
"back": "tilbake",
"flagImageAlt": "Britisk flagg"
"back": "tilbake"
},
"404": {
"title": "404: Siden finnes ikke",
@@ -33,8 +37,9 @@
},
"office": {
"title": "Kontoret",
"text": "Vi har et foreningskontor på Chateau Neuf der man kan bruke utstyret vårt og henge med andre medlemmer! Vi har blant annet alle de nyeste konsollene, 6 gaming-PCer samt et bredt utvalg av brettspill.",
"tip": "Tips: Du kan kikke rundt i kontoret ved å klikke og dra på bildet nedenfor 👀"
"text": "Vi har foreningskontor på Chateau Neuf der man kan bruke utstyret vårt og henge med andre medlemmer! Vi har blant annet alle de nyeste konsollene, 6 gaming-PCer samt et bredt utvalg av brettspill.",
"tip": "Tips: Du kan kikke rundt i kontoret ved å klikke og dra på bildet nedenfor 👀",
"pcRoomImageAlt": "PC-rom som inneholder 6 spillmaskiner"
},
"boardMembers": {
"title": "Styret",
+3
View File
@@ -4,6 +4,9 @@ import createNextIntlPlugin from "next-intl/plugin"
const withNextIntl = createNextIntlPlugin()
const nextConfig: NextConfig = {
// Bundles a minimal server with only the needed dependencies, used by the
// Docker image
output: "standalone",
images: {
remotePatterns: [
{
+1989 -3297
View File
File diff suppressed because it is too large. Load diff
+20 -22
View File
@@ -1,6 +1,6 @@
{
"name": "uiogaming.no",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"description": "UiO Gaming Website",
"author": "Leander Furumo <leander@furumo.eu>",
@@ -8,32 +8,30 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start -p 9000",
"lint": "next lint",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\""
"lint": "eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@portabletext/react": "^3.2.1",
"@sanity/client": "^7.8.1",
"@sanity/image-url": "^1.1.0",
"country-flag-icons": "^1.5.19",
"install": "^0.13.0",
"next": "15.4.4",
"next-intl": "^4.3.4",
"npm": "^11.5.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"@portabletext/react": "^8.0.1",
"@sanity/client": "^8.14.1",
"@sanity/image-url": "^2.1.1",
"@sanity/webhook": "^4.0.4",
"country-flag-icons": "^1.6.20",
"next": "^16.4.3",
"next-intl": "^4.14.9",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-icons": "^5.7.0",
"react-photo-sphere-viewer": "^6.2.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.4.4",
"prettier": "^3.6.2",
"typescript": "^5"
"@types/node": "^26.6.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"eslint": "^9.39.5",
"eslint-config-next": "^16.4.3",
"prettier": "^3.9.10",
"typescript": "~6.0.3"
},
"repository": {
"type": "git",
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"release-type": "node",
"include-v-in-tag": false
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import { notFound } from "next/navigation"
// Catch unknown paths below a locale so they render [locale]/not-found.tsx
export default function CatchAll() {
notFound()
}
@@ -5,6 +5,7 @@
}
.backButton {
justify-self: start;
margin-left: 2rem;
}
.article {
display: grid;
@@ -1,15 +1,14 @@
import Layout from "@/app/layout"
import Footer from "@/components/page-sections/footer"
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
import { generateSeoMetadata } from "@/components/seo"
import Back from "@/components/ui/back"
import { urlFor } from "@/lib/imageUrlBuilder"
import { type SanityImage, urlFor } from "@/lib/imageUrlBuilder"
import { getBlogPost } from "@/lib/sanity"
import { PortableText, PortableTextComponents } from "@portabletext/react"
import { Metadata, Viewport } from "next"
import { getLocale } from "next-intl/server"
import { Metadata } from "next"
import type { Locale } from "next-intl"
import { notFound } from "next/navigation"
import Image from "next/image"
import * as styles from "./page.module.css"
@@ -17,14 +16,11 @@ import * as styles from "./page.module.css"
interface BlogPageParams {
params: Promise<{
slug: string
locale: Locale
}>
}
interface ImageValue {
asset?: {
_id: string
url: string
}
type ImageValue = Partial<SanityImage> & {
alt?: string
caption?: string
}
@@ -33,30 +29,49 @@ interface LinkValue {
href: string
}
const SAFE_LINK_PROTOCOLS = ["http:", "https:", "mailto:"]
// Links come from the CMS, so reject schemes like javascript: that would run
// code when clicked. Parsing with URL also catches tricks like leading
// whitespace or mixed case that a simple prefix check would miss
const getSafeHref = (href?: string) => {
if (!href) return null
try {
const { protocol } = new URL(href, "https://uiogaming.no")
return SAFE_LINK_PROTOCOLS.includes(protocol) ? href : null
} catch {
return null
}
}
// Render posts on first visit and cache them, instead of on every request
export async function generateStaticParams() {
return []
}
export async function generateMetadata({
params,
}: BlogPageParams): Promise<Metadata> {
const { slug } = await params
const { slug, locale } = await params
const data = await getBlogPost(slug)
if (!data) {
return {
title: "Blog Post Not Found | UiO Gaming",
}
}
if (!data) notFound()
return generateSeoMetadata({
locale,
path: `/blog/${slug}`,
title: data.title,
description: data.excerpt,
imageURL: data.mainImage?.asset?.url,
// Cropped around the hotspot to the size link previews expect
imageURL: data.mainImage?.asset
? urlFor(data.mainImage).width(1200).height(630).url()
: undefined,
author: data.author?.name,
publishedTime: data._createdAt,
})
}
export function generateViewport(): Viewport {
return generateSeoViewport()
}
const components: PortableTextComponents = {
types: {
image: ({ value }: { value: ImageValue }) => {
@@ -64,10 +79,13 @@ const components: PortableTextComponents = {
return (
<div className={styles.imageContainer}>
<Image
src={urlFor(value).url()}
// Only a width, so the image keeps its shape and just the crop
// applies. fit("max") avoids upscaling small images
src={urlFor(value).width(1200).fit("max").url()}
alt={value.alt || "Blog image"}
width={800}
height={400}
sizes="(max-width: 640px) 100vw, 640px"
style={{ width: "100%", height: "auto" }}
/>
{value.caption && (
@@ -84,28 +102,33 @@ const components: PortableTextComponents = {
}: {
children?: React.ReactNode
value?: LinkValue
}) => (
<a
href={value?.href ?? "#"}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
{children}
</a>
),
}) => {
const href = getSafeHref(value?.href)
if (!href) return <>{children}</>
// Only links to other sites open in a new tab, not mailto: or links
// within this site
const isExternal = /^https?:\/\//i.test(href)
return (
<a
href={href}
className={styles.link}
{...(isExternal && { target: "_blank", rel: "noopener noreferrer" })}
>
{children}
</a>
)
},
},
}
const BlogPost = async ({ params }: BlogPageParams) => {
const { slug }: { slug: string } = await params
const locale = await getLocale()
const { slug, locale } = await params
const data = await getBlogPost(slug)
if (!data) {
throw new Error("Blog post not found")
}
if (!data) return notFound()
const createdAt = new Date(data._createdAt).toLocaleDateString(locale, {
day: "numeric",
@@ -114,7 +137,7 @@ const BlogPost = async ({ params }: BlogPageParams) => {
})
return (
<Layout>
<>
<div className={styles.container}>
<div className={styles.backButton}>
<Back />
@@ -123,10 +146,11 @@ const BlogPost = async ({ params }: BlogPageParams) => {
<header className={styles.header}>
{data.mainImage && (
<Image
src={urlFor(data.mainImage).url()}
src={urlFor(data.mainImage).width(1600).fit("max").url()}
alt={data.title}
width={800}
height={400}
sizes="(max-width: 850px) 100vw, 850px"
className={styles.headerImage}
/>
)}
@@ -136,8 +160,8 @@ const BlogPost = async ({ params }: BlogPageParams) => {
{data.author?.image && (
<Image
className="circular"
alt={`Forfatteren av innlegget, ${data.author.name}`}
src={urlFor(data.author.image).width(50).height(50).url()}
alt={data.author.name}
src={urlFor(data.author.image).width(100).height(100).url()}
width={50}
height={50}
/>
@@ -154,7 +178,7 @@ const BlogPost = async ({ params }: BlogPageParams) => {
</article>
</div>
<Footer />
</Layout>
</>
)
}
@@ -2,7 +2,8 @@
display: grid;
place-content: center;
min-height: 100vh;
margin: 1rem;
padding-block: 6rem;
padding-inline: 1rem;
}
.event {
+77
View File
@@ -0,0 +1,77 @@
import { generateSeoMetadata } from "@/components/seo"
import Back from "@/components/ui/back"
import { formatEventDate } from "@/lib/dates"
import { getEvent } from "@/lib/sanity"
import { Metadata } from "next"
import type { Locale } from "next-intl"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { FaCalendarAlt, FaMapMarkerAlt } from "react-icons/fa"
import * as styles from "./page.module.css"
interface EventPageParams {
params: Promise<{
slug: string
locale: Locale
}>
}
// Render events on first visit and cache them, instead of on every request
export async function generateStaticParams() {
return []
}
export async function generateMetadata({
params,
}: EventPageParams): Promise<Metadata> {
const { slug, locale } = await params
const data = await getEvent(slug)
if (!data) notFound()
const t = await getTranslations({ locale, namespace: "metaTags" })
return generateSeoMetadata({
locale,
path: `/event/${slug}`,
title: data.title,
description: [
`${t("time")}: ${formatEventDate(data.date, locale)}`,
data.location && `${t("location")}: ${data.location}`,
data.description && `\n${data.description}`,
]
.filter(Boolean)
.join("\n"),
})
}
const Event = async ({ params }: EventPageParams) => {
const { slug, locale } = await params
const data = await getEvent(slug)
if (!data) return notFound()
return (
<div className={styles.container}>
<Back />
<div className={styles.event}>
<h1>{data.title}</h1>
<div className={styles.metadata}>
<FaMapMarkerAlt />
<p>{data.location}</p>
</div>
<div className={styles.metadata}>
<FaCalendarAlt />
<p>{formatEventDate(data.date, locale)}</p>
</div>
<p className={styles.description}>{data.description}</p>
</div>
</div>
)
}
export default Event
+68
View File
@@ -0,0 +1,68 @@
import "@/app/globals.css"
import { hasLocale, NextIntlClientProvider } from "next-intl"
import type { Metadata, Viewport } from "next"
import { getMessages } from "next-intl/server"
import { notFound } from "next/navigation"
import LanguageSelector from "@/components/ui/languageSelector"
import { SITE_URL, THEME_COLOR } from "@/components/seo"
import UmamiAnalytics from "@/components/umamiAnalytics"
import { routing } from "@/i18n/routing"
// Namespaces used by client components. Server components read their
// translations on the server, so only these need to be sent to the browser.
// Add to this list when a client component starts using a new namespace
const CLIENT_NAMESPACES = ["about", "buttons", "common", "office"]
export const metadata: Metadata = {
// Resolves relative URLs in metadata, like Open Graph images and alternates
metadataBase: new URL(SITE_URL),
title: "UiO Gaming",
description:
"En hobbyforening som ønsker å skape et sosialt miljø rundt gaming og e-sport på UiO!",
icons: {
icon: "/logo_2024_neon_pink.png",
shortcut: "/logo_2024_neon_pink.png",
apple: "/logo_2024_neon_pink.png",
},
}
export const viewport: Viewport = {
themeColor: THEME_COLOR,
colorScheme: "dark",
}
export function generateStaticParams() {
return routing.locales.map(locale => ({ locale }))
}
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale } = await params
// [locale] matches any first path segment, e.g. /favicon.txt or /de
if (!hasLocale(routing.locales, locale)) notFound()
const messages = await getMessages()
const clientMessages = Object.fromEntries(
CLIENT_NAMESPACES.map(namespace => [namespace, messages[namespace]])
)
return (
<html lang={locale}>
<body>
<NextIntlClientProvider locale={locale} messages={clientMessages}>
<LanguageSelector />
<main>{children}</main>
<UmamiAnalytics />
</NextIntlClientProvider>
</body>
</html>
)
}
+9
View File
@@ -0,0 +1,9 @@
import NotFound from "@/components/page-sections/notFound"
import { getLocale } from "next-intl/server"
export default async function LocaleNotFound() {
const locale = await getLocale()
return <NotFound locale={locale} />
}
+15 -10
View File
@@ -1,5 +1,3 @@
import Layout from "@/app/layout"
import About from "@/components/page-sections/about"
import BoardMembers from "@/components/page-sections/boardMembers"
import Contact from "@/components/page-sections/contact"
@@ -7,23 +5,30 @@ import Events from "@/components/page-sections/events"
import Footer from "@/components/page-sections/footer"
import Hero from "@/components/page-sections/hero"
import Office from "@/components/page-sections/office"
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
import { generateSeoMetadata } from "@/components/seo"
import { Metadata, Viewport } from "next"
import { Metadata } from "next"
import type { Locale } from "next-intl"
export const revalidate = 3600 // 1 hour
export function generateMetadata(): Metadata {
return generateSeoMetadata({})
interface HomePageParams {
params: Promise<{
locale: Locale
}>
}
export function generateViewport(): Viewport {
return generateSeoViewport()
export async function generateMetadata({
params,
}: HomePageParams): Promise<Metadata> {
const { locale } = await params
return generateSeoMetadata({ locale, path: "/" })
}
export default function Home() {
return (
<Layout>
<>
<Hero />
<About />
<Events />
@@ -31,6 +36,6 @@ export default function Home() {
<BoardMembers />
<Contact />
<Footer />
</Layout>
</>
)
}
+41 -11
View File
@@ -1,15 +1,43 @@
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook"
import { revalidateTag } from "next/cache"
import { NextRequest, NextResponse } from "next/server"
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret")
// Expire immediately so the first visit after a publish gets fresh content.
// The "max" profile would serve the stale version once more while refetching
const expireTag = (tag: string) => revalidateTag(tag, { expire: 0 })
if (secret !== process.env.SANITY_REVALIDATE_SECRET) {
return NextResponse.json({ message: "Invalid secret" }, { status: 401 })
export async function POST(request: NextRequest) {
const secret = process.env.SANITY_REVALIDATE_SECRET
if (!secret) {
console.error("SANITY_REVALIDATE_SECRET is not set")
return NextResponse.json(
{ message: "Webhook is not configured" },
{ status: 500 }
)
}
// Sanity signs the raw body with the webhook secret, so verify it before
// parsing
const rawBody = await request.text()
const signature = request.headers.get(SIGNATURE_HEADER_NAME) ?? ""
// Throws on a malformed signature header instead of returning false
const isValid = await isValidSignature(rawBody, signature, secret).catch(
() => false
)
if (!isValid) {
return NextResponse.json({ message: "Invalid signature" }, { status: 401 })
}
let body
try {
body = JSON.parse(rawBody)
} catch {
return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 })
}
try {
const body = await request.json()
const { _type, slug } = body
console.log(
@@ -17,19 +45,21 @@ export async function POST(request: NextRequest) {
)
if (_type === "post") {
revalidateTag("blog-posts")
expireTag("blog-posts")
if (slug?.current) {
revalidateTag(`post-${slug.current}`)
expireTag(`post-${slug.current}`)
}
} else if (_type === "event") {
revalidateTag("events")
expireTag("events")
if (slug?.current) {
revalidateTag(`event-${slug.current}`)
expireTag(`event-${slug.current}`)
}
} else if (_type === "author") {
expireTag("authors")
// Blog posts show the author's name and image
expireTag("blog-posts")
}
revalidateTag("home")
return NextResponse.json({
revalidated: true,
now: Date.now(),
-98
View File
@@ -1,98 +0,0 @@
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
import Back from "@/components/ui/back"
import { getEvent } from "@/lib/sanity"
import { Metadata, Viewport } from "next"
import { getLocale } from "next-intl/server"
import { FaCalendarAlt, FaMapMarkerAlt } from "react-icons/fa"
import * as styles from "./page.module.css"
interface EventPageParams {
params: Promise<{
slug: string
}>
}
export async function generateMetadata({
params,
}: EventPageParams): Promise<Metadata> {
const { slug } = await params
const data = await getEvent(slug)
if (!data) {
return {
title: "Event Not Found | UiO Gaming",
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const options: Intl.DateTimeFormatOptions = {
weekday: "long",
month: "long",
year: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "Europe/Oslo",
}
return date.toLocaleString("no", options)
}
return generateSeoMetadata({
title: data.title,
description: `Tid: ${formatDate(data.date)}\nSted: ${data.location}\n\n${data.description}`,
})
}
export function generateViewport(): Viewport {
return generateSeoViewport()
}
const Event = async ({ params }: EventPageParams) => {
const { slug }: { slug: string } = await params
const locale = await getLocale()
const data = await getEvent(slug)
if (!data) {
throw new Error("Event not found")
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const options: Intl.DateTimeFormatOptions = {
weekday: "long",
month: "long",
year: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "Europe/Oslo",
}
return date.toLocaleString(locale, options)
}
return (
<div className={styles.container}>
<Back />
<div className={styles.event}>
<h1>{data.title}</h1>
<div className={styles.metadata}>
<FaMapMarkerAlt />
<p>{data.location}</p>
</div>
<div className={styles.metadata}>
<FaCalendarAlt />
<p>{formatDate(data.date)}</p>
</div>
<p className={styles.description}>{data.description}</p>
</div>
</div>
)
}
export default Event
-43
View File
@@ -1,43 +0,0 @@
import Layout from "@/app/layout"
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
import { Metadata, Viewport } from "next"
import { useTranslations } from "next-intl"
import Link from "next/link"
import * as styles from "./global-not-found.module.css"
export function generateMetadata(): Metadata {
return generateSeoMetadata({
title: "404: Ikke funnet",
description: "Uh oh :(",
})
}
export function generateViewport(): Viewport {
return generateSeoViewport()
}
const NotFoundPage = () => {
const t = useTranslations("404")
return (
<Layout>
<div className={styles.container}>
<h1 className={styles.centeredText}>{t("title")}</h1>
<p className={styles.centeredText}>
{t("text")}{" "}
<span role="img" aria-label="eyes">
👀
</span>
</p>
<Link href="/" className={`${styles.centeredText} ${styles.homeLink}`}>
{t("back")}
</Link>
</div>
</Layout>
)
}
export default NotFoundPage
-48
View File
@@ -1,48 +0,0 @@
import "@/app/globals.css"
import { LocaleProvider } from "@/components/localeProvider"
import UmamiAnalytics from "@/components/umamiAnalytics"
import { getAnalyticsConfig } from "@/lib/analyticsConfig"
import type { Metadata } from "next"
import { getLocale } from "next-intl/server"
export const metadata: Metadata = {
title: "UiO Gaming",
description:
"En hobbyforening som ønsker skape et sosialt miljø rundt gaming og e-sport på UiO!",
icons: {
icon: "/logo_2024_neon_pink.png",
shortcut: "/logo_2024_neon_pink.png",
apple: "/logo_2024_neon_pink.png",
},
}
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
const locale = (await getLocale()) as "no" | "en"
const messages = (await import(`../../messages/${locale}.json`)).default // TODO: duplicate of request.ts?
const analyticsConfig = getAnalyticsConfig()
return (
<html lang={locale}>
<body>
<LocaleProvider locale={locale} messages={messages}>
<main>{children}</main>
{analyticsConfig?.enabled && (
<UmamiAnalytics
websiteId={analyticsConfig.websiteId}
src={analyticsConfig.src}
domains={analyticsConfig.domains}
/>
)}
</LocaleProvider>
</body>
</html>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { SITE_URL } from "@/components/seo"
import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: "/api/",
},
sitemap: `${SITE_URL}/sitemap.xml`,
}
}
+39
View File
@@ -0,0 +1,39 @@
import { getLocalizedPath, SITE_URL } from "@/components/seo"
import { routing } from "@/i18n/routing"
import { getSitemapDocuments } from "@/lib/sanity"
import type { MetadataRoute } from "next"
export const revalidate = 3600 // 1 hour
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const { posts, events } = await getSitemapDocuments()
const pages: { path: string; lastModified?: string }[] = [
{ path: "/" },
...posts.map(post => ({
path: `/blog/${post.slug}`,
lastModified: post._updatedAt,
})),
...events.map(event => ({
path: `/event/${event.slug}`,
lastModified: event._updatedAt,
})),
]
// One entry per language version, each linking to the other versions
return pages.flatMap(({ path, lastModified }) => {
const languages = Object.fromEntries(
routing.locales.map(locale => [
locale,
`${SITE_URL}${getLocalizedPath(locale, path)}`,
])
)
return routing.locales.map(locale => ({
url: languages[locale],
lastModified,
alternates: { languages },
}))
})
}
-24
View File
@@ -1,24 +0,0 @@
.button {
display: flex;
gap: 0.25rem;
position: fixed;
top: 0.5rem;
right: 0.5rem;
z-index: 0;
background: pink;
color: black;
border: 1px solid black;
font-size: 1.1rem;
border-radius: 0.5rem;
padding-inline: 0.5em;
}
.button:hover {
background: #ff7b7b;
color: white;
}
.flag {
width: 1.2rem;
}
-63
View File
@@ -1,63 +0,0 @@
"use client"
import { GB, NO } from "country-flag-icons/react/3x2"
import { NextIntlClientProvider } from "next-intl"
import { ReactNode, useEffect, useState } from "react"
import * as styles from "./localeProvider.module.css"
type LocaleProviderProps = {
locale: "no" | "en"
messages: Record<string, string>
children: ReactNode
}
export function LocaleProvider({
locale: serverLocale,
messages,
children,
}: LocaleProviderProps) {
const [locale, setLocale] = useState<"no" | "en">(serverLocale)
const [loadedMessages, setLoadedMessages] =
useState<Record<string, string>>(messages)
useEffect(() => {
const match = document.cookie.match(/(?:^|; )locale=([^;]+)/)
const saved = match ? decodeURIComponent(match[1]) : null
if (saved && saved !== locale) {
setLocale(saved as "no" | "en")
import(`../../messages/${saved}.json`).then(module => {
setLoadedMessages(module.default)
})
}
}, [locale])
const changeLocale = (newLocale: "no" | "en") => {
setLocale(newLocale)
document.cookie = `locale=${encodeURIComponent(
newLocale
)}; path=/; max-age=31536000; SameSite=Lax`
window.location.reload()
}
return (
<NextIntlClientProvider locale={locale} messages={loadedMessages}>
{children}
<div className={styles.languageSwitcher}>
{locale === "no" ? (
<button onClick={() => changeLocale("en")} className={styles.button}>
<GB className={styles.flag} title="English flag" />
English
</button>
) : (
<button onClick={() => changeLocale("no")} className={styles.button}>
<NO className={styles.flag} title="Norwegian flag" />
Norsk
</button>
)}
</div>
</NextIntlClientProvider>
)
}
+4 -4
View File
@@ -1,6 +1,6 @@
import DiscordButton from "@components/ui/discordButton"
import MemberButton from "@components/ui/memberButton"
import OtherButton from "@components/ui/otherButton"
import DiscordButton from "@/components/ui/discordButton"
import MemberButton from "@/components/ui/memberButton"
import OtherButton from "@/components/ui/otherButton"
import { useTranslations } from "next-intl"
import Image from "next/image"
@@ -12,7 +12,7 @@ const About = () => {
const tCommon = useTranslations("common")
return (
<section>
<section id="about">
<div className={styles.container}>
<div className={"no-mobile " + styles.image}>
<Image
+9 -39
View File
@@ -1,5 +1,5 @@
import { urlFor } from "@/lib/imageUrlBuilder"
import { sanityClient } from "@/lib/sanity"
import { type SanityImage, urlFor } from "@/lib/imageUrlBuilder"
import { getBoardMembers } from "@/lib/sanity"
import { getLocale, getTranslations } from "next-intl/server"
import Image from "next/image"
@@ -8,19 +8,13 @@ import * as styles from "./boardMembers.module.css"
interface BoardMember {
_id: string
boardMember: boolean
contactInfo?: string
name: string
role?: {
no: string
en: string
}
image?: {
asset?: {
_id: string
url: string
}
}
image: SanityImage | null
bio?: {
no: string
en: string
@@ -29,34 +23,12 @@ interface BoardMember {
const BoardMembers = async () => {
const t = await getTranslations("boardMembers")
const locale = (await getLocale()) as "no" | "en"
const locale = await getLocale()
const query = `
*[_type == "author" && boardMember == true] | order(orderRank asc) {
_id,
boardMember,
contactInfo,
name,
role {
no,
en
},
image {
asset->{
_id,
url
}
},
bio {
no,
en
}
}
`
const data: BoardMember[] = await sanityClient.fetch(query)
const data: BoardMember[] = await getBoardMembers()
return (
<section>
<section id="board-members">
<h2>{t("title")}</h2>
<div className={styles.container}>
{data.map((member: BoardMember) => (
@@ -67,11 +39,9 @@ const BoardMembers = async () => {
>
<Image
src={
member.image?.asset?.url
? urlFor(member.image.asset.url)
.width(512)
.height(512)
.url()
member.image?.asset
? // Cropped to a square around the hotspot
urlFor(member.image).width(512).height(512).url()
: "/person.png"
}
width={256}
+2 -4
View File
@@ -17,14 +17,12 @@ const Contact = () => {
const t = useTranslations("contact")
return (
<section>
<section id="contact">
<h2 className="section-center-header">{t("title")}</h2>
<div className={styles.container}>
<div className={styles.item}>
<FaAt />
<a href="mailto:styret@uiogaming.no" target="_blank" rel="noreferrer">
{t("email")}
</a>
<a href="mailto:styret@uiogaming.no">{t("email")}</a>
</div>
<div className={styles.item}>
<FaDiscord />
+27 -35
View File
@@ -1,4 +1,5 @@
import { getAllEvents } from "@/lib/sanity"
import { formatEventDate } from "@/lib/dates"
import { getUpcomingEvents } from "@/lib/sanity"
import { getLocale, getTranslations } from "next-intl/server"
import Image from "next/image"
@@ -15,42 +16,42 @@ interface Event {
slug: {
current: string
}
description: string
description: string | null
}
const DESCRIPTION_PREVIEW_LENGTH = 70
const Events = async () => {
const t = await getTranslations("events")
const tCommon = await getTranslations("common")
const locale = await getLocale()
const data: Event[] = await getAllEvents()
const events: Event[] = await getUpcomingEvents(2)
const currentDate = new Date()
const filteredEvents = data
.filter((event: Event) => {
return currentDate <= new Date(event.date)
})
.slice(0, 2)
const previewDescription = (description: string | null) => {
if (!description) return null
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const options: Intl.DateTimeFormatOptions = {
weekday: "long",
month: "long",
year: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "Europe/Oslo",
}
// Count graphemes so emoji and accented characters are never cut in half
const graphemes = Array.from(new Intl.Segmenter().segment(description))
if (graphemes.length <= DESCRIPTION_PREVIEW_LENGTH) return description
return date.toLocaleString(locale, options)
const preview = graphemes
.slice(0, DESCRIPTION_PREVIEW_LENGTH)
.map(({ segment }) => segment)
.join("")
.trimEnd()
return (
<>
{preview}... <span className={styles.readMore}>{t("readMore")}</span>
</>
)
}
const mapEvents = (events: Event[]) => {
if (events.length !== 0) {
return events.map((event: Event) => (
<Link key={event._id} href={"/event/" + event.slug.current}>
<Link key={event._id} href={`/${locale}/event/${event.slug.current}`}>
<article className={styles.card}>
<h3>{event.title}</h3>
<div>
@@ -60,19 +61,10 @@ const Events = async () => {
</div>
<div className={styles.metadata}>
<FaCalendarAlt />
<p>{formatDate(event.date)}</p>
<p>{formatEventDate(event.date, locale)}</p>
</div>
</div>
<p>
{event.description.length < 70 ? (
event.description
) : (
<>
{event.description.substring(0, 70)}...{" "}
<span className={styles.readMore}>{t("readMore")}</span>
</>
)}
</p>
<p>{previewDescription(event.description)}</p>
</article>
</Link>
))
@@ -89,10 +81,10 @@ const Events = async () => {
}
return (
<section>
<section id="events">
<div className={styles.container}>
<h2 className={styles.sectionHeader}>{t("title")}</h2>
<div className={styles.events}>{mapEvents(filteredEvents)}</div>
<div className={styles.events}>{mapEvents(events)}</div>
<div className={"no-mobile " + styles.image}>
<Image
src="/event.jpg"
+29
View File
@@ -0,0 +1,29 @@
import type { Locale } from "next-intl"
import { getTranslations } from "next-intl/server"
import Link from "next/link"
import * as styles from "./notFound.module.css"
const NotFound = async ({ locale }: { locale: Locale }) => {
const t = await getTranslations({ locale, namespace: "404" })
return (
<div className={styles.container}>
<h1 className={styles.centeredText}>{t("title")}</h1>
<p className={styles.centeredText}>
{t("text")}{" "}
<span role="img" aria-label="eyes">
👀
</span>
</p>
<Link
href={`/${locale}`}
className={`${styles.centeredText} ${styles.homeLink}`}
>
{t("back")}
</Link>
</div>
)
}
export default NotFound
+25 -11
View File
@@ -1,25 +1,39 @@
.container {
display: grid;
gap: 2rem;
justify-content: center;
place-items: center;
}
.image {
width: 100%;
aspect-ratio: 1 / 1;
}
.panoramaContainer {
width: 100%;
height: 400px;
background: #000000;
border-radius: 8px;
overflow: hidden;
position: relative;
aspect-ratio: 4 / 3;
max-height: 75vh;
}
.panoramaContainer > div {
width: 100% !important;
height: 100% !important;
}
.panoramaFallback {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.pcRoomImage {
width: 100%;
height: auto;
max-width: 60ch;
border-radius: 8px;
object-fit: contain;
}
.map {
width: 100%;
aspect-ratio: 4 / 3;
min-height: 400px;
max-height: 75vh;
border-radius: 8px;
}
+55 -17
View File
@@ -1,36 +1,74 @@
"use client"
import { useSyncExternalStore } from "react"
import { useTranslations } from "next-intl"
import Image from "next/image"
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"
import * as styles from "./office.module.css"
let webGL2Support: boolean | undefined
// Checked once, since creating a WebGL context is not free
const getWebGL2Support = () => {
webGL2Support ??= Boolean(
document.createElement("canvas").getContext("webgl2")
)
return webGL2Support
}
// WebGL support never changes, so there is nothing to subscribe to
const subscribe = () => () => {}
const Office = () => {
const t = useTranslations("office")
// null on the server, which renders the fallback image
const supportsWebGL2 = useSyncExternalStore(
subscribe,
getWebGL2Support,
() => null
)
return (
<section>
<section id="office">
<h2>{t("title")}</h2>
<div className={styles.container}>
<p>{t("text")}</p>
<p>{t("tip")}</p>
<div
style={{
width: "100%",
height: "500px",
background: "#000000",
borderRadius: "8px",
overflow: "hidden",
}}
>
<ReactPhotoSphereViewer
src="/3D_office_2024_11_03.jpg"
height="100%"
width="100%"
defaultZoomLvl={0}
navbar={true}
/>
<div className={styles.panoramaContainer}>
{supportsWebGL2 === true ? (
<ReactPhotoSphereViewer
width={"100%"}
height={"100%"}
src="/3D_office_2025_08_20.jpg"
defaultZoomLvl={0}
navbar={true}
/>
) : (
<Image
src="/3D_office_2025_08_20.jpg"
alt={t("title")}
className={styles.panoramaFallback}
width={1200}
height={900}
/>
)}
</div>
<Image
src="/pc_room.jpg"
alt={t("pcRoomImageAlt")}
className={styles.pcRoomImage}
width={666}
height={500}
/>
<iframe
width={"100%"}
className={styles.map}
src="https://use.mazemap.com/embed.html#v=1&config=uio&zlevel=3&center=10.712463,59.932424&zoom=17&campusid=797&sharepoitype=poi&sharepoi=1000986035&utm_medium=iframe"
allow="geolocation"
loading="lazy"
title="Map by MazeMap"
></iframe>
</div>
</section>
)
+48 -22
View File
@@ -1,34 +1,68 @@
import { Metadata, Viewport } from "next"
import { Metadata } from "next"
import type { Locale } from "next-intl"
import { getTranslations } from "next-intl/server"
import { routing } from "@/i18n/routing"
export const SITE_URL = "https://uiogaming.no"
export const THEME_COLOR = "#7d36e7"
/** Adds the locale prefix to a path, e.g. ("en", "/event/x") -> "/en/event/x" */
export const getLocalizedPath = (locale: Locale, path: string) =>
path === "/" ? `/${locale}` : `/${locale}${path}`
type SeoProps = {
locale: Locale
/** Path without the locale prefix, e.g. "/" or "/event/some-slug" */
path: string
title?: string
description?: string
imageURL?: string
author?: string
/** Marks the page as an article, e.g. a blog post */
publishedTime?: string
}
export function generateSeoMetadata({
export async function generateSeoMetadata({
locale,
path,
title,
description,
imageURL,
author,
}: SeoProps): Metadata {
publishedTime,
}: SeoProps): Promise<Metadata> {
const t = await getTranslations({ locale, namespace: "metaTags" })
const defaultTitle = "UiO Gaming"
const metaDescription =
description ||
"En hobbyforening som ønsker skape et sosialt miljö rundt gaming og e-sport på UiO!"
const siteUrl = "https://uiogaming.no"
const themeColor = "#7d36e7"
const metaDescription = description || t("description")
return {
title: title ? `${title} | ${defaultTitle}` : defaultTitle,
description: metaDescription,
authors: author ? [{ name: author }] : [{ name: "UiO Gaming" }],
// Tells search engines that the Norwegian and English versions are the
// same page. The unprefixed path redirects based on the browser language
alternates: {
canonical: getLocalizedPath(locale, path),
languages: {
...Object.fromEntries(
routing.locales.map(pageLocale => [
pageLocale,
getLocalizedPath(pageLocale, path),
])
),
"x-default": path,
},
},
openGraph: {
title: title || defaultTitle,
description: metaDescription,
type: "website",
locale: "no",
...(publishedTime
? { type: "article", publishedTime, authors: author && [author] }
: { type: "website" }),
locale: locale,
url: getLocalizedPath(locale, path),
siteName: defaultTitle,
images: imageURL
? [
@@ -41,7 +75,7 @@ export function generateSeoMetadata({
]
: [
{
url: `${siteUrl}/logo_2024_neon_trans.png`,
url: "/logo_2024_neon_trans.png",
alt: defaultTitle,
},
],
@@ -50,20 +84,12 @@ export function generateSeoMetadata({
card: imageURL ? "summary_large_image" : "summary",
title: title || defaultTitle,
description: metaDescription,
images: imageURL ? [imageURL] : [`${siteUrl}/logo_2024_neon_trans.png`],
images: imageURL ? [imageURL] : ["/logo_2024_neon_trans.png"],
},
other: {
"theme-color": themeColor,
"msapplication-TileColor": themeColor,
"msapplication-navbutton-color": themeColor,
"msapplication-TileColor": THEME_COLOR,
"msapplication-navbutton-color": THEME_COLOR,
"apple-mobile-web-app-status-bar-style": "black-translucent",
},
}
}
export function generateSeoViewport(): Viewport {
return {
themeColor: "#7d36e7",
colorScheme: "dark",
}
}
+1
View File
@@ -9,6 +9,7 @@
background: none;
border: none;
color: var(--link-color);
cursor: pointer;
}
.button:hover,
.button *:hover {
+17 -9
View File
@@ -3,22 +3,30 @@
import { useTranslations } from "next-intl"
import { FaArrowLeft } from "react-icons/fa"
import { useRouter } from "@/i18n/navigation"
import * as styles from "./back.module.css"
// document.referrer only reflects the last full page load, so it misses
// client-side navigations within the site. Only used where the Navigation
// API isn't supported
const cameFromThisSite = () =>
document.referrer !== "" &&
new URL(document.referrer).origin === window.location.origin
const Back = () => {
const router = useRouter()
const t = useTranslations("buttons")
const handleBack = () => {
const currentDomain = window.location.hostname
const referrer = document.referrer
// The Navigation API only lists this site's own history entries, so
// canGoBack is true exactly when the previous page was on this site
const canGoBack = window.navigation?.canGoBack ?? cameFromThisSite()
const isSameDomain =
referrer && new URL(referrer).hostname === currentDomain
if (window.history.length > 1 && isSameDomain) {
window.history.back()
if (canGoBack) {
router.back()
} else {
window.location.href = "/"
router.push("/")
}
}
@@ -28,7 +36,7 @@ const Back = () => {
onClick={handleBack}
>
<FaArrowLeft />
<p>{t("back")}</p>
<span>{t("back")}</span>
</button>
)
}
@@ -0,0 +1,30 @@
.languageSwitcher {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.35);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.button:hover {
background: rgba(0, 0, 0, 1);
}
.flag {
width: 20px;
height: 15px;
border-radius: 2px;
}
+40
View File
@@ -0,0 +1,40 @@
"use client"
import { GB, NO } from "country-flag-icons/react/3x2"
import { type Locale, useLocale } from "next-intl"
import { usePathname, useRouter } from "@/i18n/navigation"
import * as styles from "./languageSelector.module.css"
const LanguageSelector = () => {
const locale = useLocale()
const router = useRouter()
// Pathname without the locale prefix
const pathname = usePathname()
const changeLocale = (newLocale: Locale) => {
// Read at click time, since useSearchParams() would opt pages out of
// static rendering
const { search, hash } = window.location
router.push(`${pathname}${search}${hash}`, { locale: newLocale })
}
return (
<div className={styles.languageSwitcher}>
{locale === "no" ? (
<button onClick={() => changeLocale("en")} className={styles.button}>
<GB className={styles.flag} aria-hidden="true" />
<span lang="en">English</span>
</button>
) : (
<button onClick={() => changeLocale("no")} className={styles.button}>
<NO className={styles.flag} aria-hidden="true" />
<span lang="no">Norsk</span>
</button>
)}
</div>
)
}
export default LanguageSelector
+9 -7
View File
@@ -36,15 +36,13 @@
border-radius: 10px;
}
.dropdownButton {
.dropdownLink {
display: block;
width: 100%;
margin-top: 4px;
color: black;
background-color: #cccccc;
border: none;
border-radius: 999em !important;
cursor: pointer;
outline: none;
border-radius: 10px;
border-radius: 999em;
text-align: left;
font-size: 1.6rem;
box-shadow: var(--shadow);
@@ -52,7 +50,11 @@
padding-right: 2em;
}
.dropdownButton:hover {
.dropdownLink:first-child {
margin-top: 8px;
}
.dropdownLink:hover {
color: black;
background-color: #e6e6e6;
}
+62 -13
View File
@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useEffect, useId, useRef, useState } from "react"
import { useLocale, useTranslations } from "next-intl"
import * as styles from "./otherButton.module.css"
@@ -11,7 +11,37 @@ const OtherButton = () => {
const locale = useLocale()
const [isOpen, setIsOpen] = useState(false)
const toggleDropdown = () => setIsOpen(!isOpen)
const toggleDropdown = () => setIsOpen(open => !open)
const containerRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const dropdownId = useId()
// Close when clicking outside the dropdown or pressing Escape
useEffect(() => {
if (!isOpen) return
const handlePointerDown = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false)
buttonRef.current?.focus()
}
}
document.addEventListener("pointerdown", handlePointerDown)
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("pointerdown", handlePointerDown)
document.removeEventListener("keydown", handleKeyDown)
}
}, [isOpen])
const statutesUrl =
locale === "en"
@@ -28,22 +58,41 @@ const OtherButton = () => {
]
return (
<div className={styles.dropdownContainer}>
<button onClick={toggleDropdown} className={styles.button}>
<div
ref={containerRef}
className={styles.dropdownContainer}
// Close when keyboard focus moves out of the dropdown
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsOpen(false)
}
}}
>
<button
ref={buttonRef}
onClick={toggleDropdown}
className={styles.button}
aria-expanded={isOpen}
aria-controls={dropdownId}
>
{t("other")}{" "}
<span className={isOpen ? styles.arrowUp : styles.arrowDown}></span>
<span
className={isOpen ? styles.arrowUp : styles.arrowDown}
aria-hidden="true"
></span>
</button>
{isOpen && (
<div className={styles.dropdown}>
{links.map((link, index) => (
<button
key={index}
onClick={() => window.open(link.url, "_blank")}
className={styles.dropdownButton}
style={{ marginTop: index === 0 ? "8px" : "4px" }}
<div id={dropdownId} className={styles.dropdown}>
{links.map(link => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className={styles.dropdownLink}
>
{link.label}
</button>
</a>
))}
</div>
)}
+9 -37
View File
@@ -1,49 +1,21 @@
"use client"
import Script from "next/script"
interface UmamiAnalyticsProps {
websiteId: string
src?: string
domains?: string[]
}
// Umami ignores visits from other domains, so local and preview deployments
// aren't counted
const DOMAINS = ["uiogaming.no", "www.uiogaming.no"]
export default function UmamiAnalytics({
websiteId,
src = "https://analytics.umami.is/script.js",
domains,
}: UmamiAnalyticsProps) {
if (!websiteId) {
console.warn("Umami: Website ID is required")
return null
}
export default function UmamiAnalytics() {
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID
const src =
process.env.NEXT_PUBLIC_UMAMI_SRC || "https://analytics.umami.is/script.js"
// Only load analytics in production or if explicitly enabled
if (process.env.NODE_ENV !== "production") {
return null
}
// Domain validation (client-side check as additional layer)
if (domains && typeof window !== "undefined") {
const currentDomain = window.location.hostname
const isAllowedDomain = domains.some(
domain => currentDomain === domain || currentDomain.endsWith(`.${domain}`)
)
if (!isAllowedDomain) {
console.warn(
`Umami: Domain ${currentDomain} not in allowed list:`,
domains
)
return null
}
}
if (!websiteId || process.env.NODE_ENV !== "production") return null
return (
<Script
src={src}
data-website-id={websiteId}
data-domains={domains?.join(",")}
data-domains={DOMAINS.join(",")}
strategy="afterInteractive"
/>
)
+7
View File
@@ -0,0 +1,7 @@
import { createNavigation } from "next-intl/navigation"
import { routing } from "./routing"
// Locale-aware wrappers around Next's navigation APIs
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing)
+14 -6
View File
@@ -1,12 +1,20 @@
import { hasLocale } from "next-intl"
import { getRequestConfig } from "next-intl/server"
import { cookies } from "next/headers"
import { locale as rootLocale } from "next/root-params"
export default getRequestConfig(async () => {
const cookieStore = await cookies()
const locale = cookieStore.get("locale")?.value || "no"
import { routing } from "./routing"
export default getRequestConfig(async ({ locale }) => {
// An explicit locale (e.g. getTranslations({ locale })) takes precedence.
// The root param can be any first path segment until the layout rejects it,
// so fall back to the default instead of throwing
const requested = locale ?? (await rootLocale())
const resolved = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
locale: resolved,
messages: (await import(`../../messages/${resolved}.json`)).default,
}
})
+14
View File
@@ -0,0 +1,14 @@
import { defineRouting } from "next-intl/routing"
export const routing = defineRouting({
locales: ["no", "en"],
defaultLocale: "no",
localePrefix: "always",
})
// Types getLocale(), useLocale() etc. as "no" | "en" instead of string
declare module "next-intl" {
interface AppConfig {
Locale: (typeof routing.locales)[number]
}
}
-29
View File
@@ -1,29 +0,0 @@
interface AnalyticsConfig {
websiteId: string
src: string
domains: string[]
enabled: boolean
}
export function getAnalyticsConfig(): AnalyticsConfig | null {
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID
const src =
process.env.NEXT_PUBLIC_UMAMI_SRC || "https://analytics.umami.is/script.js"
if (!websiteId) {
return null
}
// Define allowed domains
const domains = ["uiogaming.no", "www.uiogaming.no"]
// Additional environment-based checks
const enabled = process.env.NODE_ENV === "production"
return {
websiteId,
src,
domains,
enabled,
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { Locale } from "next-intl"
const TIME_ZONE = "Europe/Oslo"
// Event dates are usually stored as Oslo wall-clock time without an offset
// ("2026-11-05 17:00"). The Studio's datetime field stores ISO timestamps
// instead, so both formats have to be handled
const LOCAL_DATE_FORMAT = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/
const osloPartsFormatter = new Intl.DateTimeFormat("en-GB", {
timeZone: TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
const getOsloParts = (date: Date) =>
Object.fromEntries(
osloPartsFormatter
.formatToParts(date)
.map(({ type, value }) => [type, value])
)
/** Formats a date as Oslo wall-clock time, e.g. "2026-11-05 17:00" */
export const toOsloDateString = (date: Date) => {
const { year, month, day, hour, minute } = getOsloParts(date)
return `${year}-${month}-${day} ${hour}:${minute}`
}
// How far Oslo's clock is ahead of UTC at the given moment (1 or 2 hours)
const getOsloOffset = (timestamp: number) => {
const { year, month, day, hour, minute } = getOsloParts(new Date(timestamp))
const osloAsUtc = Date.UTC(+year, +month - 1, +day, +hour, +minute)
return osloAsUtc - Math.floor(timestamp / 60_000) * 60_000
}
/**
* Parses an event date. Oslo wall-clock strings are interpreted in Oslo time
* regardless of the server's timezone
*/
export const parseEventDate = (value: string) => {
const match = value.match(LOCAL_DATE_FORMAT)
if (!match) return new Date(value)
const [year, month, day, hour, minute] = match.slice(1).map(Number)
const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute)
// The offset depends on daylight saving time at the resulting moment, so
// guess with the offset at the wall-clock time, then correct
const guess = wallClockAsUtc - getOsloOffset(wallClockAsUtc)
return new Date(wallClockAsUtc - getOsloOffset(guess))
}
/** Formats an event date like "torsdag 5. november 2026 kl. 17:00" */
export const formatEventDate = (value: string, locale: Locale) =>
parseEventDate(value).toLocaleString(locale, {
weekday: "long",
month: "long",
year: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: TIME_ZONE,
})
+11 -3
View File
@@ -1,9 +1,17 @@
import imageUrlBuilder from "@sanity/image-url"
import type { SanityImageSource } from "@sanity/image-url/lib/types/types"
import {
createImageUrlBuilder,
type SanityImageObject,
type SanityImageSource,
} from "@sanity/image-url"
import { sanityClient } from "./sanity"
const builder = imageUrlBuilder(sanityClient)
const builder = createImageUrlBuilder(sanityClient)
// An image field as stored in Sanity: an asset reference plus the crop and
// hotspot chosen in the Studio. Pass the whole object to urlFor, not the
// asset URL, or the crop and hotspot are ignored
export type SanityImage = SanityImageObject
export function urlFor(source: SanityImageSource) {
return builder.image(source)
+80 -16
View File
@@ -1,10 +1,12 @@
import { createClient } from "@sanity/client"
import { toOsloDateString } from "./dates"
export const sanityClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: "2025-02-19",
useCdn: true,
useCdn: false,
})
export async function getBlogPost(slug: string) {
@@ -15,19 +17,9 @@ export async function getBlogPost(slug: string) {
title,
author-> {
name,
image {
asset-> {
_id,
url
}
}
},
mainImage {
asset-> {
_id,
url
}
image
},
mainImage,
excerpt,
body
}
@@ -71,9 +63,20 @@ export async function getEvent(slug: string) {
)
}
export async function getAllEvents() {
export async function getUpcomingEvents(limit: number) {
// Most event dates are Oslo wall-clock strings ("2026-11-05 17:00"), which
// sort correctly as text, so compare them against the current Oslo time in
// the same format. ISO timestamps from the Studio are compared as datetimes
const now = toOsloDateString(new Date())
const query = `
*[_type == "event"] | order(date asc) {
*[
_type == "event" &&
select(
defined(dateTime(date)) => dateTime(date) >= dateTime(now()),
date >= $now
)
] | order(date asc)[0...$limit] {
_id,
title,
location,
@@ -85,7 +88,7 @@ export async function getAllEvents() {
return sanityClient.fetch(
query,
{},
{ now, limit },
{
cache: "force-cache",
next: {
@@ -95,3 +98,64 @@ export async function getAllEvents() {
}
)
}
export async function getBoardMembers() {
const query = `
*[_type == "author" && boardMember == true] | order(orderRank asc) {
_id,
contactInfo,
name,
role {
no,
en
},
image,
bio {
no,
en
}
}
`
return sanityClient.fetch(
query,
{},
{
cache: "force-cache",
next: {
tags: ["authors"],
revalidate: 3600, // 1 hour
},
}
)
}
export async function getSitemapDocuments(): Promise<{
posts: { slug: string; _updatedAt: string }[]
events: { slug: string; _updatedAt: string }[]
}> {
const query = `
{
"posts": *[_type == "post" && defined(slug.current)] {
"slug": slug.current,
_updatedAt
},
"events": *[_type == "event" && defined(slug.current)] {
"slug": slug.current,
_updatedAt
}
}
`
return sanityClient.fetch(
query,
{},
{
cache: "force-cache",
next: {
tags: ["blog-posts", "events"],
revalidate: 3600, // 1 hour
},
}
)
}
+11
View File
@@ -0,0 +1,11 @@
import createMiddleware from "next-intl/middleware"
import { routing } from "./i18n/routing"
export default createMiddleware(routing)
export const config = {
// Match everything except API routes, Next internals and files with an
// extension, so paths without a locale get redirected to one
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
}
+14 -7
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,9 @@
}
],
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@lib/*": ["./src/lib/*"]
"@/*": [
"./src/*"
]
}
},
"include": [
@@ -29,7 +33,10 @@
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"global.d.ts"
"global.d.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules"
]
}