Merge branch 'slop/sloppy-fixes'

# Conflicts:
#	README.md
This commit is contained in:
leander committed 2026-11-02 22:15:02 +01:00
commit 15982ed804
55 files changed
+1921 -1043

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# Installed and built inside the image
node_modules
.next
# Secrets are passed in by docker compose, never baked into the image
.env*
.git
.github
Dockerfile
docker-compose.yml
*.md
npm-debug.log*
+40
View File
@@ -0,0 +1,40 @@
name: CI
on:
pull_request:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
# Public values from env.example. The build prerenders pages with data
# from Sanity, so it needs to know which project to read from
env:
NEXT_PUBLIC_SANITY_PROJECT_ID: mmqlu667
NEXT_PUBLIC_SANITY_DATASET: production
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 26
cache: npm
- run: npm ci
- name: Check formatting
run: npx prettier --check .
- name: Lint
run: npm run lint
# Generates the route and next/root-params types tsc relies on
- name: Type check
run: npx next typegen && npx tsc --noEmit
- name: Build
run: npm run build
+8 -2
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+1
View File
@@ -2,3 +2,4 @@
package.json package.json
package-lock.json package-lock.json
public public
tsconfig.json
+1 -1
View File
@@ -7,6 +7,6 @@
"jsxSingleQuote": false, "jsxSingleQuote": false,
"trailingComma": "es5", "trailingComma": "es5",
"bracketSpacing": true, "bracketSpacing": true,
"jsxBracketSameLine": false, "bracketSameLine": false,
"arrowParens": "avoid" "arrowParens": "avoid"
} }
+3
View File
@@ -0,0 +1,3 @@
{
".": "1.0.0"
}
+9 -2
View File
@@ -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
View File
@@ -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"]
+4 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: [
{ {
+1011 -497
View File
File diff suppressed because it is too large. Load diff
+19 -20
View File
@@ -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",
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"release-type": "node",
"include-v-in-tag": false
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import { notFound } from "next/navigation"
// Catch unknown paths below a locale so they render [locale]/not-found.tsx
export default function CatchAll() {
notFound()
}
+65 -35
View File
@@ -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> </>
) )
} }
+37 -59
View File
@@ -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
View File
@@ -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>
) )
} }
+9
View File
@@ -0,0 +1,9 @@
import NotFound from "@/components/page-sections/notFound"
import { getLocale } from "next-intl/server"
export default async function LocaleNotFound() {
const locale = await getLocale()
return <NotFound locale={locale} />
}
+15 -10
View File
@@ -1,5 +1,3 @@
import Layout from "@/app/[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> </>
) )
} }
+41 -11
View File
@@ -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(),
-46
View File
@@ -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
-26
View File
@@ -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>
)
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation"
export default function RootPage() {
redirect("/en/404")
}
+14
View File
@@ -0,0 +1,14 @@
import { SITE_URL } from "@/components/seo"
import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: "/api/",
},
sitemap: `${SITE_URL}/sitemap.xml`,
}
}
+39
View File
@@ -0,0 +1,39 @@
import { getLocalizedPath, SITE_URL } from "@/components/seo"
import { routing } from "@/i18n/routing"
import { getSitemapDocuments } from "@/lib/sanity"
import type { MetadataRoute } from "next"
export const revalidate = 3600 // 1 hour
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const { posts, events } = await getSitemapDocuments()
const pages: { path: string; lastModified?: string }[] = [
{ path: "/" },
...posts.map(post => ({
path: `/blog/${post.slug}`,
lastModified: post._updatedAt,
})),
...events.map(event => ({
path: `/event/${event.slug}`,
lastModified: event._updatedAt,
})),
]
// One entry per language version, each linking to the other versions
return pages.flatMap(({ path, lastModified }) => {
const languages = Object.fromEntries(
routing.locales.map(locale => [
locale,
`${SITE_URL}${getLocalizedPath(locale, path)}`,
])
)
return routing.locales.map(locale => ({
url: languages[locale],
lastModified,
alternates: { languages },
}))
})
}
+3 -3
View File
@@ -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"
+8 -38
View File
@@ -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}
+1 -3
View File
@@ -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 />
+25 -33
View File
@@ -1,4 +1,5 @@
import { getAllEvents } from "@/lib/sanity" import { formatEventDate } from "@/lib/dates"
import { getUpcomingEvents } from "@/lib/sanity"
import { getLocale, getTranslations } from "next-intl/server" import { 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"
+29
View File
@@ -0,0 +1,29 @@
import type { Locale } from "next-intl"
import { getTranslations } from "next-intl/server"
import Link from "next/link"
import * as styles from "./notFound.module.css"
const NotFound = async ({ locale }: { locale: Locale }) => {
const t = await getTranslations({ locale, namespace: "404" })
return (
<div className={styles.container}>
<h1 className={styles.centeredText}>{t("title")}</h1>
<p className={styles.centeredText}>
{t("text")}{" "}
<span role="img" aria-label="eyes">
👀
</span>
</p>
<Link
href={`/${locale}`}
className={`${styles.centeredText} ${styles.homeLink}`}
>
{t("back")}
</Link>
</div>
)
}
export default NotFound
@@ -37,4 +37,3 @@
max-height: 75vh; max-height: 75vh;
border-radius: 8px; border-radius: 8px;
} }
+21 -8
View File
@@ -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&center=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&center=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
View File
@@ -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
View File
@@ -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>
) )
} }
+13 -10
View File
@@ -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>
+9 -7
View File
@@ -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;
} }
+62 -13
View File
@@ -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>
)} )}
+9 -37
View File
@@ -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
View File
@@ -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,
}
})
+7
View File
@@ -0,0 +1,7 @@
import { createNavigation } from "next-intl/navigation"
import { routing } from "./routing"
// Locale-aware wrappers around Next's navigation APIs
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing)
+14 -7
View File
@@ -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",
} }
}) })
+14
View File
@@ -0,0 +1,14 @@
import { defineRouting } from "next-intl/routing"
export const routing = defineRouting({
locales: ["no", "en"],
defaultLocale: "no",
localePrefix: "always",
})
// Types getLocale(), useLocale() etc. as "no" | "en" instead of string
declare module "next-intl" {
interface AppConfig {
Locale: (typeof routing.locales)[number]
}
}
-29
View File
@@ -1,29 +0,0 @@
interface AnalyticsConfig {
websiteId: string
src: string
domains: string[]
enabled: boolean
}
export function getAnalyticsConfig(): AnalyticsConfig | null {
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID
const src =
process.env.NEXT_PUBLIC_UMAMI_SRC || "https://analytics.umami.is/script.js"
if (!websiteId) {
return null
}
// Define allowed domains
const domains = ["uiogaming.no", "www.uiogaming.no"]
// Additional environment-based checks
const enabled = process.env.NODE_ENV === "production"
return {
websiteId,
src,
domains,
enabled,
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { Locale } from "next-intl"
const TIME_ZONE = "Europe/Oslo"
// Event dates are usually stored as Oslo wall-clock time without an offset
// ("2026-11-05 17:00"). The Studio's datetime field stores ISO timestamps
// instead, so both formats have to be handled
const LOCAL_DATE_FORMAT = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/
const osloPartsFormatter = new Intl.DateTimeFormat("en-GB", {
timeZone: TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
const getOsloParts = (date: Date) =>
Object.fromEntries(
osloPartsFormatter
.formatToParts(date)
.map(({ type, value }) => [type, value])
)
/** Formats a date as Oslo wall-clock time, e.g. "2026-11-05 17:00" */
export const toOsloDateString = (date: Date) => {
const { year, month, day, hour, minute } = getOsloParts(date)
return `${year}-${month}-${day} ${hour}:${minute}`
}
// How far Oslo's clock is ahead of UTC at the given moment (1 or 2 hours)
const getOsloOffset = (timestamp: number) => {
const { year, month, day, hour, minute } = getOsloParts(new Date(timestamp))
const osloAsUtc = Date.UTC(+year, +month - 1, +day, +hour, +minute)
return osloAsUtc - Math.floor(timestamp / 60_000) * 60_000
}
/**
* Parses an event date. Oslo wall-clock strings are interpreted in Oslo time
* regardless of the server's timezone
*/
export const parseEventDate = (value: string) => {
const match = value.match(LOCAL_DATE_FORMAT)
if (!match) return new Date(value)
const [year, month, day, hour, minute] = match.slice(1).map(Number)
const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute)
// The offset depends on daylight saving time at the resulting moment, so
// guess with the offset at the wall-clock time, then correct
const guess = wallClockAsUtc - getOsloOffset(wallClockAsUtc)
return new Date(wallClockAsUtc - getOsloOffset(guess))
}
/** Formats an event date like "torsdag 5. november 2026 kl. 17:00" */
export const formatEventDate = (value: string, locale: Locale) =>
parseEventDate(value).toLocaleString(locale, {
weekday: "long",
month: "long",
year: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: TIME_ZONE,
})
+11 -3
View File
@@ -1,9 +1,17 @@
import imageUrlBuilder from "@sanity/image-url" import {
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
View File
@@ -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
},
}
)
}
-12
View File
@@ -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*"],
}
+11
View File
@@ -0,0 +1,11 @@
import createMiddleware from "next-intl/middleware"
import { routing } from "./i18n/routing"
export default createMiddleware(routing)
export const config = {
// Match everything except API routes, Next internals and files with an
// extension, so paths without a locale get redirected to one
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
}
-6
View File
@@ -25,12 +25,6 @@
"paths": { "paths": {
"@/*": [ "@/*": [
"./src/*" "./src/*"
],
"@components/*": [
"./src/components/*"
],
"@lib/*": [
"./src/lib/*"
] ]
} }
}, },