mirror of
https://github.com/UiO-Gaming/uiogaming.no.git
synced 2026-11-05 13:56:47 +01:00
Merge branch 'slop/sloppy-fixes'
# Conflicts: # README.md
This commit is contained in:
55 files changed
+1921
-1043
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
|
||||||
@@ -5,6 +5,11 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
# Let a running deploy finish before the next one starts
|
||||||
|
concurrency:
|
||||||
|
group: deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
run_pull:
|
run_pull:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -17,12 +22,13 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/id_ed25519 # This should be redundant but seems to do the trick.
|
chmod 600 ~/.ssh/id_ed25519 # This should be redundant but seems to do the trick.
|
||||||
ssh-keyscan -H ${{ vars.SSH_HOST }} > ~/.ssh/known_hosts
|
ssh-keyscan -H ${{ vars.SSH_HOST }} > ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
# up --build builds the new image while the old container keeps serving,
|
||||||
|
# then swaps the container. Old images are pruned so they don't fill the disk
|
||||||
- name: Connect, pull, build and deploy
|
- 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"
|
run: ssh ${{ secrets.SSH_USER }}@${{ vars.SSH_HOST }} "cd ${{ secrets.WORK_DIR }} && git checkout ${{ vars.MAIN_BRANCH }} && git pull && docker compose up -d --build && docker image prune -f && exit"
|
||||||
|
|
||||||
- name: Clean up
|
- name: Clean up
|
||||||
run: rm -rf ~/.ssh
|
run: rm -rf ~/.ssh
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
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.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
public
|
public
|
||||||
|
tsconfig.json
|
||||||
+1
-1
@@ -7,6 +7,6 @@
|
|||||||
"jsxSingleQuote": false,
|
"jsxSingleQuote": false,
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
"bracketSpacing": true,
|
"bracketSpacing": true,
|
||||||
"jsxBracketSameLine": false,
|
"bracketSameLine": false,
|
||||||
"arrowParens": "avoid"
|
"arrowParens": "avoid"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
".": "1.0.0"
|
||||||
|
}
|
||||||
+9
-2
@@ -1,9 +1,16 @@
|
|||||||
# Huskeliste for bidrag
|
# 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`)
|
- 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
|
- 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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY package-lock.json ./
|
RUN npm ci
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
|
|
||||||
|
FROM node:26-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
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
|
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
|
EXPOSE 9000
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["node", "server.js"]
|
||||||
@@ -47,16 +47,14 @@ MEN
|
|||||||
|
|
||||||
Du må sette opp et webhook i [Sanity sitt dashbord](https://www.sanity.io/manage) under _API → 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?secret=<SANITY_REVALIDATE_SECRET>`
|
- **URL:** `https://uiogaming.no/api/revalidate`
|
||||||
- **HTTP method:** POST
|
- **HTTP method:** POST
|
||||||
- **Projection:** Må minst inneholde `_type` og `slug`, f.eks `{_type, slug}`
|
- **Projection:** Må minst inneholde `_type` og `slug`, f.eks `{_type, slug}`
|
||||||
|
- **Secret:** Samme verdi som `SANITY_REVALIDATE_SECRET` i `.env`
|
||||||
|
|
||||||
Verdien til `secret` på slutten av _URL_-feltet må være lik `SANITY_REVALIDATE_SECRET` i `.env`. Om disse ikke stemmer overens svarer APIet med `401`.
|
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.
|
||||||
|
|
||||||
> [!NOTE]
|
Webhooket oppdaterer blogginnlegg, arrangementer og forfattere med en gang. Annet innhold oppdateres først når cachen går ut (opptil en time).
|
||||||
> Webhook-skjemaet i Sanity har også et eget felt som heter _Secret_. Det brukes til å signere forespørselen med headeren `sanity-webhook-signature`, som dette APIet ikke sjekker. Hemmeligheten må derfor ligge i _URL_-feltet, ikke i _Secret_-feltet.
|
|
||||||
|
|
||||||
Webhooket oppdaterer blogginnlegg og arrangementer 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).
|
Du kan lese mer om webhooks [her](https://www.sanity.io/docs/webhooks).
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -1,7 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
uiogaming.no:
|
uiogaming.no:
|
||||||
container_name: 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:
|
ports:
|
||||||
- 9000:9000
|
- 9000:9000
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+6
-14
@@ -1,16 +1,8 @@
|
|||||||
import { dirname } from "path";
|
import { defineConfig } from "eslint/config"
|
||||||
import { fileURLToPath } from "url";
|
import nextVitals from "eslint-config-next/core-web-vitals"
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import nextTs from "eslint-config-next/typescript"
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
// eslint-config-next already ignores .next, out, build and next-env.d.ts
|
||||||
const __dirname = dirname(__filename);
|
const eslintConfig = defineConfig([...nextVitals, ...nextTs])
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
export default eslintConfig
|
||||||
baseDirectory: __dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eslintConfig = [
|
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
|
||||||
];
|
|
||||||
|
|
||||||
export default eslintConfig;
|
|
||||||
+2
-3
@@ -12,8 +12,7 @@
|
|||||||
"description": "A student organisation that aims to create an inclusive environment around gaming and e-sports for the students at UiO"
|
"description": "A student organisation that aims to create an inclusive environment around gaming and e-sports for the students at UiO"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"back": "back",
|
"back": "back"
|
||||||
"flagImageAlt": "Norwegian flag"
|
|
||||||
},
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"title": "404: Page not found",
|
"title": "404: Page not found",
|
||||||
@@ -44,7 +43,7 @@
|
|||||||
},
|
},
|
||||||
"boardMembers": {
|
"boardMembers": {
|
||||||
"title": "The Board",
|
"title": "The Board",
|
||||||
"imageAlt": "Potrait of board member",
|
"imageAlt": "Portrait of board member",
|
||||||
"noContact": "No contact info"
|
"noContact": "No contact info"
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
|
|||||||
+2
-3
@@ -9,11 +9,10 @@
|
|||||||
"metaTags": {
|
"metaTags": {
|
||||||
"time": "Tid",
|
"time": "Tid",
|
||||||
"location": "Sted",
|
"location": "Sted",
|
||||||
"description": "En hobbyforening som ønsker skape et sosialt miljø rundt gaming og e-sport på UiO!"
|
"description": "En hobbyforening som ønsker å skape et sosialt miljø rundt gaming og e-sport på UiO!"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"back": "tilbake",
|
"back": "tilbake"
|
||||||
"flagImageAlt": "Britisk flagg"
|
|
||||||
},
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"title": "404: Siden finnes ikke",
|
"title": "404: Siden finnes ikke",
|
||||||
|
|||||||
+4
-1
@@ -1,9 +1,12 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
import createNextIntlPlugin from "next-intl/plugin"
|
import createNextIntlPlugin from "next-intl/plugin"
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin("./src/i18n.ts")
|
const withNextIntl = createNextIntlPlugin()
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Bundles a minimal server with only the needed dependencies, used by the
|
||||||
|
// Docker image
|
||||||
|
output: "standalone",
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+1011
-497
File diff suppressed because it is too large.
Load diff
+19
-20
@@ -8,31 +8,30 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 9000",
|
"start": "next start -p 9000",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\""
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@portabletext/react": "^3.2.1",
|
"@portabletext/react": "^8.0.1",
|
||||||
"@sanity/client": "^7.8.1",
|
"@sanity/client": "^8.14.1",
|
||||||
"@sanity/image-url": "^1.1.0",
|
"@sanity/image-url": "^2.1.1",
|
||||||
"country-flag-icons": "^1.5.19",
|
"@sanity/webhook": "^4.0.4",
|
||||||
"install": "^0.13.0",
|
"country-flag-icons": "^1.6.20",
|
||||||
"next": "^16.3.3",
|
"next": "^16.4.3",
|
||||||
"next-intl": "^4.3.4",
|
"next-intl": "^4.14.9",
|
||||||
"react": "19.1.0",
|
"react": "^19.3.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "^19.3.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-photo-sphere-viewer": "^6.2.3"
|
"react-photo-sphere-viewer": "^6.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@types/node": "^26.6.6",
|
||||||
"@types/node": "^20",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react": "^19",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/react-dom": "^19",
|
"eslint": "^9.39.5",
|
||||||
"eslint": "^9",
|
"eslint-config-next": "^16.4.3",
|
||||||
"eslint-config-next": "15.4.4",
|
"prettier": "^3.9.10",
|
||||||
"prettier": "^3.6.2",
|
"typescript": "~6.0.3"
|
||||||
"typescript": "^5"
|
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
import Layout from "@/app/[locale]/layout"
|
|
||||||
|
|
||||||
import Footer from "@/components/page-sections/footer"
|
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 Back from "@/components/ui/back"
|
||||||
|
|
||||||
import { urlFor } from "@/lib/imageUrlBuilder"
|
import { type SanityImage, urlFor } from "@/lib/imageUrlBuilder"
|
||||||
import { getBlogPost } from "@/lib/sanity"
|
import { getBlogPost } from "@/lib/sanity"
|
||||||
|
|
||||||
import { PortableText, PortableTextComponents } from "@portabletext/react"
|
import { PortableText, PortableTextComponents } from "@portabletext/react"
|
||||||
import { Metadata, Viewport } from "next"
|
import { Metadata } from "next"
|
||||||
|
import type { Locale } from "next-intl"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { getLocale } from "next-intl/server"
|
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
|
||||||
import * as styles from "./page.module.css"
|
import * as styles from "./page.module.css"
|
||||||
@@ -18,15 +16,11 @@ import * as styles from "./page.module.css"
|
|||||||
interface BlogPageParams {
|
interface BlogPageParams {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
slug: string
|
slug: string
|
||||||
locale: string
|
locale: Locale
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImageValue {
|
type ImageValue = Partial<SanityImage> & {
|
||||||
asset?: {
|
|
||||||
_id: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
alt?: string
|
alt?: string
|
||||||
caption?: string
|
caption?: string
|
||||||
}
|
}
|
||||||
@@ -35,24 +29,49 @@ interface LinkValue {
|
|||||||
href: string
|
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({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: BlogPageParams): Promise<Metadata> {
|
}: BlogPageParams): Promise<Metadata> {
|
||||||
const { slug } = await params
|
const { slug, locale } = await params
|
||||||
const data = await getBlogPost(slug)
|
const data = await getBlogPost(slug)
|
||||||
|
|
||||||
|
if (!data) notFound()
|
||||||
|
|
||||||
return generateSeoMetadata({
|
return generateSeoMetadata({
|
||||||
|
locale,
|
||||||
|
path: `/blog/${slug}`,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.excerpt,
|
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,
|
author: data.author?.name,
|
||||||
|
publishedTime: data._createdAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateViewport(): Viewport {
|
|
||||||
return generateSeoViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
const components: PortableTextComponents = {
|
const components: PortableTextComponents = {
|
||||||
types: {
|
types: {
|
||||||
image: ({ value }: { value: ImageValue }) => {
|
image: ({ value }: { value: ImageValue }) => {
|
||||||
@@ -60,10 +79,13 @@ const components: PortableTextComponents = {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.imageContainer}>
|
<div className={styles.imageContainer}>
|
||||||
<Image
|
<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"}
|
alt={value.alt || "Blog image"}
|
||||||
width={800}
|
width={800}
|
||||||
height={400}
|
height={400}
|
||||||
|
sizes="(max-width: 640px) 100vw, 640px"
|
||||||
style={{ width: "100%", height: "auto" }}
|
style={{ width: "100%", height: "auto" }}
|
||||||
/>
|
/>
|
||||||
{value.caption && (
|
{value.caption && (
|
||||||
@@ -80,22 +102,29 @@ const components: PortableTextComponents = {
|
|||||||
}: {
|
}: {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
value?: LinkValue
|
value?: LinkValue
|
||||||
}) => (
|
}) => {
|
||||||
<a
|
const href = getSafeHref(value?.href)
|
||||||
href={value?.href ?? "#"}
|
if (!href) return <>{children}</>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
// Only links to other sites open in a new tab, not mailto: or links
|
||||||
className={styles.link}
|
// within this site
|
||||||
>
|
const isExternal = /^https?:\/\//i.test(href)
|
||||||
{children}
|
|
||||||
</a>
|
return (
|
||||||
),
|
<a
|
||||||
|
href={href}
|
||||||
|
className={styles.link}
|
||||||
|
{...(isExternal && { target: "_blank", rel: "noopener noreferrer" })}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlogPost = async ({ params }: BlogPageParams) => {
|
const BlogPost = async ({ params }: BlogPageParams) => {
|
||||||
const { slug }: { slug: string } = await params
|
const { slug, locale } = await params
|
||||||
const locale = await getLocale()
|
|
||||||
|
|
||||||
const data = await getBlogPost(slug)
|
const data = await getBlogPost(slug)
|
||||||
|
|
||||||
@@ -108,7 +137,7 @@ const BlogPost = async ({ params }: BlogPageParams) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.backButton}>
|
<div className={styles.backButton}>
|
||||||
<Back />
|
<Back />
|
||||||
@@ -117,10 +146,11 @@ const BlogPost = async ({ params }: BlogPageParams) => {
|
|||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
{data.mainImage && (
|
{data.mainImage && (
|
||||||
<Image
|
<Image
|
||||||
src={urlFor(data.mainImage).url()}
|
src={urlFor(data.mainImage).width(1600).fit("max").url()}
|
||||||
alt={data.title}
|
alt={data.title}
|
||||||
width={800}
|
width={800}
|
||||||
height={400}
|
height={400}
|
||||||
|
sizes="(max-width: 850px) 100vw, 850px"
|
||||||
className={styles.headerImage}
|
className={styles.headerImage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -131,7 +161,7 @@ const BlogPost = async ({ params }: BlogPageParams) => {
|
|||||||
<Image
|
<Image
|
||||||
className="circular"
|
className="circular"
|
||||||
alt={data.author.name}
|
alt={data.author.name}
|
||||||
src={urlFor(data.author.image).width(50).height(50).url()}
|
src={urlFor(data.author.image).width(100).height(100).url()}
|
||||||
width={50}
|
width={50}
|
||||||
height={50}
|
height={50}
|
||||||
/>
|
/>
|
||||||
@@ -148,7 +178,7 @@ const BlogPost = async ({ params }: BlogPageParams) => {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Layout>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import Layout from "@/app/[locale]/layout"
|
import { generateSeoMetadata } from "@/components/seo"
|
||||||
|
|
||||||
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
|
|
||||||
import Back from "@/components/ui/back"
|
import Back from "@/components/ui/back"
|
||||||
|
|
||||||
|
import { formatEventDate } from "@/lib/dates"
|
||||||
import { getEvent } from "@/lib/sanity"
|
import { getEvent } from "@/lib/sanity"
|
||||||
|
|
||||||
import { Metadata, Viewport } from "next"
|
import { Metadata } from "next"
|
||||||
|
import type { Locale } from "next-intl"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { getLocale, getTranslations } from "next-intl/server"
|
import { getTranslations } from "next-intl/server"
|
||||||
import { FaCalendarAlt, FaMapMarkerAlt } from "react-icons/fa"
|
import { FaCalendarAlt, FaMapMarkerAlt } from "react-icons/fa"
|
||||||
|
|
||||||
import * as styles from "./page.module.css"
|
import * as styles from "./page.module.css"
|
||||||
@@ -15,84 +15,62 @@ import * as styles from "./page.module.css"
|
|||||||
interface EventPageParams {
|
interface EventPageParams {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
slug: string
|
slug: string
|
||||||
locale: 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({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: EventPageParams): Promise<Metadata> {
|
}: EventPageParams): Promise<Metadata> {
|
||||||
const { slug } = await params
|
const { slug, locale } = await params
|
||||||
const data = await getEvent(slug)
|
const data = await getEvent(slug)
|
||||||
|
|
||||||
const locale = await getLocale()
|
if (!data) notFound()
|
||||||
const t = await getTranslations("metaTags")
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const t = await getTranslations({ locale, namespace: "metaTags" })
|
||||||
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 generateSeoMetadata({
|
return generateSeoMetadata({
|
||||||
|
locale,
|
||||||
|
path: `/event/${slug}`,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: `${t("time")}: ${formatDate(data.date)}\n${t("location")}: ${data.location}\n\n${data.description}`,
|
description: [
|
||||||
|
`${t("time")}: ${formatEventDate(data.date, locale)}`,
|
||||||
|
data.location && `${t("location")}: ${data.location}`,
|
||||||
|
data.description && `\n${data.description}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateViewport(): Viewport {
|
|
||||||
return generateSeoViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
const Event = async ({ params }: EventPageParams) => {
|
const Event = async ({ params }: EventPageParams) => {
|
||||||
const { slug }: { slug: string } = await params
|
const { slug, locale } = await params
|
||||||
const locale = await getLocale()
|
|
||||||
|
|
||||||
const data = await getEvent(slug)
|
const data = await getEvent(slug)
|
||||||
|
|
||||||
if (!data) return notFound()
|
if (!data) return notFound()
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Layout>
|
<div className={styles.container}>
|
||||||
<div className={styles.container}>
|
<Back />
|
||||||
<Back />
|
<div className={styles.event}>
|
||||||
<div className={styles.event}>
|
<h1>{data.title}</h1>
|
||||||
<h1>{data.title}</h1>
|
<div className={styles.metadata}>
|
||||||
<div className={styles.metadata}>
|
<FaMapMarkerAlt />
|
||||||
<FaMapMarkerAlt />
|
<p>{data.location}</p>
|
||||||
<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>
|
||||||
|
<div className={styles.metadata}>
|
||||||
|
<FaCalendarAlt />
|
||||||
|
<p>{formatEventDate(data.date, locale)}</p>
|
||||||
|
</div>
|
||||||
|
<p className={styles.description}>{data.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-20
@@ -1,32 +1,68 @@
|
|||||||
// app/[locale]/layout.tsx
|
import "@/app/globals.css"
|
||||||
import { NextIntlClientProvider } from "next-intl"
|
|
||||||
import { getLocale } from "next-intl/server"
|
|
||||||
|
|
||||||
import LanguageSelector from "@components/ui/languageSelector"
|
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 UmamiAnalytics from "@/components/umamiAnalytics"
|
||||||
import { getAnalyticsConfig } from "@/lib/analyticsConfig"
|
import { routing } from "@/i18n/routing"
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
// 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,
|
children,
|
||||||
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
}) {
|
}) {
|
||||||
const locale = (await getLocale()) as "no" | "en"
|
const { locale } = await params
|
||||||
const messages = (await import(`../../../messages/${locale}.json`)).default
|
|
||||||
|
|
||||||
const analyticsConfig = getAnalyticsConfig()
|
// [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 (
|
return (
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<html lang={locale}>
|
||||||
<LanguageSelector />
|
<body>
|
||||||
<main>{children}</main>
|
<NextIntlClientProvider locale={locale} messages={clientMessages}>
|
||||||
{analyticsConfig?.enabled && (
|
<LanguageSelector />
|
||||||
<UmamiAnalytics
|
<main>{children}</main>
|
||||||
websiteId={analyticsConfig.websiteId}
|
<UmamiAnalytics />
|
||||||
src={analyticsConfig.src}
|
</NextIntlClientProvider>
|
||||||
domains={analyticsConfig.domains}
|
</body>
|
||||||
/>
|
</html>
|
||||||
)}
|
|
||||||
</NextIntlClientProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
@@ -1,5 +1,3 @@
|
|||||||
import Layout from "@/app/[locale]/layout"
|
|
||||||
|
|
||||||
import About from "@/components/page-sections/about"
|
import About from "@/components/page-sections/about"
|
||||||
import BoardMembers from "@/components/page-sections/boardMembers"
|
import BoardMembers from "@/components/page-sections/boardMembers"
|
||||||
import Contact from "@/components/page-sections/contact"
|
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 Footer from "@/components/page-sections/footer"
|
||||||
import Hero from "@/components/page-sections/hero"
|
import Hero from "@/components/page-sections/hero"
|
||||||
import Office from "@/components/page-sections/office"
|
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 const revalidate = 3600 // 1 hour
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
interface HomePageParams {
|
||||||
return generateSeoMetadata({})
|
params: Promise<{
|
||||||
|
locale: Locale
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateViewport(): Viewport {
|
export async function generateMetadata({
|
||||||
return generateSeoViewport()
|
params,
|
||||||
|
}: HomePageParams): Promise<Metadata> {
|
||||||
|
const { locale } = await params
|
||||||
|
|
||||||
|
return generateSeoMetadata({ locale, path: "/" })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<>
|
||||||
<Hero />
|
<Hero />
|
||||||
<About />
|
<About />
|
||||||
<Events />
|
<Events />
|
||||||
@@ -31,6 +36,6 @@ export default function Home() {
|
|||||||
<BoardMembers />
|
<BoardMembers />
|
||||||
<Contact />
|
<Contact />
|
||||||
<Footer />
|
<Footer />
|
||||||
</Layout>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,43 @@
|
|||||||
|
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook"
|
||||||
import { revalidateTag } from "next/cache"
|
import { revalidateTag } from "next/cache"
|
||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
// Expire immediately so the first visit after a publish gets fresh content.
|
||||||
const secret = request.nextUrl.searchParams.get("secret")
|
// 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) {
|
export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({ message: "Invalid secret" }, { status: 401 })
|
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 {
|
try {
|
||||||
const body = await request.json()
|
|
||||||
const { _type, slug } = body
|
const { _type, slug } = body
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -17,19 +45,21 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (_type === "post") {
|
if (_type === "post") {
|
||||||
revalidateTag("blog-posts", "max")
|
expireTag("blog-posts")
|
||||||
if (slug?.current) {
|
if (slug?.current) {
|
||||||
revalidateTag(`post-${slug.current}`, "max")
|
expireTag(`post-${slug.current}`)
|
||||||
}
|
}
|
||||||
} else if (_type === "event") {
|
} else if (_type === "event") {
|
||||||
revalidateTag("events", "max")
|
expireTag("events")
|
||||||
if (slug?.current) {
|
if (slug?.current) {
|
||||||
revalidateTag(`event-${slug.current}`, "max")
|
expireTag(`event-${slug.current}`)
|
||||||
}
|
}
|
||||||
|
} else if (_type === "author") {
|
||||||
|
expireTag("authors")
|
||||||
|
// Blog posts show the author's name and image
|
||||||
|
expireTag("blog-posts")
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidateTag("home", "max")
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
revalidated: true,
|
revalidated: true,
|
||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import Layout from "@/app/layout"
|
|
||||||
|
|
||||||
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
|
|
||||||
|
|
||||||
import { Metadata, Viewport } from "next"
|
|
||||||
import { getTranslations } from "next-intl/server"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
import * as styles from "./global-not-found.module.css"
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
|
||||||
const t = await getTranslations("404")
|
|
||||||
|
|
||||||
return generateSeoMetadata({
|
|
||||||
title: t("title"),
|
|
||||||
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,26 +0,0 @@
|
|||||||
import "@/app/globals.css"
|
|
||||||
|
|
||||||
import type { Metadata } from "next"
|
|
||||||
|
|
||||||
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 function RootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html>
|
|
||||||
<body>{children}</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
export default function RootPage() {
|
|
||||||
redirect("/en/404")
|
|
||||||
}
|
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
import DiscordButton from "@components/ui/discordButton"
|
import DiscordButton from "@/components/ui/discordButton"
|
||||||
import MemberButton from "@components/ui/memberButton"
|
import MemberButton from "@/components/ui/memberButton"
|
||||||
import OtherButton from "@components/ui/otherButton"
|
import OtherButton from "@/components/ui/otherButton"
|
||||||
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { urlFor } from "@/lib/imageUrlBuilder"
|
import { type SanityImage, urlFor } from "@/lib/imageUrlBuilder"
|
||||||
import { sanityClient } from "@/lib/sanity"
|
import { getBoardMembers } from "@/lib/sanity"
|
||||||
|
|
||||||
import { getLocale, getTranslations } from "next-intl/server"
|
import { getLocale, getTranslations } from "next-intl/server"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
@@ -8,19 +8,13 @@ import * as styles from "./boardMembers.module.css"
|
|||||||
|
|
||||||
interface BoardMember {
|
interface BoardMember {
|
||||||
_id: string
|
_id: string
|
||||||
boardMember: boolean
|
|
||||||
contactInfo?: string
|
contactInfo?: string
|
||||||
name: string
|
name: string
|
||||||
role?: {
|
role?: {
|
||||||
no: string
|
no: string
|
||||||
en: string
|
en: string
|
||||||
}
|
}
|
||||||
image?: {
|
image: SanityImage | null
|
||||||
asset?: {
|
|
||||||
_id: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bio?: {
|
bio?: {
|
||||||
no: string
|
no: string
|
||||||
en: string
|
en: string
|
||||||
@@ -29,31 +23,9 @@ interface BoardMember {
|
|||||||
|
|
||||||
const BoardMembers = async () => {
|
const BoardMembers = async () => {
|
||||||
const t = await getTranslations("boardMembers")
|
const t = await getTranslations("boardMembers")
|
||||||
const locale = (await getLocale()) as "no" | "en"
|
const locale = await getLocale()
|
||||||
|
|
||||||
const query = `
|
const data: BoardMember[] = await getBoardMembers()
|
||||||
*[_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)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="board-members">
|
<section id="board-members">
|
||||||
@@ -67,11 +39,9 @@ const BoardMembers = async () => {
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={
|
src={
|
||||||
member.image?.asset?.url
|
member.image?.asset
|
||||||
? urlFor(member.image.asset.url)
|
? // Cropped to a square around the hotspot
|
||||||
.width(512)
|
urlFor(member.image).width(512).height(512).url()
|
||||||
.height(512)
|
|
||||||
.url()
|
|
||||||
: "/person.png"
|
: "/person.png"
|
||||||
}
|
}
|
||||||
width={256}
|
width={256}
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ const Contact = () => {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.item}>
|
<div className={styles.item}>
|
||||||
<FaAt />
|
<FaAt />
|
||||||
<a href="mailto:styret@uiogaming.no" target="_blank" rel="noreferrer">
|
<a href="mailto:styret@uiogaming.no">{t("email")}</a>
|
||||||
{t("email")}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.item}>
|
<div className={styles.item}>
|
||||||
<FaDiscord />
|
<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 { getLocale, getTranslations } from "next-intl/server"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
@@ -15,36 +16,36 @@ interface Event {
|
|||||||
slug: {
|
slug: {
|
||||||
current: string
|
current: string
|
||||||
}
|
}
|
||||||
description: string
|
description: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DESCRIPTION_PREVIEW_LENGTH = 70
|
||||||
|
|
||||||
const Events = async () => {
|
const Events = async () => {
|
||||||
const t = await getTranslations("events")
|
const t = await getTranslations("events")
|
||||||
const tCommon = await getTranslations("common")
|
const tCommon = await getTranslations("common")
|
||||||
const locale = await getLocale()
|
const locale = await getLocale()
|
||||||
|
|
||||||
const data: Event[] = await getAllEvents()
|
const events: Event[] = await getUpcomingEvents(2)
|
||||||
|
|
||||||
const currentDate = new Date()
|
const previewDescription = (description: string | null) => {
|
||||||
const filteredEvents = data
|
if (!description) return null
|
||||||
.filter((event: Event) => {
|
|
||||||
return currentDate <= new Date(event.date)
|
|
||||||
})
|
|
||||||
.slice(0, 2)
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
// Count graphemes so emoji and accented characters are never cut in half
|
||||||
const date = new Date(dateString)
|
const graphemes = Array.from(new Intl.Segmenter().segment(description))
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
if (graphemes.length <= DESCRIPTION_PREVIEW_LENGTH) return description
|
||||||
weekday: "long",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
timeZone: "Europe/Oslo",
|
|
||||||
}
|
|
||||||
|
|
||||||
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[]) => {
|
const mapEvents = (events: Event[]) => {
|
||||||
@@ -60,19 +61,10 @@ const Events = async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.metadata}>
|
<div className={styles.metadata}>
|
||||||
<FaCalendarAlt />
|
<FaCalendarAlt />
|
||||||
<p>{formatDate(event.date)}</p>
|
<p>{formatEventDate(event.date, locale)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>{previewDescription(event.description)}</p>
|
||||||
{event.description.length < 70 ? (
|
|
||||||
event.description
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{event.description.substring(0, 70)}...{" "}
|
|
||||||
<span className={styles.readMore}>{t("readMore")}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</article>
|
</article>
|
||||||
</Link>
|
</Link>
|
||||||
))
|
))
|
||||||
@@ -92,7 +84,7 @@ const Events = async () => {
|
|||||||
<section id="events">
|
<section id="events">
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h2 className={styles.sectionHeader}>{t("title")}</h2>
|
<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}>
|
<div className={"no-mobile " + styles.image}>
|
||||||
<Image
|
<Image
|
||||||
src="/event.jpg"
|
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
|
||||||
@@ -37,4 +37,3 @@
|
|||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,21 +1,33 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useSyncExternalStore } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"
|
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"
|
||||||
|
|
||||||
import * as styles from "./office.module.css"
|
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 Office = () => {
|
||||||
const t = useTranslations("office")
|
const t = useTranslations("office")
|
||||||
const [supportsWebGL2, setSupportsWebGL2] = useState<boolean | null>(null)
|
// null on the server, which renders the fallback image
|
||||||
|
const supportsWebGL2 = useSyncExternalStore(
|
||||||
useEffect(() => {
|
subscribe,
|
||||||
const canvas = document.createElement("canvas")
|
getWebGL2Support,
|
||||||
const gl = canvas.getContext("webgl2")
|
() => null
|
||||||
setSupportsWebGL2(Boolean(gl))
|
)
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="office">
|
<section id="office">
|
||||||
@@ -54,6 +66,7 @@ const Office = () => {
|
|||||||
className={styles.map}
|
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"
|
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"
|
allow="geolocation"
|
||||||
|
loading="lazy"
|
||||||
title="Map by MazeMap"
|
title="Map by MazeMap"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+42
-23
@@ -1,41 +1,68 @@
|
|||||||
import { Metadata, Viewport } from "next"
|
import { Metadata } from "next"
|
||||||
import { getLocale, getTranslations } from "next-intl/server"
|
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 = {
|
type SeoProps = {
|
||||||
|
locale: Locale
|
||||||
|
/** Path without the locale prefix, e.g. "/" or "/event/some-slug" */
|
||||||
|
path: string
|
||||||
title?: string
|
title?: string
|
||||||
description?: string
|
description?: string
|
||||||
imageURL?: string
|
imageURL?: string
|
||||||
author?: string
|
author?: string
|
||||||
|
/** Marks the page as an article, e.g. a blog post */
|
||||||
|
publishedTime?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateSeoMetadata({
|
export async function generateSeoMetadata({
|
||||||
|
locale,
|
||||||
|
path,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
imageURL,
|
imageURL,
|
||||||
author,
|
author,
|
||||||
|
publishedTime,
|
||||||
}: SeoProps): Promise<Metadata> {
|
}: SeoProps): Promise<Metadata> {
|
||||||
const t = await getTranslations("metaTags")
|
const t = await getTranslations({ locale, namespace: "metaTags" })
|
||||||
const locale = await getLocale()
|
|
||||||
|
|
||||||
const defaultTitle = "UiO Gaming"
|
const defaultTitle = "UiO Gaming"
|
||||||
const metaDescription = description || t("description")
|
const metaDescription = description || t("description")
|
||||||
const siteUrl = "https://uiogaming.no"
|
|
||||||
const themeColor = "#7d36e7"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: title ? `${title} | ${defaultTitle}` : defaultTitle,
|
title: title ? `${title} | ${defaultTitle}` : defaultTitle,
|
||||||
description: metaDescription,
|
description: metaDescription,
|
||||||
authors: author ? [{ name: author }] : [{ name: "UiO Gaming" }],
|
authors: author ? [{ name: author }] : [{ name: "UiO Gaming" }],
|
||||||
icons: {
|
// Tells search engines that the Norwegian and English versions are the
|
||||||
icon: "/logo_2024_neon_pink.png",
|
// same page. The unprefixed path redirects based on the browser language
|
||||||
shortcut: "/logo_2024_neon_pink.png",
|
alternates: {
|
||||||
apple: "/logo_2024_neon_pink.png",
|
canonical: getLocalizedPath(locale, path),
|
||||||
|
languages: {
|
||||||
|
...Object.fromEntries(
|
||||||
|
routing.locales.map(pageLocale => [
|
||||||
|
pageLocale,
|
||||||
|
getLocalizedPath(pageLocale, path),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
"x-default": path,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: title || defaultTitle,
|
title: title || defaultTitle,
|
||||||
description: metaDescription,
|
description: metaDescription,
|
||||||
type: "website",
|
...(publishedTime
|
||||||
|
? { type: "article", publishedTime, authors: author && [author] }
|
||||||
|
: { type: "website" }),
|
||||||
locale: locale,
|
locale: locale,
|
||||||
|
url: getLocalizedPath(locale, path),
|
||||||
siteName: defaultTitle,
|
siteName: defaultTitle,
|
||||||
images: imageURL
|
images: imageURL
|
||||||
? [
|
? [
|
||||||
@@ -48,7 +75,7 @@ export async function generateSeoMetadata({
|
|||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/logo_2024_neon_trans.png`,
|
url: "/logo_2024_neon_trans.png",
|
||||||
alt: defaultTitle,
|
alt: defaultTitle,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -57,20 +84,12 @@ export async function generateSeoMetadata({
|
|||||||
card: imageURL ? "summary_large_image" : "summary",
|
card: imageURL ? "summary_large_image" : "summary",
|
||||||
title: title || defaultTitle,
|
title: title || defaultTitle,
|
||||||
description: metaDescription,
|
description: metaDescription,
|
||||||
images: imageURL ? [imageURL] : [`${siteUrl}/logo_2024_neon_trans.png`],
|
images: imageURL ? [imageURL] : ["/logo_2024_neon_trans.png"],
|
||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
"theme-color": themeColor,
|
"msapplication-TileColor": THEME_COLOR,
|
||||||
"msapplication-TileColor": themeColor,
|
"msapplication-navbutton-color": THEME_COLOR,
|
||||||
"msapplication-navbutton-color": themeColor,
|
|
||||||
"apple-mobile-web-app-status-bar-style": "black-translucent",
|
"apple-mobile-web-app-status-bar-style": "black-translucent",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSeoViewport(): Viewport {
|
|
||||||
return {
|
|
||||||
themeColor: "#7d36e7",
|
|
||||||
colorScheme: "dark",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+17
-12
@@ -1,27 +1,32 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useLocale, useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { FaArrowLeft } from "react-icons/fa"
|
import { FaArrowLeft } from "react-icons/fa"
|
||||||
|
|
||||||
|
import { useRouter } from "@/i18n/navigation"
|
||||||
|
|
||||||
import * as styles from "./back.module.css"
|
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 Back = () => {
|
||||||
const locale = useLocale()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const t = useTranslations("buttons")
|
const t = useTranslations("buttons")
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
const currentDomain = window.location.hostname
|
// The Navigation API only lists this site's own history entries, so
|
||||||
const referrer = document.referrer
|
// canGoBack is true exactly when the previous page was on this site
|
||||||
|
const canGoBack = window.navigation?.canGoBack ?? cameFromThisSite()
|
||||||
|
|
||||||
const isSameDomain =
|
if (canGoBack) {
|
||||||
referrer && new URL(referrer).hostname === currentDomain
|
router.back()
|
||||||
|
|
||||||
if (window.history.length > 1 && isSameDomain) {
|
|
||||||
window.history.back()
|
|
||||||
} else {
|
} else {
|
||||||
router.push(`/${locale}`)
|
router.push("/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +36,7 @@ const Back = () => {
|
|||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
>
|
>
|
||||||
<FaArrowLeft />
|
<FaArrowLeft />
|
||||||
<p>{t("back")}</p>
|
<span>{t("back")}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { GB, NO } from "country-flag-icons/react/3x2"
|
import { GB, NO } from "country-flag-icons/react/3x2"
|
||||||
import { useLocale } from "next-intl"
|
import { type Locale, useLocale } from "next-intl"
|
||||||
import { useRouter, usePathname } from "next/navigation"
|
|
||||||
|
import { usePathname, useRouter } from "@/i18n/navigation"
|
||||||
|
|
||||||
import * as styles from "./languageSelector.module.css"
|
import * as styles from "./languageSelector.module.css"
|
||||||
|
|
||||||
const LanguageSelector = () => {
|
const LanguageSelector = () => {
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
// Pathname without the locale prefix
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const changeLocale = (newLocale: "no" | "en") => {
|
const changeLocale = (newLocale: Locale) => {
|
||||||
// Remove the current locale from the pathname
|
// Read at click time, since useSearchParams() would opt pages out of
|
||||||
const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}/, "")
|
// static rendering
|
||||||
router.push(`/${newLocale}${pathWithoutLocale}`)
|
const { search, hash } = window.location
|
||||||
|
router.push(`${pathname}${search}${hash}`, { locale: newLocale })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.languageSwitcher}>
|
<div className={styles.languageSwitcher}>
|
||||||
{locale === "no" ? (
|
{locale === "no" ? (
|
||||||
<button onClick={() => changeLocale("en")} className={styles.button}>
|
<button onClick={() => changeLocale("en")} className={styles.button}>
|
||||||
<GB className={styles.flag} title="English flag" />
|
<GB className={styles.flag} aria-hidden="true" />
|
||||||
English
|
<span lang="en">English</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => changeLocale("no")} className={styles.button}>
|
<button onClick={() => changeLocale("no")} className={styles.button}>
|
||||||
<NO className={styles.flag} title="Norwegian flag" />
|
<NO className={styles.flag} aria-hidden="true" />
|
||||||
Norsk
|
<span lang="no">Norsk</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,15 +36,13 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownButton {
|
.dropdownLink {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
color: black;
|
color: black;
|
||||||
background-color: #cccccc;
|
background-color: #cccccc;
|
||||||
border: none;
|
border-radius: 999em;
|
||||||
border-radius: 999em !important;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
@@ -52,7 +50,11 @@
|
|||||||
padding-right: 2em;
|
padding-right: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownButton:hover {
|
.dropdownLink:first-child {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownLink:hover {
|
||||||
color: black;
|
color: black;
|
||||||
background-color: #e6e6e6;
|
background-color: #e6e6e6;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useEffect, useId, useRef, useState } from "react"
|
||||||
|
|
||||||
import { useLocale, useTranslations } from "next-intl"
|
import { useLocale, useTranslations } from "next-intl"
|
||||||
import * as styles from "./otherButton.module.css"
|
import * as styles from "./otherButton.module.css"
|
||||||
@@ -11,7 +11,37 @@ const OtherButton = () => {
|
|||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
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 =
|
const statutesUrl =
|
||||||
locale === "en"
|
locale === "en"
|
||||||
@@ -28,22 +58,41 @@ const OtherButton = () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.dropdownContainer}>
|
<div
|
||||||
<button onClick={toggleDropdown} className={styles.button}>
|
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")}{" "}
|
{t("other")}{" "}
|
||||||
<span className={isOpen ? styles.arrowUp : styles.arrowDown}></span>
|
<span
|
||||||
|
className={isOpen ? styles.arrowUp : styles.arrowDown}
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className={styles.dropdown}>
|
<div id={dropdownId} className={styles.dropdown}>
|
||||||
{links.map((link, index) => (
|
{links.map(link => (
|
||||||
<button
|
<a
|
||||||
key={index}
|
key={link.url}
|
||||||
onClick={() => window.open(link.url, "_blank")}
|
href={link.url}
|
||||||
className={styles.dropdownButton}
|
target="_blank"
|
||||||
style={{ marginTop: index === 0 ? "8px" : "4px" }}
|
rel="noopener noreferrer"
|
||||||
|
className={styles.dropdownLink}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</button>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,49 +1,21 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
|
||||||
interface UmamiAnalyticsProps {
|
// Umami ignores visits from other domains, so local and preview deployments
|
||||||
websiteId: string
|
// aren't counted
|
||||||
src?: string
|
const DOMAINS = ["uiogaming.no", "www.uiogaming.no"]
|
||||||
domains?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UmamiAnalytics({
|
export default function UmamiAnalytics() {
|
||||||
websiteId,
|
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
src = "https://analytics.umami.is/script.js",
|
const src =
|
||||||
domains,
|
process.env.NEXT_PUBLIC_UMAMI_SRC || "https://analytics.umami.is/script.js"
|
||||||
}: UmamiAnalyticsProps) {
|
|
||||||
if (!websiteId) {
|
|
||||||
console.warn("Umami: Website ID is required")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only load analytics in production or if explicitly enabled
|
if (!websiteId || process.env.NODE_ENV !== "production") return null
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Script
|
<Script
|
||||||
src={src}
|
src={src}
|
||||||
data-website-id={websiteId}
|
data-website-id={websiteId}
|
||||||
data-domains={domains?.join(",")}
|
data-domains={DOMAINS.join(",")}
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
import { notFound } from "next/navigation"
|
|
||||||
import { getRequestConfig } from "next-intl/server"
|
|
||||||
|
|
||||||
const locales = ["no", "en"]
|
|
||||||
|
|
||||||
export default getRequestConfig(async ({ requestLocale }) => {
|
|
||||||
const locale = await requestLocale
|
|
||||||
|
|
||||||
if (!locale || !locales.includes(locale as "no" | "en")) notFound()
|
|
||||||
|
|
||||||
return {
|
|
||||||
locale,
|
|
||||||
messages: (await import(`../messages/${locale}.json`)).default,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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
-7
@@ -1,13 +1,20 @@
|
|||||||
|
import { hasLocale } from "next-intl"
|
||||||
import { getRequestConfig } from "next-intl/server"
|
import { getRequestConfig } from "next-intl/server"
|
||||||
import { cookies } from "next/headers"
|
import { locale as rootLocale } from "next/root-params"
|
||||||
|
|
||||||
export default getRequestConfig(async () => {
|
import { routing } from "./routing"
|
||||||
const cookieStore = await cookies()
|
|
||||||
const locale = cookieStore.get("locale")?.value || "no"
|
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 {
|
return {
|
||||||
locale,
|
locale: resolved,
|
||||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
messages: (await import(`../../messages/${resolved}.json`)).default,
|
||||||
timeZone: "Europe/Oslo",
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -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 {
|
||||||
import type { SanityImageSource } from "@sanity/image-url/lib/types/types"
|
createImageUrlBuilder,
|
||||||
|
type SanityImageObject,
|
||||||
|
type SanityImageSource,
|
||||||
|
} from "@sanity/image-url"
|
||||||
|
|
||||||
import { sanityClient } from "./sanity"
|
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) {
|
export function urlFor(source: SanityImageSource) {
|
||||||
return builder.image(source)
|
return builder.image(source)
|
||||||
|
|||||||
+80
-16
@@ -1,10 +1,12 @@
|
|||||||
import { createClient } from "@sanity/client"
|
import { createClient } from "@sanity/client"
|
||||||
|
|
||||||
|
import { toOsloDateString } from "./dates"
|
||||||
|
|
||||||
export const sanityClient = createClient({
|
export const sanityClient = createClient({
|
||||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
||||||
apiVersion: "2025-02-19",
|
apiVersion: "2025-02-19",
|
||||||
useCdn: true,
|
useCdn: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function getBlogPost(slug: string) {
|
export async function getBlogPost(slug: string) {
|
||||||
@@ -15,19 +17,9 @@ export async function getBlogPost(slug: string) {
|
|||||||
title,
|
title,
|
||||||
author-> {
|
author-> {
|
||||||
name,
|
name,
|
||||||
image {
|
image
|
||||||
asset-> {
|
|
||||||
_id,
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mainImage {
|
|
||||||
asset-> {
|
|
||||||
_id,
|
|
||||||
url
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
mainImage,
|
||||||
excerpt,
|
excerpt,
|
||||||
body
|
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 = `
|
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,
|
_id,
|
||||||
title,
|
title,
|
||||||
location,
|
location,
|
||||||
@@ -85,7 +88,7 @@ export async function getAllEvents() {
|
|||||||
|
|
||||||
return sanityClient.fetch(
|
return sanityClient.fetch(
|
||||||
query,
|
query,
|
||||||
{},
|
{ now, limit },
|
||||||
{
|
{
|
||||||
cache: "force-cache",
|
cache: "force-cache",
|
||||||
next: {
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import createMiddleware from "next-intl/middleware"
|
|
||||||
|
|
||||||
export default createMiddleware({
|
|
||||||
locales: ["no", "en"],
|
|
||||||
defaultLocale: "no",
|
|
||||||
localePrefix: "always",
|
|
||||||
})
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
// Match only internationalized pathnames
|
|
||||||
matcher: ["/", "/(no|en)/:path*"],
|
|
||||||
}
|
|
||||||
@@ -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|.*\\..*).*)"],
|
||||||
|
}
|
||||||
@@ -25,12 +25,6 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
],
|
|
||||||
"@components/*": [
|
|
||||||
"./src/components/*"
|
|
||||||
],
|
|
||||||
"@lib/*": [
|
|
||||||
"./src/lib/*"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user