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 | ||
|
|
269c041a7b | ||
|
|
6ebe7565c7 | ||
|
|
bb9bf87bf5 | ||
|
|
83d5088e92 | ||
|
|
5b7d00cee3 | ||
|
|
53952c614c | ||
|
|
a33ffce59a | ||
|
|
11b872dbb8 | ||
|
|
f72f351618 | ||
|
|
33aab96bc7 | ||
|
|
01e57edc41 |
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: `#171520`,
|
||||
theme_color: `#E2231A`,
|
||||
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.1.2",
|
||||
"author": "LBlend <lblend@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 |
|
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 {
|
||||
@@ -29,8 +25,13 @@ body {
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
font-weight: 100;
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
.metadata > *:hover {
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
|
||||
.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,18 +1,20 @@
|
||||
@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: rgb(226, 35, 26);
|
||||
--main-background-color: rgb(22, 20, 31);
|
||||
--secondary-background-color: rgb(44, 53, 69);
|
||||
--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: rgb(228, 228, 228);
|
||||
--light-text-color: rgb(190, 190, 190);
|
||||
--link-color: rgb(226, 35, 26);
|
||||
--link-color-hover: rgb(255, 157, 160);
|
||||
--text-color: #eeeeee;
|
||||
--light-text-color: #cccccc;
|
||||
--link-color: #7d36e7;
|
||||
--link-color-hover: #d1b3ff;
|
||||
|
||||
--main-font: "Roboto", sans-serif;
|
||||
--main-font: "Nunito", sans-serif;
|
||||
|
||||
--card-section-divider: 2px solid rgba(128, 128, 128, 0.5);
|
||||
--blog-header-divider: 1px solid rgba(128, 128, 128, 0.25);
|
||||
@@ -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: rgb(255, 62, 52);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
@media (min-width: 500px) {
|
||||
.card {
|
||||
width: 35ch;
|
||||
width: 37ch;
|
||||
}
|
||||
}
|
||||
.card > p {
|
||||
@@ -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);
|
||||
@@ -53,8 +54,9 @@
|
||||
}
|
||||
.info > p {
|
||||
font-weight: 100;
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.2rem;
|
||||
margin: 0.5rem 0;
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
.info > h3 {
|
||||
margin: 0.5rem 0;
|
||||
@@ -64,6 +66,7 @@
|
||||
border-top: var(--card-section-divider);
|
||||
width: 100%;
|
||||
font-size: 1.1rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
.footer > p {
|
||||
margin-top: 2rem;
|
||||
@@ -71,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 {
|
||||
@@ -48,18 +48,30 @@
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.card > div {
|
||||
font-weight: 100;
|
||||
font-weight: 200;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.card > div * {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
.metadata {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
font-weight: 100;
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
.metadata > *:hover {
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -67,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
|
||||
@@ -0,0 +1,43 @@
|
||||
.container {
|
||||
background-color: #7c5ca8;
|
||||
background-image: url("/mustafa.png");
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
|
||||
clip-path: polygon(0% 0%, 100% 0%, 100% 90%, 0% 100%);
|
||||
|
||||
padding: 1rem;
|
||||
padding-bottom: 10vh;
|
||||
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;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 2em;
|
||||
margin-block: 2.5em;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.content > p {
|
||||
max-width: 40ch;
|
||||
color: #130729;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.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>
|
||||
<FaMapMarkerAlt />
|
||||
<p>{event.location}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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,23 +0,0 @@
|
||||
.container {
|
||||
background-image: url("../../images/keyboard.jpg"); /* Placeholder image */
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
|
||||
clip-path: polygon(0% 0%, 100% 0%, 100% 90%, 0% 100%);
|
||||
|
||||
padding: 1rem;
|
||||
padding-bottom: 10vh;
|
||||
height: 100vh;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
|
||||
max-width: none;
|
||||
text-align: center;
|
||||
}
|
||||
.container > p {
|
||||
max-width: 40ch;
|
||||
}
|
||||
@@ -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 |
@@ -1,10 +0,0 @@
|
||||
<svg width="71" height="55" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z" fill="#ffffff"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="71" height="55" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 45 KiB |