mirror of
https://github.com/UiO-Gaming/uiogaming.no.git
synced 2026-11-05 13:56:47 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a8076813b
|
||
|
|
d9efb8c51f | ||
|
|
a89c2f609d | ||
|
|
15982ed804
|
||
|
|
d20de4b455
|
||
|
|
a09b7ae871
|
||
|
|
1a18096cf6 | ||
|
|
f647dd869d | ||
|
|
28346d6907 | ||
|
|
b60f8a92e1 | ||
|
|
2e54e32ea6 | ||
|
|
115afd4275 | ||
|
|
67641c5719 | ||
|
|
70ba50c515 | ||
|
|
633237f262 | ||
|
|
eab0e0bbab | ||
|
|
7822daa557 | ||
|
|
773d21b709 | ||
|
|
b8e352ba22 | ||
|
|
703ab5a32c | ||
|
|
edf0eed3dc | ||
|
|
1d506a7b4c | ||
|
|
aca183a4a2 | ||
|
|
3a0ad44e06 | ||
|
|
0eb47846e7 | ||
|
|
5c612d995d | ||
|
|
35c175c1ad | ||
|
|
19cda1b817 | ||
|
|
7c344a2c74 | ||
|
|
5b135ce712 | ||
|
|
b84781ebe5 | ||
|
|
aca584791e | ||
|
|
a1729494b4 | ||
|
|
d04afb3e0c | ||
|
|
c997b74d01 | ||
|
|
c5c366884a | ||
|
|
b05ecf757d | ||
|
|
1f82345a2f | ||
|
|
d03278ef0d | ||
|
|
da3d26d16b | ||
|
|
a8b31a5379 | ||
|
|
30da54c78e | ||
|
|
13463d4eeb | ||
|
|
d44fbfa9d7 | ||
|
|
52b8f2b7b7 | ||
|
|
38f1ba69b4 | ||
|
|
39ad3947fd | ||
|
|
14c1403e6d
|
||
|
|
e238eed619 | ||
|
|
08bc43942a | ||
|
|
b0e343a390 | ||
|
|
c7d646362b | ||
|
|
2183175de6 | ||
|
|
bf6f282155 | ||
|
|
8480327a4c | ||
|
|
30abf637ac | ||
|
|
93b2dc4d4e
|
||
|
|
183e519a19
|
||
|
|
1c02352aad
|
||
|
|
abcc39ce6d
|
||
|
|
e9cff6ab84
|
||
|
|
6cc19ea4fb | ||
|
|
743df3691c | ||
|
|
afa4e5580c | ||
|
|
8a7ecf223d | ||
|
|
3c6c55afd9
|
||
|
|
38d015fb20
|
||
|
|
d871eee968
|
||
|
|
2591eacd14 | ||
|
|
017f5154b1
|
||
|
|
1434528e4f
|
||
|
|
0e283cc794
|
||
|
|
e04422acb4
|
||
|
|
30b49ee8c9
|
||
|
|
267bce6422
|
||
|
|
540ce583c9
|
||
|
|
48ff006c13
|
No files matched your search
@@ -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*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -2,3 +2,4 @@
|
||||
package.json
|
||||
package-lock.json
|
||||
public
|
||||
tsconfig.json
|
||||
+1
-1
@@ -7,6 +7,6 @@
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "1.0.1"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Generated
+1989
-3297
File diff suppressed because it is too large.
Load diff
+20
-22
@@ -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 |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -2,7 +2,8 @@
|
||||
display: grid;
|
||||
place-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 1rem;
|
||||
padding-block: 6rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.event {
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
File renamed without changes.
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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¢er=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
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--link-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.button:hover,
|
||||
.button *:hover {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user