mirror of
https://github.com/UiO-Gaming/uiogaming.no.git
synced 2026-11-05 13:56:47 +01:00
Merge branch 'slop/sloppy-fixes'
# Conflicts: # README.md
This commit is contained in:
55 files changed
+1901
-1023
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# Installed and built inside the image
|
||||
node_modules
|
||||
.next
|
||||
|
||||
# Secrets are passed in by docker compose, never baked into the image
|
||||
.env*
|
||||
|
||||
.git
|
||||
.github
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
*.md
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,40 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Public values from env.example. The build prerenders pages with data
|
||||
# from Sanity, so it needs to know which project to read from
|
||||
env:
|
||||
NEXT_PUBLIC_SANITY_PROJECT_ID: mmqlu667
|
||||
NEXT_PUBLIC_SANITY_DATASET: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
run: npx prettier --check .
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
# Generates the route and next/root-params types tsc relies on
|
||||
- name: Type check
|
||||
run: npx next typegen && npx tsc --noEmit
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
branches:
|
||||
- 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
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
@@ -2,3 +2,4 @@
|
||||
package.json
|
||||
package-lock.json
|
||||
public
|
||||
tsconfig.json
|
||||
+1
-1
@@ -7,6 +7,6 @@
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "1.0.0"
|
||||
}
|
||||
+9
-2
@@ -1,9 +1,16 @@
|
||||
# Huskeliste for bidrag
|
||||
|
||||
- Sørg for å formatere koden med Prettier
|
||||
- Sørg for å formatere koden med Prettier (`npm run format`)
|
||||
|
||||
- Sjekk at koden ikke har lint-feil (`npm run lint`)
|
||||
|
||||
- Sjekk om nettsiden bygges uten problemer (`npm run build`)
|
||||
|
||||
- Formatering, linting, typesjekk og bygg kjøres også automatisk på alle pull requests
|
||||
|
||||
- Commit messages samt issues skal helst være skrevet på engelsk
|
||||
|
||||
- Kode skal skrives på engelsk. Det vil si variabelnavn osv. Tekst som vises til bruker skal være på norsk
|
||||
- Kode skal skrives på engelsk. Det vil si variabelnavn osv.
|
||||
|
||||
- Nettsiden finnes på både norsk og engelsk. Tekst som vises til bruker skal ikke skrives rett i komponentene, men legges inn i både [messages/no.json](messages/no.json) og [messages/en.json](messages/en.json)
|
||||
|
||||
+36
-5
@@ -1,15 +1,46 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:26-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
COPY package-lock.json ./
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
|
||||
FROM node:26-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# NEXT_PUBLIC_* values are inlined at build time, and pages are prerendered
|
||||
# with data from Sanity during the build
|
||||
ARG NEXT_PUBLIC_SANITY_PROJECT_ID
|
||||
ARG NEXT_PUBLIC_SANITY_DATASET
|
||||
ARG NEXT_PUBLIC_UMAMI_SRC
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Only the standalone server and its traced dependencies end up in the final
|
||||
# image, not the full node_modules or the source
|
||||
FROM node:26-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=9000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# The server writes regenerated pages to .next, so the files must be owned by
|
||||
# the user it runs as
|
||||
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
||||
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=node:node /app/public ./public
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 9000
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: [
|
||||
{
|
||||
|
||||
Generated
+1011
-497
File diff suppressed because it is too large.
Load diff
+19
-20
@@ -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",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "node",
|
||||
"include-v-in-tag": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
// Catch unknown paths below a locale so they render [locale]/not-found.tsx
|
||||
export default function CatchAll() {
|
||||
notFound()
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import Layout from "@/app/[locale]/layout"
|
||||
|
||||
import Footer from "@/components/page-sections/footer"
|
||||
import { 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import NotFound from "@/components/page-sections/notFound"
|
||||
|
||||
import { getLocale } from "next-intl/server"
|
||||
|
||||
export default async function LocaleNotFound() {
|
||||
const locale = await getLocale()
|
||||
|
||||
return <NotFound locale={locale} />
|
||||
}
|
||||
+15
-10
@@ -1,5 +1,3 @@
|
||||
import Layout from "@/app/[locale]/layout"
|
||||
|
||||
import About from "@/components/page-sections/about"
|
||||
import 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import Layout from "@/app/layout"
|
||||
|
||||
import { generateSeoMetadata, generateSeoViewport } from "@/components/seo"
|
||||
|
||||
import { Metadata, Viewport } from "next"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Link from "next/link"
|
||||
|
||||
import * as styles from "./global-not-found.module.css"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("404")
|
||||
|
||||
return generateSeoMetadata({
|
||||
title: t("title"),
|
||||
description: "Uh oh :(",
|
||||
})
|
||||
}
|
||||
|
||||
export function generateViewport(): Viewport {
|
||||
return generateSeoViewport()
|
||||
}
|
||||
|
||||
const NotFoundPage = () => {
|
||||
const t = useTranslations("404")
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.centeredText}>{t("title")}</h1>
|
||||
<p className={styles.centeredText}>
|
||||
{t("text")}{" "}
|
||||
<span role="img" aria-label="eyes">
|
||||
👀
|
||||
</span>
|
||||
</p>
|
||||
<Link href="/" className={`${styles.centeredText} ${styles.homeLink}`}>
|
||||
{t("back")}
|
||||
</Link>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFoundPage
|
||||
@@ -1,26 +0,0 @@
|
||||
import "@/app/globals.css"
|
||||
|
||||
import type { Metadata } from "next"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "UiO Gaming",
|
||||
description:
|
||||
"En hobbyforening som ønsker skape et sosialt miljø rundt gaming og e-sport på UiO!",
|
||||
icons: {
|
||||
icon: "/logo_2024_neon_pink.png",
|
||||
shortcut: "/logo_2024_neon_pink.png",
|
||||
apple: "/logo_2024_neon_pink.png",
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/en/404")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SITE_URL } from "@/components/seo"
|
||||
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: "/api/",
|
||||
},
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getLocalizedPath, SITE_URL } from "@/components/seo"
|
||||
import { routing } from "@/i18n/routing"
|
||||
import { getSitemapDocuments } from "@/lib/sanity"
|
||||
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
export const revalidate = 3600 // 1 hour
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const { posts, events } = await getSitemapDocuments()
|
||||
|
||||
const pages: { path: string; lastModified?: string }[] = [
|
||||
{ path: "/" },
|
||||
...posts.map(post => ({
|
||||
path: `/blog/${post.slug}`,
|
||||
lastModified: post._updatedAt,
|
||||
})),
|
||||
...events.map(event => ({
|
||||
path: `/event/${event.slug}`,
|
||||
lastModified: event._updatedAt,
|
||||
})),
|
||||
]
|
||||
|
||||
// One entry per language version, each linking to the other versions
|
||||
return pages.flatMap(({ path, lastModified }) => {
|
||||
const languages = Object.fromEntries(
|
||||
routing.locales.map(locale => [
|
||||
locale,
|
||||
`${SITE_URL}${getLocalizedPath(locale, path)}`,
|
||||
])
|
||||
)
|
||||
|
||||
return routing.locales.map(locale => ({
|
||||
url: languages[locale],
|
||||
lastModified,
|
||||
alternates: { languages },
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import DiscordButton from "@components/ui/discordButton"
|
||||
import 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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,29 @@
|
||||
import type { Locale } from "next-intl"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import Link from "next/link"
|
||||
|
||||
import * as styles from "./notFound.module.css"
|
||||
|
||||
const NotFound = async ({ locale }: { locale: Locale }) => {
|
||||
const t = await getTranslations({ locale, namespace: "404" })
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.centeredText}>{t("title")}</h1>
|
||||
<p className={styles.centeredText}>
|
||||
{t("text")}{" "}
|
||||
<span role="img" aria-label="eyes">
|
||||
👀
|
||||
</span>
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className={`${styles.centeredText} ${styles.homeLink}`}
|
||||
>
|
||||
{t("back")}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFound
|
||||
@@ -37,4 +37,3 @@
|
||||
max-height: 75vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -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¢er=10.712463,59.932424&zoom=17&campusid=797&sharepoitype=poi&sharepoi=1000986035&utm_medium=iframe"
|
||||
allow="geolocation"
|
||||
loading="lazy"
|
||||
title="Map by MazeMap"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
+42
-23
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -36,15 +36,13 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dropdownButton {
|
||||
.dropdownLink {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
color: black;
|
||||
background-color: #cccccc;
|
||||
border: none;
|
||||
border-radius: 999em !important;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
border-radius: 10px;
|
||||
border-radius: 999em;
|
||||
text-align: left;
|
||||
font-size: 1.6rem;
|
||||
box-shadow: var(--shadow);
|
||||
@@ -52,7 +50,11 @@
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
.dropdownButton:hover {
|
||||
.dropdownLink:first-child {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dropdownLink:hover {
|
||||
color: black;
|
||||
background-color: #e6e6e6;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useEffect, useId, useRef, useState } from "react"
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import * as styles from "./otherButton.module.css"
|
||||
@@ -11,7 +11,37 @@ const OtherButton = () => {
|
||||
const locale = useLocale()
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const toggleDropdown = () => setIsOpen(!isOpen)
|
||||
const toggleDropdown = () => setIsOpen(open => !open)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const dropdownId = useId()
|
||||
|
||||
// Close when clicking outside the dropdown or pressing Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown)
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown)
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const statutesUrl =
|
||||
locale === "en"
|
||||
@@ -28,22 +58,41 @@ const OtherButton = () => {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={styles.dropdownContainer}>
|
||||
<button onClick={toggleDropdown} className={styles.button}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.dropdownContainer}
|
||||
// Close when keyboard focus moves out of the dropdown
|
||||
onBlur={event => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={toggleDropdown}
|
||||
className={styles.button}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={dropdownId}
|
||||
>
|
||||
{t("other")}{" "}
|
||||
<span className={isOpen ? styles.arrowUp : styles.arrowDown}></span>
|
||||
<span
|
||||
className={isOpen ? styles.arrowUp : styles.arrowDown}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={styles.dropdown}>
|
||||
{links.map((link, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => window.open(link.url, "_blank")}
|
||||
className={styles.dropdownButton}
|
||||
style={{ marginTop: index === 0 ? "8px" : "4px" }}
|
||||
<div id={dropdownId} className={styles.dropdown}>
|
||||
{links.map(link => (
|
||||
<a
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.dropdownLink}
|
||||
>
|
||||
{link.label}
|
||||
</button>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,49 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import Script from "next/script"
|
||||
|
||||
interface UmamiAnalyticsProps {
|
||||
websiteId: string
|
||||
src?: string
|
||||
domains?: string[]
|
||||
}
|
||||
// Umami ignores visits from other domains, so local and preview deployments
|
||||
// aren't counted
|
||||
const DOMAINS = ["uiogaming.no", "www.uiogaming.no"]
|
||||
|
||||
export default function UmamiAnalytics({
|
||||
websiteId,
|
||||
src = "https://analytics.umami.is/script.js",
|
||||
domains,
|
||||
}: UmamiAnalyticsProps) {
|
||||
if (!websiteId) {
|
||||
console.warn("Umami: Website ID is required")
|
||||
return null
|
||||
}
|
||||
export default function UmamiAnalytics() {
|
||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
const src =
|
||||
process.env.NEXT_PUBLIC_UMAMI_SRC || "https://analytics.umami.is/script.js"
|
||||
|
||||
// Only load analytics in production or if explicitly enabled
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return null
|
||||
}
|
||||
|
||||
// Domain validation (client-side check as additional layer)
|
||||
if (domains && typeof window !== "undefined") {
|
||||
const currentDomain = window.location.hostname
|
||||
const isAllowedDomain = domains.some(
|
||||
domain => currentDomain === domain || currentDomain.endsWith(`.${domain}`)
|
||||
)
|
||||
|
||||
if (!isAllowedDomain) {
|
||||
console.warn(
|
||||
`Umami: Domain ${currentDomain} not in allowed list:`,
|
||||
domains
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (!websiteId || process.env.NODE_ENV !== "production") return null
|
||||
|
||||
return (
|
||||
<Script
|
||||
src={src}
|
||||
data-website-id={websiteId}
|
||||
data-domains={domains?.join(",")}
|
||||
data-domains={DOMAINS.join(",")}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { getRequestConfig } from "next-intl/server"
|
||||
|
||||
const locales = ["no", "en"]
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const locale = await requestLocale
|
||||
|
||||
if (!locale || !locales.includes(locale as "no" | "en")) notFound()
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createNavigation } from "next-intl/navigation"
|
||||
|
||||
import { routing } from "./routing"
|
||||
|
||||
// Locale-aware wrappers around Next's navigation APIs
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||
createNavigation(routing)
|
||||
+14
-7
@@ -1,13 +1,20 @@
|
||||
import { hasLocale } from "next-intl"
|
||||
import { getRequestConfig } from "next-intl/server"
|
||||
import { 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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineRouting } from "next-intl/routing"
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ["no", "en"],
|
||||
defaultLocale: "no",
|
||||
localePrefix: "always",
|
||||
})
|
||||
|
||||
// Types getLocale(), useLocale() etc. as "no" | "en" instead of string
|
||||
declare module "next-intl" {
|
||||
interface AppConfig {
|
||||
Locale: (typeof routing.locales)[number]
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
interface AnalyticsConfig {
|
||||
websiteId: string
|
||||
src: string
|
||||
domains: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function getAnalyticsConfig(): AnalyticsConfig | null {
|
||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
const src =
|
||||
process.env.NEXT_PUBLIC_UMAMI_SRC || "https://analytics.umami.is/script.js"
|
||||
|
||||
if (!websiteId) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Define allowed domains
|
||||
const domains = ["uiogaming.no", "www.uiogaming.no"]
|
||||
|
||||
// Additional environment-based checks
|
||||
const enabled = process.env.NODE_ENV === "production"
|
||||
|
||||
return {
|
||||
websiteId,
|
||||
src,
|
||||
domains,
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Locale } from "next-intl"
|
||||
|
||||
const TIME_ZONE = "Europe/Oslo"
|
||||
|
||||
// Event dates are usually stored as Oslo wall-clock time without an offset
|
||||
// ("2026-11-05 17:00"). The Studio's datetime field stores ISO timestamps
|
||||
// instead, so both formats have to be handled
|
||||
const LOCAL_DATE_FORMAT = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/
|
||||
|
||||
const osloPartsFormatter = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
})
|
||||
|
||||
const getOsloParts = (date: Date) =>
|
||||
Object.fromEntries(
|
||||
osloPartsFormatter
|
||||
.formatToParts(date)
|
||||
.map(({ type, value }) => [type, value])
|
||||
)
|
||||
|
||||
/** Formats a date as Oslo wall-clock time, e.g. "2026-11-05 17:00" */
|
||||
export const toOsloDateString = (date: Date) => {
|
||||
const { year, month, day, hour, minute } = getOsloParts(date)
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
// How far Oslo's clock is ahead of UTC at the given moment (1 or 2 hours)
|
||||
const getOsloOffset = (timestamp: number) => {
|
||||
const { year, month, day, hour, minute } = getOsloParts(new Date(timestamp))
|
||||
const osloAsUtc = Date.UTC(+year, +month - 1, +day, +hour, +minute)
|
||||
return osloAsUtc - Math.floor(timestamp / 60_000) * 60_000
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an event date. Oslo wall-clock strings are interpreted in Oslo time
|
||||
* regardless of the server's timezone
|
||||
*/
|
||||
export const parseEventDate = (value: string) => {
|
||||
const match = value.match(LOCAL_DATE_FORMAT)
|
||||
if (!match) return new Date(value)
|
||||
|
||||
const [year, month, day, hour, minute] = match.slice(1).map(Number)
|
||||
const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute)
|
||||
|
||||
// The offset depends on daylight saving time at the resulting moment, so
|
||||
// guess with the offset at the wall-clock time, then correct
|
||||
const guess = wallClockAsUtc - getOsloOffset(wallClockAsUtc)
|
||||
return new Date(wallClockAsUtc - getOsloOffset(guess))
|
||||
}
|
||||
|
||||
/** Formats an event date like "torsdag 5. november 2026 kl. 17:00" */
|
||||
export const formatEventDate = (value: string, locale: Locale) =>
|
||||
parseEventDate(value).toLocaleString(locale, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: TIME_ZONE,
|
||||
})
|
||||
@@ -1,9 +1,17 @@
|
||||
import imageUrlBuilder from "@sanity/image-url"
|
||||
import type { SanityImageSource } from "@sanity/image-url/lib/types/types"
|
||||
import {
|
||||
createImageUrlBuilder,
|
||||
type SanityImageObject,
|
||||
type SanityImageSource,
|
||||
} from "@sanity/image-url"
|
||||
|
||||
import { sanityClient } from "./sanity"
|
||||
|
||||
const builder = imageUrlBuilder(sanityClient)
|
||||
const builder = createImageUrlBuilder(sanityClient)
|
||||
|
||||
// An image field as stored in Sanity: an asset reference plus the crop and
|
||||
// hotspot chosen in the Studio. Pass the whole object to urlFor, not the
|
||||
// asset URL, or the crop and hotspot are ignored
|
||||
export type SanityImage = SanityImageObject
|
||||
|
||||
export function urlFor(source: SanityImageSource) {
|
||||
return builder.image(source)
|
||||
|
||||
+80
-16
@@ -1,10 +1,12 @@
|
||||
import { createClient } from "@sanity/client"
|
||||
|
||||
import { toOsloDateString } from "./dates"
|
||||
|
||||
export const sanityClient = createClient({
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
|
||||
apiVersion: "2025-02-19",
|
||||
useCdn: true,
|
||||
useCdn: false,
|
||||
})
|
||||
|
||||
export async function getBlogPost(slug: string) {
|
||||
@@ -15,19 +17,9 @@ export async function getBlogPost(slug: string) {
|
||||
title,
|
||||
author-> {
|
||||
name,
|
||||
image {
|
||||
asset-> {
|
||||
_id,
|
||||
url
|
||||
}
|
||||
}
|
||||
},
|
||||
mainImage {
|
||||
asset-> {
|
||||
_id,
|
||||
url
|
||||
}
|
||||
image
|
||||
},
|
||||
mainImage,
|
||||
excerpt,
|
||||
body
|
||||
}
|
||||
@@ -71,9 +63,20 @@ export async function getEvent(slug: string) {
|
||||
)
|
||||
}
|
||||
|
||||
export async function getAllEvents() {
|
||||
export async function getUpcomingEvents(limit: number) {
|
||||
// Most event dates are Oslo wall-clock strings ("2026-11-05 17:00"), which
|
||||
// sort correctly as text, so compare them against the current Oslo time in
|
||||
// the same format. ISO timestamps from the Studio are compared as datetimes
|
||||
const now = toOsloDateString(new Date())
|
||||
|
||||
const query = `
|
||||
*[_type == "event"] | order(date asc) {
|
||||
*[
|
||||
_type == "event" &&
|
||||
select(
|
||||
defined(dateTime(date)) => dateTime(date) >= dateTime(now()),
|
||||
date >= $now
|
||||
)
|
||||
] | order(date asc)[0...$limit] {
|
||||
_id,
|
||||
title,
|
||||
location,
|
||||
@@ -85,7 +88,7 @@ export async function getAllEvents() {
|
||||
|
||||
return sanityClient.fetch(
|
||||
query,
|
||||
{},
|
||||
{ now, limit },
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
@@ -95,3 +98,64 @@ export async function getAllEvents() {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function getBoardMembers() {
|
||||
const query = `
|
||||
*[_type == "author" && boardMember == true] | order(orderRank asc) {
|
||||
_id,
|
||||
contactInfo,
|
||||
name,
|
||||
role {
|
||||
no,
|
||||
en
|
||||
},
|
||||
image,
|
||||
bio {
|
||||
no,
|
||||
en
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
return sanityClient.fetch(
|
||||
query,
|
||||
{},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: ["authors"],
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function getSitemapDocuments(): Promise<{
|
||||
posts: { slug: string; _updatedAt: string }[]
|
||||
events: { slug: string; _updatedAt: string }[]
|
||||
}> {
|
||||
const query = `
|
||||
{
|
||||
"posts": *[_type == "post" && defined(slug.current)] {
|
||||
"slug": slug.current,
|
||||
_updatedAt
|
||||
},
|
||||
"events": *[_type == "event" && defined(slug.current)] {
|
||||
"slug": slug.current,
|
||||
_updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
return sanityClient.fetch(
|
||||
query,
|
||||
{},
|
||||
{
|
||||
cache: "force-cache",
|
||||
next: {
|
||||
tags: ["blog-posts", "events"],
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import createMiddleware from "next-intl/middleware"
|
||||
|
||||
export default createMiddleware({
|
||||
locales: ["no", "en"],
|
||||
defaultLocale: "no",
|
||||
localePrefix: "always",
|
||||
})
|
||||
|
||||
export const config = {
|
||||
// Match only internationalized pathnames
|
||||
matcher: ["/", "/(no|en)/:path*"],
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import createMiddleware from "next-intl/middleware"
|
||||
|
||||
import { routing } from "./i18n/routing"
|
||||
|
||||
export default createMiddleware(routing)
|
||||
|
||||
export const config = {
|
||||
// Match everything except API routes, Next internals and files with an
|
||||
// extension, so paths without a locale get redirected to one
|
||||
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
|
||||
}
|
||||
@@ -25,12 +25,6 @@
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@components/*": [
|
||||
"./src/components/*"
|
||||
],
|
||||
"@lib/*": [
|
||||
"./src/lib/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user