Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a8076813b
|
||
|
|
d9efb8c51f | ||
|
|
a89c2f609d | ||
|
|
15982ed804
|
||
|
|
d20de4b455
|
||
|
|
a09b7ae871
|
||
|
|
1a18096cf6 | ||
|
|
f647dd869d | ||
|
|
28346d6907 | ||
|
|
b60f8a92e1 | ||
|
|
2e54e32ea6 | ||
|
|
115afd4275 | ||
|
|
67641c5719 | ||
|
|
70ba50c515 | ||
|
|
633237f262 | ||
|
|
eab0e0bbab | ||
|
|
7822daa557 | ||
|
|
773d21b709 | ||
|
|
b8e352ba22 | ||
|
|
703ab5a32c | ||
|
|
edf0eed3dc | ||
|
|
1d506a7b4c | ||
|
|
aca183a4a2 | ||
|
|
3a0ad44e06 | ||
|
|
0eb47846e7 | ||
|
|
5c612d995d | ||
|
|
35c175c1ad | ||
|
|
19cda1b817 | ||
|
|
7c344a2c74 | ||
|
|
5b135ce712 | ||
|
|
b84781ebe5 | ||
|
|
aca584791e | ||
|
|
a1729494b4 | ||
|
|
d04afb3e0c | ||
|
|
c997b74d01 | ||
|
|
c5c366884a | ||
|
|
b05ecf757d | ||
|
|
1f82345a2f | ||
|
|
d03278ef0d | ||
|
|
da3d26d16b | ||
|
|
a8b31a5379 | ||
|
|
30da54c78e | ||
|
|
13463d4eeb | ||
|
|
d44fbfa9d7 | ||
|
|
52b8f2b7b7 | ||
|
|
38f1ba69b4 | ||
|
|
39ad3947fd | ||
|
|
14c1403e6d
|
||
|
|
e238eed619 | ||
|
|
08bc43942a | ||
|
|
b0e343a390 | ||
|
|
c7d646362b | ||
|
|
2183175de6 | ||
|
|
bf6f282155 | ||
|
|
8480327a4c | ||
|
|
30abf637ac | ||
|
|
93b2dc4d4e
|
||
|
|
183e519a19
|
||
|
|
1c02352aad
|
||
|
|
abcc39ce6d
|
||
|
|
e9cff6ab84
|
||
|
|
6cc19ea4fb | ||
|
|
743df3691c | ||
|
|
afa4e5580c | ||
|
|
8a7ecf223d | ||
|
|
3c6c55afd9
|
||
|
|
38d015fb20
|
||
|
|
d871eee968
|
||
|
|
2591eacd14 | ||
|
|
017f5154b1
|
||
|
|
1434528e4f
|
||
|
|
0e283cc794
|
||
|
|
e04422acb4
|
||
|
|
30b49ee8c9
|
||
|
|
267bce6422
|
||
|
|
540ce583c9
|
||
|
|
48ff006c13
|
||
|
|
0fc5a7a7b6
|
||
|
|
864aef1b7b
|
||
|
|
50bdf6503e
|
||
|
|
b4bb073d87
|
||
|
|
8e7f4b7659
|
||
|
|
b00debdbb0
|
||
|
|
bd6012b0ab
|
||
|
|
d313f903ee
|
||
|
|
d9155dec10
|
||
|
|
95f75d9bb1
|
||
|
|
d9a6019c7c
|
||
|
|
ae59bfeb5f
|
||
|
|
171a3053f3 | ||
|
|
ee32c1723b
|
||
|
|
e2d2836e69 | ||
|
|
e7d7e29edf
|
||
|
|
e07e6ebcd0
|
||
|
|
0a3894e14d
|
||
|
|
626d749611
|
||
|
|
cf84a5086e
|
||
|
|
f2d7b15c4e
|
||
|
|
5bd7321558 | ||
|
|
400af2fb31 | ||
|
|
c7185a79f3 | ||
|
|
2c6e4c2412
|
||
|
|
31a4b1cf81
|
||
|
|
ef764a4b8d
|
||
|
|
d21053b544
|
||
|
|
828468e70e
|
||
|
|
3d798a8913
|
||
|
|
7d17abc2c5
|
||
|
|
dff4669321
|
||
|
|
dca3c08ee1
|
||
|
|
42c75194ed
|
||
|
|
ead41eeaea
|
||
|
|
d20022899a
|
||
|
|
e0735a5c97
|
||
|
|
cb7cdb8128
|
||
|
|
36ea11441f
|
||
|
|
fda4846200
|
||
|
|
91dc9542e4
|
||
|
|
0e65652e76
|
||
|
|
8a869a10eb
|
||
|
|
c0200f67f2
|
||
|
|
26e2e75765
|
||
|
|
d5131824d9
|
||
|
|
9394f1f7b4 | ||
|
|
0f5c7306d3 | ||
|
|
9f7dacfb21 | ||
|
|
ca81402a45 | ||
|
|
39dfeb21a3 | ||
|
|
3184215135 | ||
|
|
75c59eb8bb | ||
|
|
1e2444150b | ||
|
|
51a58f5c55 | ||
|
|
9ff744e5b1 | ||
|
|
d28db43afd | ||
|
|
d7ad18c60f | ||
|
|
6dacc4e5b5 | ||
|
|
568b0524e0 | ||
|
|
1c7ca69a81 | ||
|
|
89d0e6ab9c | ||
|
|
c29e9d8f57 | ||
|
|
aec4a9406e | ||
|
|
224847b1a7 | ||
|
|
dc5ac4b2c7 | ||
|
|
232f69472c | ||
|
|
0a62a61ccf | ||
|
|
2857cc7048 | ||
|
|
37c9618ad6 | ||
|
|
a9c250063c | ||
|
|
e2367eb29e | ||
|
|
8097ea89cd | ||
|
|
3d5c9d4bd9 | ||
|
|
fd5d8d1cf1 | ||
|
|
13c0eab15c | ||
|
|
88df17818c |
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 @@
|
||||
* @LBlend @centauridesigns
|
||||
@@ -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
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
# Never run two deploys of this repo at the same time
|
||||
concurrency: deploy
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
install -m 600 -D /dev/null ~/.ssh/id_ed25519
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H ${{ vars.SSH_HOST }} > ~/.ssh/known_hosts
|
||||
|
||||
# The deploy key can only run /usr/local/bin/uiogaming-deploy on the server. It takes the
|
||||
# service name from the top-level docker-compose.yml (see drift/driftsdokumentasjon.md).
|
||||
- name: Deploy
|
||||
run: ssh ${{ secrets.SSH_USER }}@${{ vars.SSH_HOST }} uiogaming.no
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: rm -rf ~/.ssh
|
||||
@@ -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,69 +1,41 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# dotenv environment variable files
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# gatsby files
|
||||
.cache/
|
||||
public
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# Mac files
|
||||
.DS_Store
|
||||
|
||||
# Yarn
|
||||
yarn-error.log
|
||||
.pnp/
|
||||
.pnp.js
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -2,3 +2,4 @@
|
||||
package.json
|
||||
package-lock.json
|
||||
public
|
||||
tsconfig.json
|
||||
@@ -7,6 +7,6 @@
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "1.0.1"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0.1](https://github.com/UiO-Gaming/uiogaming.no/compare/uiogaming.no-1.0.0...uiogaming.no-1.0.1) (2026-11-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* filter function not working in firefox ([52e8087](https://github.com/UiO-Gaming/uiogaming.no/commit/52e8087229153d8465a9b257c1da5778b997e20a))
|
||||
* photo credit text placement ([95f75d9](https://github.com/UiO-Gaming/uiogaming.no/commit/95f75d9bb1022407915da2eba5e9245e8b9e2ee4))
|
||||
@@ -1,7 +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)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
FROM node:26-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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 ["node", "server.js"]
|
||||
@@ -1,38 +1,62 @@
|
||||
# uiogaming.no
|
||||
|
||||
Dette er repoet for UiO Gaming sin nettside. Nettsiden består av tre deler hvorav to av disse er i dette repoet. Her finner du frontend for nettsiden samt et webhook API. Dette APIet brukes til å trigge bygging av nettsiden når innhold endrer seg i CMS, backenden. Repoet for dette CMSet er [her](https://github.com/UiO-Gaming/uiogaming.no-backend). Nettsiden er bygget med GatsbyJS.
|
||||
<img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/574daf4d-085c-4df0-ab86-7f843a1a403c" />
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
Dette er repoet for UiO Gaming sin nettside. Nettsiden består av to deler. Her finner du frontend for nettsiden. Repoet for CMSet er [her](https://github.com/UiO-Gaming/uiogaming.no-backend).
|
||||
|
||||
## Oppsett
|
||||
|
||||
### Frontend
|
||||
1. Lag konfigurasjonsfil
|
||||
|
||||
1. Installer avhengigheter
|
||||
I repoet er det en fil som heter [env.example](env.example). Kopier denne og sett navnet på den nye filen til `.env`.
|
||||
|
||||
Fyll så ut verdiene.
|
||||
|
||||
2. Installer avhengigheter
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Kjør nettsiden
|
||||
3. Bygg nettsiden
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
npm run build
|
||||
```
|
||||
|
||||
4. Kjør nettsiden
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Om du skal utvikle kan du kjøre følgende istedenfor steg 3 og 4
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Webhook API
|
||||
|
||||
Om du vil at innhold skal oppdatere seg når innholdet endres i backend må du kjøre dette APIet. Dette gjelder bare om du bruker en produksjonsversjon av nettsiden (`yarn serve`)
|
||||
Om du vil at innhold skal oppdatere seg når innholdet endres i backend må du kjøre dette APIet. Det kjører automatisk når du starter applikasjonen.
|
||||
|
||||
```bash
|
||||
cd webhook-api
|
||||
yarn install
|
||||
yarn start
|
||||
```
|
||||
MEN
|
||||
|
||||
Du må også spesifisere URL for webhook i Sanity sitt dashbord. Du kan lese mer om det [her](https://www.sanity.io/docs/webhooks). Denne URLen skal peke på APIet ditt.
|
||||
Du må sette opp et webhook i [Sanity sitt dashbord](https://www.sanity.io/manage) under _API → Webhooks_:
|
||||
|
||||
### Docker
|
||||
- **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`
|
||||
|
||||
Docker er for øyeblikket ikke støttet, men vil støttes fremtiden.
|
||||
Sanity bruker _Secret_ til å signere hver forespørsel med headeren `sanity-webhook-signature`, og APIet avviser forespørsler uten gyldig signatur med `401`. Hemmeligheten skal altså ligge i _Secret_-feltet, ikke i URLen.
|
||||
|
||||
Webhooket oppdaterer blogginnlegg, arrangementer og forfattere med en gang. Annet innhold oppdateres først når cachen går ut (opptil en time).
|
||||
|
||||
Du kan lese mer om webhooks [her](https://www.sanity.io/docs/webhooks).
|
||||
|
||||
## Vil du bidra?
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
uiogaming.no:
|
||||
container_name: uiogaming.no
|
||||
build:
|
||||
context: .
|
||||
# Values are read from .env
|
||||
args:
|
||||
NEXT_PUBLIC_SANITY_PROJECT_ID: ${NEXT_PUBLIC_SANITY_PROJECT_ID}
|
||||
NEXT_PUBLIC_SANITY_DATASET: ${NEXT_PUBLIC_SANITY_DATASET}
|
||||
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
env_file: .env
|
||||
ports:
|
||||
- 9000:9000
|
||||
environment:
|
||||
- TZ=Europe/Oslo
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,7 @@
|
||||
NEXT_PUBLIC_SANITY_PROJECT_ID=mmqlu667
|
||||
NEXT_PUBLIC_SANITY_DATASET=production
|
||||
|
||||
SANITY_REVALIDATE_SECRET=your-super-secret-webhook-token-here
|
||||
|
||||
NEXT_PUBLIC_UMAMI_SRC=https://umami.uiogaming.no/script.js
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=1e985053-9158-4d62-be07-0fe83a9dce6e
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "eslint/config"
|
||||
import nextVitals from "eslint-config-next/core-web-vitals"
|
||||
import nextTs from "eslint-config-next/typescript"
|
||||
|
||||
// eslint-config-next already ignores .next, out, build and next-env.d.ts
|
||||
const eslintConfig = defineConfig([...nextVitals, ...nextTs])
|
||||
|
||||
export default eslintConfig
|
||||
@@ -1,50 +0,0 @@
|
||||
module.exports = {
|
||||
siteMetadata: {
|
||||
title: `UiO Gaming`,
|
||||
description: `En hobbyforening som ønsker skape et sosialt miljø rundt gaming og e-sport på UiO!`,
|
||||
author: `@uiogaming`,
|
||||
url: `https://uiogaming.no`,
|
||||
},
|
||||
plugins: [
|
||||
`gatsby-plugin-react-helmet`,
|
||||
{
|
||||
resolve: "gatsby-plugin-manifest",
|
||||
options: {
|
||||
icon: "src/images/logo.jpg",
|
||||
},
|
||||
},
|
||||
`gatsby-plugin-image`,
|
||||
{
|
||||
resolve: `gatsby-source-filesystem`,
|
||||
options: {
|
||||
name: `images`,
|
||||
path: `${__dirname}/src/images`,
|
||||
},
|
||||
},
|
||||
`gatsby-transformer-sharp`,
|
||||
`gatsby-plugin-sharp`,
|
||||
{
|
||||
resolve: `gatsby-source-sanity`,
|
||||
options: {
|
||||
projectId: `mmqlu667`,
|
||||
dataset: `production`,
|
||||
watchMode: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: `gatsby-plugin-manifest`,
|
||||
options: {
|
||||
name: `UiO Gaming`,
|
||||
short_name: `uiogaming.no`,
|
||||
start_url: `/`,
|
||||
background_color: `#1D1320`,
|
||||
theme_color: `#7D36E7`,
|
||||
display: `minimal-ui`,
|
||||
icon: `src/images/logo.jpg`, // This path is relative to the root of the site.
|
||||
},
|
||||
},
|
||||
// this (optional) plugin enables Progressive Web App + Offline functionality
|
||||
// To learn more, visit: https://gatsby.dev/offline
|
||||
// `gatsby-plugin-offline`,
|
||||
],
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
const path = require("path")
|
||||
|
||||
module.exports.createPages = async ({ graphql, actions }) => {
|
||||
const { createPage } = actions
|
||||
const blogTemplate = path.resolve("./src/pages/blogPost.js")
|
||||
const eventTemplate = path.resolve("./src/pages/eventPost.js")
|
||||
const result = await graphql(`
|
||||
query {
|
||||
allSanityPost {
|
||||
nodes {
|
||||
_id
|
||||
slug {
|
||||
current
|
||||
}
|
||||
}
|
||||
}
|
||||
allSanityEvent {
|
||||
nodes {
|
||||
_id
|
||||
slug {
|
||||
current
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const posts = result.data.allSanityPost.nodes
|
||||
|
||||
posts.forEach(post => {
|
||||
const path = `/blog/${post.slug.current}`
|
||||
createPage({
|
||||
path,
|
||||
component: blogTemplate,
|
||||
context: {
|
||||
id: post._id,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const events = result.data.allSanityEvent.nodes
|
||||
|
||||
events.forEach(event => {
|
||||
const path = `/event/${event.slug.current}`
|
||||
createPage({
|
||||
path,
|
||||
component: eventTemplate,
|
||||
context: {
|
||||
id: event._id,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module "*.module.css" {
|
||||
const classes: { readonly [key: string]: string }
|
||||
export = classes
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"common": {
|
||||
"photo": "Photo",
|
||||
"join": "Become a member",
|
||||
"statutes": "Statutes",
|
||||
"complaint": "Complaint",
|
||||
"documents": "Documents"
|
||||
},
|
||||
"metaTags": {
|
||||
"time": "Time",
|
||||
"location": "Location",
|
||||
"description": "A student organisation that aims to create an inclusive environment around gaming and e-sports for the students at UiO"
|
||||
},
|
||||
"buttons": {
|
||||
"back": "back"
|
||||
},
|
||||
"404": {
|
||||
"title": "404: Page not found",
|
||||
"text": "Our journey ended here. Please don't leave us :(",
|
||||
"back": "Back to safe grounds"
|
||||
},
|
||||
"hero": {
|
||||
"text": "A student organisation that aims to create an inclusive environment around gaming and e-sports for the students at UiO"
|
||||
},
|
||||
"about": {
|
||||
"title": "Who are we?",
|
||||
"imageAlt": "2 members playing together",
|
||||
"text": "UiO Gaming is a student organisation with a primary focus on creating an inclusive environment around gaming and e-sports. We wish to establish a community where it’s easy to make new friends and cultivate your gaming interests. Each week we host both casual and more intense gamenights, as well as physical events such as board game nights and other social gatherings. Our main channel is a Discord server where you can meet new people with common interests and play games with other students.",
|
||||
"interested": "Interested?",
|
||||
"other": "Other"
|
||||
},
|
||||
"events": {
|
||||
"title": "Upcoming Events",
|
||||
"imageAlt": "Members playing board games",
|
||||
"readMore": "Read more",
|
||||
"noEvents": "No upcoming events"
|
||||
},
|
||||
"office": {
|
||||
"title": "The Office",
|
||||
"text": "We have an office at Chateau Neuf where you can use our gear and hang out with other members! We've got all the latest and greatest consoles, 6 high-end PCs as well as a plethora of board games.",
|
||||
"tip": "Tip: You can look around the office by clicking and dragging the image below 👀",
|
||||
"pcRoomImageAlt": "PC-room with 6 gaming setups"
|
||||
},
|
||||
"boardMembers": {
|
||||
"title": "The Board",
|
||||
"imageAlt": "Portrait of board member",
|
||||
"noContact": "No contact info"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Find us",
|
||||
"email": "E-mail"
|
||||
},
|
||||
"footer": {
|
||||
"sourceCode": "Source code",
|
||||
"admin": "Admin page",
|
||||
"orgNum": "Org. No."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"common": {
|
||||
"photo": "Foto",
|
||||
"join": "Bli medlem",
|
||||
"statutes": "Vedtekter",
|
||||
"complaint": "Klage",
|
||||
"documents": "Dokumenter"
|
||||
},
|
||||
"metaTags": {
|
||||
"time": "Tid",
|
||||
"location": "Sted",
|
||||
"description": "En hobbyforening som ønsker å skape et sosialt miljø rundt gaming og e-sport på UiO!"
|
||||
},
|
||||
"buttons": {
|
||||
"back": "tilbake"
|
||||
},
|
||||
"404": {
|
||||
"title": "404: Siden finnes ikke",
|
||||
"text": "Du har nådd en blindvei, men ikke på Blindern",
|
||||
"back": "Tilbake til hjemmesiden"
|
||||
},
|
||||
"hero": {
|
||||
"text": "En hobbyforening som ønsker å skape et sosialt miljø rundt gaming og e-sport på UiO"
|
||||
},
|
||||
"about": {
|
||||
"title": "Hvem er vi?",
|
||||
"imageAlt": "2 medlemmer som spiller sammen",
|
||||
"text": "UiO Gaming er en forening med hovedfokus på å danne et inkluderende fellesskap rundt videospill og e-sport. Hos oss er det enkelt å skaffe seg venner, ettersom alle har felles interesser. Vi arrangerer ukentlige spillkvelder og fysiske sosiale arrangementer, som for eksempel brettspillkvelder og sosiale sammenkomster. Vår hovedkanal er en aktiv Discord-server hvor man kan møte nye folk med felles interesser og game med andre studenter.",
|
||||
"interested": "Interessert?",
|
||||
"other": "Annet"
|
||||
},
|
||||
"events": {
|
||||
"title": "Hva skjer?",
|
||||
"imageAlt": "Medlemmer som spiller brettspill",
|
||||
"readMore": "Les mer",
|
||||
"noEvents": "Ingen kommende arrangementer"
|
||||
},
|
||||
"office": {
|
||||
"title": "Kontoret",
|
||||
"text": "Vi har foreningskontor på Chateau Neuf der man kan bruke utstyret vårt og henge med andre medlemmer! Vi har blant annet alle de nyeste konsollene, 6 gaming-PCer samt et bredt utvalg av brettspill.",
|
||||
"tip": "Tips: Du kan kikke rundt i kontoret ved å klikke og dra på bildet nedenfor 👀",
|
||||
"pcRoomImageAlt": "PC-rom som inneholder 6 spillmaskiner"
|
||||
},
|
||||
"boardMembers": {
|
||||
"title": "Styret",
|
||||
"imageAlt": "Bilde av styremedlem",
|
||||
"noContact": "Ingen kontaktinfo"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Finn oss",
|
||||
"email": "E-post"
|
||||
},
|
||||
"footer": {
|
||||
"sourceCode": "Kildekode",
|
||||
"admin": "Adminområdet",
|
||||
"orgNum": "Org.Nr."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextConfig } from "next"
|
||||
import createNextIntlPlugin from "next-intl/plugin"
|
||||
|
||||
const withNextIntl = createNextIntlPlugin()
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Bundles a minimal server with only the needed dependencies, used by the
|
||||
// Docker image
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "cdn.sanity.io",
|
||||
port: "",
|
||||
pathname: "/images/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default withNextIntl(nextConfig)
|
||||
@@ -1,38 +1,37 @@
|
||||
{
|
||||
"name": "uiogaming.no",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"description": "UiO Gaming Website",
|
||||
"version": "0.2.0",
|
||||
"author": "LBlend <leanderfurumo@protonmail.com",
|
||||
"author": "Leander Furumo <leander@furumo.eu>",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start -p 9000",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@sanity/block-content-to-react": "^3.0.0",
|
||||
"gatsby": "^3.4.1",
|
||||
"gatsby-plugin-image": "^1.4.0",
|
||||
"gatsby-plugin-manifest": "^3.4.0",
|
||||
"gatsby-plugin-react-helmet": "^4.4.0",
|
||||
"gatsby-plugin-sharp": "^3.7.1",
|
||||
"gatsby-source-filesystem": "^3.7.1",
|
||||
"gatsby-source-sanity": "^7.0.4",
|
||||
"gatsby-transformer-sharp": "^3.7.1",
|
||||
"moment-timezone": "^0.5.33",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-icons": "^4.2.0",
|
||||
"react-pannellum": "^0.2.14"
|
||||
"@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": {
|
||||
"prettier": "2.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "gatsby build",
|
||||
"develop": "gatsby develop",
|
||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"",
|
||||
"start": "npm run develop",
|
||||
"serve": "gatsby serve",
|
||||
"clean": "gatsby clean",
|
||||
"test": "echo \"Write tests! -> https://gatsby.dev/unit-testing\" && exit 1"
|
||||
"@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",
|
||||
|
||||
|
Before Width: | Height: | Size: 5.7 MiB After Width: | Height: | Size: 5.7 MiB |
|
After Width: | Height: | Size: 5.9 MiB |
|
After Width: | Height: | Size: 18 MiB |
|
After Width: | Height: | Size: 16 MiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 696 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "node",
|
||||
"include-v-in-tag": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
// Catch unknown paths below a locale so they render [locale]/not-found.tsx
|
||||
export default function CatchAll() {
|
||||
notFound()
|
||||
}
|
||||
@@ -5,10 +5,12 @@
|
||||
}
|
||||
.backButton {
|
||||
justify-self: start;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
.article {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.header {
|
||||
display: grid;
|
||||
@@ -21,6 +23,9 @@
|
||||
.headerImage {
|
||||
padding: 0;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
place-self: center;
|
||||
@@ -53,6 +58,7 @@
|
||||
display: grid;
|
||||
gap: 2em;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -60,11 +66,6 @@
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Block content styling */
|
||||
/* .content {
|
||||
padding-inline-start: 1rem;
|
||||
border-inline-start: 8px solid var(--main-color);
|
||||
} */
|
||||
.content blockquote {
|
||||
color: rgb(225, 225, 225);
|
||||
font-weight: 100;
|
||||
@@ -77,18 +78,9 @@
|
||||
.content h2,
|
||||
.content h3,
|
||||
.content h4 {
|
||||
margin-top: 3rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* .content blockquote::before {
|
||||
content: '"';
|
||||
font-size: 3rem;
|
||||
}
|
||||
.content blockquote::after {
|
||||
content: '"';
|
||||
font-size: 3rem;
|
||||
} */
|
||||
|
||||
@media (min-width: 60em) {
|
||||
.content {
|
||||
padding: 0;
|
||||
@@ -0,0 +1,185 @@
|
||||
import Footer from "@/components/page-sections/footer"
|
||||
import { generateSeoMetadata } from "@/components/seo"
|
||||
import Back from "@/components/ui/back"
|
||||
|
||||
import { type SanityImage, urlFor } from "@/lib/imageUrlBuilder"
|
||||
import { getBlogPost } from "@/lib/sanity"
|
||||
|
||||
import { PortableText, PortableTextComponents } from "@portabletext/react"
|
||||
import { Metadata } from "next"
|
||||
import type { Locale } from "next-intl"
|
||||
import { notFound } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
|
||||
import * as styles from "./page.module.css"
|
||||
|
||||
interface BlogPageParams {
|
||||
params: Promise<{
|
||||
slug: string
|
||||
locale: Locale
|
||||
}>
|
||||
}
|
||||
|
||||
type ImageValue = Partial<SanityImage> & {
|
||||
alt?: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
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, locale } = await params
|
||||
const data = await getBlogPost(slug)
|
||||
|
||||
if (!data) notFound()
|
||||
|
||||
return generateSeoMetadata({
|
||||
locale,
|
||||
path: `/blog/${slug}`,
|
||||
title: data.title,
|
||||
description: data.excerpt,
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
const components: PortableTextComponents = {
|
||||
types: {
|
||||
image: ({ value }: { value: ImageValue }) => {
|
||||
if (!value?.asset) return null
|
||||
return (
|
||||
<div className={styles.imageContainer}>
|
||||
<Image
|
||||
// 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 && (
|
||||
<p className={styles.imageCaption}>{value.caption}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
marks: {
|
||||
link: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
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={href}
|
||||
className={styles.link}
|
||||
{...(isExternal && { target: "_blank", rel: "noopener noreferrer" })}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const BlogPost = async ({ params }: BlogPageParams) => {
|
||||
const { slug, locale } = await params
|
||||
|
||||
const data = await getBlogPost(slug)
|
||||
|
||||
if (!data) return notFound()
|
||||
|
||||
const createdAt = new Date(data._createdAt).toLocaleDateString(locale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.backButton}>
|
||||
<Back />
|
||||
</div>
|
||||
<article className={styles.article}>
|
||||
<header className={styles.header}>
|
||||
{data.mainImage && (
|
||||
<Image
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<h1 className={styles.title}>{data.title}</h1>
|
||||
<p className={styles.excerpt}>{data.excerpt}</p>
|
||||
<div className={styles.author}>
|
||||
{data.author?.image && (
|
||||
<Image
|
||||
className="circular"
|
||||
alt={data.author.name}
|
||||
src={urlFor(data.author.image).width(100).height(100).url()}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.authorNameSection}>
|
||||
<p>{data.author?.name}</p>
|
||||
<p>{createdAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
<PortableText value={data.body} components={components} />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlogPost
|
||||
@@ -1,13 +1,9 @@
|
||||
body {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
min-height: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 1rem;
|
||||
place-content: center;
|
||||
min-height: 100vh;
|
||||
padding-block: 6rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.event {
|
||||
@@ -37,4 +33,5 @@ body {
|
||||
|
||||
.description {
|
||||
margin-top: 1rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { generateSeoMetadata } from "@/components/seo"
|
||||
import Back from "@/components/ui/back"
|
||||
|
||||
import { formatEventDate } from "@/lib/dates"
|
||||
import { getEvent } from "@/lib/sanity"
|
||||
|
||||
import { Metadata } from "next"
|
||||
import type { Locale } from "next-intl"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { FaCalendarAlt, FaMapMarkerAlt } from "react-icons/fa"
|
||||
|
||||
import * as styles from "./page.module.css"
|
||||
|
||||
interface EventPageParams {
|
||||
params: Promise<{
|
||||
slug: string
|
||||
locale: Locale
|
||||
}>
|
||||
}
|
||||
|
||||
// Render events on first visit and cache them, instead of on every request
|
||||
export async function generateStaticParams() {
|
||||
return []
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: EventPageParams): Promise<Metadata> {
|
||||
const { slug, locale } = await params
|
||||
const data = await getEvent(slug)
|
||||
|
||||
if (!data) notFound()
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "metaTags" })
|
||||
|
||||
return generateSeoMetadata({
|
||||
locale,
|
||||
path: `/event/${slug}`,
|
||||
title: data.title,
|
||||
description: [
|
||||
`${t("time")}: ${formatEventDate(data.date, locale)}`,
|
||||
data.location && `${t("location")}: ${data.location}`,
|
||||
data.description && `\n${data.description}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
})
|
||||
}
|
||||
|
||||
const Event = async ({ params }: EventPageParams) => {
|
||||
const { slug, locale } = await params
|
||||
|
||||
const data = await getEvent(slug)
|
||||
|
||||
if (!data) return notFound()
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Back />
|
||||
<div className={styles.event}>
|
||||
<h1>{data.title}</h1>
|
||||
<div className={styles.metadata}>
|
||||
<FaMapMarkerAlt />
|
||||
<p>{data.location}</p>
|
||||
</div>
|
||||
<div className={styles.metadata}>
|
||||
<FaCalendarAlt />
|
||||
<p>{formatEventDate(data.date, locale)}</p>
|
||||
</div>
|
||||
<p className={styles.description}>{data.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Event
|
||||
@@ -0,0 +1,68 @@
|
||||
import "@/app/globals.css"
|
||||
|
||||
import { hasLocale, NextIntlClientProvider } from "next-intl"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { getMessages } from "next-intl/server"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import LanguageSelector from "@/components/ui/languageSelector"
|
||||
import { SITE_URL, THEME_COLOR } from "@/components/seo"
|
||||
import UmamiAnalytics from "@/components/umamiAnalytics"
|
||||
import { routing } from "@/i18n/routing"
|
||||
|
||||
// Namespaces used by client components. Server components read their
|
||||
// translations on the server, so only these need to be sent to the browser.
|
||||
// Add to this list when a client component starts using a new namespace
|
||||
const CLIENT_NAMESPACES = ["about", "buttons", "common", "office"]
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// Resolves relative URLs in metadata, like Open Graph images and alternates
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: "UiO Gaming",
|
||||
description:
|
||||
"En hobbyforening som ønsker å skape et sosialt miljø rundt gaming og e-sport på UiO!",
|
||||
icons: {
|
||||
icon: "/logo_2024_neon_pink.png",
|
||||
shortcut: "/logo_2024_neon_pink.png",
|
||||
apple: "/logo_2024_neon_pink.png",
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: THEME_COLOR,
|
||||
colorScheme: "dark",
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map(locale => ({ locale }))
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
|
||||
// [locale] matches any first path segment, e.g. /favicon.txt or /de
|
||||
if (!hasLocale(routing.locales, locale)) notFound()
|
||||
|
||||
const messages = await getMessages()
|
||||
const clientMessages = Object.fromEntries(
|
||||
CLIENT_NAMESPACES.map(namespace => [namespace, messages[namespace]])
|
||||
)
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<NextIntlClientProvider locale={locale} messages={clientMessages}>
|
||||
<LanguageSelector />
|
||||
<main>{children}</main>
|
||||
<UmamiAnalytics />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import NotFound from "@/components/page-sections/notFound"
|
||||
|
||||
import { getLocale } from "next-intl/server"
|
||||
|
||||
export default async function LocaleNotFound() {
|
||||
const locale = await getLocale()
|
||||
|
||||
return <NotFound locale={locale} />
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import About from "@/components/page-sections/about"
|
||||
import BoardMembers from "@/components/page-sections/boardMembers"
|
||||
import Contact from "@/components/page-sections/contact"
|
||||
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 } from "@/components/seo"
|
||||
|
||||
import { Metadata } from "next"
|
||||
import type { Locale } from "next-intl"
|
||||
|
||||
export const revalidate = 3600 // 1 hour
|
||||
|
||||
interface HomePageParams {
|
||||
params: Promise<{
|
||||
locale: Locale
|
||||
}>
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: HomePageParams): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
|
||||
return generateSeoMetadata({ locale, path: "/" })
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<About />
|
||||
<Events />
|
||||
<Office />
|
||||
<BoardMembers />
|
||||
<Contact />
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook"
|
||||
import { revalidateTag } from "next/cache"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
// 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 })
|
||||
|
||||
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 { _type, slug } = body
|
||||
|
||||
console.log(
|
||||
`Revalidating content: ${_type}${slug ? ` (${slug.current})` : ""}`
|
||||
)
|
||||
|
||||
if (_type === "post") {
|
||||
expireTag("blog-posts")
|
||||
if (slug?.current) {
|
||||
expireTag(`post-${slug.current}`)
|
||||
}
|
||||
} else if (_type === "event") {
|
||||
expireTag("events")
|
||||
if (slug?.current) {
|
||||
expireTag(`event-${slug.current}`)
|
||||
}
|
||||
} else if (_type === "author") {
|
||||
expireTag("authors")
|
||||
// Blog posts show the author's name and image
|
||||
expireTag("blog-posts")
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
revalidated: true,
|
||||
now: Date.now(),
|
||||
type: _type,
|
||||
slug: slug?.current,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Error revalidating:", err)
|
||||
return NextResponse.json({ message: "Error revalidating" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,400;0,900;1,100;1,400;1,900&display=swap");
|
||||
|
||||
/* ===== Variables ===== */
|
||||
|
||||
:root {
|
||||
--main-color: #7D36E7;
|
||||
--main-background-color: #1D1320;
|
||||
--secondary-background-color: #261F31;
|
||||
--main-color: #7d36e7;
|
||||
--main-background-color: #1d1320;
|
||||
--secondary-background-color: #261f31;
|
||||
--footer-color: rgb(12, 12, 15);
|
||||
|
||||
--header-color: rgb(255, 255, 255);
|
||||
--text-color: #EEEEEE;
|
||||
--light-text-color: #CCCCCC;
|
||||
--link-color: #7D36E7;
|
||||
--link-color-hover: #D1B3FF;
|
||||
--text-color: #eeeeee;
|
||||
--light-text-color: #cccccc;
|
||||
--link-color: #7d36e7;
|
||||
--link-color-hover: #d1b3ff;
|
||||
|
||||
--main-font: "Nunito", sans-serif;
|
||||
|
||||
@@ -90,8 +92,6 @@ select {
|
||||
|
||||
/* ===== Base styling ===== */
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,400;0,900;1,100;1,400;1,900&display=swap");
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0.5em;
|
||||
}
|
||||
@@ -107,7 +107,7 @@ select {
|
||||
background-color: var(--main-color);
|
||||
}
|
||||
a::selection {
|
||||
color: blue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -161,6 +161,7 @@ section {
|
||||
padding: 7em 1em;
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
section:nth-child(2n + 1) {
|
||||
background-color: var(--secondary-background-color);
|
||||
@@ -173,9 +174,9 @@ section h2 {
|
||||
text-align: center;
|
||||
justify-self: center;
|
||||
}
|
||||
@media (min-width: 60em) {
|
||||
@media (min-width: 70em) {
|
||||
section {
|
||||
padding: 10em 5em;
|
||||
padding: 10em 4em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +220,13 @@ section h2 {
|
||||
mask-image: radial-gradient(white, black);
|
||||
-webkit-mask-image: -webkit-radial-gradient(white, black);
|
||||
}
|
||||
@media (min-width: 60em) {
|
||||
.photo-credit,
|
||||
.photo-credit:hover {
|
||||
color: var(--light-text-color);
|
||||
text-align: end;
|
||||
place-self: end;
|
||||
}
|
||||
@media (min-width: 70em) {
|
||||
.no-mobile {
|
||||
display: block;
|
||||
}
|
||||
@@ -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,16 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as styles from "./back.module.css"
|
||||
|
||||
import { FaArrowLeft } from "react-icons/fa"
|
||||
|
||||
const Back = () => (
|
||||
<button
|
||||
className={`${styles.container} ${styles.button}`}
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<p>Tilbake</p>
|
||||
</button>
|
||||
)
|
||||
|
||||
export default Back
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as styles from "./memberButton.module.css"
|
||||
|
||||
const MemberButton = () => (
|
||||
<a
|
||||
href="https://innmelding.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.button}
|
||||
>
|
||||
Bli medlem!
|
||||
</a>
|
||||
)
|
||||
export default MemberButton
|
||||
@@ -1,16 +0,0 @@
|
||||
.button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 1.6rem;
|
||||
background-color: var(--main-color);
|
||||
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 999em;
|
||||
|
||||
color: var(--text-color);
|
||||
}
|
||||
.button:hover {
|
||||
color: white;
|
||||
background-color: #A76CFF;
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<main>{children}</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Layout.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default Layout
|
||||
@@ -4,16 +4,11 @@
|
||||
gap: 10rem;
|
||||
}
|
||||
|
||||
.textContent {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.joinSection {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
place-items: center;
|
||||
margin: 3.5rem 0;
|
||||
margin-top: 3.5rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.joinSection h3 {
|
||||
@@ -24,8 +19,19 @@
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.image {
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
.image img {
|
||||
border-radius: 1rem;
|
||||
object-fit: cover;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
@media (min-width: 60em) {
|
||||
@media (min-width: 70em) {
|
||||
.container {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3rem;
|
||||
@@ -36,4 +42,10 @@
|
||||
.buttons {
|
||||
flex-direction: row;
|
||||
}
|
||||
.image {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
justify-self: right;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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"
|
||||
|
||||
import * as styles from "./about.module.css"
|
||||
|
||||
const About = () => {
|
||||
const t = useTranslations("about")
|
||||
const tCommon = useTranslations("common")
|
||||
|
||||
return (
|
||||
<section id="about">
|
||||
<div className={styles.container}>
|
||||
<div className={"no-mobile " + styles.image}>
|
||||
<Image
|
||||
src="/about.jpg"
|
||||
alt={t("imageAlt")}
|
||||
width={640}
|
||||
height={480}
|
||||
/>
|
||||
<p className="photo-credit">
|
||||
{tCommon("photo")}: 장태민, Studentenes Fotoklubb
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="left-section-header">{t("title")}</h2>
|
||||
<div>
|
||||
<p>{t("text")}</p>
|
||||
<div className={styles.joinSection}>
|
||||
<h3>{t("interested")}</h3>
|
||||
<div className={styles.buttons}>
|
||||
<MemberButton />
|
||||
<DiscordButton />
|
||||
<OtherButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default About
|
||||
@@ -1,9 +1,9 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
justify-content: space-evenly;
|
||||
gap: 7rem;
|
||||
gap: 4rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -39,11 +39,12 @@
|
||||
}
|
||||
.headerImageContainer {
|
||||
border: 5px solid white;
|
||||
display: inline-block;
|
||||
}
|
||||
/* .headerImage {
|
||||
box-shadow: inset 0 0 10px #000000;
|
||||
} */
|
||||
.headerImageContainer img {
|
||||
object-fit: cover;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info {
|
||||
border-bottom: var(--card-section-divider);
|
||||
@@ -65,6 +66,7 @@
|
||||
border-top: var(--card-section-divider);
|
||||
width: 100%;
|
||||
font-size: 1.1rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
.footer > p {
|
||||
margin-top: 2rem;
|
||||
@@ -72,7 +74,7 @@
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@media (min-width: 60em) {
|
||||
@media (min-width: 70em) {
|
||||
.header {
|
||||
flex-direction: row;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type SanityImage, urlFor } from "@/lib/imageUrlBuilder"
|
||||
import { getBoardMembers } from "@/lib/sanity"
|
||||
|
||||
import { getLocale, getTranslations } from "next-intl/server"
|
||||
import Image from "next/image"
|
||||
|
||||
import * as styles from "./boardMembers.module.css"
|
||||
|
||||
interface BoardMember {
|
||||
_id: string
|
||||
contactInfo?: string
|
||||
name: string
|
||||
role?: {
|
||||
no: string
|
||||
en: string
|
||||
}
|
||||
image: SanityImage | null
|
||||
bio?: {
|
||||
no: string
|
||||
en: string
|
||||
}
|
||||
}
|
||||
|
||||
const BoardMembers = async () => {
|
||||
const t = await getTranslations("boardMembers")
|
||||
const locale = await getLocale()
|
||||
|
||||
const data: BoardMember[] = await getBoardMembers()
|
||||
|
||||
return (
|
||||
<section id="board-members">
|
||||
<h2>{t("title")}</h2>
|
||||
<div className={styles.container}>
|
||||
{data.map((member: BoardMember) => (
|
||||
<article key={member._id} className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<div
|
||||
className={`circular-container ${styles.headerImageContainer}`}
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
member.image?.asset
|
||||
? // Cropped to a square around the hotspot
|
||||
urlFor(member.image).width(512).height(512).url()
|
||||
: "/person.png"
|
||||
}
|
||||
width={256}
|
||||
height={256}
|
||||
className="circular"
|
||||
alt={`${t("imageAlt")}, ${member.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<h3>{member.name}</h3>
|
||||
<p>{member.role?.[locale]}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p>{member.bio?.[locale]}</p>
|
||||
<div className={styles.footer}>
|
||||
<p className="text-center">
|
||||
{member.contactInfo ? (
|
||||
member.contactInfo
|
||||
) : (
|
||||
<i>{t("noContact")}</i>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default BoardMembers
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import * as styles from "./contact.module.css"
|
||||
|
||||
import {
|
||||
FaAt,
|
||||
FaDiscord,
|
||||
FaFacebook,
|
||||
FaGithub,
|
||||
FaInstagram,
|
||||
FaTiktok,
|
||||
FaTwitch,
|
||||
FaYoutube,
|
||||
} from "react-icons/fa"
|
||||
|
||||
const Contact = () => {
|
||||
const t = useTranslations("contact")
|
||||
|
||||
return (
|
||||
<section id="contact">
|
||||
<h2 className="section-center-header">{t("title")}</h2>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.item}>
|
||||
<FaAt />
|
||||
<a href="mailto:styret@uiogaming.no">{t("email")}</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaDiscord />
|
||||
<a
|
||||
href="https://discord.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaInstagram />
|
||||
<a
|
||||
href="https://instagram.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Instagram
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaTiktok />
|
||||
<a
|
||||
href="https://tiktok.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
TikTok
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaTwitch />
|
||||
<a
|
||||
href="https://twitch.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Twitch
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaYoutube />
|
||||
<a
|
||||
href="https://youtube.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
YouTube
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaFacebook />
|
||||
<a
|
||||
href="https://facebook.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Facebook
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaGithub />
|
||||
<a
|
||||
href="https://github.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Contact
|
||||
@@ -5,7 +5,7 @@
|
||||
.container {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 6em;
|
||||
gap: 4em;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
.container > h2 {
|
||||
@@ -23,7 +23,7 @@
|
||||
gap: 2rem;
|
||||
}
|
||||
.events > *,
|
||||
*:hover {
|
||||
.events > *:hover {
|
||||
color: white;
|
||||
}
|
||||
.events > h2 {
|
||||
@@ -59,7 +59,7 @@
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
font-weight: 100;
|
||||
color: var(--light-text-color)
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
.metadata > *:hover {
|
||||
color: var(--light-text-color);
|
||||
@@ -67,8 +67,11 @@
|
||||
|
||||
.image {
|
||||
grid-row: 1 / -1;
|
||||
align-self: center;
|
||||
justify-self: left;
|
||||
margin-top: 2em;
|
||||
}
|
||||
@media (min-width: 60em) {
|
||||
@media (min-width: 70em) {
|
||||
.stretch {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -76,15 +79,16 @@
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3rem;
|
||||
}
|
||||
.container > h2 {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.sectionHeader {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
.image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
height: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.readMore {
|
||||
@@ -0,0 +1,105 @@
|
||||
import { formatEventDate } from "@/lib/dates"
|
||||
import { getUpcomingEvents } from "@/lib/sanity"
|
||||
|
||||
import { getLocale, getTranslations } from "next-intl/server"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaCalendarAlt, FaMapMarkerAlt } from "react-icons/fa"
|
||||
|
||||
import * as styles from "./events.module.css"
|
||||
|
||||
interface Event {
|
||||
_id: string
|
||||
title: string
|
||||
location: string
|
||||
date: string
|
||||
slug: {
|
||||
current: 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 events: Event[] = await getUpcomingEvents(2)
|
||||
|
||||
const previewDescription = (description: string | null) => {
|
||||
if (!description) return null
|
||||
|
||||
// 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
|
||||
|
||||
const preview = graphemes
|
||||
.slice(0, DESCRIPTION_PREVIEW_LENGTH)
|
||||
.map(({ segment }) => segment)
|
||||
.join("")
|
||||
.trimEnd()
|
||||
|
||||
return (
|
||||
<>
|
||||
{preview}... <span className={styles.readMore}>{t("readMore")}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const mapEvents = (events: Event[]) => {
|
||||
if (events.length !== 0) {
|
||||
return events.map((event: Event) => (
|
||||
<Link key={event._id} href={`/${locale}/event/${event.slug.current}`}>
|
||||
<article className={styles.card}>
|
||||
<h3>{event.title}</h3>
|
||||
<div>
|
||||
<div className={styles.metadata}>
|
||||
<FaMapMarkerAlt />
|
||||
<p>{event.location}</p>
|
||||
</div>
|
||||
<div className={styles.metadata}>
|
||||
<FaCalendarAlt />
|
||||
<p>{formatEventDate(event.date, locale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p>{previewDescription(event.description)}</p>
|
||||
</article>
|
||||
</Link>
|
||||
))
|
||||
} else {
|
||||
return (
|
||||
<h3 className={styles.noEvents}>
|
||||
{t("noEvents")}
|
||||
<br />
|
||||
<br />
|
||||
(っ˘̩╭╮˘̩)っ
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="events">
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.sectionHeader}>{t("title")}</h2>
|
||||
<div className={styles.events}>{mapEvents(events)}</div>
|
||||
<div className={"no-mobile " + styles.image}>
|
||||
<Image
|
||||
src="/event.jpg"
|
||||
width={850}
|
||||
height={480}
|
||||
style={{ borderRadius: "1rem", objectFit: "cover", height: "100%" }}
|
||||
alt={t("imageAlt")}
|
||||
/>
|
||||
<p className="photo-credit">
|
||||
{tCommon("photo")}: 장태민, Studentenes Fotoklubb
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Events
|
||||
@@ -1,26 +1,37 @@
|
||||
footer {
|
||||
.container {
|
||||
background-color: var(--footer-color);
|
||||
padding: 10rem 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
justify-items: space-around;
|
||||
align-items: center;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
justify-self: center;
|
||||
padding: 10rem 3rem;
|
||||
gap: 4rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 2rem;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.orgInfo {
|
||||
text-align: center;
|
||||
justify-self: center;
|
||||
}
|
||||
.orgInfo * {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.links {
|
||||
justify-self: center;
|
||||
}
|
||||
.links * {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.logo {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
@media (min-width: 70em) {
|
||||
.footer {
|
||||
@@ -28,6 +39,10 @@ footer {
|
||||
}
|
||||
.orgInfo {
|
||||
text-align: left;
|
||||
justify-self: center;
|
||||
}
|
||||
.logo {
|
||||
justify-self: right;
|
||||
}
|
||||
.links {
|
||||
justify-self: start;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import Image from "next/image"
|
||||
|
||||
import * as styles from "./footer.module.css"
|
||||
|
||||
const Footer = () => {
|
||||
const t = useTranslations("footer")
|
||||
const tCommon = useTranslations("common")
|
||||
const locale = useLocale()
|
||||
|
||||
const statutesUrl =
|
||||
locale === "en"
|
||||
? "https://statutes.uiogaming.no"
|
||||
: "https://vedtekter.uiogaming.no"
|
||||
|
||||
return (
|
||||
<footer className={styles.container}>
|
||||
<div className={styles.footer}>
|
||||
<Image
|
||||
src="/logo_2024_neon_trans.png"
|
||||
alt="UiO Gaming logo"
|
||||
className={styles.logo}
|
||||
width={187}
|
||||
height={256}
|
||||
/>
|
||||
<div className={styles.links}>
|
||||
<ul className="remove-bullets">
|
||||
<li>
|
||||
<a href={statutesUrl} rel="noreferrer">
|
||||
{tCommon("statutes")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://innmelding.uiogaming.no" rel="noreferrer">
|
||||
{tCommon("join")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://klage.uiogaming.no" rel="noreferrer">
|
||||
{tCommon("complaint")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/UiO-Gaming/uiogaming.no/"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("sourceCode")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://admin.uiogaming.no" rel="noreferrer">
|
||||
{t("admin")}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.orgInfo}>
|
||||
<p>{t("orgNum")}: 925 719 153</p>
|
||||
<address>Slemdalsveien 15, 0369 Oslo</address>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
@@ -1,6 +1,6 @@
|
||||
.container {
|
||||
background-color: #7C5CA8;
|
||||
background-image: url("../../images/mustafa_blurred.png"); /* Placeholder image */
|
||||
background-color: #7c5ca8;
|
||||
background-image: url("/mustafa.png");
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
|
||||
@@ -8,24 +8,36 @@
|
||||
|
||||
padding: 1rem;
|
||||
padding-bottom: 10vh;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
max-width: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
|
||||
max-width: none;
|
||||
text-align: center;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 2em;
|
||||
margin-block: 2.5em;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.container > p {
|
||||
|
||||
.content > p {
|
||||
max-width: 40ch;
|
||||
color: #130729;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.container > h1 {
|
||||
.content > h1 {
|
||||
color: #130729;
|
||||
font-size: 4rem;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import Image from "next/image"
|
||||
|
||||
import * as styles from "./hero.module.css"
|
||||
|
||||
const Hero = () => {
|
||||
const t = useTranslations("hero")
|
||||
|
||||
return (
|
||||
<header id="hjem" className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<Image
|
||||
src="/logo_2024_black_trans.png"
|
||||
alt="UiO Gaming logo"
|
||||
width={187}
|
||||
height={256}
|
||||
/>
|
||||
<p>{t("text")}</p>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Hero
|
||||
@@ -3,6 +3,7 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,39 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.panoramaContainer {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
||||
.panoramaContainer > div {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.panoramaFallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pcRoomImage {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 60ch;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.map {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
min-height: 400px;
|
||||
max-height: 75vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { useSyncExternalStore } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Image from "next/image"
|
||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"
|
||||
|
||||
import * as styles from "./office.module.css"
|
||||
|
||||
let webGL2Support: boolean | undefined
|
||||
|
||||
// Checked once, since creating a WebGL context is not free
|
||||
const getWebGL2Support = () => {
|
||||
webGL2Support ??= Boolean(
|
||||
document.createElement("canvas").getContext("webgl2")
|
||||
)
|
||||
return webGL2Support
|
||||
}
|
||||
|
||||
// WebGL support never changes, so there is nothing to subscribe to
|
||||
const subscribe = () => () => {}
|
||||
|
||||
const Office = () => {
|
||||
const t = useTranslations("office")
|
||||
// null on the server, which renders the fallback image
|
||||
const supportsWebGL2 = useSyncExternalStore(
|
||||
subscribe,
|
||||
getWebGL2Support,
|
||||
() => null
|
||||
)
|
||||
|
||||
return (
|
||||
<section id="office">
|
||||
<h2>{t("title")}</h2>
|
||||
<div className={styles.container}>
|
||||
<p>{t("text")}</p>
|
||||
<p>{t("tip")}</p>
|
||||
<div className={styles.panoramaContainer}>
|
||||
{supportsWebGL2 === true ? (
|
||||
<ReactPhotoSphereViewer
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
src="/3D_office_2025_08_20.jpg"
|
||||
defaultZoomLvl={0}
|
||||
navbar={true}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src="/3D_office_2025_08_20.jpg"
|
||||
alt={t("title")}
|
||||
className={styles.panoramaFallback}
|
||||
width={1200}
|
||||
height={900}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Image
|
||||
src="/pc_room.jpg"
|
||||
alt={t("pcRoomImageAlt")}
|
||||
className={styles.pcRoomImage}
|
||||
width={666}
|
||||
height={500}
|
||||
/>
|
||||
<iframe
|
||||
width={"100%"}
|
||||
className={styles.map}
|
||||
src="https://use.mazemap.com/embed.html#v=1&config=uio&zlevel=3¢er=10.712463,59.932424&zoom=17&campusid=797&sharepoitype=poi&sharepoi=1000986035&utm_medium=iframe"
|
||||
allow="geolocation"
|
||||
loading="lazy"
|
||||
title="Map by MazeMap"
|
||||
></iframe>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Office
|
||||
@@ -1,61 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as styles from "./about.module.css"
|
||||
|
||||
import { graphql, useStaticQuery } from "gatsby"
|
||||
import { GatsbyImage } from "gatsby-plugin-image"
|
||||
|
||||
import DiscordButton from "../items/discordButton"
|
||||
import MemberButton from "../items/memberButton"
|
||||
|
||||
const About = () => {
|
||||
const data = useStaticQuery(graphql`
|
||||
query {
|
||||
logo: file(relativePath: { eq: "about.jpg" }) {
|
||||
childImageSharp {
|
||||
gatsbyImageData(
|
||||
placeholder: BLURRED
|
||||
transformOptions: { fit: CONTAIN }
|
||||
width: 650
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className={styles.container}>
|
||||
<div className="no-mobile">
|
||||
<GatsbyImage
|
||||
image={data.logo.childImageSharp.gatsbyImageData}
|
||||
alt="Medlemmer som spiller brettspill"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="left-section-header">Hvem er vi?</h2>
|
||||
<div>
|
||||
<p>
|
||||
UiO Gaming er en forening med hovedfokus på å danne et
|
||||
inkluderende fellesskap rundt videospill og e-sport. Hos oss er
|
||||
det enkelt å skaffe seg venner, ettersom alle har felles
|
||||
interesser. Vi arrangerer ukentlige spillkvelder og fysiske
|
||||
sosiale arrangementer, som for eksempel brettspillkvelder og
|
||||
sosiale sammenkomster. Vår hovedkanal er en aktiv Discord-server
|
||||
hvor man kan møte nye folk med felles interesser og game med andre
|
||||
studenter.
|
||||
</p>
|
||||
<div className={styles.joinSection}>
|
||||
<h3>Interessert?</h3>
|
||||
<div className={styles.buttons}>
|
||||
<MemberButton />
|
||||
<DiscordButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default About
|
||||
@@ -1,65 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as styles from "./boardMembers.module.css"
|
||||
|
||||
import { graphql, useStaticQuery } from "gatsby"
|
||||
import { GatsbyImage } from "gatsby-plugin-image"
|
||||
|
||||
const BoardMembers = () => {
|
||||
const data = useStaticQuery(graphql`
|
||||
query {
|
||||
allSanityAuthor(
|
||||
filter: { boardMember: { eq: true } }
|
||||
sort: { fields: order }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
_id
|
||||
boardMember
|
||||
email
|
||||
name
|
||||
role
|
||||
image {
|
||||
asset {
|
||||
gatsbyImageData(width: 256, height: 256)
|
||||
}
|
||||
}
|
||||
bio
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>Styret</h2>
|
||||
<div className={styles.container}>
|
||||
{data.allSanityAuthor.edges.map(({ node: member }) => (
|
||||
<article key={member._id} className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<div
|
||||
className={`circular-container ${styles.headerImageContainer}`}
|
||||
>
|
||||
<GatsbyImage
|
||||
image={member.image.asset.gatsbyImageData}
|
||||
className="circular"
|
||||
alt={"Bilde av styemedlem, " + member.name}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<h3>{member.name}</h3>
|
||||
<p>{member.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p>{member.bio}</p>
|
||||
<div className={styles.footer}>
|
||||
<p className="text-center">{member.email}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default BoardMembers
|
||||
@@ -1,76 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as styles from "./contact.module.css"
|
||||
|
||||
import {
|
||||
FaYoutube,
|
||||
FaTwitch,
|
||||
FaFacebook,
|
||||
FaGithub,
|
||||
FaDiscord,
|
||||
FaAt,
|
||||
FaInstagram,
|
||||
} from "react-icons/fa"
|
||||
|
||||
const Contact = () => (
|
||||
<section>
|
||||
<h2 className="section-center-header">Finn oss</h2>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.item}>
|
||||
<FaAt />
|
||||
<a
|
||||
href="mailto:styret@uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
E-post
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaDiscord />
|
||||
<a href="https://discord.uiogaming.no" target="_blank" rel="noreferrer">
|
||||
Discord
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaFacebook />
|
||||
<a
|
||||
href="https://facebook.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Facebook
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaInstagram />
|
||||
<a
|
||||
href="https://instagram.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Instagram
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaYoutube />
|
||||
<a href="https://youtube.uiogaming.no" target="_blank" rel="noreferrer">
|
||||
YouTube
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaTwitch />
|
||||
<a href="https://twitch.uiogaming.no" target="_blank" rel="noreferrer">
|
||||
Twitch
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.item}>
|
||||
<FaGithub />
|
||||
<a href="https://github.uiogaming.no" target="_blank" rel="noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
export default Contact
|
||||
@@ -1,101 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as styles from "./events.module.css"
|
||||
|
||||
import { Link, graphql, useStaticQuery } from "gatsby"
|
||||
import { GatsbyImage } from "gatsby-plugin-image"
|
||||
import moment from "moment-timezone"
|
||||
import "moment/locale/nb"
|
||||
|
||||
import { FaMapMarkerAlt, FaCalendarAlt } from "react-icons/fa"
|
||||
|
||||
const Events = () => {
|
||||
const data = useStaticQuery(graphql`
|
||||
query {
|
||||
placeholder: file(relativePath: { eq: "event.jpg" }) {
|
||||
childImageSharp {
|
||||
gatsbyImageData(
|
||||
placeholder: BLURRED
|
||||
transformOptions: { fit: COVER }
|
||||
width: 650
|
||||
)
|
||||
}
|
||||
}
|
||||
allSanityEvent(sort: { fields: date, order: ASC }) {
|
||||
edges {
|
||||
node {
|
||||
_id
|
||||
title
|
||||
location
|
||||
date
|
||||
slug {
|
||||
current
|
||||
}
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
moment.locale("nb_NO")
|
||||
const currentDate = moment().utc()
|
||||
|
||||
const filteredEvents = data.allSanityEvent.edges
|
||||
.filter(({ node: event }) => {
|
||||
return currentDate <= moment.utc(event.date)
|
||||
})
|
||||
.slice(0, 2)
|
||||
|
||||
const mapEvents = events => {
|
||||
if (events.length !== 0) {
|
||||
return events.map(({ node: event }) => (
|
||||
<Link key={event._id} to={"event/" + event.slug.current}>
|
||||
<article className={styles.card}>
|
||||
<h3>{event.title}</h3>
|
||||
<div>
|
||||
<div className={styles.metadata}>
|
||||
<FaMapMarkerAlt />
|
||||
<p>{event.location}</p>
|
||||
</div>
|
||||
<div className={styles.metadata}>
|
||||
<FaCalendarAlt />
|
||||
<p>
|
||||
{moment(event.date)
|
||||
.tz("Europe/Oslo")
|
||||
.format("dddd Do MMMM, [kl.] H:mm")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p>{event.description.slice(0, 120)}... <span className={styles.readMore}>Les mer</span></p>
|
||||
</article>
|
||||
</Link>
|
||||
))
|
||||
} else {
|
||||
return (
|
||||
<h3 className={styles.noEvents}>
|
||||
Ingen kommende arrangementer
|
||||
<br />
|
||||
<br />
|
||||
(っ˘̩╭╮˘̩)っ
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.stretch}>
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.sectionHeader}>Hva skjer?</h2>
|
||||
<div className={styles.events}>{mapEvents(filteredEvents)}</div>
|
||||
<div className={"no-mobile " + styles.image}>
|
||||
<GatsbyImage
|
||||
image={data.placeholder.childImageSharp.gatsbyImageData}
|
||||
alt="Medlem som spiller VR"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Events
|
||||
@@ -1,68 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as styles from "./footer.module.css"
|
||||
|
||||
import { graphql, useStaticQuery } from "gatsby"
|
||||
import { GatsbyImage } from "gatsby-plugin-image"
|
||||
|
||||
const Footer = () => {
|
||||
const data = useStaticQuery(graphql`
|
||||
query {
|
||||
logo: file(relativePath: { eq: "logo.jpg" }) {
|
||||
childImageSharp {
|
||||
gatsbyImageData(
|
||||
placeholder: BLURRED
|
||||
transformOptions: { fit: COVER }
|
||||
width: 256
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
return (
|
||||
<footer>
|
||||
<div className={styles.footer}>
|
||||
<div>
|
||||
<GatsbyImage
|
||||
image={data.logo.childImageSharp.gatsbyImageData}
|
||||
alt="UiO Gaming logo"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.links}>
|
||||
<ul className="remove-bullets">
|
||||
<li>
|
||||
<a href="https://vedtekter.uiogaming.no" rel="noreferrer">
|
||||
Vedtekter
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://innmelding.uiogaming.no" rel="noreferrer">
|
||||
Bli medlem
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/UiO-Gaming/uiogaming-website/"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Kildekode
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://admin.uiogaming.no" rel="noreferrer">
|
||||
Adminområdet
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.orgInfo}>
|
||||
<p>Orgnr.: 925 719 153</p>
|
||||
<address>Slemdalsveien 15, 0369 Oslo</address>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as styles from "./header.module.css"
|
||||
|
||||
import { graphql, useStaticQuery } from "gatsby"
|
||||
import { GatsbyImage } from "gatsby-plugin-image"
|
||||
|
||||
const Header = () => {
|
||||
const data = useStaticQuery(graphql`
|
||||
query {
|
||||
placeholder: file(relativePath: { eq: "logo.jpg" }) {
|
||||
childImageSharp {
|
||||
gatsbyImageData(
|
||||
placeholder: BLURRED
|
||||
transformOptions: { fit: CONTAIN }
|
||||
width: 256
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
return (
|
||||
<header id="hjem" className={styles.container}>
|
||||
<GatsbyImage
|
||||
image={data.placeholder.childImageSharp.gatsbyImageData}
|
||||
alt="UiO Gaming logo"
|
||||
/>
|
||||
|
||||
<h1>UiO Gaming</h1>
|
||||
<p>
|
||||
En hobbyforening som ønsker å skape et sosialt miljø rundt gaming og
|
||||
e-sport på UiO
|
||||
</p>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as styles from "./office.module.css"
|
||||
|
||||
import ReactPannellum from "react-pannellum"
|
||||
|
||||
const config = {
|
||||
autoLoad: true,
|
||||
}
|
||||
|
||||
const Office = () => (
|
||||
<section>
|
||||
<h2>Kontoret</h2>
|
||||
<div className={styles.container}>
|
||||
<p>
|
||||
Vi har et foreningskontor på Chateau Neuf der man kan bruke utstyret
|
||||
vårt og henge med andre medlemmer!
|
||||
</p>
|
||||
|
||||
<ReactPannellum
|
||||
id="1"
|
||||
sceneId="office"
|
||||
config={config}
|
||||
imageSource="/3D_office_2023_09_15.jpg"
|
||||
style={{ width: "100%", aspectRatio: "1 / 1", background: "#000000" }} // yes, apparantly applying a css class to the component doesn't work
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
export default Office
|
||||
@@ -1,11 +0,0 @@
|
||||
.container {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import PropTypes from "prop-types"
|
||||
import { Helmet } from "react-helmet"
|
||||
import { useStaticQuery, graphql } from "gatsby"
|
||||
|
||||
function Seo({ description, lang, meta, imageURL, title, author }) {
|
||||
const { site } = useStaticQuery(
|
||||
graphql`
|
||||
query {
|
||||
site {
|
||||
siteMetadata {
|
||||
title
|
||||
description
|
||||
author
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
|
||||
const metaAuthor = author || site.siteMetadata.author
|
||||
const metaDescription = description || site.siteMetadata.description
|
||||
const defaultTitle = site.siteMetadata?.title
|
||||
|
||||
return (
|
||||
<Helmet
|
||||
htmlAttributes={{
|
||||
lang,
|
||||
}}
|
||||
title={title ? title : defaultTitle}
|
||||
titleTemplate={title ? `%s | ${defaultTitle}` : defaultTitle}
|
||||
meta={[
|
||||
{
|
||||
name: `author`,
|
||||
content: metaAuthor,
|
||||
},
|
||||
{
|
||||
name: `description`,
|
||||
content: metaDescription,
|
||||
},
|
||||
{
|
||||
property: `og:site_name`,
|
||||
content: site.siteMetadata.title,
|
||||
},
|
||||
{
|
||||
property: `og:title`,
|
||||
content: title ? title : defaultTitle,
|
||||
},
|
||||
{
|
||||
property: `og:description`,
|
||||
content: metaDescription,
|
||||
},
|
||||
{
|
||||
property: `og:type`,
|
||||
content: `website`,
|
||||
},
|
||||
{
|
||||
property: `og:locale`,
|
||||
content: `nb_NO`,
|
||||
},
|
||||
{
|
||||
name: `twitter:creator`,
|
||||
content: metaAuthor,
|
||||
},
|
||||
{
|
||||
name: `twitter:title`,
|
||||
content: title,
|
||||
},
|
||||
{
|
||||
name: `twitter:description`,
|
||||
content: metaDescription,
|
||||
},
|
||||
]
|
||||
.concat(
|
||||
imageURL
|
||||
? [
|
||||
{
|
||||
property: "og:image",
|
||||
content: imageURL,
|
||||
},
|
||||
{
|
||||
name: "twitter:card",
|
||||
content: "summary_large_image",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
property: "og:image",
|
||||
content: `${site.siteMetadata.url}/logo.jpg`,
|
||||
},
|
||||
{
|
||||
name: "twitter:card",
|
||||
content: "summary",
|
||||
},
|
||||
]
|
||||
)
|
||||
.concat(meta)}
|
||||
>
|
||||
<script
|
||||
async
|
||||
defer
|
||||
data-website-id="c375ccba-0b3e-4bf0-a4c9-165bd9b0176c"
|
||||
src="https://umami.uiogaming.no/umami.js"
|
||||
></script>
|
||||
</Helmet>
|
||||
)
|
||||
}
|
||||
|
||||
Seo.defaultProps = {
|
||||
lang: `no`,
|
||||
meta: [],
|
||||
description: ``,
|
||||
}
|
||||
|
||||
Seo.propTypes = {
|
||||
description: PropTypes.string,
|
||||
lang: PropTypes.string,
|
||||
meta: PropTypes.arrayOf(PropTypes.object),
|
||||
title: PropTypes.string,
|
||||
}
|
||||
|
||||
export default Seo
|
||||
@@ -0,0 +1,95 @@
|
||||
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({ locale, namespace: "metaTags" })
|
||||
|
||||
const defaultTitle = "UiO Gaming"
|
||||
const metaDescription = description || t("description")
|
||||
|
||||
return {
|
||||
title: title ? `${title} | ${defaultTitle}` : defaultTitle,
|
||||
description: metaDescription,
|
||||
authors: author ? [{ name: author }] : [{ name: "UiO Gaming" }],
|
||||
// Tells search engines that the Norwegian and English versions are the
|
||||
// same page. The unprefixed path redirects based on the browser language
|
||||
alternates: {
|
||||
canonical: getLocalizedPath(locale, path),
|
||||
languages: {
|
||||
...Object.fromEntries(
|
||||
routing.locales.map(pageLocale => [
|
||||
pageLocale,
|
||||
getLocalizedPath(pageLocale, path),
|
||||
])
|
||||
),
|
||||
"x-default": path,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: title || defaultTitle,
|
||||
description: metaDescription,
|
||||
...(publishedTime
|
||||
? { type: "article", publishedTime, authors: author && [author] }
|
||||
: { type: "website" }),
|
||||
locale: locale,
|
||||
url: getLocalizedPath(locale, path),
|
||||
siteName: defaultTitle,
|
||||
images: imageURL
|
||||
? [
|
||||
{
|
||||
url: imageURL,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title || defaultTitle,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
url: "/logo_2024_neon_trans.png",
|
||||
alt: defaultTitle,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: imageURL ? "summary_large_image" : "summary",
|
||||
title: title || defaultTitle,
|
||||
description: metaDescription,
|
||||
images: imageURL ? [imageURL] : ["/logo_2024_neon_trans.png"],
|
||||
},
|
||||
other: {
|
||||
"msapplication-TileColor": THEME_COLOR,
|
||||
"msapplication-navbutton-color": THEME_COLOR,
|
||||
"apple-mobile-web-app-status-bar-style": "black-translucent",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--link-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.button:hover,
|
||||
.button *:hover {
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { FaArrowLeft } from "react-icons/fa"
|
||||
|
||||
import { useRouter } from "@/i18n/navigation"
|
||||
|
||||
import * as styles from "./back.module.css"
|
||||
|
||||
// document.referrer only reflects the last full page load, so it misses
|
||||
// client-side navigations within the site. Only used where the Navigation
|
||||
// API isn't supported
|
||||
const cameFromThisSite = () =>
|
||||
document.referrer !== "" &&
|
||||
new URL(document.referrer).origin === window.location.origin
|
||||
|
||||
const Back = () => {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("buttons")
|
||||
|
||||
const handleBack = () => {
|
||||
// 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()
|
||||
|
||||
if (canGoBack) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push("/")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${styles.container} ${styles.button}`}
|
||||
onClick={handleBack}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<span>{t("back")}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Back
|
||||
@@ -1,12 +1,12 @@
|
||||
.button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.6rem;
|
||||
background-color: #5865F2;
|
||||
font-size: 1.6rem;
|
||||
background-color: #5865f2;
|
||||
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 999em;
|
||||
border-radius: 999em;
|
||||
|
||||
color: var(--text-color);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import * as styles from "./discordButton.module.css"
|
||||
|
||||
const DiscordButton = () => (
|
||||
@@ -0,0 +1,30 @@
|
||||
.languageSwitcher {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: rgba(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
.flag {
|
||||
width: 20px;
|
||||
height: 15px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import { GB, NO } from "country-flag-icons/react/3x2"
|
||||
import { type Locale, useLocale } from "next-intl"
|
||||
|
||||
import { usePathname, useRouter } from "@/i18n/navigation"
|
||||
|
||||
import * as styles from "./languageSelector.module.css"
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
// Pathname without the locale prefix
|
||||
const pathname = usePathname()
|
||||
|
||||
const changeLocale = (newLocale: Locale) => {
|
||||
// Read at click time, since useSearchParams() would opt pages out of
|
||||
// static rendering
|
||||
const { search, hash } = window.location
|
||||
router.push(`${pathname}${search}${hash}`, { locale: newLocale })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.languageSwitcher}>
|
||||
{locale === "no" ? (
|
||||
<button onClick={() => changeLocale("en")} className={styles.button}>
|
||||
<GB className={styles.flag} aria-hidden="true" />
|
||||
<span lang="en">English</span>
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => changeLocale("no")} className={styles.button}>
|
||||
<NO className={styles.flag} aria-hidden="true" />
|
||||
<span lang="no">Norsk</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LanguageSelector
|
||||
@@ -0,0 +1,20 @@
|
||||
.button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-size: 1.6rem;
|
||||
background-color: var(--main-color);
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 999em;
|
||||
|
||||
color: var(--text-color);
|
||||
}
|
||||
.button:hover {
|
||||
color: white;
|
||||
background-color: #a76cff;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import * as styles from "./memberButton.module.css"
|
||||
|
||||
const MemberButton = () => {
|
||||
const t = useTranslations("common")
|
||||
|
||||
return (
|
||||
<a
|
||||
href="https://innmelding.uiogaming.no"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.button}
|
||||
>
|
||||
{t("join")}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
export default MemberButton
|
||||
@@ -0,0 +1,80 @@
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
justify-content: center;
|
||||
font-size: 1.6rem;
|
||||
background-color: #cccccc;
|
||||
width: 100%;
|
||||
|
||||
padding: 0.5em 1em;
|
||||
border: 0;
|
||||
border-radius: 999em;
|
||||
|
||||
color: black;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
cursor: pointer;
|
||||
color: black;
|
||||
background-color: #e6e6e6;
|
||||
}
|
||||
|
||||
.dropdownContainer {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dropdownLink {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
color: black;
|
||||
background-color: #cccccc;
|
||||
border-radius: 999em;
|
||||
text-align: left;
|
||||
font-size: 1.6rem;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.5em 1em;
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
.dropdownLink:first-child {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dropdownLink:hover {
|
||||
color: black;
|
||||
background-color: #e6e6e6;
|
||||
}
|
||||
|
||||
.arrowUp,
|
||||
.arrowDown {
|
||||
border: solid black;
|
||||
border-width: 0 2px 2px 0;
|
||||
display: inline-block;
|
||||
padding: 3px;
|
||||
margin-left: 5px;
|
||||
transition: transform 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.arrowDown {
|
||||
transform: rotate(45deg);
|
||||
-webkit-transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.arrowUp {
|
||||
transform: rotate(-135deg);
|
||||
-webkit-transform: rotate(-135deg);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react"
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import * as styles from "./otherButton.module.css"
|
||||
|
||||
const OtherButton = () => {
|
||||
const t = useTranslations("about")
|
||||
const tCommon = useTranslations("common")
|
||||
const locale = useLocale()
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
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"
|
||||
? "https://statutes.uiogaming.no"
|
||||
: "https://vedtekter.uiogaming.no"
|
||||
|
||||
const links = [
|
||||
{ url: statutesUrl, label: tCommon("statutes") },
|
||||
{ url: "https://klage.uiogaming.no", label: tCommon("complaint") },
|
||||
{
|
||||
url: "https://github.com/UiO-Gaming/dokumenter",
|
||||
label: tCommon("documents"),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<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}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<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}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OtherButton
|
||||
@@ -0,0 +1,22 @@
|
||||
import Script from "next/script"
|
||||
|
||||
// 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() {
|
||||
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 || process.env.NODE_ENV !== "production") return null
|
||||
|
||||
return (
|
||||
<Script
|
||||
src={src}
|
||||
data-website-id={websiteId}
|
||||
data-domains={DOMAINS.join(",")}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createNavigation } from "next-intl/navigation"
|
||||
|
||||
import { routing } from "./routing"
|
||||
|
||||
// Locale-aware wrappers around Next's navigation APIs
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||
createNavigation(routing)
|
||||
@@ -0,0 +1,20 @@
|
||||
import { hasLocale } from "next-intl"
|
||||
import { getRequestConfig } from "next-intl/server"
|
||||
import { locale as rootLocale } from "next/root-params"
|
||||
|
||||
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: 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]
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 519 KiB |
@@ -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,
|
||||
})
|
||||