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
+1901 -1023

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:
- main
# Let a running deploy finish before the next one starts
concurrency:
group: deploy
cancel-in-progress: false
jobs:
run_pull:
runs-on: ubuntu-latest
@@ -17,12 +22,13 @@ jobs:
chmod 600 ~/.ssh/id_ed25519 # This should be redundant but seems to do the trick.
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
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
run: rm -rf ~/.ssh
permissions:
contents: read
pull-requests: write
+19
View File
@@ -0,0 +1,19 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+1
View File
@@ -2,3 +2,4 @@
package.json
package-lock.json
public
tsconfig.json
+1 -1
View File
@@ -7,6 +7,6 @@
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"bracketSameLine": false,
"arrowParens": "avoid"
}
+3
View File
@@ -0,0 +1,3 @@
{
".": "1.0.0"
}
+9 -2
View File
@@ -1,9 +1,16 @@
# Huskeliste for bidrag
- Sørg for å formatere koden med Prettier
- Sørg for å formatere koden med Prettier (`npm run format`)
- Sjekk at koden ikke har lint-feil (`npm run lint`)
- Sjekk om nettsiden bygges uten problemer (`npm run build`)
- Formatering, linting, typesjekk og bygg kjøres også automatisk på alle pull requests
- Commit messages samt issues skal helst være skrevet på engelsk
- Kode skal skrives på engelsk. Det vil si variabelnavn osv. Tekst som vises til bruker skal være på norsk
- Kode skal skrives på engelsk. Det vil si variabelnavn osv.
- Nettsiden finnes på både norsk og engelsk. Tekst som vises til bruker skal ikke skrives rett i komponentene, men legges inn i både [messages/no.json](messages/no.json) og [messages/en.json](messages/en.json)
+36 -5
View File
@@ -1,15 +1,46 @@
FROM node:20-alpine
FROM node:26-alpine AS deps
WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
RUN npm install
COPY package.json package-lock.json ./
RUN npm ci
FROM node:26-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_* values are inlined at build time, and pages are prerendered
# with data from Sanity during the build
ARG NEXT_PUBLIC_SANITY_PROJECT_ID
ARG NEXT_PUBLIC_SANITY_DATASET
ARG NEXT_PUBLIC_UMAMI_SRC
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
RUN npm run build
# Only the standalone server and its traced dependencies end up in the final
# image, not the full node_modules or the source
FROM node:26-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=9000
ENV HOSTNAME=0.0.0.0
# The server writes regenerated pages to .next, so the files must be owned by
# the user it runs as
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
USER node
EXPOSE 9000
CMD ["npm", "run", "start"]
CMD ["node", "server.js"]
+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_:
- **URL:** `https://uiogaming.no/api/revalidate?secret=<SANITY_REVALIDATE_SECRET>`
- **URL:** `https://uiogaming.no/api/revalidate`
- **HTTP method:** POST
- **Projection:** Må minst inneholde `_type` og `slug`, f.eks `{_type, slug}`
- **Secret:** Samme verdi som `SANITY_REVALIDATE_SECRET` i `.env`
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]
> 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).
Webhooket oppdaterer blogginnlegg, arrangementer og forfattere med en gang. Annet innhold oppdateres først når cachen går ut (opptil en time).
Du kan lese mer om webhooks [her](https://www.sanity.io/docs/webhooks).
+9 -1
View File
@@ -1,7 +1,15 @@
services:
uiogaming.no:
container_name: uiogaming.no
build: .
build:
context: .
# Values are read from .env
args:
NEXT_PUBLIC_SANITY_PROJECT_ID: ${NEXT_PUBLIC_SANITY_PROJECT_ID}
NEXT_PUBLIC_SANITY_DATASET: ${NEXT_PUBLIC_SANITY_DATASET}
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
env_file: .env
ports:
- 9000:9000
environment:
+6 -14
View File
@@ -1,16 +1,8 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import { defineConfig } from "eslint/config"
import nextVitals from "eslint-config-next/core-web-vitals"
import nextTs from "eslint-config-next/typescript"
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// eslint-config-next already ignores .next, out, build and next-env.d.ts
const eslintConfig = defineConfig([...nextVitals, ...nextTs])
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
export default eslintConfig
+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"
},
"buttons": {
"back": "back",
"flagImageAlt": "Norwegian flag"
"back": "back"
},
"404": {
"title": "404: Page not found",
@@ -44,7 +43,7 @@
},
"boardMembers": {
"title": "The Board",
"imageAlt": "Potrait of board member",
"imageAlt": "Portrait of board member",
"noContact": "No contact info"
},
"contact": {
+2 -3
View File
@@ -9,11 +9,10 @@
"metaTags": {
"time": "Tid",
"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": {
"back": "tilbake",
"flagImageAlt": "Britisk flagg"
"back": "tilbake"
},
"404": {
"title": "404: Siden finnes ikke",
+4 -1
View File
@@ -1,9 +1,12 @@
import type { NextConfig } from "next"
import createNextIntlPlugin from "next-intl/plugin"
const withNextIntl = createNextIntlPlugin("./src/i18n.ts")
const withNextIntl = createNextIntlPlugin()
const nextConfig: NextConfig = {
// Bundles a minimal server with only the needed dependencies, used by the
// Docker image
output: "standalone",
images: {
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",
"build": "next build",
"start": "next start -p 9000",
"lint": "next lint",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\""
"lint": "eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@portabletext/react": "^3.2.1",
"@sanity/client": "^7.8.1",
"@sanity/image-url": "^1.1.0",
"country-flag-icons": "^1.5.19",
"install": "^0.13.0",
"next": "^16.3.3",
"next-intl": "^4.3.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"@portabletext/react": "^8.0.1",
"@sanity/client": "^8.14.1",
"@sanity/image-url": "^2.1.1",
"@sanity/webhook": "^4.0.4",
"country-flag-icons": "^1.6.20",
"next": "^16.4.3",
"next-intl": "^4.14.9",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-icons": "^5.7.0",
"react-photo-sphere-viewer": "^6.2.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.4.4",
"prettier": "^3.6.2",
"typescript": "^5"
"@types/node": "^26.6.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"eslint": "^9.39.5",
"eslint-config-next": "^16.4.3",
"prettier": "^3.9.10",
"typescript": "~6.0.3"
},
"repository": {
"type": "git",
+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()
}
+60 -30
View File
@@ -1,16 +1,14 @@
import Layout from "@/app/[locale]/layout"
import Footer from "@/components/page-sections/footer"
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
import { generateSeoMetadata } from "@/components/seo"
import Back from "@/components/ui/back"
import { urlFor } from "@/lib/imageUrlBuilder"
import { type SanityImage, urlFor } from "@/lib/imageUrlBuilder"
import { getBlogPost } from "@/lib/sanity"
import { PortableText, PortableTextComponents } from "@portabletext/react"
import { Metadata, Viewport } from "next"
import { Metadata } from "next"
import type { Locale } from "next-intl"
import { notFound } from "next/navigation"
import { getLocale } from "next-intl/server"
import Image from "next/image"
import * as styles from "./page.module.css"
@@ -18,15 +16,11 @@ import * as styles from "./page.module.css"
interface BlogPageParams {
params: Promise<{
slug: string
locale: string
locale: Locale
}>
}
interface ImageValue {
asset?: {
_id: string
url: string
}
type ImageValue = Partial<SanityImage> & {
alt?: string
caption?: string
}
@@ -35,24 +29,49 @@ interface LinkValue {
href: string
}
const SAFE_LINK_PROTOCOLS = ["http:", "https:", "mailto:"]
// Links come from the CMS, so reject schemes like javascript: that would run
// code when clicked. Parsing with URL also catches tricks like leading
// whitespace or mixed case that a simple prefix check would miss
const getSafeHref = (href?: string) => {
if (!href) return null
try {
const { protocol } = new URL(href, "https://uiogaming.no")
return SAFE_LINK_PROTOCOLS.includes(protocol) ? href : null
} catch {
return null
}
}
// Render posts on first visit and cache them, instead of on every request
export async function generateStaticParams() {
return []
}
export async function generateMetadata({
params,
}: BlogPageParams): Promise<Metadata> {
const { slug } = await params
const { slug, locale } = await params
const data = await getBlogPost(slug)
if (!data) notFound()
return generateSeoMetadata({
locale,
path: `/blog/${slug}`,
title: data.title,
description: data.excerpt,
imageURL: data.mainImage?.asset?.url,
// Cropped around the hotspot to the size link previews expect
imageURL: data.mainImage?.asset
? urlFor(data.mainImage).width(1200).height(630).url()
: undefined,
author: data.author?.name,
publishedTime: data._createdAt,
})
}
export function generateViewport(): Viewport {
return generateSeoViewport()
}
const components: PortableTextComponents = {
types: {
image: ({ value }: { value: ImageValue }) => {
@@ -60,10 +79,13 @@ const components: PortableTextComponents = {
return (
<div className={styles.imageContainer}>
<Image
src={urlFor(value).url()}
// Only a width, so the image keeps its shape and just the crop
// applies. fit("max") avoids upscaling small images
src={urlFor(value).width(1200).fit("max").url()}
alt={value.alt || "Blog image"}
width={800}
height={400}
sizes="(max-width: 640px) 100vw, 640px"
style={{ width: "100%", height: "auto" }}
/>
{value.caption && (
@@ -80,22 +102,29 @@ const components: PortableTextComponents = {
}: {
children?: React.ReactNode
value?: LinkValue
}) => (
}) => {
const href = getSafeHref(value?.href)
if (!href) return <>{children}</>
// Only links to other sites open in a new tab, not mailto: or links
// within this site
const isExternal = /^https?:\/\//i.test(href)
return (
<a
href={value?.href ?? "#"}
target="_blank"
rel="noopener noreferrer"
href={href}
className={styles.link}
{...(isExternal && { target: "_blank", rel: "noopener noreferrer" })}
>
{children}
</a>
),
)
},
},
}
const BlogPost = async ({ params }: BlogPageParams) => {
const { slug }: { slug: string } = await params
const locale = await getLocale()
const { slug, locale } = await params
const data = await getBlogPost(slug)
@@ -108,7 +137,7 @@ const BlogPost = async ({ params }: BlogPageParams) => {
})
return (
<Layout>
<>
<div className={styles.container}>
<div className={styles.backButton}>
<Back />
@@ -117,10 +146,11 @@ const BlogPost = async ({ params }: BlogPageParams) => {
<header className={styles.header}>
{data.mainImage && (
<Image
src={urlFor(data.mainImage).url()}
src={urlFor(data.mainImage).width(1600).fit("max").url()}
alt={data.title}
width={800}
height={400}
sizes="(max-width: 850px) 100vw, 850px"
className={styles.headerImage}
/>
)}
@@ -131,7 +161,7 @@ const BlogPost = async ({ params }: BlogPageParams) => {
<Image
className="circular"
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}
height={50}
/>
@@ -148,7 +178,7 @@ const BlogPost = async ({ params }: BlogPageParams) => {
</article>
</div>
<Footer />
</Layout>
</>
)
}
+25 -47
View File
@@ -1,13 +1,13 @@
import Layout from "@/app/[locale]/layout"
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
import { generateSeoMetadata } from "@/components/seo"
import Back from "@/components/ui/back"
import { formatEventDate } from "@/lib/dates"
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 { getLocale, getTranslations } from "next-intl/server"
import { getTranslations } from "next-intl/server"
import { FaCalendarAlt, FaMapMarkerAlt } from "react-icons/fa"
import * as styles from "./page.module.css"
@@ -15,68 +15,47 @@ import * as styles from "./page.module.css"
interface EventPageParams {
params: Promise<{
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({
params,
}: EventPageParams): Promise<Metadata> {
const { slug } = await params
const { slug, locale } = await params
const data = await getEvent(slug)
const locale = await getLocale()
const t = await getTranslations("metaTags")
if (!data) 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)
}
const t = await getTranslations({ locale, namespace: "metaTags" })
return generateSeoMetadata({
locale,
path: `/event/${slug}`,
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 { slug }: { slug: string } = await params
const locale = await getLocale()
const { slug, locale } = await params
const data = await getEvent(slug)
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 (
<Layout>
<div className={styles.container}>
<Back />
<div className={styles.event}>
@@ -87,12 +66,11 @@ const Event = async ({ params }: EventPageParams) => {
</div>
<div className={styles.metadata}>
<FaCalendarAlt />
<p>{formatDate(data.date)}</p>
<p>{formatEventDate(data.date, locale)}</p>
</div>
<p className={styles.description}>{data.description}</p>
</div>
</div>
</Layout>
)
}
+53 -17
View File
@@ -1,32 +1,68 @@
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl"
import { getLocale } from "next-intl/server"
import "@/app/globals.css"
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 { 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,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const locale = (await getLocale()) as "no" | "en"
const messages = (await import(`../../../messages/${locale}.json`)).default
const { locale } = await params
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 (
<NextIntlClientProvider locale={locale} messages={messages}>
<html lang={locale}>
<body>
<NextIntlClientProvider locale={locale} messages={clientMessages}>
<LanguageSelector />
<main>{children}</main>
{analyticsConfig?.enabled && (
<UmamiAnalytics
websiteId={analyticsConfig.websiteId}
src={analyticsConfig.src}
domains={analyticsConfig.domains}
/>
)}
<UmamiAnalytics />
</NextIntlClientProvider>
</body>
</html>
)
}
+9
View File
@@ -0,0 +1,9 @@
import NotFound from "@/components/page-sections/notFound"
import { getLocale } from "next-intl/server"
export default async function LocaleNotFound() {
const locale = await getLocale()
return <NotFound locale={locale} />
}
+15 -10
View File
@@ -1,5 +1,3 @@
import Layout from "@/app/[locale]/layout"
import About from "@/components/page-sections/about"
import BoardMembers from "@/components/page-sections/boardMembers"
import Contact from "@/components/page-sections/contact"
@@ -7,23 +5,30 @@ import Events from "@/components/page-sections/events"
import Footer from "@/components/page-sections/footer"
import Hero from "@/components/page-sections/hero"
import Office from "@/components/page-sections/office"
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
import { generateSeoMetadata } from "@/components/seo"
import { Metadata, Viewport } from "next"
import { Metadata } from "next"
import type { Locale } from "next-intl"
export const revalidate = 3600 // 1 hour
export async function generateMetadata(): Promise<Metadata> {
return generateSeoMetadata({})
interface HomePageParams {
params: Promise<{
locale: Locale
}>
}
export function generateViewport(): Viewport {
return generateSeoViewport()
export async function generateMetadata({
params,
}: HomePageParams): Promise<Metadata> {
const { locale } = await params
return generateSeoMetadata({ locale, path: "/" })
}
export default function Home() {
return (
<Layout>
<>
<Hero />
<About />
<Events />
@@ -31,6 +36,6 @@ export default function Home() {
<BoardMembers />
<Contact />
<Footer />
</Layout>
</>
)
}
+41 -11
View File
@@ -1,15 +1,43 @@
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook"
import { revalidateTag } from "next/cache"
import { NextRequest, NextResponse } from "next/server"
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret")
// Expire immediately so the first visit after a publish gets fresh content.
// The "max" profile would serve the stale version once more while refetching
const expireTag = (tag: string) => revalidateTag(tag, { expire: 0 })
if (secret !== process.env.SANITY_REVALIDATE_SECRET) {
return NextResponse.json({ message: "Invalid secret" }, { status: 401 })
export async function POST(request: NextRequest) {
const secret = process.env.SANITY_REVALIDATE_SECRET
if (!secret) {
console.error("SANITY_REVALIDATE_SECRET is not set")
return NextResponse.json(
{ message: "Webhook is not configured" },
{ status: 500 }
)
}
// Sanity signs the raw body with the webhook secret, so verify it before
// parsing
const rawBody = await request.text()
const signature = request.headers.get(SIGNATURE_HEADER_NAME) ?? ""
// Throws on a malformed signature header instead of returning false
const isValid = await isValidSignature(rawBody, signature, secret).catch(
() => false
)
if (!isValid) {
return NextResponse.json({ message: "Invalid signature" }, { status: 401 })
}
let body
try {
body = JSON.parse(rawBody)
} catch {
return NextResponse.json({ message: "Invalid JSON body" }, { status: 400 })
}
try {
const body = await request.json()
const { _type, slug } = body
console.log(
@@ -17,19 +45,21 @@ export async function POST(request: NextRequest) {
)
if (_type === "post") {
revalidateTag("blog-posts", "max")
expireTag("blog-posts")
if (slug?.current) {
revalidateTag(`post-${slug.current}`, "max")
expireTag(`post-${slug.current}`)
}
} else if (_type === "event") {
revalidateTag("events", "max")
expireTag("events")
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({
revalidated: true,
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 MemberButton from "@components/ui/memberButton"
import OtherButton from "@components/ui/otherButton"
import DiscordButton from "@/components/ui/discordButton"
import MemberButton from "@/components/ui/memberButton"
import OtherButton from "@/components/ui/otherButton"
import { useTranslations } from "next-intl"
import Image from "next/image"
+8 -38
View File
@@ -1,5 +1,5 @@
import { urlFor } from "@/lib/imageUrlBuilder"
import { sanityClient } from "@/lib/sanity"
import { type SanityImage, urlFor } from "@/lib/imageUrlBuilder"
import { getBoardMembers } from "@/lib/sanity"
import { getLocale, getTranslations } from "next-intl/server"
import Image from "next/image"
@@ -8,19 +8,13 @@ import * as styles from "./boardMembers.module.css"
interface BoardMember {
_id: string
boardMember: boolean
contactInfo?: string
name: string
role?: {
no: string
en: string
}
image?: {
asset?: {
_id: string
url: string
}
}
image: SanityImage | null
bio?: {
no: string
en: string
@@ -29,31 +23,9 @@ interface BoardMember {
const BoardMembers = async () => {
const t = await getTranslations("boardMembers")
const locale = (await getLocale()) as "no" | "en"
const locale = await getLocale()
const query = `
*[_type == "author" && boardMember == true] | order(orderRank asc) {
_id,
boardMember,
contactInfo,
name,
role {
no,
en
},
image {
asset->{
_id,
url
}
},
bio {
no,
en
}
}
`
const data: BoardMember[] = await sanityClient.fetch(query)
const data: BoardMember[] = await getBoardMembers()
return (
<section id="board-members">
@@ -67,11 +39,9 @@ const BoardMembers = async () => {
>
<Image
src={
member.image?.asset?.url
? urlFor(member.image.asset.url)
.width(512)
.height(512)
.url()
member.image?.asset
? // Cropped to a square around the hotspot
urlFor(member.image).width(512).height(512).url()
: "/person.png"
}
width={256}
+1 -3
View File
@@ -22,9 +22,7 @@ const Contact = () => {
<div className={styles.container}>
<div className={styles.item}>
<FaAt />
<a href="mailto:styret@uiogaming.no" target="_blank" rel="noreferrer">
{t("email")}
</a>
<a href="mailto:styret@uiogaming.no">{t("email")}</a>
</div>
<div className={styles.item}>
<FaDiscord />
+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 Image from "next/image"
@@ -15,36 +16,36 @@ interface Event {
slug: {
current: string
}
description: string
description: string | null
}
const DESCRIPTION_PREVIEW_LENGTH = 70
const Events = async () => {
const t = await getTranslations("events")
const tCommon = await getTranslations("common")
const locale = await getLocale()
const data: Event[] = await getAllEvents()
const events: Event[] = await getUpcomingEvents(2)
const currentDate = new Date()
const filteredEvents = data
.filter((event: Event) => {
return currentDate <= new Date(event.date)
})
.slice(0, 2)
const previewDescription = (description: string | null) => {
if (!description) return null
const formatDate = (dateString: string) => {
const date = new Date(dateString)
const options: Intl.DateTimeFormatOptions = {
weekday: "long",
month: "long",
year: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "Europe/Oslo",
}
// Count graphemes so emoji and accented characters are never cut in half
const graphemes = Array.from(new Intl.Segmenter().segment(description))
if (graphemes.length <= DESCRIPTION_PREVIEW_LENGTH) return description
return date.toLocaleString(locale, options)
const preview = graphemes
.slice(0, DESCRIPTION_PREVIEW_LENGTH)
.map(({ segment }) => segment)
.join("")
.trimEnd()
return (
<>
{preview}... <span className={styles.readMore}>{t("readMore")}</span>
</>
)
}
const mapEvents = (events: Event[]) => {
@@ -60,19 +61,10 @@ const Events = async () => {
</div>
<div className={styles.metadata}>
<FaCalendarAlt />
<p>{formatDate(event.date)}</p>
<p>{formatEventDate(event.date, locale)}</p>
</div>
</div>
<p>
{event.description.length < 70 ? (
event.description
) : (
<>
{event.description.substring(0, 70)}...{" "}
<span className={styles.readMore}>{t("readMore")}</span>
</>
)}
</p>
<p>{previewDescription(event.description)}</p>
</article>
</Link>
))
@@ -92,7 +84,7 @@ const Events = async () => {
<section id="events">
<div className={styles.container}>
<h2 className={styles.sectionHeader}>{t("title")}</h2>
<div className={styles.events}>{mapEvents(filteredEvents)}</div>
<div className={styles.events}>{mapEvents(events)}</div>
<div className={"no-mobile " + styles.image}>
<Image
src="/event.jpg"
+29
View File
@@ -0,0 +1,29 @@
import type { Locale } from "next-intl"
import { getTranslations } from "next-intl/server"
import Link from "next/link"
import * as styles from "./notFound.module.css"
const NotFound = async ({ locale }: { locale: Locale }) => {
const t = await getTranslations({ locale, namespace: "404" })
return (
<div className={styles.container}>
<h1 className={styles.centeredText}>{t("title")}</h1>
<p className={styles.centeredText}>
{t("text")}{" "}
<span role="img" aria-label="eyes">
👀
</span>
</p>
<Link
href={`/${locale}`}
className={`${styles.centeredText} ${styles.homeLink}`}
>
{t("back")}
</Link>
</div>
)
}
export default NotFound
@@ -37,4 +37,3 @@
max-height: 75vh;
border-radius: 8px;
}
+21 -8
View File
@@ -1,21 +1,33 @@
"use client"
import { useEffect, useState } from "react"
import { useSyncExternalStore } from "react"
import { useTranslations } from "next-intl"
import Image from "next/image"
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"
import * as styles from "./office.module.css"
let webGL2Support: boolean | undefined
// Checked once, since creating a WebGL context is not free
const getWebGL2Support = () => {
webGL2Support ??= Boolean(
document.createElement("canvas").getContext("webgl2")
)
return webGL2Support
}
// WebGL support never changes, so there is nothing to subscribe to
const subscribe = () => () => {}
const Office = () => {
const t = useTranslations("office")
const [supportsWebGL2, setSupportsWebGL2] = useState<boolean | null>(null)
useEffect(() => {
const canvas = document.createElement("canvas")
const gl = canvas.getContext("webgl2")
setSupportsWebGL2(Boolean(gl))
}, [])
// null on the server, which renders the fallback image
const supportsWebGL2 = useSyncExternalStore(
subscribe,
getWebGL2Support,
() => null
)
return (
<section id="office">
@@ -54,6 +66,7 @@ const Office = () => {
className={styles.map}
src="https://use.mazemap.com/embed.html#v=1&config=uio&zlevel=3&center=10.712463,59.932424&zoom=17&campusid=797&sharepoitype=poi&sharepoi=1000986035&utm_medium=iframe"
allow="geolocation"
loading="lazy"
title="Map by MazeMap"
></iframe>
</div>
+42 -23
View File
@@ -1,41 +1,68 @@
import { Metadata, Viewport } from "next"
import { getLocale, getTranslations } from "next-intl/server"
import { Metadata } from "next"
import type { Locale } from "next-intl"
import { getTranslations } from "next-intl/server"
import { routing } from "@/i18n/routing"
export const SITE_URL = "https://uiogaming.no"
export const THEME_COLOR = "#7d36e7"
/** Adds the locale prefix to a path, e.g. ("en", "/event/x") -> "/en/event/x" */
export const getLocalizedPath = (locale: Locale, path: string) =>
path === "/" ? `/${locale}` : `/${locale}${path}`
type SeoProps = {
locale: Locale
/** Path without the locale prefix, e.g. "/" or "/event/some-slug" */
path: string
title?: string
description?: string
imageURL?: string
author?: string
/** Marks the page as an article, e.g. a blog post */
publishedTime?: string
}
export async function generateSeoMetadata({
locale,
path,
title,
description,
imageURL,
author,
publishedTime,
}: SeoProps): Promise<Metadata> {
const t = await getTranslations("metaTags")
const locale = await getLocale()
const t = await getTranslations({ locale, namespace: "metaTags" })
const defaultTitle = "UiO Gaming"
const metaDescription = description || t("description")
const siteUrl = "https://uiogaming.no"
const themeColor = "#7d36e7"
return {
title: title ? `${title} | ${defaultTitle}` : defaultTitle,
description: metaDescription,
authors: author ? [{ name: author }] : [{ name: "UiO Gaming" }],
icons: {
icon: "/logo_2024_neon_pink.png",
shortcut: "/logo_2024_neon_pink.png",
apple: "/logo_2024_neon_pink.png",
// Tells search engines that the Norwegian and English versions are the
// same page. The unprefixed path redirects based on the browser language
alternates: {
canonical: getLocalizedPath(locale, path),
languages: {
...Object.fromEntries(
routing.locales.map(pageLocale => [
pageLocale,
getLocalizedPath(pageLocale, path),
])
),
"x-default": path,
},
},
openGraph: {
title: title || defaultTitle,
description: metaDescription,
type: "website",
...(publishedTime
? { type: "article", publishedTime, authors: author && [author] }
: { type: "website" }),
locale: locale,
url: getLocalizedPath(locale, path),
siteName: defaultTitle,
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,
},
],
@@ -57,20 +84,12 @@ export async function generateSeoMetadata({
card: imageURL ? "summary_large_image" : "summary",
title: title || defaultTitle,
description: metaDescription,
images: imageURL ? [imageURL] : [`${siteUrl}/logo_2024_neon_trans.png`],
images: imageURL ? [imageURL] : ["/logo_2024_neon_trans.png"],
},
other: {
"theme-color": themeColor,
"msapplication-TileColor": themeColor,
"msapplication-navbutton-color": themeColor,
"msapplication-TileColor": THEME_COLOR,
"msapplication-navbutton-color": THEME_COLOR,
"apple-mobile-web-app-status-bar-style": "black-translucent",
},
}
}
export function generateSeoViewport(): Viewport {
return {
themeColor: "#7d36e7",
colorScheme: "dark",
}
}
+17 -12
View File
@@ -1,27 +1,32 @@
"use client"
import { useLocale, useTranslations } from "next-intl"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { FaArrowLeft } from "react-icons/fa"
import { useRouter } from "@/i18n/navigation"
import * as styles from "./back.module.css"
// document.referrer only reflects the last full page load, so it misses
// client-side navigations within the site. Only used where the Navigation
// API isn't supported
const cameFromThisSite = () =>
document.referrer !== "" &&
new URL(document.referrer).origin === window.location.origin
const Back = () => {
const locale = useLocale()
const router = useRouter()
const t = useTranslations("buttons")
const handleBack = () => {
const currentDomain = window.location.hostname
const referrer = document.referrer
// The Navigation API only lists this site's own history entries, so
// canGoBack is true exactly when the previous page was on this site
const canGoBack = window.navigation?.canGoBack ?? cameFromThisSite()
const isSameDomain =
referrer && new URL(referrer).hostname === currentDomain
if (window.history.length > 1 && isSameDomain) {
window.history.back()
if (canGoBack) {
router.back()
} else {
router.push(`/${locale}`)
router.push("/")
}
}
@@ -31,7 +36,7 @@ const Back = () => {
onClick={handleBack}
>
<FaArrowLeft />
<p>{t("back")}</p>
<span>{t("back")}</span>
</button>
)
}
+13 -10
View File
@@ -1,33 +1,36 @@
"use client"
import { GB, NO } from "country-flag-icons/react/3x2"
import { useLocale } from "next-intl"
import { useRouter, usePathname } from "next/navigation"
import { type Locale, useLocale } from "next-intl"
import { usePathname, useRouter } from "@/i18n/navigation"
import * as styles from "./languageSelector.module.css"
const LanguageSelector = () => {
const locale = useLocale()
const router = useRouter()
// Pathname without the locale prefix
const pathname = usePathname()
const changeLocale = (newLocale: "no" | "en") => {
// Remove the current locale from the pathname
const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}/, "")
router.push(`/${newLocale}${pathWithoutLocale}`)
const changeLocale = (newLocale: Locale) => {
// Read at click time, since useSearchParams() would opt pages out of
// static rendering
const { search, hash } = window.location
router.push(`${pathname}${search}${hash}`, { locale: newLocale })
}
return (
<div className={styles.languageSwitcher}>
{locale === "no" ? (
<button onClick={() => changeLocale("en")} className={styles.button}>
<GB className={styles.flag} title="English flag" />
English
<GB className={styles.flag} aria-hidden="true" />
<span lang="en">English</span>
</button>
) : (
<button onClick={() => changeLocale("no")} className={styles.button}>
<NO className={styles.flag} title="Norwegian flag" />
Norsk
<NO className={styles.flag} aria-hidden="true" />
<span lang="no">Norsk</span>
</button>
)}
</div>
+9 -7
View File
@@ -36,15 +36,13 @@
border-radius: 10px;
}
.dropdownButton {
.dropdownLink {
display: block;
width: 100%;
margin-top: 4px;
color: black;
background-color: #cccccc;
border: none;
border-radius: 999em !important;
cursor: pointer;
outline: none;
border-radius: 10px;
border-radius: 999em;
text-align: left;
font-size: 1.6rem;
box-shadow: var(--shadow);
@@ -52,7 +50,11 @@
padding-right: 2em;
}
.dropdownButton:hover {
.dropdownLink:first-child {
margin-top: 8px;
}
.dropdownLink:hover {
color: black;
background-color: #e6e6e6;
}
+62 -13
View File
@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useEffect, useId, useRef, useState } from "react"
import { useLocale, useTranslations } from "next-intl"
import * as styles from "./otherButton.module.css"
@@ -11,7 +11,37 @@ const OtherButton = () => {
const locale = useLocale()
const [isOpen, setIsOpen] = useState(false)
const toggleDropdown = () => setIsOpen(!isOpen)
const toggleDropdown = () => setIsOpen(open => !open)
const containerRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const dropdownId = useId()
// Close when clicking outside the dropdown or pressing Escape
useEffect(() => {
if (!isOpen) return
const handlePointerDown = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false)
buttonRef.current?.focus()
}
}
document.addEventListener("pointerdown", handlePointerDown)
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("pointerdown", handlePointerDown)
document.removeEventListener("keydown", handleKeyDown)
}
}, [isOpen])
const statutesUrl =
locale === "en"
@@ -28,22 +58,41 @@ const OtherButton = () => {
]
return (
<div className={styles.dropdownContainer}>
<button onClick={toggleDropdown} className={styles.button}>
<div
ref={containerRef}
className={styles.dropdownContainer}
// Close when keyboard focus moves out of the dropdown
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsOpen(false)
}
}}
>
<button
ref={buttonRef}
onClick={toggleDropdown}
className={styles.button}
aria-expanded={isOpen}
aria-controls={dropdownId}
>
{t("other")}{" "}
<span className={isOpen ? styles.arrowUp : styles.arrowDown}></span>
<span
className={isOpen ? styles.arrowUp : styles.arrowDown}
aria-hidden="true"
></span>
</button>
{isOpen && (
<div className={styles.dropdown}>
{links.map((link, index) => (
<button
key={index}
onClick={() => window.open(link.url, "_blank")}
className={styles.dropdownButton}
style={{ marginTop: index === 0 ? "8px" : "4px" }}
<div id={dropdownId} className={styles.dropdown}>
{links.map(link => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className={styles.dropdownLink}
>
{link.label}
</button>
</a>
))}
</div>
)}
+9 -37
View File
@@ -1,49 +1,21 @@
"use client"
import Script from "next/script"
interface UmamiAnalyticsProps {
websiteId: string
src?: string
domains?: string[]
}
// Umami ignores visits from other domains, so local and preview deployments
// aren't counted
const DOMAINS = ["uiogaming.no", "www.uiogaming.no"]
export default function UmamiAnalytics({
websiteId,
src = "https://analytics.umami.is/script.js",
domains,
}: UmamiAnalyticsProps) {
if (!websiteId) {
console.warn("Umami: Website ID is required")
return null
}
export default function UmamiAnalytics() {
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID
const src =
process.env.NEXT_PUBLIC_UMAMI_SRC || "https://analytics.umami.is/script.js"
// Only load analytics in production or if explicitly enabled
if (process.env.NODE_ENV !== "production") {
return null
}
// Domain validation (client-side check as additional layer)
if (domains && typeof window !== "undefined") {
const currentDomain = window.location.hostname
const isAllowedDomain = domains.some(
domain => currentDomain === domain || currentDomain.endsWith(`.${domain}`)
)
if (!isAllowedDomain) {
console.warn(
`Umami: Domain ${currentDomain} not in allowed list:`,
domains
)
return null
}
}
if (!websiteId || process.env.NODE_ENV !== "production") return null
return (
<Script
src={src}
data-website-id={websiteId}
data-domains={domains?.join(",")}
data-domains={DOMAINS.join(",")}
strategy="afterInteractive"
/>
)
-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 { cookies } from "next/headers"
import { locale as rootLocale } from "next/root-params"
export default getRequestConfig(async () => {
const cookieStore = await cookies()
const locale = cookieStore.get("locale")?.value || "no"
import { routing } from "./routing"
export default getRequestConfig(async ({ locale }) => {
// An explicit locale (e.g. getTranslations({ locale })) takes precedence.
// The root param can be any first path segment until the layout rejects it,
// so fall back to the default instead of throwing
const requested = locale ?? (await rootLocale())
const resolved = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
timeZone: "Europe/Oslo",
locale: resolved,
messages: (await import(`../../messages/${resolved}.json`)).default,
}
})
+14
View File
@@ -0,0 +1,14 @@
import { defineRouting } from "next-intl/routing"
export const routing = defineRouting({
locales: ["no", "en"],
defaultLocale: "no",
localePrefix: "always",
})
// Types getLocale(), useLocale() etc. as "no" | "en" instead of string
declare module "next-intl" {
interface AppConfig {
Locale: (typeof routing.locales)[number]
}
}
-29
View File
@@ -1,29 +0,0 @@
interface AnalyticsConfig {
websiteId: string
src: string
domains: string[]
enabled: boolean
}
export function getAnalyticsConfig(): AnalyticsConfig | null {
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID
const src =
process.env.NEXT_PUBLIC_UMAMI_SRC || "https://analytics.umami.is/script.js"
if (!websiteId) {
return null
}
// Define allowed domains
const domains = ["uiogaming.no", "www.uiogaming.no"]
// Additional environment-based checks
const enabled = process.env.NODE_ENV === "production"
return {
websiteId,
src,
domains,
enabled,
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { Locale } from "next-intl"
const TIME_ZONE = "Europe/Oslo"
// Event dates are usually stored as Oslo wall-clock time without an offset
// ("2026-11-05 17:00"). The Studio's datetime field stores ISO timestamps
// instead, so both formats have to be handled
const LOCAL_DATE_FORMAT = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/
const osloPartsFormatter = new Intl.DateTimeFormat("en-GB", {
timeZone: TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
const getOsloParts = (date: Date) =>
Object.fromEntries(
osloPartsFormatter
.formatToParts(date)
.map(({ type, value }) => [type, value])
)
/** Formats a date as Oslo wall-clock time, e.g. "2026-11-05 17:00" */
export const toOsloDateString = (date: Date) => {
const { year, month, day, hour, minute } = getOsloParts(date)
return `${year}-${month}-${day} ${hour}:${minute}`
}
// How far Oslo's clock is ahead of UTC at the given moment (1 or 2 hours)
const getOsloOffset = (timestamp: number) => {
const { year, month, day, hour, minute } = getOsloParts(new Date(timestamp))
const osloAsUtc = Date.UTC(+year, +month - 1, +day, +hour, +minute)
return osloAsUtc - Math.floor(timestamp / 60_000) * 60_000
}
/**
* Parses an event date. Oslo wall-clock strings are interpreted in Oslo time
* regardless of the server's timezone
*/
export const parseEventDate = (value: string) => {
const match = value.match(LOCAL_DATE_FORMAT)
if (!match) return new Date(value)
const [year, month, day, hour, minute] = match.slice(1).map(Number)
const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute)
// The offset depends on daylight saving time at the resulting moment, so
// guess with the offset at the wall-clock time, then correct
const guess = wallClockAsUtc - getOsloOffset(wallClockAsUtc)
return new Date(wallClockAsUtc - getOsloOffset(guess))
}
/** Formats an event date like "torsdag 5. november 2026 kl. 17:00" */
export const formatEventDate = (value: string, locale: Locale) =>
parseEventDate(value).toLocaleString(locale, {
weekday: "long",
month: "long",
year: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: TIME_ZONE,
})
+11 -3
View File
@@ -1,9 +1,17 @@
import imageUrlBuilder from "@sanity/image-url"
import type { SanityImageSource } from "@sanity/image-url/lib/types/types"
import {
createImageUrlBuilder,
type SanityImageObject,
type SanityImageSource,
} from "@sanity/image-url"
import { sanityClient } from "./sanity"
const builder = imageUrlBuilder(sanityClient)
const builder = createImageUrlBuilder(sanityClient)
// An image field as stored in Sanity: an asset reference plus the crop and
// hotspot chosen in the Studio. Pass the whole object to urlFor, not the
// asset URL, or the crop and hotspot are ignored
export type SanityImage = SanityImageObject
export function urlFor(source: SanityImageSource) {
return builder.image(source)
+80 -16
View File
@@ -1,10 +1,12 @@
import { createClient } from "@sanity/client"
import { toOsloDateString } from "./dates"
export const sanityClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: "2025-02-19",
useCdn: true,
useCdn: false,
})
export async function getBlogPost(slug: string) {
@@ -15,19 +17,9 @@ export async function getBlogPost(slug: string) {
title,
author-> {
name,
image {
asset-> {
_id,
url
}
}
},
mainImage {
asset-> {
_id,
url
}
image
},
mainImage,
excerpt,
body
}
@@ -71,9 +63,20 @@ export async function getEvent(slug: string) {
)
}
export async function getAllEvents() {
export async function getUpcomingEvents(limit: number) {
// Most event dates are Oslo wall-clock strings ("2026-11-05 17:00"), which
// sort correctly as text, so compare them against the current Oslo time in
// the same format. ISO timestamps from the Studio are compared as datetimes
const now = toOsloDateString(new Date())
const query = `
*[_type == "event"] | order(date asc) {
*[
_type == "event" &&
select(
defined(dateTime(date)) => dateTime(date) >= dateTime(now()),
date >= $now
)
] | order(date asc)[0...$limit] {
_id,
title,
location,
@@ -85,7 +88,7 @@ export async function getAllEvents() {
return sanityClient.fetch(
query,
{},
{ now, limit },
{
cache: "force-cache",
next: {
@@ -95,3 +98,64 @@ export async function getAllEvents() {
}
)
}
export async function getBoardMembers() {
const query = `
*[_type == "author" && boardMember == true] | order(orderRank asc) {
_id,
contactInfo,
name,
role {
no,
en
},
image,
bio {
no,
en
}
}
`
return sanityClient.fetch(
query,
{},
{
cache: "force-cache",
next: {
tags: ["authors"],
revalidate: 3600, // 1 hour
},
}
)
}
export async function getSitemapDocuments(): Promise<{
posts: { slug: string; _updatedAt: string }[]
events: { slug: string; _updatedAt: string }[]
}> {
const query = `
{
"posts": *[_type == "post" && defined(slug.current)] {
"slug": slug.current,
_updatedAt
},
"events": *[_type == "event" && defined(slug.current)] {
"slug": slug.current,
_updatedAt
}
}
`
return sanityClient.fetch(
query,
{},
{
cache: "force-cache",
next: {
tags: ["blog-posts", "events"],
revalidate: 3600, // 1 hour
},
}
)
}
-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": {
"@/*": [
"./src/*"
],
"@components/*": [
"./src/components/*"
],
"@lib/*": [
"./src/lib/*"
]
}
},