├── .dockerignore
├── .env.example
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ ├── feature_request.md
│ └── feedback.md
└── workflows
│ ├── cypress_test.yaml
│ ├── docker_push.yaml
│ ├── jest_test.yaml
│ ├── lint_format.yaml
│ └── rebase.yaml
├── .gitignore
├── .husky
└── pre-commit
├── .lintstagedrc.js
├── .prettierignore
├── Dockerfile
├── LICENSE
├── README.md
├── cypress.json
├── cypress
├── fixtures
│ ├── happening.json
│ └── users.json
├── integration
│ ├── entry-box.spec.ts
│ ├── happening-registration.spec.ts
│ ├── nav.spec.ts
│ └── registration-deletion.spec.ts
└── tsconfig.json
├── docker-compose.yaml
├── jest.setup.js
├── next-env.d.ts
├── next.config.js
├── package.json
├── public
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── apple-touch-icon.png
├── bedpres.png
├── bekk.png
├── browserconfig.xml
├── christmas-icons
│ └── santa_hat.svg
├── echo-logo-text-only-white-no-padding-bottom.png
├── echo-logo-white.png
├── echo-logo.png
├── event.png
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── halloween-icons
│ ├── ghost.svg
│ ├── hat.svg
│ ├── pumpkin.svg
│ └── skull.svg
├── maskable-icon-192x192.png
├── maskable-icon-512x512.png
├── mstile-150x150.png
├── post.png
├── powered-by-vercel.svg
├── safari-pinned-tab.svg
├── sanity-logo.svg
├── site.webmanifest
└── static
│ └── valg.md
├── src
├── components
│ ├── __tests__
│ │ ├── footer.test.tsx
│ │ ├── happening-meta-info.test.tsx
│ │ ├── header.test.tsx
│ │ ├── info-panels.test.tsx
│ │ ├── layout.test.tsx
│ │ ├── navbar.test.tsx
│ │ ├── testing-utils.ts
│ │ └── testing-wrapper.tsx
│ ├── animated-icons.tsx
│ ├── article.tsx
│ ├── bedpres-preview.tsx
│ ├── button-link.tsx
│ ├── button.tsx
│ ├── calendar-popup.tsx
│ ├── color-mode-button.tsx
│ ├── countdown.tsx
│ ├── entry-box.tsx
│ ├── entry-list.tsx
│ ├── entry-overview.tsx
│ ├── error-box.tsx
│ ├── event-preview.tsx
│ ├── footer.tsx
│ ├── form-question.tsx
│ ├── form-term.tsx
│ ├── happening-key-info.tsx
│ ├── happening-meta-info.tsx
│ ├── header-logo.tsx
│ ├── header.tsx
│ ├── icon-text.tsx
│ ├── info-panels.tsx
│ ├── job-advert-overview.tsx
│ ├── job-advert-preview.tsx
│ ├── layout.tsx
│ ├── logo-accesory.tsx
│ ├── member-profile.tsx
│ ├── minute-list.tsx
│ ├── nav-link.tsx
│ ├── navbar.tsx
│ ├── post-list.tsx
│ ├── post-preview.tsx
│ ├── profile-info.tsx
│ ├── registration-form.tsx
│ ├── registration-row.tsx
│ ├── section.tsx
│ ├── seo.tsx
│ ├── sidebar-wrapper.tsx
│ ├── sidebar.tsx
│ └── student-group-view.tsx
├── declarations.d.ts
├── lib
│ ├── api
│ │ ├── api.ts
│ │ ├── banner.ts
│ │ ├── decoders.ts
│ │ ├── errors.ts
│ │ ├── happening.ts
│ │ ├── index.ts
│ │ ├── job-advert.ts
│ │ ├── minute.ts
│ │ ├── post.ts
│ │ ├── registration.ts
│ │ ├── static-info.tsx
│ │ ├── student-group.ts
│ │ ├── types.ts
│ │ └── user.ts
│ ├── generate-rss-feed.ts
│ ├── hooks
│ │ ├── index.ts
│ │ └── use-countdown.ts
│ └── utils.ts
├── markdown.ts
├── pages
│ ├── 404.tsx
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── api
│ │ ├── auth
│ │ │ └── [...nextauth].ts
│ │ └── user
│ │ │ └── index.ts
│ ├── bedpres
│ │ └── index.tsx
│ ├── event
│ │ ├── [slug].tsx
│ │ └── index.tsx
│ ├── happenings-overview
│ │ └── index.tsx
│ ├── index.tsx
│ ├── job
│ │ ├── [slug].tsx
│ │ └── index.tsx
│ ├── om-echo
│ │ ├── [slug].tsx
│ │ ├── moetereferat
│ │ │ └── index.tsx
│ │ └── studentgrupper
│ │ │ └── [slug].tsx
│ ├── posts
│ │ ├── [slug].tsx
│ │ └── index.tsx
│ ├── profile.tsx
│ ├── registration
│ │ └── [slug].tsx
│ └── valg
│ │ └── index.tsx
└── styles
│ ├── fonts.tsx
│ ├── theme.ts
│ └── themes
│ ├── christmas-theme.ts
│ ├── halloween-theme.ts
│ ├── index.ts
│ └── main-theme.ts
├── tsconfig.json
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .next/
3 | .git/
4 | .husky/
5 | .cache/
6 | .github/
7 |
8 | LICENSE
9 | README.md
10 | docker-compose*
11 | Dockerfile
12 | .prettierignore
13 | jest*
14 | public/rss.xml
15 |
16 | cypress/videos/
17 | cypress/screenshots/
18 | cypress/downloads/
19 | cypress/fixtures/example.json
20 | cypress/plugins/
21 | cypress/support/
22 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Needed for frontend.
2 | # This specifies what key the frontend uses
3 | # to try to access authorized endpoints in the backend.
4 | # Must match whatever key the backend you are sending requests
5 | # to uses.
6 | ADMIN_KEY=key_here
7 |
8 | # Needed for Feide authentication.
9 | FEIDE_CLIENT_ID=client_id_here
10 | FEIDE_CLIENT_SECRET=client_secret_here
11 | NEXTAUTH_URL=http://localhost:3000
12 | NEXTAUTH_SECRET=secret_here
13 |
14 | # Optional.
15 | #
16 | # For normal use:
17 | # SANITY_DATASET=production
18 | #
19 | # For Cypress testing:
20 | # SANITY_DATASET=production
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug-rapport
3 | about: Har du opplevd en bug? Opprett en issue her.
4 | title: '[Bug]'
5 | labels: 'bug :bug:'
6 | assignees: bakseter, boaanes
7 | ---
8 |
9 | **Beskriv buggen**
10 | En konsis beskrivelse av hva som skjer.
11 |
12 | **Hvordan skjer buggen?**
13 | Steg for å reprodusere:
14 |
15 | 1. Gå til '...'
16 | 2. Klikk på '....'
17 | 3. Scroll ned til '....'
18 | 4. Se buggen
19 |
20 | **Forventet oppførsel**
21 | En konsis beskrivelse av hva du fortventer skal skje.
22 |
23 | **Screenshots**
24 | Legg gjerne med skjermbilder/gifer.
25 |
26 | **Detaljer:**
27 |
28 | - OS: [e.g. iOS]
29 | - Nettleser [e.g. chrome, safari]
30 |
31 | **Eventuelle greier**
32 | Legg til eventuelle småting her.
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Foreslag til funksjonalitet
3 | about: Har du en idé til nettsiden?
4 | title: '[Funksjonalitet]'
5 | labels: 'feature :sparkles:'
6 | assignees: bakseter, boaanes
7 | ---
8 |
9 | **Hva ønsker du av ny funksjonalitet?**
10 | En konsis beskrivelse av hva du vil ha/hva problemet er. F.eks. "Det plager meg at [...]"
11 |
12 | **Beskriv løsningen(e) du ser for deg**
13 | En konsis beskrivelse av hva løsningen(e) kan være.
14 |
15 | **Eventuelle ting**
16 | Legg til eventuelle greier her.
17 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feedback.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Tilbakemelding
3 | about: Hjelp oss bli bedre
4 | title: '[Tilbakemelding]'
5 | labels: 'feedback :scroll:'
6 | assignees: bakseter, boaanes
7 | ---
8 |
9 | **Tilbakemelding**
10 | Beskriv tilbakemeldingen din her.
11 |
12 | **Eventuelt**
13 | Legg til eventuelle småting her.
14 |
--------------------------------------------------------------------------------
/.github/workflows/cypress_test.yaml:
--------------------------------------------------------------------------------
1 | name: Cypress
2 | on:
3 | pull_request:
4 | branches: [master]
5 |
6 | env:
7 | REGISTRY: ghcr.io
8 | BACKEND_TAG: latest-prod
9 |
10 | permissions:
11 | packages: write
12 | actions: read
13 |
14 | jobs:
15 | cypress_tests:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout frontend repository
19 | uses: actions/checkout@v3
20 |
21 | - name: Login to GitHub Container Registry
22 | uses: docker/login-action@v1
23 | with:
24 | registry: ${{ env.REGISTRY }}
25 | username: ${{ github.actor }}
26 | password: ${{ secrets.GITHUB_TOKEN }}
27 |
28 | - name: Build frontend with cache & push
29 | run: |
30 | docker build \
31 | --cache-from "$REGISTRY/$GITHUB_REPOSITORY" \
32 | -t "$REGISTRY/$GITHUB_REPOSITORY:$GITHUB_SHA" \
33 | --build-arg BUILDKIT_INLINE_CACHE=1 \
34 | --build-arg SANITY_DATASET=$SANITY_DATASET \
35 | .
36 | docker push "$REGISTRY/$GITHUB_REPOSITORY" --all-tags
37 | env:
38 | DOCKER_BUILDKIT: 1
39 | REGISTRY: ${{ env.REGISTRY }}
40 | SANITY_DATASET: ${{ secrets.SANITY_DATASET }}
41 |
42 | - name: Pull backend image
43 | run: docker pull "$REGISTRY/$BACKEND_REPOSITORY:$TAG"
44 | env:
45 | REGISTRY: ${{ env.REGISTRY }}
46 | BACKEND_REPOSITORY: echo-webkom/echo-web-backend
47 | TAG: ${{ env.BACKEND_TAG }}
48 |
49 | - name: Run Cypress end-to-end tests against backend
50 | run: docker compose up --exit-code-from=frontend --attach=frontend
51 | env:
52 | REGISTRY: ${{ env.REGISTRY }}
53 | TAG: ${{ env.BACKEND_TAG }}
54 | SANITY_DATASET: ${{ secrets.SANITY_DATASET }}
55 | ADMIN_KEY: admin-passord
56 | NEXTAUTH_SECRET: very-secret-string-123
57 |
--------------------------------------------------------------------------------
/.github/workflows/docker_push.yaml:
--------------------------------------------------------------------------------
1 | name: Docker push image
2 | on:
3 | push:
4 | branches: [master]
5 |
6 | env:
7 | REGISTRY: ghcr.io
8 |
9 | permissions:
10 | packages: write
11 | actions: read
12 |
13 | jobs:
14 | docker_push:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout frontend repository
18 | uses: actions/checkout@v3
19 |
20 | - name: Login to GitHub Container Registry
21 | uses: docker/login-action@v1
22 | with:
23 | registry: ${{ env.REGISTRY }}
24 | username: ${{ github.actor }}
25 | password: ${{ secrets.GITHUB_TOKEN }}
26 |
27 | - name: Build frontend with cache & push
28 | run: |
29 | docker build \
30 | --cache-from "$REGISTRY/$GITHUB_REPOSITORY" \
31 | -t "$REGISTRY/$GITHUB_REPOSITORY:$TAG" \
32 | --build-arg BUILDKIT_INLINE_CACHE=1 \
33 | --build-arg SANITY_DATASET=$SANITY_DATASET \
34 | .
35 | docker push "$REGISTRY/$GITHUB_REPOSITORY" --all-tags
36 | env:
37 | DOCKER_BUILDKIT: 1
38 | REGISTRY: ${{ env.REGISTRY }}
39 | SANITY_DATASET: ${{ secrets.SANITY_DATASET }}
40 | TAG: latest
41 |
--------------------------------------------------------------------------------
/.github/workflows/jest_test.yaml:
--------------------------------------------------------------------------------
1 | name: Jest
2 | on:
3 | pull_request:
4 | branches: [master]
5 |
6 | jobs:
7 | jest_tests:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout repository
11 | uses: actions/checkout@v3
12 |
13 | - name: Cache dependencies
14 | uses: actions/cache@v3
15 | with:
16 | path: '**/node_modules'
17 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
18 |
19 | - name: Install dependencies
20 | run: yarn --frozen-lockfile --ignore-scripts
21 |
22 | - name: Run API & component tests
23 | run: yarn test
24 |
--------------------------------------------------------------------------------
/.github/workflows/lint_format.yaml:
--------------------------------------------------------------------------------
1 | name: Lint & format
2 | on:
3 | pull_request:
4 | branches: [master]
5 |
6 | jobs:
7 | lint_format:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout repository
11 | uses: actions/checkout@v3
12 |
13 | - name: Cache dependencies
14 | uses: actions/cache@v3
15 | with:
16 | path: '**/node_modules'
17 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
18 |
19 | - name: Install dependencies
20 | run: yarn --frozen-lockfile --ignore-scripts
21 |
22 | - name: Run linter
23 | run: yarn next lint
24 |
25 | - name: Run prettier check
26 | run: |
27 | yarn prettier -c "**/*.{js,jsx,ts,tsx,json,md}"
28 | yarn prettier -c --tab-width=2 "**/*.{yaml,yml}"
29 |
--------------------------------------------------------------------------------
/.github/workflows/rebase.yaml:
--------------------------------------------------------------------------------
1 | name: Automatic rebase
2 | on:
3 | issue_comment:
4 | types: [created]
5 |
6 | jobs:
7 | rebase:
8 | name: Rebase
9 | if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase')
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout the latest code
13 | uses: actions/checkout@v3
14 | with:
15 | token: ${{ secrets.REPO_PAT }}
16 | fetch-depth: 0
17 |
18 | - name: Automatic rebase
19 | uses: cirrus-actions/rebase@1.6
20 | env:
21 | GITHUB_TOKEN: ${{ secrets.REPO_PAT }}
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .cache
2 | public/rss.xml
3 | public/sw.js*
4 | public/workbox-*.js*
5 |
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Cypress
20 | cypress/videos/
21 | cypress/screenshots/
22 | cypress/downloads/
23 | cypress/fixtures/example.json
24 | cypress/plugins/
25 | cypress/support/
26 |
27 | # Directory for instrumented libs generated by jscoverage/JSCover
28 | lib-cov
29 |
30 | # Coverage directory used by tools like istanbul
31 | coverage
32 |
33 | # nyc test coverage
34 | .nyc_output
35 |
36 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
37 | .grunt
38 |
39 | # Bower dependency directory (https://bower.io/)
40 | bower_components
41 |
42 | # node-waf configuration
43 | .lock-wscript
44 |
45 | # Compiled binary addons (http://nodejs.org/api/addons.html)
46 | build/Release
47 |
48 | # Dependency directories
49 | node_modules/
50 | jspm_packages/
51 |
52 | # Typescript v1 declaration files
53 | typings/
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # Optional REPL history
62 | .node_repl_history
63 |
64 | # Output of 'npm pack'
65 | *.tgz
66 |
67 | # dotenv environment variable files
68 | .env*
69 |
70 | # example dotenv
71 | !.env.example
72 |
73 | # next
74 | .next
75 |
76 | # Mac files
77 | .DS_Store
78 |
79 | # Yarn
80 | yarn-error.log
81 | .pnp/
82 | .pnp.js
83 | # Yarn Integrity file
84 | .yarn-integrity
85 |
86 | #vs code
87 | .vscode/
88 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | yarn lint-staged
5 |
--------------------------------------------------------------------------------
/.lintstagedrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '*.{js,jsx,ts,tsx}': (filenames) =>
3 | `next lint --fix --file ${filenames.map((file) => file.split(process.cwd())[1]).join(' --file ')}`,
4 | '*.{js,jsx,ts,tsx,json,md}': 'prettier --write',
5 | '*.{yaml,yml}': 'prettier --write --tab-width=2',
6 | };
7 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .next/
3 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # ONLY FOR DEVELOPMENT AND TESTING USE
2 |
3 | # Install dependencies with yarn.
4 | # Node 14 is the version Vercel uses.
5 | FROM node:14-alpine as deps
6 |
7 | WORKDIR /opt/build
8 | COPY package.json yarn.lock ./
9 |
10 | RUN yarn --frozen-lockfile
11 |
12 |
13 | # Build with Next (no default command).
14 | FROM cypress/base:latest as build
15 |
16 | ARG SANITY_DATASET
17 |
18 | WORKDIR /opt/build
19 | COPY --from=deps /opt/build/node_modules ./node_modules/
20 | COPY --from=deps /root/.cache /root/.cache/
21 | # NB! Copies any .env files as well.
22 | COPY . .
23 |
24 | RUN yarn build
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # echo web frontend
2 |
3 | [](https://github.com/echo-webkom/echo-web-frontend/actions/workflows/jest_test.yaml)
4 | [](https://github.com/echo-webkom/echo-web-frontend/actions/workflows/cypress_test.yaml)
5 |
6 |
7 |
8 |
9 |
10 | Frontend til nettsiden til **echo – Linjeforeningen for informatikk** ved Universitetet i Bergen.
11 |
12 | Utviklet av frivillige informatikkstudenter fra undergruppen **echo Webkom**.
13 |
14 | ## Tilbakemeldinger
15 |
16 | Har du noen tilbakemeldinger til nettsiden?
17 | Vi jobber hele tiden med å forbedre den,
18 | og setter stor pris på om du sier ifra om noe er feil,
19 | eller du har idéer til nye endringer!
20 |
21 | Fyll gjerne ut skjemaet [her](https://forms.gle/r9LNMFjanUNP7Gph9),
22 | eller send oss en mail på [webkom-styret@echo.uib.no](mailto:webkom-styret@echo.uib.no).
23 |
24 | ## Oppsett for utviklere
25 |
26 | **1. Klon Git-repoet.**
27 |
28 | git clone git@github.com:echo-webkom/echo-web-frontend
29 |
30 | **2. Naviger til riktig mappe.**
31 |
32 | cd echo-web-frontend
33 |
34 | **3. Installer dependencies (du trenger [yarn](https://classic.yarnpkg.com/en/docs/install) for dette).**
35 |
36 | yarn
37 |
38 | **4. Kopier innholdet i `.env.example` til en fil med navn `.env` (og evt. fyll inn verdier for feltene).**
39 |
40 | cp .env.example .env
41 |
42 | **5. Start en lokal server.**
43 |
44 | yarn dev
45 |
46 | Gå til `localhost:3000` i en nettleser for å se nettsiden.
47 |
--------------------------------------------------------------------------------
/cypress.json:
--------------------------------------------------------------------------------
1 | {
2 | "baseUrl": "http://localhost:3000",
3 | "defaultCommandTimeout": 5000,
4 | "testFiles": ["entry-box.spec.ts", "nav.spec.ts", "happening-registration.spec.ts", "registration-deletion.spec.ts"]
5 | }
6 |
--------------------------------------------------------------------------------
/cypress/fixtures/happening.json:
--------------------------------------------------------------------------------
1 | {
2 | "happenings": [
3 | { "slug": "fest-med-tilde", "type": "EVENT" },
4 | { "slug": "bedriftspresentasjon-med-bekk", "type": "BEDPRES" }
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/cypress/fixtures/users.json:
--------------------------------------------------------------------------------
1 | {
2 | "bachelorDegrees": ["DTEK", "DSIK", "DVIT", "BINF", "IMO"],
3 | "masterDegrees": ["INF", "PROG"],
4 | "validArmninfUser": {
5 | "degree": "ARMNINF",
6 | "degreeYear": 1
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/cypress/integration/entry-box.spec.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-call */
2 |
3 | describe('Entry Box', () => {
4 | describe('720p res', () => {
5 | beforeEach(() => {
6 | cy.viewport(1280, 720);
7 | });
8 |
9 | describe('Bedpres Entry Box', () => {
10 | it('Should link to bedpreses page', () => {
11 | const bedpresPage = '/bedpres';
12 |
13 | cy.visit('/');
14 | cy.get('[data-cy=entry-box-bedpres]').within(() => {
15 | cy.get(`[data-cy="${bedpresPage}"]`).click();
16 | cy.url().should('include', bedpresPage);
17 | });
18 | });
19 | });
20 |
21 | describe('Event Entry Box', () => {
22 | it('Should link to event page', () => {
23 | const eventPage = '/event';
24 |
25 | cy.visit('/');
26 | cy.get('[data-cy=entry-box-event]').within(() => {
27 | cy.get(`[data-cy="${eventPage}"]`).click();
28 | cy.url().should('include', eventPage);
29 | });
30 | });
31 | });
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/cypress/integration/happening-registration.spec.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-call */
2 |
3 | import users from '../fixtures/users.json';
4 | import { happenings } from '../fixtures/happening.json';
5 |
6 | const checkSubmitRegistration = (degree: string, degreeYear: number) => {
7 | cy.get('[data-cy=reg-btn]').click();
8 | cy.get('[data-cy=reg-form]').should('be.visible');
9 |
10 | cy.get('input[name=email]').type(`${degree}${degreeYear}@test.com`);
11 | cy.get('input[name=firstName]').type('Test');
12 | cy.get('input[name=lastName]').type('McTest');
13 | cy.get('select[name=degree]').select(degree);
14 | cy.get('input[name=degreeYear]').check(degreeYear.toString(), { force: true });
15 | cy.get('input[id=terms1]').check({ force: true });
16 | cy.get('input[id=terms2]').check({ force: true });
17 | cy.get('input[id=terms3]').check({ force: true });
18 |
19 | cy.get('button[type=submit]').click();
20 |
21 | cy.get('li[class=chakra-toast]').contains('Påmeldingen din er registrert!');
22 | };
23 |
24 | describe('Happening registration', () => {
25 | describe('720p res', () => {
26 | beforeEach(() => {
27 | cy.viewport(1280, 720);
28 | });
29 |
30 | for (const { slug, type } of happenings) {
31 | context('Happening form registration', () => {
32 | beforeEach(() => {
33 | cy.visit(`/event/${slug}`);
34 | });
35 |
36 | it('Popup form appears correctly', () => {
37 | cy.get('[data-cy=reg-btn]').click();
38 | cy.get('[data-cy=reg-form]').should('be.visible');
39 |
40 | cy.get('input[name=email]').should('be.visible');
41 | cy.get('input[name=firstName]').should('be.visible');
42 | cy.get('input[name=lastName]').should('be.visible');
43 | cy.get('select[name=degree]').should('be.visible');
44 |
45 | /*
46 |
47 | TODO: fix this.
48 |
49 | Chakra hides radio and checkbox input beneath another element,
50 | therefore it will never be visible.
51 |
52 | cy.get('input[name=degreeYear]').should('be.visible');
53 | cy.get('input[id=terms1]').should('be.visible');
54 | cy.get('input[id=terms2]').should('be.visible');
55 | cy.get('input[id=terms3]').should('be.visible');
56 |
57 | */
58 | });
59 |
60 | for (const b of users.bachelorDegrees) {
61 | for (const y of [1, 2, 3]) {
62 | it(`User can sign up with valid input (degree = ${b}, degreeYear = ${y}, type = ${type})`, () => {
63 | checkSubmitRegistration(b, y);
64 | });
65 | }
66 | }
67 |
68 | for (const m of users.masterDegrees) {
69 | for (const y of [4, 5]) {
70 | it(`User can sign up with valid input (degree = ${m}, degreeYear = ${y}, type = ${type})`, () => {
71 | checkSubmitRegistration(m, y);
72 | });
73 | }
74 | }
75 |
76 | it(`User can sign up with valid input (degree = ${users.validArmninfUser.degree}, degreeYear = ${users.validArmninfUser.degreeYear}, type = ${type})`, () => {
77 | checkSubmitRegistration(users.validArmninfUser.degree, users.validArmninfUser.degreeYear);
78 | });
79 | });
80 | }
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/cypress/integration/nav.spec.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-call */
2 |
3 | Cypress.on('uncaught:exception', (err) => {
4 | if (err.message.includes('loop limit exceeded')) {
5 | return false;
6 | }
7 | });
8 |
9 | describe('Nav Menus', () => {
10 | describe('720p res', () => {
11 | beforeEach(() => {
12 | cy.viewport(1280, 720);
13 | });
14 |
15 | describe('When visiting the home page', () => {
16 | it('Should visit homepage', () => {
17 | cy.visit('/');
18 | });
19 |
20 | describe('navbar', () => {
21 | it('Should navigate to om-echo page', () => {
22 | cy.get('[data-cy=nav-item]').contains('Om echo').click();
23 | cy.url().should('include', '/om-echo/om-oss');
24 | });
25 | it('Should navigate to home page', () => {
26 | cy.get('[data-cy=nav-item]').contains('Hjem').click();
27 | cy.url().should('eq', 'http://localhost:3000/');
28 | });
29 | });
30 | });
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/cypress/integration/registration-deletion.spec.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-call */
2 | import { happenings } from '../fixtures/happening.json';
3 |
4 | const registrationRoute = 'registration';
5 |
6 | describe('Happening registration', () => {
7 | describe('720p res', () => {
8 | beforeEach(() => {
9 | cy.viewport(1280, 720);
10 | });
11 |
12 | for (const { slug } of happenings) {
13 | for (let rows = 20; rows > 0; rows--) {
14 | describe('Happening registration deletion', () => {
15 | beforeEach(() => {
16 | cy.visit(`/${registrationRoute}/${slug}`);
17 | });
18 |
19 | it('Registrations are deleted properly', () => {
20 | cy.get('[data-cy=reg-row]').should('have.length', rows);
21 | cy.get('[data-cy=delete-button]').first().should('be.visible');
22 | cy.get('[data-cy=delete-button]').first().click();
23 |
24 | cy.get('[data-cy=confirm-delete-button]').click();
25 | });
26 | });
27 | }
28 | }
29 |
30 | after(() => {
31 | for (const { slug } of happenings) {
32 | cy.visit(`/${registrationRoute}/${slug}`);
33 | cy.get('[data-cy=no-regs]').should('be.visible');
34 | cy.get('[data-cy=no-regs]').should('contain.text', 'Ingen påmeldinger enda');
35 | }
36 | });
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/cypress/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "es2015"],
5 | "types": ["cypress"],
6 | "resolveJsonModule": true,
7 | "esModuleInterop": true
8 | },
9 | "include": ["**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: '3.9'
2 | services:
3 | frontend:
4 | build: .
5 | image: '${REGISTRY:-ghcr.io}/echo-webkom/echo-web-frontend:${GITHUB_SHA:-latest}'
6 | command: bash -c "yarn start & yarn cypress run --config video=false,screenshotOnRunFailure=false && kill $$!"
7 | # Don't start tests before backend is up.
8 | depends_on:
9 | backend:
10 | condition: service_healthy
11 | links:
12 | - backend
13 | ports:
14 | - '3000:3000'
15 | environment:
16 | BACKEND_URL: http://backend:8080
17 | # Values from .env file.
18 | SANITY_DATASET: ${SANITY_DATASET:?Must specify SANITY_DATASET in .env file or environment.}
19 | ADMIN_KEY: ${ADMIN_KEY:?Must specify ADMIN_KEY in .env file or environment.}
20 | NEXTAUTH_URL: http://localhost:3000
21 | NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-very-secret-string-123}
22 |
23 | backend:
24 | image: '${REGISTRY:-ghcr.io}/echo-webkom/echo-web-backend:${TAG:-latest-prod}'
25 | # Don't start backend before database is up.
26 | depends_on:
27 | database:
28 | condition: service_healthy
29 | links:
30 | - database
31 | ports:
32 | - '8080:8080'
33 | # Check if backend is ready, and insert bedpres for testing.
34 | healthcheck:
35 | test: ['CMD-SHELL', './scripts/submit_happening -t -x $$ADMIN_KEY || exit 1']
36 | interval: 5s
37 | timeout: 5s
38 | retries: 5
39 | logging:
40 | driver: 'none'
41 | environment:
42 | DATABASE_URL: postgres://postgres:password@database/postgres
43 | # The value of DEV doesn't matter, only that it's defined.
44 | DEV: 'yes'
45 | # Values from .env file.
46 | ADMIN_KEY: ${ADMIN_KEY:?Must specify ADMIN_KEY in .env file or environment.}
47 |
48 | database:
49 | # Postgres 13.4 is the version Heroku uses.
50 | image: postgres:13.4-alpine
51 | restart: always
52 | ports:
53 | - '5432:5432'
54 | # Check if database is ready.
55 | healthcheck:
56 | test: ['CMD-SHELL', 'pg_isready -U postgres']
57 | interval: 5s
58 | timeout: 5s
59 | retries: 5
60 | environment:
61 | POSTGRES_USER: postgres
62 | POSTGRES_PASSWORD: password
63 | POSTGRES_DB: postgres
64 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
3 | Object.defineProperty(window, 'matchMedia', {
4 | writable: true,
5 | value: jest.fn().mockImplementation((query) => ({
6 | matches: false,
7 | media: query,
8 | onchange: null,
9 | addListener: jest.fn(), // deprecated
10 | removeListener: jest.fn(), // deprecated
11 | addEventListener: jest.fn(),
12 | removeEventListener: jest.fn(),
13 | dispatchEvent: jest.fn(),
14 | })),
15 | });
16 |
17 | process.env = {
18 | ...process.env,
19 | __NEXT_IMAGE_OPTS: {
20 | deviceSizes: [320, 420, 768, 1024, 1200],
21 | imageSizes: [],
22 | domains: ['images.ctfassets.net'],
23 | path: '/_next/image',
24 | loader: 'default',
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const withPWA = require('next-pwa');
2 |
3 | module.exports = withPWA({
4 | pwa: {
5 | dest: 'public',
6 | disable: process.env.NODE_ENV === 'development',
7 | },
8 | webpack: (config) => {
9 | config.module.rules.push({
10 | test: /\.md$/,
11 | type: 'asset/source',
12 | });
13 | return config;
14 | },
15 | images: {
16 | domains: ['cdn.sanity.io'],
17 | },
18 | experimental: {
19 | esmExternals: false,
20 | },
21 | async redirects() {
22 | return [
23 | { source: '/events', destination: '/event', permanent: true },
24 | {
25 | source: '/events/:path',
26 | destination: '/event/:path',
27 | permanent: true,
28 | },
29 | { source: '/bedpres/:path', destination: '/event/:path', permanent: true },
30 | ];
31 | },
32 | reactStrictMode: true,
33 | });
34 |
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/bedpres.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/bedpres.png
--------------------------------------------------------------------------------
/public/bekk.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/bekk.png
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #603cba
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/christmas-icons/santa_hat.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/echo-logo-text-only-white-no-padding-bottom.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/echo-logo-text-only-white-no-padding-bottom.png
--------------------------------------------------------------------------------
/public/echo-logo-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/echo-logo-white.png
--------------------------------------------------------------------------------
/public/echo-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/echo-logo.png
--------------------------------------------------------------------------------
/public/event.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/event.png
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/favicon.ico
--------------------------------------------------------------------------------
/public/halloween-icons/ghost.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
51 |
--------------------------------------------------------------------------------
/public/halloween-icons/hat.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
45 |
--------------------------------------------------------------------------------
/public/maskable-icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/maskable-icon-192x192.png
--------------------------------------------------------------------------------
/public/maskable-icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/maskable-icon-512x512.png
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/post.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/echo-webkom/echo-web-frontend/bbaa8a0f302ada3946110ae0fc8d542e02f658a0/public/post.png
--------------------------------------------------------------------------------
/public/powered-by-vercel.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
20 |
--------------------------------------------------------------------------------
/public/sanity-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "echo – Linjeforeningen for informatikk",
3 | "description": "Nettsiden til echo – Linjeforeningen for informatikk.",
4 | "start_url": ".",
5 | "short_name": "echo",
6 | "shortcuts": [
7 | {
8 | "name": "Bedriftspresentasjoner",
9 | "short_name": "Bedpres",
10 | "description": "Gå til bedriftspresentasjoner",
11 | "url": "/bedpres",
12 | "icons": [{ "src": "bedpres.png", "sizes": "120x120" }]
13 | },
14 | {
15 | "name": "Arrangementer",
16 | "short_name": "Events",
17 | "description": "Gå til arrangementer",
18 | "url": "/event",
19 | "icons": [{ "src": "event.png", "sizes": "120x120" }]
20 | },
21 | {
22 | "name": "Innlegg",
23 | "short_name": "Innlegg",
24 | "description": "Gå til innlegg",
25 | "url": "/posts",
26 | "icons": [{ "src": "post.png", "sizes": "120x120" }]
27 | }
28 | ],
29 | "icons": [
30 | {
31 | "src": "/android-chrome-192x192.png",
32 | "sizes": "192x192",
33 | "type": "image/png"
34 | },
35 | {
36 | "src": "/android-chrome-512x512.png",
37 | "sizes": "512x512",
38 | "type": "image/png"
39 | },
40 | {
41 | "src": "/maskable-icon-512x512.png",
42 | "sizes": "512x512",
43 | "type": "image/png",
44 | "purpose": "maskable any"
45 | },
46 | {
47 | "src": "/maskable-icon-192x192.png",
48 | "sizes": "192x192",
49 | "type": "image/png",
50 | "purpose": "maskable any"
51 | }
52 | ],
53 | "theme_color": "#E6E6E6",
54 | "background_color": "#FFFFFF",
55 | "display": "standalone"
56 | }
57 |
--------------------------------------------------------------------------------
/public/static/valg.md:
--------------------------------------------------------------------------------
1 | **Husk å stemme innen fredag kl 23:59!**
2 |
3 | BLI MED Å BESTEMME HVEM SOM SKAL REPRESENTERE DEG OG DINE MENINGER I HOVEDSTYRE DET NESTE ÅRET!
4 |
5 | DIN VALGSEDDEL ER SENDT TIL DEG PÅ STUDENTMAILEN DIN.
6 |
7 | Det er rekordmange kandidater som stiller i år 🎉 Informasjon om alle kandidatene finner du her: [bit.ly/Appeller](https://bit.ly/Appeller)
8 | Valget stenger 6. mai klokken 2359.
9 |
10 | **Bruk stemmeretten din, og godt valg!**
11 |
--------------------------------------------------------------------------------
/src/components/__tests__/footer.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import React from 'react';
3 | import Footer from '../footer';
4 | import { render } from './testing-utils';
5 |
6 | describe('Footer', () => {
7 | test('renders without crashing', () => {
8 | render();
9 | expect(screen.getByTestId(/footer/i)).toBeInTheDocument();
10 | });
11 |
12 | test('renders correctly', () => {
13 | render();
14 | expect(screen.getByText(/thormøhlensgate 55/i)).toBeInTheDocument();
15 | expect(screen.getByText(/5006 bergen/i)).toBeInTheDocument();
16 | expect(screen.getByText(/org nr: 998 995 035/i)).toBeInTheDocument();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/components/__tests__/header.test.tsx:
--------------------------------------------------------------------------------
1 | import userEvent from '@testing-library/user-event';
2 | import React from 'react';
3 | import Header from '../header';
4 | import { render } from './testing-utils';
5 |
6 | describe('Header', () => {
7 | test('renders without crashing', () => {
8 | const { getByTestId } = render();
9 | expect(getByTestId(/header-standard/i)).toBeInTheDocument();
10 | });
11 |
12 | test('renders correctly', () => {
13 | const { getByTestId } = render();
14 | expect(getByTestId(/header-standard/i)).toBeInTheDocument();
15 | expect(getByTestId(/header-logo/i)).toBeInTheDocument();
16 | expect(getByTestId(/navbar-standard/i)).toBeInTheDocument();
17 | expect(getByTestId(/drawer-button/i)).toBeInTheDocument();
18 | });
19 |
20 | test('drawer button opens a chakra drawer', () => {
21 | const { getByTestId, getByText } = render();
22 | const drawerButton = getByTestId(/drawer-button/i);
23 | userEvent.click(drawerButton);
24 | // drawer exists in DOM
25 | expect(getByTestId(/navbar-drawer/i)).toBeInTheDocument();
26 | // Drawer Header exists
27 | expect(getByText(/navigasjon/i)).toBeInTheDocument();
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/components/__tests__/info-panels.test.tsx:
--------------------------------------------------------------------------------
1 | import Markdown from 'markdown-to-jsx';
2 | import React from 'react';
3 | import MapMarkdownChakra from '../../markdown';
4 | import InfoPanels from '../info-panels';
5 | import { render } from './testing-utils';
6 |
7 | const tabNames = ['en fane', 'enda en fane', 'fane 100'];
8 | const markdownFiles = [
9 | '# masse markdown omg må ha mer enn 20 chars',
10 | '# enda mer damn ja what sjukt bruh moment',
11 | '## lorem ipsum dolor sit amet [link](https://google.com)',
12 | ];
13 | const noSection = [false, false, true];
14 |
15 | describe('InfoPanels', () => {
16 | test('renders without crashing', () => {
17 | const { getByTestId } = render(
18 | {
21 | return (
22 |
23 | {file}
24 |
25 | );
26 | })}
27 | noSection={noSection}
28 | />,
29 | );
30 | expect(getByTestId(/info-panels/i)).toBeInTheDocument();
31 | });
32 |
33 | test('renders correctly', () => {
34 | const { getByTestId } = render(
35 | {
38 | return (
39 |
40 | {file}
41 |
42 | );
43 | })}
44 | noSection={noSection}
45 | />,
46 | );
47 |
48 | tabNames.map((tabName) => {
49 | return expect(getByTestId(new RegExp(`^${tabName}-tab$`, 'i'))).toBeInTheDocument();
50 | });
51 |
52 | markdownFiles.map((_, i) => {
53 | return expect(getByTestId(new RegExp(`^${i.toString()}-tabPanel$`, 'i'))).toBeInTheDocument();
54 | });
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/src/components/__tests__/layout.test.tsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import React from 'react';
3 | import Layout from '../layout';
4 | import { render } from './testing-utils';
5 |
6 | describe('Layout', () => {
7 | test('renders without crashing', () => {
8 | render(
9 |
10 | test
11 | ,
12 | );
13 | expect(screen.getByTestId(/layout/i)).toBeInTheDocument();
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/__tests__/navbar.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import NavBar from '../navbar';
3 | import { render } from './testing-utils';
4 |
5 | describe('NavBar', () => {
6 | test('renders without crashing', () => {
7 | const btnRef = React.createRef();
8 | const { getByTestId } = render(
9 | {
12 | return;
13 | }}
14 | btnRef={btnRef}
15 | />,
16 | );
17 | expect(getByTestId(/navbar-standard/i)).toBeInTheDocument();
18 | });
19 |
20 | test('renders correctly', () => {
21 | const btnRef = React.createRef();
22 | const { getByTestId } = render(
23 | {
26 | return;
27 | }}
28 | btnRef={btnRef}
29 | />,
30 | );
31 | // navbar buttons exist
32 | expect(getByTestId(/hjem/i)).toBeInTheDocument();
33 | expect(getByTestId(/om-oss/i)).toBeInTheDocument();
34 | expect(getByTestId(/colormode-button/i)).toBeInTheDocument();
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/src/components/__tests__/testing-utils.ts:
--------------------------------------------------------------------------------
1 | import { ReactElement, JSXElementConstructor } from 'react';
2 | import { render, RenderOptions } from '@testing-library/react';
3 | import AllTheProviders from './testing-wrapper';
4 |
5 | const customRender = (ui: ReactElement>, options?: RenderOptions) =>
6 | render(ui, {
7 | wrapper: AllTheProviders,
8 | ...options,
9 | });
10 |
11 | /* eslint-disable import/export */
12 | // re-export everything
13 | export * from '@testing-library/react';
14 | // override render method
15 | export { customRender as render };
16 | /* eslint-enable import/export */
17 |
--------------------------------------------------------------------------------
/src/components/__tests__/testing-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import { ChakraProvider } from '@chakra-ui/react';
2 | import { SessionProvider } from 'next-auth/react';
3 | import React, { FunctionComponent } from 'react';
4 | import theme from '../../styles/theme';
5 |
6 | const AllTheProviders: FunctionComponent = ({ children }) => {
7 | return (
8 |
14 | {children}
15 |
16 | );
17 | };
18 |
19 | export default AllTheProviders;
20 |
--------------------------------------------------------------------------------
/src/components/animated-icons.tsx:
--------------------------------------------------------------------------------
1 | import { getMonth } from 'date-fns';
2 | import Image from 'next/image';
3 | import { motion } from 'framer-motion';
4 | import { Box } from '@chakra-ui/react';
5 |
6 | interface Props {
7 | n: number;
8 | children: React.ReactNode;
9 | }
10 |
11 | const AnimatedIcons = ({ n, children }: Props): JSX.Element => {
12 | const month = getMonth(new Date());
13 | // Only for Halloween, no animated icons for Christmas
14 | if (month !== 10) return <>{children}>;
15 |
16 | const keys = [...new Array(n).keys()];
17 | const folder = '/halloween-icons/';
18 | const icons = ['ghost.svg', 'pumpkin.svg', 'skull.svg'];
19 |
20 | return (
21 |
22 |
23 | {children}
24 |
25 |
26 | {keys.map((key) => {
27 | const xOffset = Math.floor(Math.random() * 95); //between 10% and 90%
28 | const yOffset = Math.floor(Math.random() * 95);
29 |
30 | const icon = icons[Math.floor(Math.random() * icons.length)];
31 | return (
32 |
40 | );
41 | })}
42 |
43 |
44 | );
45 | };
46 |
47 | const AnimatedIcon = ({
48 | xOffset,
49 | yOffset,
50 | delay,
51 | repeatDelay,
52 | iconSrc,
53 | }: {
54 | xOffset: string;
55 | yOffset: string;
56 | delay: number;
57 | repeatDelay: number;
58 | iconSrc: string;
59 | }): JSX.Element => (
60 |
76 |
77 |
78 | );
79 |
80 | export default AnimatedIcons;
81 |
--------------------------------------------------------------------------------
/src/components/article.tsx:
--------------------------------------------------------------------------------
1 | import { Divider, Heading } from '@chakra-ui/react';
2 | import Markdown from 'markdown-to-jsx';
3 | import React from 'react';
4 | import MapMarkdownChakra from '../markdown';
5 |
6 | interface Props {
7 | heading: string;
8 | body: string;
9 | }
10 |
11 | const Article = ({ heading, body }: Props): JSX.Element => {
12 | return (
13 | <>
14 |
15 | {heading}
16 |
17 |
18 | {body}
19 | >
20 | );
21 | };
22 |
23 | export default Article;
24 |
--------------------------------------------------------------------------------
/src/components/bedpres-preview.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Center, Flex, Heading, LinkBox, LinkOverlay, Spacer, useColorModeValue } from '@chakra-ui/react';
2 | import Image from 'next/image';
3 | import NextLink from 'next/link';
4 | import React from 'react';
5 | import { Happening, RegistrationCount } from '../lib/api';
6 | import HappeningKeyInfo from './happening-key-info';
7 |
8 | interface Props {
9 | bedpres: Happening;
10 | registrationCounts?: Array;
11 | }
12 |
13 | const BedpresPreview = ({ bedpres, registrationCounts }: Props): JSX.Element => {
14 | const hoverColor = useColorModeValue('bg.light.hover', 'bg.dark.hover');
15 | // logoUrl must always be defined in a Happening of type 'BEDPRES'.
16 | const logoUrl = bedpres.logoUrl as string;
17 |
18 | return (
19 |
20 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | {bedpres.title}
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default BedpresPreview;
51 |
--------------------------------------------------------------------------------
/src/components/button-link.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonProps, LinkBox, LinkOverlay, useColorModeValue } from '@chakra-ui/react';
2 | import NextLink from 'next/link';
3 |
4 | interface Props extends ButtonProps {
5 | linkTo: string;
6 | isExternal?: boolean;
7 | }
8 |
9 | const ButtonLink = ({ linkTo, isExternal = false, ...props }: Props): JSX.Element => {
10 | const bg = useColorModeValue('button.light.primary', 'button.dark.primary');
11 | const hover = useColorModeValue('button.light.primaryHover', 'button.dark.primaryHover');
12 | const active = useColorModeValue('button.light.primaryActive', 'button.dark.primaryActive');
13 | const textColor = useColorModeValue('button.light.text', 'button.dark.text');
14 |
15 | return (
16 |
17 |
18 |
19 |
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | export default ButtonLink;
36 |
--------------------------------------------------------------------------------
/src/components/button.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonProps, useColorModeValue } from '@chakra-ui/react';
2 |
3 | const ButtonLink = ({ ...props }: ButtonProps): JSX.Element => {
4 | const bg = useColorModeValue('button.light.primary', 'button.dark.primary');
5 | const hover = useColorModeValue('button.light.primaryHover', 'button.dark.primaryHover');
6 | const active = useColorModeValue('button.light.primaryActive', 'button.dark.primaryActive');
7 | const textColor = useColorModeValue('button.light.text', 'button.dark.text');
8 |
9 | return (
10 |
18 | );
19 | };
20 |
21 | export default ButtonLink;
22 |
--------------------------------------------------------------------------------
/src/components/calendar-popup.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Modal,
3 | ModalOverlay,
4 | ModalContent,
5 | ModalHeader,
6 | ModalFooter,
7 | ModalBody,
8 | ModalCloseButton,
9 | useDisclosure,
10 | SimpleGrid,
11 | LinkBox,
12 | LinkOverlay,
13 | Icon,
14 | } from '@chakra-ui/react';
15 | import { google, outlook, yahoo, ics } from 'calendar-link';
16 | import { addHours, format } from 'date-fns';
17 | import React from 'react';
18 | import NextLink from 'next/link';
19 | import { FaFileDownload, FaGoogle, FaYahoo } from 'react-icons/fa';
20 | import { SiMicrosoftoutlook } from 'react-icons/si';
21 | import { BiCalendar } from 'react-icons/bi';
22 | import { nb } from 'date-fns/locale';
23 | import { HappeningType } from '../lib/api';
24 | import IconText from './icon-text';
25 |
26 | interface Props {
27 | date: Date;
28 | location: string;
29 | title: string;
30 | type: HappeningType;
31 | slug: string;
32 | }
33 |
34 | const CalendarPopup = ({ date, location, title, type, slug }: Props): JSX.Element => {
35 | const { isOpen, onOpen, onClose } = useDisclosure();
36 | const event = {
37 | title: `${title} ${type === 'EVENT' ? 'Arrangement' : 'Bedriftspresentasjon'}`,
38 | description:
39 | `${title} ${type === 'EVENT' ? 'Arrangementet' : 'Bedriftspresentasjonen'}: ` +
40 | `https://echo.uib.no/${type}/${slug}`.toLowerCase(),
41 | start: date,
42 | end: addHours(date, 2),
43 | location: `${location}`,
44 | };
45 | //This one is needed because yahoo is off by one hour
46 | const eventPlusOneHour = {
47 | ...event,
48 | start: addHours(event.start, 1),
49 | end: addHours(event.end, 1),
50 | };
51 | return (
52 | <>
53 |
59 |
60 |
61 |
62 |
63 |
64 | Legg {type === 'EVENT' ? 'Arrangementet' : 'Bedriftspresentasjonen'} til i kalenderen:
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | Google
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | Outlook
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 | Yahoo
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | .ics fil
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | >
108 | );
109 | };
110 |
111 | export default CalendarPopup;
112 |
--------------------------------------------------------------------------------
/src/components/color-mode-button.tsx:
--------------------------------------------------------------------------------
1 | import { Box, HStack, Switch, useColorMode, useColorModeValue } from '@chakra-ui/react';
2 | import React, { ChangeEvent } from 'react';
3 | import { BsSun } from 'react-icons/bs';
4 | import { BiMoon } from 'react-icons/bi';
5 |
6 | const ColorModeButton = (): JSX.Element => {
7 | const { colorMode, toggleColorMode } = useColorMode();
8 | const sunBg = useColorModeValue('yellow.500', 'yellow.200');
9 | const moonBg = useColorModeValue('blue.500', 'blue.200');
10 | const label = 'colormode-button';
11 |
12 | return (
13 |
14 |
15 |
16 |
17 | ) => {
22 | if (event.target.checked && colorMode === 'light') toggleColorMode();
23 | else if (!event.target.checked && colorMode === 'dark') toggleColorMode();
24 | }}
25 | />
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default ColorModeButton;
34 |
--------------------------------------------------------------------------------
/src/components/countdown.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Text } from '@chakra-ui/react';
2 | import React from 'react';
3 | import { useCountdown } from '../lib/hooks';
4 |
5 | interface Props {
6 | date: Date;
7 | }
8 |
9 | const Countdown = ({ date }: Props): JSX.Element => {
10 | const { hours, minutes, seconds } = useCountdown(date);
11 |
12 | return (
13 |
14 |
20 | {hours < 10 ? `0${hours}` : hours} : {minutes < 10 ? `0${minutes}` : minutes} :{' '}
21 | {seconds < 10 ? `0${seconds}` : seconds}
22 |
23 |
24 | );
25 | };
26 |
27 | export default Countdown;
28 |
--------------------------------------------------------------------------------
/src/components/entry-box.tsx:
--------------------------------------------------------------------------------
1 | import { BoxProps, Flex, Heading, Spacer, Text, useBreakpointValue } from '@chakra-ui/react';
2 | import React from 'react';
3 | import { Happening, Post, JobAdvert, RegistrationCount } from '../lib/api';
4 | import ButtonLink from './button-link';
5 | import EntryList from './entry-list';
6 | import Section from './section';
7 |
8 | interface Props extends BoxProps {
9 | title?: string;
10 | titles?: Array;
11 | entries: Array;
12 | entryLimit?: number;
13 | altText?: string;
14 | linkTo?: string;
15 | type: 'event' | 'bedpres' | 'post' | 'job-advert';
16 | registrationCounts?: Array;
17 | enableJobAdverts?: boolean;
18 | }
19 |
20 | const EntryBox = ({
21 | title,
22 | titles,
23 | entries,
24 | entryLimit,
25 | altText,
26 | linkTo,
27 | type,
28 | registrationCounts,
29 | enableJobAdverts = false,
30 | ...props
31 | }: Props): JSX.Element => {
32 | const choices = titles ?? [title];
33 | const heading = useBreakpointValue(choices); // cannot call hooks conditionally
34 |
35 | return (
36 |
37 |
38 | {heading && {heading}}
39 | {altText && entries.length === 0 && {altText}}
40 | {entries.length > 0 && (
41 |
48 | )}
49 |
50 | {linkTo && (
51 |
57 | Se mer
58 |
59 | )}
60 |
61 |
62 | );
63 | };
64 |
65 | export default EntryBox;
66 |
--------------------------------------------------------------------------------
/src/components/entry-list.tsx:
--------------------------------------------------------------------------------
1 | import { Stack, StackDivider } from '@chakra-ui/react';
2 | import React from 'react';
3 | import { Happening, Post, JobAdvert, RegistrationCount } from '../lib/api';
4 | import BedpresPreview from './bedpres-preview';
5 | import EventPreview from './event-preview';
6 | import PostPreview from './post-preview';
7 | import JobAdvertPreview from './job-advert-preview';
8 |
9 | interface Props {
10 | entries: Array;
11 | entryLimit?: number;
12 | type: 'event' | 'bedpres' | 'post' | 'job-advert';
13 | registrationCounts?: Array;
14 | enableJobAdverts?: boolean;
15 | }
16 |
17 | const EntryList = ({ entries, entryLimit, type, registrationCounts, enableJobAdverts = false }: Props): JSX.Element => {
18 | if (entryLimit) {
19 | entries = entries.length > entryLimit ? entries.slice(0, entryLimit) : entries;
20 | }
21 |
22 | if (type === 'job-advert') {
23 | entries.sort((a, b) => {
24 | return (b as JobAdvert).weight - (a as JobAdvert).weight;
25 | });
26 | }
27 |
28 | return (
29 | }
33 | direction={type === 'post' && !enableJobAdverts ? ['column', null, null, 'row'] : 'column'}
34 | justifyContent="space-around"
35 | >
36 | {entries.map((entry: Happening | Post | JobAdvert) => {
37 | switch (type) {
38 | case 'bedpres':
39 | return (
40 |
46 | );
47 | case 'event':
48 | return (
49 |
54 | );
55 | case 'post':
56 | return (
57 |
62 | );
63 | case 'job-advert':
64 | return ;
65 | }
66 | })}
67 |
68 | );
69 | };
70 |
71 | export default EntryList;
72 |
--------------------------------------------------------------------------------
/src/components/entry-overview.tsx:
--------------------------------------------------------------------------------
1 | import { GridItem, SimpleGrid } from '@chakra-ui/react';
2 | import { isFuture, isPast } from 'date-fns';
3 | import React from 'react';
4 | import { Happening } from '../lib/api';
5 | import EntryBox from './entry-box';
6 |
7 | interface Props {
8 | entries: Array;
9 | type: 'event' | 'bedpres';
10 | }
11 |
12 | const EntryOverview = ({ entries, type }: Props): JSX.Element => {
13 | const alt = type === 'event' ? 'arrangementer' : 'bedriftspresentasjoner';
14 |
15 | const upcoming = entries.filter((entry: Happening) => {
16 | return isFuture(new Date(entry.date));
17 | });
18 | const past = entries
19 | .filter((entry: Happening) => {
20 | return isPast(new Date(entry.date));
21 | })
22 | .reverse();
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | };
35 |
36 | export default EntryOverview;
37 |
--------------------------------------------------------------------------------
/src/components/error-box.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Center, Text } from '@chakra-ui/react';
2 | import React from 'react';
3 |
4 | interface Props {
5 | error: string;
6 | }
7 |
8 | const ErrorBox = ({ error }: Props): JSX.Element => {
9 | return (
10 |
11 |
12 | Det har oppstått en feil:
13 | {error}
14 |
15 |
16 | );
17 | };
18 |
19 | export default ErrorBox;
20 |
--------------------------------------------------------------------------------
/src/components/event-preview.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Center, Flex, LinkBox, LinkOverlay, Text } from '@chakra-ui/react';
2 | import NextLink from 'next/link';
3 | import React from 'react';
4 | import { Happening, RegistrationCount } from '../lib/api';
5 | import HappeningKeyInfo from './happening-key-info';
6 |
7 | interface Props {
8 | event: Happening;
9 | registrationCounts?: Array;
10 | }
11 |
12 | const EventPreview = ({ event, registrationCounts }: Props): JSX.Element => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 | {event.title}
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default EventPreview;
32 |
--------------------------------------------------------------------------------
/src/components/form-question.tsx:
--------------------------------------------------------------------------------
1 | import { FormControl, FormLabel, Input, Radio, RadioGroup, VStack } from '@chakra-ui/react';
2 | import React from 'react';
3 | import { useFormContext } from 'react-hook-form';
4 | import { RegFormValues, Question } from '../lib/api';
5 |
6 | interface Props {
7 | q: Question;
8 | index: number;
9 | }
10 |
11 | const FormQuestion = ({ q, index }: Props): JSX.Element => {
12 | const { register } = useFormContext();
13 |
14 | return q.inputType === 'radio' ? (
15 |
16 | {q.questionText}
17 |
18 |
19 | {q.alternatives?.map((alt: string) => {
20 | return (
21 |
22 | {alt}
23 |
24 | );
25 | })}
26 |
27 |
28 |
29 | ) : (
30 |
31 | {q.questionText}
32 |
33 |
34 | );
35 | };
36 |
37 | export default FormQuestion;
38 |
--------------------------------------------------------------------------------
/src/components/form-term.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox, FormControl, FormLabel } from '@chakra-ui/react';
2 | import React from 'react';
3 | import { useFormContext } from 'react-hook-form';
4 | import { RegFormValues } from '../lib/api';
5 |
6 | interface Props {
7 | id: 'terms1' | 'terms2' | 'terms3';
8 | children: React.ReactNode;
9 | }
10 |
11 | const FormTerm = ({ id, children }: Props): JSX.Element => {
12 | const { register } = useFormContext();
13 |
14 | return (
15 |
16 | Bekreft
17 | {children}
18 |
19 | );
20 | };
21 |
22 | export default FormTerm;
23 |
--------------------------------------------------------------------------------
/src/components/happening-key-info.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Stack, Text } from '@chakra-ui/react';
2 | import { format, isPast } from 'date-fns';
3 | import { nb } from 'date-fns/locale';
4 | import { useRouter } from 'next/router';
5 | import { BiCalendar } from 'react-icons/bi';
6 | import { Happening, RegistrationCount, SpotRange } from '../lib/api';
7 |
8 | interface Props {
9 | event: Happening;
10 | registrationCounts?: Array;
11 | }
12 |
13 | const HappeningKeyInfo = ({ event, registrationCounts = [] }: Props): JSX.Element => {
14 | const router = useRouter();
15 | const isMainPage = router.pathname === '/';
16 |
17 | const totalReg = registrationCounts.find((regCount: RegistrationCount) => regCount.slug === event.slug)?.count ?? 0;
18 | const waitListCount =
19 | registrationCounts.find((regCount: RegistrationCount) => regCount.slug === event.slug)?.waitListCount ?? 0;
20 | const totalSpots = event.spotRanges.map((spotRange: SpotRange) => spotRange.spots).reduce((a, b) => a + b, 0);
21 |
22 | return (
23 |
24 |
25 |
26 |
27 | {format(new Date(event.date), 'dd. MMM', { locale: nb })}
28 |
29 |
30 |
31 | {event.registrationDate && isMainPage && (
32 |
33 | {isPast(new Date(event.registrationDate)) ? (
34 |
35 | {waitListCount > 0 ? `Fullt` : `${totalReg} av ${totalSpots === 0 ? '∞' : totalSpots}`}
36 |
37 | ) : (
38 |
39 | Påmelding{' '}
40 |
41 | {format(new Date(event.registrationDate), 'dd. MMM yyyy', { locale: nb })}
42 |
43 |
44 | )}
45 |
46 | )}
47 |
48 | );
49 | };
50 |
51 | export default HappeningKeyInfo;
52 |
--------------------------------------------------------------------------------
/src/components/header-logo.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Flex, LinkBox, LinkOverlay, Text, useColorModeValue } from '@chakra-ui/react';
3 | import { isFriday, isThursday, getDate, getHours, getMonth, isMonday } from 'date-fns';
4 | import Image from 'next/image';
5 | import NextLink from 'next/link';
6 | import LogoAccesory from './logo-accesory';
7 |
8 | const randomHeaderMessage = (): string => {
9 | const now = new Date();
10 |
11 | const stdMessages = () => {
12 | const baseMessages = [
13 | 'Bottom text',
14 | '🤙🤙🤙',
15 | 'Lorem ipsum',
16 | '98.5507246% stabil! rip pwc :(',
17 | 'Uten sylteagurk!',
18 | 'Spruuutnice',
19 | 'Skambra!',
20 | 'For ei skjønnas 😍',
21 | 'Vim eller forsvinn',
22 | 'Mye å gjøre? SUCK IT UP!',
23 | ];
24 |
25 | if (getMonth(now) === 9) return [...baseMessages, 'BØ!', 'UuUuuUuuUuUu'];
26 |
27 | if (getMonth(now) === 11) return [...baseMessages, 'Ho, ho, ho!'];
28 |
29 | if (isThursday(now)) return [...baseMessages, 'Vaffeltorsdag 🧇'];
30 |
31 | if (isFriday(now)) return [...baseMessages, 'Tacofredag 🌯'];
32 |
33 | return baseMessages;
34 | };
35 |
36 | if (isMonday(now)) {
37 | return 'New week, new me?';
38 | } else if (isThursday(now) && getHours(now) < 12) {
39 | return 'Husk bedpres kl. 12:00!';
40 | } else if (getMonth(now) === 11 && getDate(now) >= 24) {
41 | return 'God jul! 🎅';
42 | } else if (getMonth(now) === 0 && getDate(now) === 1) {
43 | return 'Godt nyttår! ✨';
44 | }
45 |
46 | return stdMessages()[Math.floor(Math.random() * stdMessages().length)];
47 | };
48 |
49 | const HeaderLogo = () => {
50 | // Logo without any text
51 | const smallLogo = '/android-chrome-512x512.png';
52 |
53 | // Logo with text. Changes logo depending on light/dark mode.
54 | // The small logo is the same for both modes.
55 | const bigLogo = useColorModeValue('/echo-logo.png', '/echo-logo-white.png');
56 |
57 | // Background of logo image, depending on light/dark mode.
58 | const bg = useColorModeValue('bg.light.secondary', 'bg.dark.secondary');
59 |
60 | // Logo accesory
61 | const month = getMonth(new Date());
62 | const logoAcc = month === 11 ? '/christmas-icons/santa_hat.svg' : month === 9 ? '/halloween-icons/hat.svg' : null;
63 |
64 | // Color and background of message-box
65 | const textBg = useColorModeValue('highlight.light.primary', 'highlight.dark.primary');
66 | const textColor = useColorModeValue('white', 'black');
67 |
68 | return (
69 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | {logoAcc && }
85 |
86 |
87 |
88 |
101 | {randomHeaderMessage()}
102 |
103 |
104 |
105 |
106 |
107 | );
108 | };
109 |
110 | export default HeaderLogo;
111 |
--------------------------------------------------------------------------------
/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Flex, Icon, IconButton, useColorModeValue, useDisclosure } from '@chakra-ui/react';
2 | import React, { memo, useRef } from 'react';
3 | import { IoIosMenu } from 'react-icons/io';
4 | import NavBar from './navbar';
5 | import HeaderLogo from './header-logo';
6 |
7 | const Header = (): JSX.Element => {
8 | const { isOpen, onOpen, onClose } = useDisclosure(); // state for drawer
9 | const menuButtonRef = useRef(null); // ref hook for drawer button
10 | const borderBg = useColorModeValue('bg.light.tertiary', 'bg.dark.tertiary');
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
25 |
26 |
27 | }
28 | data-testid="drawer-button"
29 | />
30 |
31 |
32 | );
33 | };
34 |
35 | export default memo(Header);
36 |
--------------------------------------------------------------------------------
/src/components/icon-text.tsx:
--------------------------------------------------------------------------------
1 | import { ColorProps, Grid, Icon, Link, Text, TextProps } from '@chakra-ui/react';
2 | import NextLink from 'next/link';
3 | import React from 'react';
4 | import { IconType } from 'react-icons';
5 |
6 | interface Props extends TextProps {
7 | icon: IconType;
8 | iconColor?: ColorProps['color'];
9 | text: string;
10 | textColor?: ColorProps['color'];
11 | link?: string;
12 | }
13 |
14 | const IconText = ({ icon, iconColor, text, textColor, link, ...props }: Props): JSX.Element => {
15 | return (
16 |
17 |
18 | {!link && (
19 |
20 | {text}
21 |
22 | )}
23 | {link && (
24 |
25 |
26 | {text}
27 |
28 |
29 | )}
30 |
31 | );
32 | };
33 |
34 | export default IconText;
35 |
--------------------------------------------------------------------------------
/src/components/info-panels.tsx:
--------------------------------------------------------------------------------
1 | import { Grid, GridItem, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
2 | import { NextRouter, useRouter } from 'next/router';
3 | import React, { useEffect, useState } from 'react';
4 | import Section from './section';
5 |
6 | interface Props {
7 | tabNames: Array;
8 | tabPanels: Array;
9 | }
10 |
11 | const InfoPanels = ({ tabNames, tabPanels }: Props): JSX.Element => {
12 | // Router is null when running Jest tests, do this to
13 | // force eslint to not remove optional chaining operator.
14 | const router = useRouter() as NextRouter | null;
15 |
16 | const queryKey = 't';
17 | const queryValue = decodeURIComponent(
18 | (router?.query[queryKey] ?? router?.asPath.match(new RegExp(`[&?]${queryKey}=(.*)(&|$)`)))?.toString() ??
19 | tabNames[0],
20 | );
21 |
22 | const initialIndex = tabNames.includes(queryValue) ? tabNames.indexOf(queryValue) : 0;
23 | const [tabIndex, setTabIndex] = useState(initialIndex);
24 |
25 | useEffect(() => {
26 | if (tabNames.includes(queryValue)) {
27 | setTabIndex(tabNames.indexOf(queryValue));
28 | }
29 | }, [queryValue, tabNames, tabIndex, setTabIndex]);
30 |
31 | const changeTab = (index: number) => {
32 | setTabIndex(index);
33 | const query = index === 0 ? undefined : encodeURIComponent(tabNames[index]);
34 | void router?.replace({ pathname: router.pathname, query: { t: query } });
35 | };
36 |
37 | return (
38 |
39 |
40 |
41 |
42 |
43 | {tabNames.map((tabName: string) => (
44 |
51 | {tabName}
52 |
53 | ))}
54 |
55 |
56 |
57 |
58 |
59 |
60 | {tabPanels.map((node: React.ReactNode, index: number) => (
61 |
62 | {node}
63 |
64 | ))}
65 |
66 |
67 |
68 |
69 |
70 | );
71 | };
72 |
73 | export default InfoPanels;
74 |
--------------------------------------------------------------------------------
/src/components/job-advert-overview.tsx:
--------------------------------------------------------------------------------
1 | import { Text, Stack, StackDivider } from '@chakra-ui/layout';
2 | import { Select } from '@chakra-ui/select';
3 | import React, { useState } from 'react';
4 | import { JobAdvert } from '../lib/api';
5 | import JobAdvertPreview from './job-advert-preview';
6 |
7 | interface Props {
8 | jobAdverts: Array;
9 | }
10 |
11 | type JobType = 'all' | 'fulltime' | 'parttime' | 'internship' | 'summerjob';
12 | type SortType = 'deadline' | 'companyName' | '_createdAt' | 'jobType';
13 |
14 | const sortJobs = (list: Array, field: SortType) => {
15 | const sorted = list.sort((a: JobAdvert, b: JobAdvert) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0));
16 | return field === '_createdAt' ? sorted.reverse() : sorted;
17 | };
18 |
19 | const JobAdvertOverview = ({ jobAdverts }: Props): JSX.Element => {
20 | const [type, setType] = useState('all');
21 | const [location, setLocation] = useState('all');
22 | const [company, setCompany] = useState('all');
23 | const [degreeYear, setDegreeYear] = useState('all');
24 | const [sortBy, setSortBy] = useState('deadline');
25 |
26 | return (
27 | <>
28 | }>
29 |
30 | Type
31 |
38 | Sted
39 |
52 | Bedrift
53 |
67 | Årstrinn
68 |
78 | Sorter etter
79 |
85 |
86 |
87 | {sortJobs(jobAdverts, sortBy).map((job: JobAdvert) =>
88 | (type === job.jobType || type === 'all') &&
89 | (job.locations.some((locations) => locations.toLocaleLowerCase() === location) ||
90 | location === 'all') &&
91 | (job.degreeYears.includes(Number(degreeYear)) || degreeYear === 'all') &&
92 | (company === job.companyName.toLowerCase() || company === 'all') ? (
93 |
94 | ) : null,
95 | )}
96 |
97 |
98 | >
99 | );
100 | };
101 |
102 | export default JobAdvertOverview;
103 |
--------------------------------------------------------------------------------
/src/components/job-advert-preview.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Avatar,
3 | GridItem,
4 | LinkBox,
5 | LinkOverlay,
6 | SimpleGrid,
7 | Spacer,
8 | Tag,
9 | Text,
10 | useColorModeValue,
11 | Wrap,
12 | } from '@chakra-ui/react';
13 | import { format } from 'date-fns';
14 | import NextLink from 'next/link';
15 | import React from 'react';
16 | import { JobAdvert } from '../lib/api';
17 |
18 | const translateJobType = (jobType: 'fulltime' | 'parttime' | 'internship' | 'summerjob'): string => {
19 | switch (jobType) {
20 | case 'fulltime':
21 | return 'Fulltid';
22 | case 'parttime':
23 | return 'Deltid';
24 | case 'internship':
25 | return 'Internship';
26 | case 'summerjob':
27 | return 'Sommerjobb';
28 | }
29 | };
30 |
31 | const JobAdvertPreview = ({ jobAdvert }: { jobAdvert: JobAdvert }): JSX.Element => {
32 | const borderColor = useColorModeValue('bg.light.border', 'bg.dark.border');
33 | const bgColor = useColorModeValue('bg.light.tertiary', 'bg.dark.tertiary');
34 |
35 | return (
36 |
48 |
49 |
50 |
51 |
52 |
53 | {jobAdvert.title}
54 |
55 |
56 |
57 | {translateJobType(jobAdvert.jobType)}
58 |
59 | {jobAdvert.locations.map((location: string, index: number) => (
60 |
61 | {location}
62 |
63 | ))}
64 |
65 | {jobAdvert.degreeYears.length === 1
66 | ? `${String(jobAdvert.degreeYears[0])}. trinn`
67 | : `${String(jobAdvert.degreeYears.sort().slice(0, -1).join(', '))} og ${String(
68 | jobAdvert.degreeYears.slice(-1),
69 | )} . trinn`}
70 |
71 |
72 |
73 | {`Søknadsfrist: ${format(
74 | new Date(jobAdvert.deadline),
75 | 'dd.MM.yyyy',
76 | )}`}
77 |
78 |
79 |
80 |
81 | {`Publisert: ${format(
82 | new Date(jobAdvert._createdAt),
83 | 'dd.MM.yyyy',
84 | )}`}
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | );
96 | };
97 |
98 | export { translateJobType };
99 | export default JobAdvertPreview;
100 |
--------------------------------------------------------------------------------
/src/components/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@chakra-ui/react';
2 | import React from 'react';
3 | import Footer from './footer';
4 | import Header from './header';
5 | import AnimatedIcons from './animated-icons';
6 |
7 | interface Props {
8 | children: React.ReactNode;
9 | }
10 |
11 | const Layout = ({ children }: Props): JSX.Element => {
12 | return (
13 |
14 |
15 |
16 |
24 | {children}
25 |
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default Layout;
33 |
--------------------------------------------------------------------------------
/src/components/logo-accesory.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@chakra-ui/react';
2 | import { motion } from 'framer-motion';
3 | import Image from 'next/image';
4 |
5 | interface Props {
6 | iconSrc: string;
7 | w: number;
8 | h: number;
9 | }
10 |
11 | const LogoAccesory = ({ iconSrc, w, h }: Props): JSX.Element => {
12 | return (
13 |
14 |
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default LogoAccesory;
29 |
--------------------------------------------------------------------------------
/src/components/member-profile.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, Box, Center, chakra, Text, useBreakpointValue } from '@chakra-ui/react';
2 | import Image from 'next/image';
3 | import React from 'react';
4 | import { Profile } from '../lib/api';
5 |
6 | const MemberImage = chakra(Image, {
7 | baseStyle: { maxH: 128, maxW: 128 },
8 | shouldForwardProp: (prop) => ['width', 'height', 'src', 'alt', 'quality'].includes(prop),
9 | });
10 |
11 | const getInitials = (name: string) => {
12 | const words = name.split(' ');
13 | return words.length >= 2 ? `${words[0].charAt(0)}${words[words.length - 1].charAt(0)}` : words[0].charAt(0);
14 | };
15 |
16 | interface Props {
17 | profile: Profile;
18 | role: string;
19 | }
20 |
21 | const MemberProfile = ({ profile, role }: Props): JSX.Element => {
22 | const memberImageSize = useBreakpointValue([96, 96, 128]);
23 |
24 | return (
25 |
26 |
27 | {profile.imageUrl && (
28 |
38 | )}
39 | {!profile.imageUrl && (
40 |
41 | )}
42 |
43 | {profile.name}
44 | {role}
45 |
46 | );
47 | };
48 |
49 | export default MemberProfile;
50 |
--------------------------------------------------------------------------------
/src/components/minute-list.tsx:
--------------------------------------------------------------------------------
1 | import { Flex, Heading, Icon, Link, List, ListItem, Text, useColorModeValue } from '@chakra-ui/react';
2 | import { format } from 'date-fns';
3 | import { nb } from 'date-fns/locale';
4 | import NextLink from 'next/link';
5 | import React from 'react';
6 | import { FaExternalLinkAlt } from 'react-icons/fa';
7 | import { Minute } from '../lib/api';
8 |
9 | interface Props {
10 | minutes: Array;
11 | }
12 |
13 | const MinuteList = ({ minutes }: Props): JSX.Element => {
14 | const color = useColorModeValue('blue', 'blue.400');
15 |
16 | return (
17 | <>
18 | Møtereferater
19 | {minutes.length === 0 && Ingen møtereferater}
20 |
21 | {minutes.map((minute: Minute) => (
22 |
23 |
24 |
25 |
26 | {format(new Date(minute.date), 'dd. MMM yyyy', { locale: nb })}
27 |
28 |
29 |
30 |
31 |
32 | ))}
33 |
34 | >
35 | );
36 | };
37 |
38 | export default MinuteList;
39 |
--------------------------------------------------------------------------------
/src/components/nav-link.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Link, LinkBox, LinkOverlay } from '@chakra-ui/react';
2 | import NextLink from 'next/link';
3 | import { useRouter } from 'next/router';
4 | import { ReactNode } from 'react';
5 |
6 | interface Props {
7 | href: string;
8 | text: string;
9 | testid?: string;
10 | }
11 |
12 | const NavLink = ({ href, text, testid }: Props) => {
13 | const router = useRouter();
14 | // Router is null with jest testing, so need optional chaining here.
15 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
16 | const isActive = router?.pathname === href;
17 |
18 | return (
19 |
20 |
21 |
22 | {text}
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | interface NavLinkButtonProps {
30 | children: ReactNode;
31 | onClick: () => void;
32 | }
33 |
34 | export const NavLinkButton = ({ children, onClick }: NavLinkButtonProps) => (
35 |
46 | );
47 |
48 | export default NavLink;
49 |
--------------------------------------------------------------------------------
/src/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Center,
4 | Drawer,
5 | DrawerBody,
6 | DrawerCloseButton,
7 | DrawerContent,
8 | DrawerHeader,
9 | DrawerOverlay,
10 | Flex,
11 | Heading,
12 | Icon,
13 | IconButton,
14 | } from '@chakra-ui/react';
15 | import { AiOutlineUser } from 'react-icons/ai';
16 | import { signIn, useSession } from 'next-auth/react';
17 | import { useRouter } from 'next/router';
18 | import React, { RefObject } from 'react';
19 | import ColorModeButton from './color-mode-button';
20 | import NavLink, { NavLinkButton } from './nav-link';
21 |
22 | const NavLinks = ({ isMobile }: { isMobile: boolean }): JSX.Element => {
23 | const { status } = useSession();
24 | const router = useRouter();
25 | const onProfileClick = () => {
26 | if (status === 'authenticated') {
27 | void router.push('/profile');
28 | } else {
29 | void signIn('feide');
30 | }
31 | };
32 |
33 | return (
34 |
41 |
42 | {/* */}
43 |
44 | {isMobile && (
45 | <>
46 | {status === 'authenticated' && }
47 | {status === 'unauthenticated' && (
48 | void onProfileClick()}>Logg inn
49 | )}
50 | >
51 | )}
52 |
53 | {!isMobile && (
54 | void onProfileClick()}
58 | variant="ghost"
59 | icon={
60 |
61 |
62 |
63 | }
64 | />
65 | )}
66 |
67 | );
68 | };
69 |
70 | interface Props {
71 | isOpen: boolean;
72 | onClose: () => void;
73 | btnRef: RefObject;
74 | }
75 |
76 | const NavBar = ({ isOpen, onClose, btnRef }: Props): JSX.Element => {
77 | return (
78 | <>
79 |
85 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | Navigasjon
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | >
111 | );
112 | };
113 |
114 | export default NavBar;
115 |
--------------------------------------------------------------------------------
/src/components/post-list.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Wrap } from '@chakra-ui/react';
2 | import React from 'react';
3 | import { Post } from '../lib/api';
4 | import PostPreview from './post-preview';
5 |
6 | interface Props {
7 | posts: Array;
8 | }
9 |
10 | const PostList = ({ posts }: Props): JSX.Element => {
11 | return (
12 |
13 |
14 | {posts.map((post: Post) => {
15 | return ;
16 | })}
17 |
18 |
19 | );
20 | };
21 |
22 | export default PostList;
23 |
--------------------------------------------------------------------------------
/src/components/post-preview.tsx:
--------------------------------------------------------------------------------
1 | import { BoxProps, Heading, LinkBox, LinkOverlay, Text, useColorModeValue } from '@chakra-ui/react';
2 | import NextLink from 'next/link';
3 | import React from 'react';
4 | import removeMD from 'remove-markdown';
5 | import { Post } from '../lib/api';
6 |
7 | interface Props extends BoxProps {
8 | post: Post;
9 | }
10 |
11 | const PostPreview = ({ post, ...props }: Props): JSX.Element => {
12 | const authorBg = useColorModeValue('highlight.light.secondary', 'highlight.dark.secondary');
13 | const borderColor = useColorModeValue('bg.light.border', 'bg.dark.border');
14 | const bgColor = useColorModeValue('bg.light.tertiary', 'bg.dark.tertiary');
15 | const textColor = useColorModeValue('text.light.secondary', 'text.dark.secondary');
16 |
17 | return (
18 |
35 |
36 |
37 |
38 | {post.title}
39 |
40 | {`«${removeMD(post.body.slice(0, 100))} ...»`}
41 |
42 |
43 |
57 | {post.author}
58 |
59 |
60 | );
61 | };
62 |
63 | export default PostPreview;
64 |
--------------------------------------------------------------------------------
/src/components/profile-info.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useForm, SubmitHandler, FormProvider } from 'react-hook-form';
3 | import { Box, Heading, Text, FormControl, Button, Select } from '@chakra-ui/react';
4 | import { ProfileFormValues, UserWithName, UserAPI, Degree } from '../lib/api';
5 |
6 | enum InfoState {
7 | IDLE,
8 | EDITED,
9 | SAVING,
10 | SAVED,
11 | ERROR,
12 | }
13 |
14 | interface ProfileState {
15 | infoState: InfoState;
16 | errorMessage: string | null;
17 | }
18 |
19 | const ProfileInfo = ({ user }: { user: UserWithName }): JSX.Element => {
20 | const methods = useForm();
21 | const { register, handleSubmit } = methods;
22 | const [profileState, setProfileState] = useState({ infoState: InfoState.IDLE, errorMessage: null });
23 |
24 | const submitForm: SubmitHandler = async (data: ProfileFormValues) => {
25 | setProfileState({ infoState: InfoState.SAVING, errorMessage: null });
26 | const res = await UserAPI.putUser({
27 | email: user.email,
28 | degree: data.degree,
29 | degreeYear: +data.degreeYear,
30 | });
31 |
32 | if (typeof res !== 'string') {
33 | setProfileState({ infoState: InfoState.ERROR, errorMessage: res.message });
34 | } else {
35 | setProfileState({ infoState: InfoState.SAVED, errorMessage: null });
36 | }
37 | };
38 |
39 | return (
40 |
41 |
42 | Navn
43 |
44 | {user.name}
45 |
46 | Email
47 |
48 | {user.email}
49 |
50 | {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */}
51 |
107 |
108 |
109 | );
110 | };
111 |
112 | export default ProfileInfo;
113 |
--------------------------------------------------------------------------------
/src/components/section.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import { Box, BoxProps, useColorModeValue } from '@chakra-ui/react';
3 | import React from 'react';
4 |
5 | const Section = (props: BoxProps): JSX.Element => {
6 | const bg = useColorModeValue('bg.light.secondary', 'bg.dark.secondary');
7 | return (
8 |
17 | );
18 | };
19 |
20 | export default Section;
21 |
--------------------------------------------------------------------------------
/src/components/seo.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import React from 'react';
3 |
4 | interface Props {
5 | description?: string;
6 | title: string;
7 | }
8 |
9 | const SEO = ({ description = 'Nettsiden til echo – Linjeforeningen for informatikk.', title }: Props): JSX.Element => {
10 | return (
11 |
12 | {title}
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default SEO;
23 |
--------------------------------------------------------------------------------
/src/components/sidebar-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Grid,
3 | GridItem,
4 | useDisclosure,
5 | IconButton,
6 | Center,
7 | Icon,
8 | Drawer,
9 | DrawerBody,
10 | DrawerContent,
11 | DrawerOverlay,
12 | DrawerCloseButton,
13 | DrawerHeader,
14 | Box,
15 | useColorModeValue,
16 | Heading,
17 | } from '@chakra-ui/react';
18 | import React, { useRef } from 'react';
19 | import { IoIosArrowForward } from 'react-icons/io';
20 | import Section from './section';
21 | import Sidebar from './sidebar';
22 |
23 | interface Props {
24 | children: React.ReactNode;
25 | }
26 |
27 | const SidebarWrapper = ({ children }: Props): JSX.Element => {
28 | const separatorColor = useColorModeValue('bg.light.border', 'bg.dark.border');
29 | const highlightColor = useColorModeValue('highlight.light.primary', 'highlight.dark.primary');
30 | const bgColor = useColorModeValue('bg.light.secondary', 'bg.dark.secondary');
31 | const menuButtonRef = useRef(null); // ref hook for drawer button
32 | const { isOpen, onOpen, onClose } = useDisclosure();
33 | return (
34 | <>
35 |
36 |
49 |
59 |
60 |
61 | }
62 | data-testid="sidebar-button"
63 | />
64 |
65 |
66 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | Info
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | {children}
91 |
92 |
93 | >
94 | );
95 | };
96 |
97 | export default SidebarWrapper;
98 |
--------------------------------------------------------------------------------
/src/components/student-group-view.tsx:
--------------------------------------------------------------------------------
1 | import { Divider, Wrap, WrapItem } from '@chakra-ui/react';
2 | import Markdown from 'markdown-to-jsx';
3 | import React from 'react';
4 | import { Member, StudentGroup } from '../lib/api';
5 | import MapMarkdownChakra from '../markdown';
6 | import MemberProfile from './member-profile';
7 |
8 | interface Props {
9 | group: StudentGroup;
10 | }
11 |
12 | const StudentGroupView = ({ group }: Props): JSX.Element => {
13 | return (
14 | <>
15 | {group.info}
16 |
17 |
18 | {group.members.map((member: Member) => (
19 |
20 |
21 |
22 | ))}
23 |
24 | >
25 | );
26 | };
27 |
28 | export default StudentGroupView;
29 |
--------------------------------------------------------------------------------
/src/declarations.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.png' {
2 | const value: string;
3 | export = value;
4 | }
5 |
6 | declare module '*.md' {
7 | const value: string;
8 | export = value;
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/api/api.ts:
--------------------------------------------------------------------------------
1 | import sanityClient from '@sanity/client';
2 |
3 | const SanityAPI = sanityClient({
4 | projectId: 'pgq2pd26',
5 | dataset: process.env.SANITY_DATASET ?? 'production',
6 | apiVersion: '2021-04-10',
7 | useCdn: true,
8 | });
9 |
10 | export default SanityAPI;
11 |
--------------------------------------------------------------------------------
/src/lib/api/banner.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { array, union, nil } from 'typescript-json-decoder';
3 | import { bannerDecoder } from './decoders';
4 | import { ErrorMessage, Banner } from './types';
5 | import { SanityAPI } from '.';
6 |
7 | const BannerAPI = {
8 | getBanner: async (): Promise => {
9 | try {
10 | const query = `
11 | *[_type == "banner" && !(_id in path('drafts.**'))] {
12 | color,
13 | text,
14 | linkTo,
15 | isExternal
16 | }`;
17 |
18 | const result = await SanityAPI.fetch(query);
19 |
20 | return array(union(bannerDecoder, nil))(result)[0];
21 | } catch (error) {
22 | console.log(error); // eslint-disable-line
23 | if (axios.isAxiosError(error)) {
24 | return { message: !error.response ? '404' : error.message };
25 | }
26 |
27 | return {
28 | message: 'Fail @ getBanner',
29 | };
30 | }
31 | },
32 | };
33 |
34 | /* eslint-disable import/prefer-default-export */
35 | export { BannerAPI };
36 |
--------------------------------------------------------------------------------
/src/lib/api/errors.ts:
--------------------------------------------------------------------------------
1 | const handleError = (code: number): string => {
2 | switch (code) {
3 | case 400:
4 | return 'Webkom klarer ikke skrive GraphQL.';
5 | case 401:
6 | return 'Webkom har ødelagt API-nøkkelen.';
7 | case 402:
8 | return 'Du har ikke echo premuim.';
9 | case 408:
10 | return 'Webkom skriver kode med O(n!) kompleksitet. (Eller så er APIen nede)';
11 | default:
12 | return 'Det var rart, vennligst kontakt Webkom så vi kan rette opp i dette.';
13 | }
14 | };
15 |
16 | export default handleError;
17 |
--------------------------------------------------------------------------------
/src/lib/api/happening.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { array } from 'typescript-json-decoder';
3 | import { happeningDecoder, happeningInfoDecoder } from './decoders';
4 | import { ErrorMessage, Happening, HappeningInfo, HappeningType } from './types';
5 | import handleError from './errors';
6 | import { SanityAPI } from '.';
7 |
8 | // Automatically creates the Happening type with the
9 | // fields we specify in our happeningDecoder.
10 |
11 | const HappeningAPI = {
12 | /**
13 | * Get the n last happeninges.
14 | * @param n how many happeninges to retrieve
15 | */
16 | getHappeningsByType: async (n: number, type: HappeningType): Promise | ErrorMessage> => {
17 | try {
18 | const limit = n === 0 ? `` : `[0...${n}]`;
19 | const query = `
20 | *[_type == "happening" && happeningType == "${type}" && !(_id in path('drafts.**'))] | order(date asc) {
21 | title,
22 | "slug": slug.current,
23 | date,
24 | body,
25 | location,
26 | locationLink,
27 | companyLink,
28 | happeningType,
29 | registrationDate,
30 | contactEmail,
31 | additionalQuestions[] -> {
32 | questionText,
33 | inputType,
34 | alternatives
35 | },
36 | "logoUrl": logo.asset -> url,
37 | "author": author -> name,
38 | _createdAt,
39 | spotRanges[] -> {
40 | minDegreeYear,
41 | maxDegreeYear,
42 | spots
43 | }
44 | }${limit}
45 | `;
46 |
47 | const result = await SanityAPI.fetch(query);
48 |
49 | return array(happeningDecoder)(result);
50 | } catch (error) {
51 | console.log(error); // eslint-disable-line
52 | return { message: handleError(axios.isAxiosError(error) ? error.response?.status ?? 500 : 500) };
53 | }
54 | },
55 |
56 | /**
57 | * Get a happening by its slug.
58 | * @param slug the slug of the desired happening.
59 | */
60 | getHappeningBySlug: async (slug: string): Promise => {
61 | try {
62 | const query = `
63 | *[_type == "happening" && slug.current == "${slug}" && !(_id in path('drafts.**'))]{
64 | title,
65 | "slug": slug.current,
66 | date,
67 | body,
68 | location,
69 | locationLink,
70 | companyLink,
71 | happeningType,
72 | registrationDate,
73 | contactEmail,
74 | additionalQuestions[] -> {
75 | questionText,
76 | inputType,
77 | alternatives
78 | },
79 | "logoUrl": logo.asset -> url,
80 | "author": author -> name,
81 | _createdAt,
82 | spotRanges[] -> {
83 | minDegreeYear,
84 | maxDegreeYear,
85 | spots
86 | }
87 | }
88 | `;
89 |
90 | const result = await SanityAPI.fetch(query);
91 |
92 | if (result.length === 0) {
93 | return {
94 | message: '404',
95 | };
96 | }
97 |
98 | // Sanity returns a list with a single element,
99 | // therefore we need [0] to get the element out of the list.
100 | return array(happeningDecoder)(result)[0];
101 | } catch (error) {
102 | console.log(error); // eslint-disable-line
103 | if (axios.isAxiosError(error)) {
104 | return { message: !error.response ? '404' : error.message };
105 | }
106 |
107 | return {
108 | message: 'Fail @ getHappeningsBySlug',
109 | };
110 | }
111 | },
112 |
113 | getHappeningInfo: async (auth: string, slug: string, backendUrl: string): Promise => {
114 | try {
115 | const { data } = await axios.get(`${backendUrl}/happening/${slug}`, {
116 | auth: {
117 | username: 'admin',
118 | password: auth,
119 | },
120 | });
121 |
122 | return happeningInfoDecoder(data);
123 | } catch (error) {
124 | console.log(error); // eslint-disable-line
125 | return { message: JSON.stringify(error) };
126 | }
127 | },
128 | };
129 |
130 | /* eslint-disable import/prefer-default-export */
131 | export { HappeningAPI };
132 |
--------------------------------------------------------------------------------
/src/lib/api/index.ts:
--------------------------------------------------------------------------------
1 | export { default as SanityAPI } from './api';
2 | export { HappeningAPI } from './happening';
3 | export { JobAdvertAPI } from './job-advert';
4 | export { MinuteAPI } from './minute';
5 | export { PostAPI } from './post';
6 | export { BannerAPI } from './banner';
7 | export { type FormValues as ProfileFormValues, UserAPI } from './user';
8 | export {
9 | type ErrorMessage,
10 | type Post,
11 | type Minute,
12 | type JobAdvert,
13 | type Happening,
14 | type Answer,
15 | type Registration,
16 | type RegistrationCount,
17 | type SpotRangeCount,
18 | type HappeningInfo,
19 | type Member,
20 | type Profile,
21 | type StudentGroup,
22 | type StaticInfo,
23 | type SpotRange,
24 | type Question,
25 | type Banner,
26 | type User,
27 | type UserWithName,
28 | isErrorMessage,
29 | HappeningType,
30 | Degree,
31 | } from './types';
32 | export { RegistrationAPI, registrationRoute, type FormValues as RegFormValues } from './registration';
33 | export { StudentGroupAPI } from './student-group';
34 | export { StaticInfoAPI } from './static-info';
35 |
--------------------------------------------------------------------------------
/src/lib/api/job-advert.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { array } from 'typescript-json-decoder';
3 | import SanityAPI from './api';
4 | import { slugDecoder, jobAdvertDecoder } from './decoders';
5 | import { ErrorMessage, Slug, JobAdvert } from './types';
6 |
7 | const JobAdvertAPI = {
8 | getPaths: async (): Promise> => {
9 | try {
10 | const query = `*[_type == "jobAdvert"]{ "slug": slug.current }`;
11 | const result = await SanityAPI.fetch(query);
12 |
13 | return array(slugDecoder)(result).map((nestedSlug: Slug) => nestedSlug.slug);
14 | } catch (error) {
15 | console.log(error); // eslint-disable-line
16 | return [];
17 | }
18 | },
19 |
20 | getJobAdverts: async (n: number): Promise | ErrorMessage> => {
21 | try {
22 | const query = `*[_type == "jobAdvert" && !(_id in path('drafts.**'))] | order(_createdAt desc) [0..${
23 | n - 1
24 | }] {
25 | "slug": slug.current,
26 | body,
27 | companyName,
28 | title,
29 | "logoUrl": logo.asset -> url,
30 | deadline,
31 | locations,
32 | advertLink,
33 | jobType,
34 | degreeYears,
35 | _createdAt,
36 | weight
37 | }`;
38 | const result = await SanityAPI.fetch(query);
39 |
40 | return array(jobAdvertDecoder)(result);
41 | } catch (error) {
42 | console.log(error); // eslint-disable-line
43 | return { message: axios.isAxiosError(error) ? error.message : 'Fail @ getJobAdverts' };
44 | }
45 | },
46 |
47 | getJobAdvertBySlug: async (slug: string): Promise => {
48 | try {
49 | const query = `
50 | *[_type == "jobAdvert" && slug.current == "${slug}" && !(_id in path('drafts.**'))] {
51 | "slug": slug.current,
52 | body,
53 | companyName,
54 | title,
55 | "logoUrl": logo.asset -> url,
56 | deadline,
57 | locations,
58 | advertLink,
59 | jobType,
60 | degreeYears,
61 | _createdAt,
62 | weight
63 | }`;
64 | const result = await SanityAPI.fetch(query);
65 |
66 | return array(jobAdvertDecoder)(result)[0];
67 | } catch (error) {
68 | console.log(error); // eslint-disable-line
69 | return { message: axios.isAxiosError(error) ? error.message : 'Fail @ getJobAdvertBySlug' };
70 | }
71 | },
72 | };
73 |
74 | /* eslint-disable import/prefer-default-export */
75 | export { JobAdvertAPI };
76 |
--------------------------------------------------------------------------------
/src/lib/api/minute.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { array } from 'typescript-json-decoder';
3 | import { minuteDecoder } from './decoders';
4 | import { ErrorMessage, Minute } from './types';
5 | import { SanityAPI } from '.';
6 |
7 | const MinuteAPI = {
8 | /**
9 | * Get the n last meeting minutes.
10 | * @param n how many meeting minutes to retrieve
11 | */
12 | getMinutes: async (): Promise | ErrorMessage> => {
13 | try {
14 | const query = `
15 | *[_type == "meetingMinute" && !(_id in path('drafts.**'))] | order(date desc) {
16 | allmote,
17 | date,
18 | title,
19 | document {
20 | asset -> {
21 | url
22 | }
23 | }
24 | }`;
25 |
26 | const result = await SanityAPI.fetch(query);
27 |
28 | return array(minuteDecoder)(result);
29 | } catch (error) {
30 | console.log(error); // eslint-disable-line
31 | return { message: axios.isAxiosError(error) ? error.message : 'Fail @ getMinutes' };
32 | }
33 | },
34 | };
35 |
36 | /* eslint-disable import/prefer-default-export */
37 | export { MinuteAPI };
38 |
--------------------------------------------------------------------------------
/src/lib/api/post.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { array } from 'typescript-json-decoder';
3 | import { slugDecoder, postDecoder } from './decoders';
4 | import { ErrorMessage, Post } from './types';
5 | import { SanityAPI } from '.';
6 |
7 | const PostAPI = {
8 | /**
9 | * Get the slugs of the 10 lasts posts.
10 | * This data is used to statically generate the pages of the 10 last published posts.
11 | */
12 | getPaths: async (): Promise> => {
13 | try {
14 | const query = `*[_type == "post"]{ "slug": slug.current }`;
15 | const result = await SanityAPI.fetch(query);
16 |
17 | return array(slugDecoder)(result).map((nestedSlug) => nestedSlug.slug);
18 | } catch (error) {
19 | console.log(error); // eslint-disable-line
20 | return [];
21 | }
22 | },
23 |
24 | /**
25 | * Get the n last published posts.
26 | * @param n how many posts to retrieve
27 | */
28 | getPosts: async (n: number): Promise | ErrorMessage> => {
29 | try {
30 | const limit = n === 0 ? `` : `[0...${n}]`;
31 | const query = `
32 | *[_type == "post" && !(_id in path('drafts.**'))] | order(_createdAt desc) {
33 | title,
34 | "slug": slug.current,
35 | body,
36 | author -> {name},
37 | _createdAt,
38 | thumbnail
39 | }${limit}`;
40 |
41 | const result = await SanityAPI.fetch(query);
42 |
43 | return array(postDecoder)(result);
44 | } catch (error) {
45 | console.log(error); // eslint-disable-line
46 | return { message: axios.isAxiosError(error) ? error.message : 'Fail @ getPosts' };
47 | }
48 | },
49 |
50 | /**
51 | * Get a post by its slug.
52 | * @param slug the slug of the desired post.
53 | */
54 | getPostBySlug: async (slug: string): Promise => {
55 | try {
56 | const query = `
57 | *[_type == "post" && slug.current == "${slug}" && !(_id in path('drafts.**'))] {
58 | title,
59 | "slug": slug.current,
60 | body,
61 | author -> {name},
62 | _createdAt,
63 | thumbnail
64 | }`;
65 | const result = await SanityAPI.fetch(query);
66 |
67 | if (result.length === 0) {
68 | return { message: '404' };
69 | }
70 |
71 | return array(postDecoder)(result)[0];
72 | } catch (error) {
73 | console.log(error); // eslint-disable-line
74 | if (axios.isAxiosError(error) && !error.response) {
75 | return {
76 | message: '404',
77 | };
78 | }
79 |
80 | return {
81 | message: axios.isAxiosError(error) ? error.message : 'Fail @ getPostBySlug',
82 | };
83 | }
84 | },
85 | };
86 |
87 | /* eslint-disable import/prefer-default-export */
88 | export { PostAPI };
89 |
--------------------------------------------------------------------------------
/src/lib/api/registration.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { array } from 'typescript-json-decoder';
3 | import { responseDecoder, registrationDecoder, registrationCountDecoder } from './decoders';
4 | import { ErrorMessage, Degree, Answer, Response, Registration, RegistrationCount } from './types';
5 | import { HappeningType } from '.';
6 |
7 | const genericError = {
8 | title: 'Det har skjedd en feil.',
9 | desc: 'Vennligst prøv igjen',
10 | date: null,
11 | };
12 |
13 | // Values directly from the form (aka form fields)
14 | interface FormValues {
15 | email: string;
16 | firstName: string;
17 | lastName: string;
18 | degree: Degree;
19 | degreeYear: number;
20 | terms1: boolean;
21 | terms2: boolean;
22 | terms3: boolean;
23 | answers: Array;
24 | }
25 |
26 | // The data from the form + slug and type
27 | interface FormRegistration {
28 | email: string;
29 | firstName: string;
30 | lastName: string;
31 | degree: Degree;
32 | degreeYear: number;
33 | slug: string;
34 | type: HappeningType;
35 | terms: boolean;
36 | answers: Array;
37 | regVerifyToken: string | null;
38 | }
39 |
40 | const registrationRoute = 'registration';
41 |
42 | const RegistrationAPI = {
43 | submitRegistration: async (
44 | registration: FormRegistration,
45 | backendUrl: string,
46 | ): Promise<{ response: Response; statusCode: number }> => {
47 | try {
48 | const { data, status } = await axios.post(`${backendUrl}/${registrationRoute}`, registration, {
49 | headers: { 'Content-Type': 'application/json' },
50 | validateStatus: (statusCode: number) => {
51 | return statusCode < 500;
52 | },
53 | });
54 |
55 | return {
56 | response: responseDecoder(data),
57 | statusCode: status,
58 | };
59 | } catch (error) {
60 | console.log(error); // eslint-disable-line
61 | if (axios.isAxiosError(error)) {
62 | if (error.response) {
63 | return {
64 | response: { ...genericError, code: 'InternalServerError' },
65 | statusCode: error.response.status,
66 | };
67 | }
68 | if (error.request) {
69 | return {
70 | response: { ...genericError, code: 'NoResponseError' },
71 | statusCode: 500,
72 | };
73 | }
74 | }
75 |
76 | return {
77 | response: { ...genericError, code: 'RequestError' },
78 | statusCode: 500,
79 | };
80 | }
81 | },
82 |
83 | getRegistrations: async (link: string, backendUrl: string): Promise | ErrorMessage> => {
84 | try {
85 | const { data } = await axios.get(`${backendUrl}/${registrationRoute}/${link}?json=y`);
86 |
87 | return array(registrationDecoder)(data);
88 | } catch (error) {
89 | console.log(error); // eslint-disable-line
90 | if (axios.isAxiosError(error)) {
91 | if (!error.response) {
92 | return { message: '404' };
93 | }
94 | return {
95 | message: error.response.status === 404 ? '404' : 'Fail @ getRegistrations',
96 | };
97 | }
98 |
99 | return {
100 | message: 'Fail @ getRegistrations',
101 | };
102 | }
103 | },
104 |
105 | getRegistrationCountForSlugs: async (
106 | slugs: Array,
107 | backendUrl: string,
108 | ): Promise | ErrorMessage> => {
109 | try {
110 | const { data } = await axios.post(`${backendUrl}/${registrationRoute}/count`, { slugs });
111 | return array(registrationCountDecoder)(data);
112 | } catch (error) {
113 | if (axios.isAxiosError(error)) {
114 | if (!error.response) {
115 | return { message: '404' };
116 | }
117 | return {
118 | message: error.response.status === 404 ? '404' : 'Fail @ getRegistrationCountForSlugs',
119 | };
120 | }
121 |
122 | return {
123 | message: 'Fail @ getRegistrationCountForSlugs',
124 | };
125 | }
126 | },
127 |
128 | deleteRegistration: async (
129 | link: string,
130 | email: string,
131 | backendUrl: string,
132 | ): Promise<{ response: string | null; error: string | null }> => {
133 | try {
134 | const { data } = await axios.delete(
135 | `${backendUrl}/${registrationRoute}/${link}/${encodeURIComponent(email)}`,
136 | );
137 |
138 | return { response: data, error: null };
139 | } catch (error) {
140 | console.log(error); // eslint-disable-line
141 | return { response: null, error: JSON.stringify(error) };
142 | }
143 | },
144 | };
145 |
146 | export { RegistrationAPI, registrationRoute };
147 | export type { FormValues };
148 |
--------------------------------------------------------------------------------
/src/lib/api/static-info.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { array } from 'typescript-json-decoder';
3 | import { slugDecoder, staticInfoDecoder } from './decoders';
4 | import { ErrorMessage, StaticInfo } from './types';
5 | import { SanityAPI } from '.';
6 |
7 | const StaticInfoAPI = {
8 | getPaths: async (): Promise> => {
9 | try {
10 | const query = `*[_type == "staticInfo"]{ "slug": slug.current }`;
11 | const result = await SanityAPI.fetch(query);
12 |
13 | return array(slugDecoder)(result).map((nestedSlug) => nestedSlug.slug);
14 | } catch (error) {
15 | console.log(error); // eslint-disable-line
16 | return [];
17 | }
18 | },
19 |
20 | getStaticInfoBySlug: async (slug: string): Promise => {
21 | try {
22 | const query = `
23 | *[_type == "staticInfo" && slug.current == "${slug}" && !(_id in path('drafts.**'))] | order(name) {
24 | name,
25 | "slug": slug.current,
26 | info,
27 | }`;
28 | const result = await SanityAPI.fetch(query);
29 |
30 | if (result.length === 0) {
31 | return {
32 | message: '404',
33 | };
34 | }
35 |
36 | return array(staticInfoDecoder)(result)[0];
37 | } catch (error) {
38 | console.log(error); // eslint-disable-line
39 | if (axios.isAxiosError(error) && !error.response) {
40 | return {
41 | message: '404',
42 | };
43 | }
44 |
45 | return {
46 | message: axios.isAxiosError(error) ? error.message : 'Fail @ getStaticInfoBySlug',
47 | };
48 | }
49 | },
50 | };
51 |
52 | /* eslint-disable import/prefer-default-export */
53 | export { StaticInfoAPI };
54 |
--------------------------------------------------------------------------------
/src/lib/api/student-group.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { array } from 'typescript-json-decoder';
3 | import { slugDecoder, studentGroupDecoder } from './decoders';
4 | import { ErrorMessage, StudentGroup } from './types';
5 | import { SanityAPI } from '.';
6 |
7 | const StudentGroupAPI = {
8 | getPaths: async (): Promise> => {
9 | try {
10 | const query = `*[_type == "studentGroup"]{ "slug": slug.current }`;
11 | const result = await SanityAPI.fetch(query);
12 |
13 | return array(slugDecoder)(result).map((nestedSlug) => nestedSlug.slug);
14 | } catch (error) {
15 | console.log(error); // eslint-disable-line
16 | return [];
17 | }
18 | },
19 |
20 | getPathsByType: async (
21 | type: 'board' | 'suborg' | 'subgroup' | 'intgroup',
22 | ): Promise | ErrorMessage> => {
23 | try {
24 | const query = `*[_type == "studentGroup" && groupType == "${type}" && !(_id in path('drafts.**'))]{ "slug": slug.current }`;
25 | const result = await SanityAPI.fetch(query);
26 |
27 | return array(slugDecoder)(result).map((nestedSlug) => nestedSlug.slug);
28 | } catch (error) {
29 | console.log(error); // eslint-disable-line
30 | return {
31 | message: axios.isAxiosError(error) ? error.message : 'Fail @ getStudentGroupsByType',
32 | };
33 | }
34 | },
35 |
36 | getStudentGroupsByType: async (
37 | type: 'board' | 'suborg' | 'subgroup' | 'intgroup',
38 | ): Promise | ErrorMessage> => {
39 | try {
40 | const query = `
41 | *[_type == "studentGroup" && groupType == "${type}" && !(_id in path('drafts.**'))] | order(name) {
42 | name,
43 | "slug": slug.current,
44 | info,
45 | "imageUrl": grpPicture.asset -> url,
46 | "members": members[] {
47 | role,
48 | "profile": profile -> {
49 | name,
50 | "imageUrl": picture.asset -> url
51 | }
52 | }
53 | }
54 | `;
55 | const result = await SanityAPI.fetch(query);
56 |
57 | return array(studentGroupDecoder)(result);
58 | } catch (error) {
59 | console.log(error); // eslint-disable-line
60 | return {
61 | message: axios.isAxiosError(error) ? error.message : 'Fail @ getStudentGroupsByType',
62 | };
63 | }
64 | },
65 |
66 | getStudentGroupBySlug: async (slug: string): Promise => {
67 | try {
68 | const query = `
69 | *[_type == "studentGroup" && slug.current == "${slug}" && !(_id in path('drafts.**'))] | order(name) {
70 | name,
71 | "slug": slug.current,
72 | info,
73 | "imageUrl": grpPicture.asset -> url,
74 | "members": members[] {
75 | role,
76 | "profile": profile -> {
77 | name,
78 | "imageUrl": picture.asset -> url
79 | }
80 | }
81 | }`;
82 | const result = await SanityAPI.fetch(query);
83 |
84 | if (result.length === 0) {
85 | return {
86 | message: '404',
87 | };
88 | }
89 |
90 | return array(studentGroupDecoder)(result)[0];
91 | } catch (error) {
92 | console.log(error); // eslint-disable-line
93 | if (axios.isAxiosError(error) && !error.response) {
94 | return {
95 | message: '404',
96 | };
97 | }
98 |
99 | return {
100 | message: axios.isAxiosError(error) ? error.message : 'Fail @ getStudentGroupBySlug',
101 | };
102 | }
103 | },
104 | };
105 |
106 | /* eslint-disable import/prefer-default-export */
107 | export { StudentGroupAPI };
108 |
--------------------------------------------------------------------------------
/src/lib/api/types.ts:
--------------------------------------------------------------------------------
1 | import { decodeType } from 'typescript-json-decoder';
2 | import {
3 | slugDecoder,
4 | spotRangeDecoder,
5 | questionDecoder,
6 | profileDecoder,
7 | memberDecoder,
8 | studentGroupDecoder,
9 | staticInfoDecoder,
10 | answerDecoder,
11 | registrationDecoder,
12 | responseDecoder,
13 | spotRangeCountDecoder,
14 | happeningInfoDecoder,
15 | postDecoder,
16 | minuteDecoder,
17 | jobAdvertDecoder,
18 | happeningDecoder,
19 | bannerDecoder,
20 | registrationCountDecoder,
21 | userDecoder,
22 | userWithNameDecoder,
23 | } from './decoders';
24 |
25 | type SpotRange = decodeType;
26 |
27 | type Question = decodeType;
28 |
29 | type Slug = decodeType;
30 |
31 | type Profile = decodeType;
32 |
33 | type Member = decodeType;
34 |
35 | type StudentGroup = decodeType;
36 |
37 | type StaticInfo = decodeType;
38 |
39 | type Answer = decodeType;
40 |
41 | type Registration = decodeType;
42 |
43 | type RegistrationCount = decodeType;
44 |
45 | type Response = decodeType;
46 |
47 | type SpotRangeCount = decodeType;
48 |
49 | type HappeningInfo = decodeType;
50 |
51 | type Post = decodeType;
52 |
53 | type Minute = decodeType;
54 |
55 | type JobAdvert = decodeType;
56 |
57 | type Happening = decodeType;
58 |
59 | type Banner = decodeType;
60 |
61 | type User = decodeType;
62 |
63 | type UserWithName = decodeType;
64 |
65 | enum HappeningType {
66 | BEDPRES = 'BEDPRES',
67 | EVENT = 'EVENT',
68 | }
69 |
70 | enum Degree {
71 | DTEK = 'DTEK',
72 | DSIK = 'DSIK',
73 | DVIT = 'DVIT',
74 | BINF = 'BINF',
75 | IMO = 'IMO',
76 | // IKT and KOGNI should not be used,
77 | // are only here for backwards compatibility.
78 | IKT = 'IKT',
79 | KOGNI = 'KOGNI',
80 | //
81 | INF = 'INF',
82 | PROG = 'PROG',
83 | ARMNINF = 'ARMNINF',
84 | POST = 'POST',
85 | MISC = 'MISC',
86 | }
87 |
88 | interface ErrorMessage {
89 | message: string;
90 | }
91 |
92 | const isErrorMessage = (object: any): object is ErrorMessage => {
93 | return 'message' in object;
94 | };
95 |
96 | export type {
97 | ErrorMessage,
98 | Slug,
99 | SpotRange,
100 | Question,
101 | Profile,
102 | Member,
103 | StudentGroup,
104 | StaticInfo,
105 | Answer,
106 | Registration,
107 | RegistrationCount,
108 | Response,
109 | SpotRangeCount,
110 | HappeningInfo,
111 | Post,
112 | Minute,
113 | JobAdvert,
114 | Happening,
115 | Banner,
116 | User,
117 | UserWithName,
118 | };
119 |
120 | export { HappeningType, Degree, isErrorMessage };
121 |
--------------------------------------------------------------------------------
/src/lib/api/user.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { string } from 'typescript-json-decoder';
3 | import { Degree, User, UserWithName, ErrorMessage } from './types';
4 | import { userWithNameDecoder } from './decoders';
5 |
6 | // Values directly from the form (aka form fields)
7 | interface FormValues {
8 | degree: Degree;
9 | degreeYear: number;
10 | }
11 |
12 | const UserAPI = {
13 | getUser: async (): Promise => {
14 | try {
15 | const { data, status } = await axios.get('/api/user', {
16 | validateStatus: (statusCode: number) => {
17 | return statusCode < 500;
18 | },
19 | });
20 |
21 | if (status === 404) {
22 | return null;
23 | }
24 |
25 | return userWithNameDecoder(data);
26 | } catch (error) {
27 | console.log(error); // eslint-disable-line
28 | return {
29 | message: 'Fail @ getUser',
30 | };
31 | }
32 | },
33 |
34 | putUser: async (user: User): Promise => {
35 | try {
36 | const { data } = await axios.put('/api/user', user, { headers: { 'Content-Type': 'application/json' } });
37 |
38 | return string(data);
39 | } catch (error) {
40 | console.log(error); // eslint-disable-line
41 |
42 | return {
43 | message: 'Fail @ putUser',
44 | };
45 | }
46 | },
47 | };
48 | /* eslint-disable import/prefer-default-export */
49 | export { UserAPI };
50 | export type { FormValues };
51 |
--------------------------------------------------------------------------------
/src/lib/generate-rss-feed.ts:
--------------------------------------------------------------------------------
1 | import { formatISO, isPast, parseISO, sub } from 'date-fns';
2 | import { Happening, Post } from './api';
3 |
4 | type GenericEntry = {
5 | slug: string;
6 | title: string;
7 | publishedAt: string;
8 | author: string;
9 | body: string;
10 | route: string;
11 | };
12 |
13 | const generatePosts = (posts: Array): { postsXML: string; latestPostDate: Date } => {
14 | let latestPostDate = new Date(0);
15 | let postsXML = '';
16 |
17 | for (const post of posts) {
18 | const date = new Date(post.publishedAt);
19 |
20 | if (date > latestPostDate) latestPostDate = date;
21 |
22 | postsXML += `
23 | -
24 |
25 | https://echo.uib.no/${post.route}/${post.slug}
26 |
27 | ${new Date(post.publishedAt).toUTCString()}
28 | https://echo.uib.no/posts/${post.slug}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | `;
37 | }
38 |
39 | return {
40 | postsXML,
41 | latestPostDate,
42 | };
43 | };
44 |
45 | const getRssXML = (posts: Array | null, happenings: Array | null): string => {
46 | const genericPosts = posts
47 | ? posts.map((post) => {
48 | return {
49 | slug: post.slug,
50 | title: post.title,
51 | publishedAt: post._createdAt,
52 | author: post.author,
53 | body: post.body,
54 | route: 'posts',
55 | };
56 | })
57 | : [];
58 |
59 | const happeningIsPast = (happening: Happening) => {
60 | if (!happening.registrationDate) return false;
61 | return isPast(sub(parseISO(happening.registrationDate), { hours: 12 }));
62 | };
63 |
64 | const genericHappenings = happenings
65 | ? happenings
66 | .filter((pred) => happeningIsPast(pred))
67 | .map((happening) => {
68 | return {
69 | slug: happening.slug,
70 | title: happening.title,
71 | publishedAt: formatISO(
72 | sub(parseISO(happening.registrationDate ?? new Date().toString()), { hours: 12 }),
73 | ),
74 | author: happening.author,
75 | body: happening.body,
76 | route: happening.happeningType.toLowerCase(),
77 | };
78 | })
79 | : [];
80 |
81 | const all = [...genericPosts, ...genericHappenings];
82 | all.sort((a, b) => {
83 | if (new Date(a.publishedAt) < new Date(b.publishedAt)) return 1;
84 | if (new Date(b.publishedAt) < new Date(a.publishedAt)) return -1;
85 | return 0;
86 | });
87 |
88 | const { postsXML, latestPostDate } = generatePosts(all);
89 |
90 | return `
91 |
97 |
98 |
99 | https://echo.uib.no/
100 |
101 | en-US
102 | ${latestPostDate.toUTCString()}
103 | ${postsXML}
104 |
105 |
106 | `;
107 | };
108 |
109 | export default getRssXML;
110 |
--------------------------------------------------------------------------------
/src/lib/hooks/index.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/prefer-default-export
2 | export { default as useCountdown } from './use-countdown';
3 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-countdown.ts:
--------------------------------------------------------------------------------
1 | import { differenceInMilliseconds } from 'date-fns';
2 | import { useEffect, useState } from 'react';
3 |
4 | interface CountdownObject {
5 | hours: number;
6 | minutes: number;
7 | seconds: number;
8 | }
9 |
10 | const formatMs = (ms: number): CountdownObject => {
11 | return {
12 | hours: ms < 0 ? 0 : Math.floor(ms / (1000 * 3600)),
13 | minutes: ms < 0 ? 0 : Math.floor((ms / (1000 * 60)) % 60),
14 | seconds: ms < 0 ? 0 : Math.floor((ms / 1000) % 60),
15 | };
16 | };
17 |
18 | const useCountdown = (toDate: Date): CountdownObject => {
19 | const initialMs = differenceInMilliseconds(toDate, new Date());
20 | const [ms, setMs] = useState(initialMs); // state with current interval
21 |
22 | useEffect(() => {
23 | const interval = setInterval(() => {
24 | if (ms < 0) {
25 | clearInterval(interval); // countdown has reached 0
26 | }
27 | setMs(ms - 1000);
28 | }, 1000); // Interval repeats every second
29 |
30 | const resync = setInterval(() => {
31 | if (ms < 0) {
32 | clearInterval(resync);
33 | }
34 | setMs(differenceInMilliseconds(toDate, new Date()));
35 | }, 1000 * 30); // refreshes local datetime every minute
36 |
37 | return () => {
38 | clearInterval(interval);
39 | };
40 | });
41 |
42 | return formatMs(ms);
43 | };
44 |
45 | export default useCountdown;
46 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | const notEmptyOrNull = (e: Array | null) => e && e.length > 0;
2 |
3 | export default notEmptyOrNull;
4 |
--------------------------------------------------------------------------------
/src/markdown.ts:
--------------------------------------------------------------------------------
1 | import { Box, Code, Heading, Img, Link, OrderedList, Text, UnorderedList, useColorModeValue } from '@chakra-ui/react';
2 |
3 | const getLinkColor = () => {
4 | // eslint-disable-next-line react-hooks/rules-of-hooks
5 | return useColorModeValue('blue', 'blue.400');
6 | };
7 |
8 | const MapMarkdownChakra = {
9 | p: {
10 | component: Text,
11 | props: {
12 | mb: '1em',
13 | },
14 | },
15 | h1: {
16 | component: Heading,
17 | props: {
18 | as: 'h1',
19 | size: '2xl',
20 | mt: '20px',
21 | mb: '10px',
22 | },
23 | },
24 | h2: {
25 | component: Heading,
26 | props: {
27 | as: 'h2',
28 | size: 'xl',
29 | mt: '20px',
30 | mb: '10px',
31 | },
32 | },
33 | h3: {
34 | component: Heading,
35 | props: {
36 | as: 'h3',
37 | size: 'lg',
38 | mt: '20px',
39 | mb: '10px',
40 | },
41 | },
42 | h4: {
43 | component: Heading,
44 | props: {
45 | as: 'h4',
46 | size: 'md',
47 | mt: '20px',
48 | mb: '10px',
49 | },
50 | },
51 | h5: {
52 | component: Heading,
53 | props: {
54 | as: 'h5',
55 | size: 'sm',
56 | mt: '20px',
57 | mb: '10px',
58 | },
59 | },
60 | h6: {
61 | component: Heading,
62 | props: {
63 | as: 'h6',
64 | size: 'xs',
65 | mt: '20px',
66 | mb: '10px',
67 | },
68 | },
69 | blockquote: {
70 | component: Box,
71 | props: {
72 | borderLeftWidth: '0.25em',
73 | pl: '2em',
74 | pt: '0.5em',
75 | pb: '0.5em',
76 | },
77 | },
78 | ul: {
79 | component: UnorderedList,
80 | props: {
81 | pl: '2em',
82 | mb: '10px',
83 | },
84 | },
85 | ol: {
86 | component: OrderedList,
87 | props: {
88 | pl: '2em',
89 | mb: '10px',
90 | },
91 | },
92 | code: {
93 | component: Code,
94 | props: {
95 | m: '0.5em',
96 | pl: '0.5em',
97 | pr: '0.5em',
98 | },
99 | },
100 | a: {
101 | component: Link,
102 | props: {
103 | isExternal: true,
104 | color: getLinkColor,
105 | },
106 | img: {
107 | component: Img,
108 | props: {
109 | htmlWidth: '100%',
110 | },
111 | },
112 | },
113 | };
114 |
115 | export default MapMarkdownChakra;
116 |
--------------------------------------------------------------------------------
/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Center, Heading, Text } from '@chakra-ui/react';
2 | import React from 'react';
3 | import SEO from '../components/seo';
4 |
5 | const NotFoundPage = (): JSX.Element => (
6 | <>
7 |
8 |
9 |
10 |
11 | 404
12 |
13 | Siden eksisterer ikke.
14 |
15 |
16 | >
17 | );
18 |
19 | export default NotFoundPage;
20 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { ChakraProvider } from '@chakra-ui/react';
2 | import { SessionProvider } from 'next-auth/react';
3 | import { getMonth } from 'date-fns';
4 | import type { AppProps } from 'next/app';
5 | import { useRouter } from 'next/router';
6 | import NextNProgress from 'nextjs-progressbar';
7 | import React from 'react';
8 | import Snowfall from 'react-snowfall';
9 | import Fonts from '../styles/fonts';
10 | import theme from '../styles/theme';
11 | import Layout from '../components/layout';
12 |
13 | const App = ({ Component, pageProps: { session, ...pageProps } }: AppProps): JSX.Element => {
14 | const router = useRouter();
15 | const SSR = typeof window === 'undefined'; //Used to disable rendering of animated component SS
16 |
17 | return (
18 |
19 |
20 |
27 | {!SSR && getMonth(new Date()) === 11 && }
28 |
29 |
30 |
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default App;
38 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { ColorModeScript, useColorModeValue } from '@chakra-ui/react';
2 | import Document, { DocumentContext, DocumentInitialProps, Head, Html, Main, NextScript } from 'next/document';
3 | import React from 'react';
4 | import theme from '../styles/theme';
5 |
6 | const getInitialProps = async (ctx: DocumentContext): Promise => {
7 | const initialProps = await Document.getInitialProps(ctx);
8 | return { ...initialProps };
9 | };
10 |
11 | const CustomDocument = (): JSX.Element => {
12 | const themeColor = useColorModeValue('bg.light.primary', 'bg.dark.primary');
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | CustomDocument.getInitialProps = getInitialProps;
40 |
41 | export default CustomDocument;
42 |
--------------------------------------------------------------------------------
/src/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth';
2 |
3 | export default NextAuth({
4 | session: {
5 | maxAge: 3600,
6 | },
7 | callbacks: {
8 | // eslint-disable-next-line @typescript-eslint/require-await
9 | async jwt({ token, account }) {
10 | if (account) {
11 | token.idToken = account.id_token;
12 | }
13 | return token;
14 | },
15 | },
16 | providers: [
17 | {
18 | id: 'feide',
19 | name: 'Feide',
20 | type: 'oauth',
21 | wellKnown: 'https://auth.dataporten.no/.well-known/openid-configuration',
22 | authorization: {
23 | params: {
24 | scope: 'email userinfo-name profile userid openid',
25 | },
26 | },
27 | clientId: process.env.FEIDE_CLIENT_ID,
28 | clientSecret: process.env.FEIDE_CLIENT_SECRET,
29 | idToken: true,
30 | profile(profile) {
31 | return {
32 | id: profile.sub,
33 | name: profile.name,
34 | email: profile.email,
35 | image: profile.picture,
36 | };
37 | },
38 | },
39 | ],
40 | });
41 |
--------------------------------------------------------------------------------
/src/pages/api/user/index.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next';
2 | import { getToken } from 'next-auth/jwt';
3 | import axios from 'axios';
4 | import { User, UserWithName } from '../../../lib/api';
5 |
6 | const handler = async (req: NextApiRequest, res: NextApiResponse) => {
7 | const session = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
8 |
9 | if (session) {
10 | const idToken = session.idToken as string;
11 | const backendUrl = process.env.BACKEND_URL ?? 'http://localhost:8080';
12 |
13 | if (req.method === 'GET') {
14 | // not authenticated
15 | if (!session.email || !session.name) {
16 | res.status(401);
17 | return;
18 | }
19 |
20 | const response = await axios.get(`${backendUrl}/user`, {
21 | headers: {
22 | Authorization: `Bearer ${idToken}`,
23 | },
24 | validateStatus: (statusCode: number) => {
25 | return statusCode < 500;
26 | },
27 | });
28 |
29 | // no user in database
30 | if (response.status === 404) {
31 | // not authenticated
32 | if (!session.email || !session.name) {
33 | res.status(401);
34 | return;
35 | }
36 |
37 | const user: UserWithName = {
38 | email: session.email,
39 | name: session.name,
40 | degreeYear: null,
41 | degree: null,
42 | };
43 |
44 | res.status(200).send(user);
45 | return;
46 | }
47 |
48 | const user: UserWithName = {
49 | email: session.email,
50 | name: session.name,
51 | degree: response.data.degree ?? null,
52 | degreeYear: response.data.degreeYear ?? null,
53 | };
54 |
55 | res.status(200).send(user);
56 | return;
57 | }
58 |
59 | if (req.method === 'PUT') {
60 | const user: User = {
61 | email: req.body.email,
62 | degree: req.body.degree,
63 | degreeYear: req.body.degreeYear,
64 | };
65 |
66 | const response = await axios.put(`${backendUrl}/user`, user, {
67 | headers: {
68 | Authorization: `Bearer ${idToken}`,
69 | 'Content-Type': 'application/json',
70 | },
71 | });
72 |
73 | // not authenticated
74 | if (response.status === 401) {
75 | res.status(401);
76 | return;
77 | }
78 |
79 | res.status(200).send(response.data);
80 | return;
81 | }
82 |
83 | // method not valid
84 | res.status(400);
85 | return;
86 | }
87 |
88 | // not authenticated
89 | res.status(401);
90 | };
91 |
92 | export default handler;
93 |
--------------------------------------------------------------------------------
/src/pages/bedpres/index.tsx:
--------------------------------------------------------------------------------
1 | import { GetStaticProps } from 'next';
2 | import React from 'react';
3 | import EntryOverview from '../../components/entry-overview';
4 | import SEO from '../../components/seo';
5 | import { isErrorMessage, Happening, HappeningAPI, HappeningType } from '../../lib/api';
6 |
7 | interface Props {
8 | bedpreses: Array;
9 | }
10 |
11 | const BedpresCollectionPage = ({ bedpreses }: Props): JSX.Element => {
12 | return (
13 | <>
14 |
15 |
16 | >
17 | );
18 | };
19 |
20 | export const getStaticProps: GetStaticProps = async () => {
21 | const happenings = await HappeningAPI.getHappeningsByType(0, HappeningType.BEDPRES);
22 |
23 | if (isErrorMessage(happenings)) throw new Error(happenings.message);
24 |
25 | const props: Props = {
26 | bedpreses: happenings,
27 | };
28 |
29 | return { props };
30 | };
31 |
32 | export default BedpresCollectionPage;
33 |
--------------------------------------------------------------------------------
/src/pages/event/index.tsx:
--------------------------------------------------------------------------------
1 | import { GetStaticProps } from 'next';
2 | import React from 'react';
3 | import EntryOverview from '../../components/entry-overview';
4 | import SEO from '../../components/seo';
5 | import { isErrorMessage, Happening, HappeningAPI, HappeningType } from '../../lib/api';
6 |
7 | interface Props {
8 | events: Array;
9 | }
10 |
11 | const EventsCollectionPage = ({ events }: Props): JSX.Element => {
12 | return (
13 | <>
14 |
15 |
16 | >
17 | );
18 | };
19 |
20 | export const getStaticProps: GetStaticProps = async () => {
21 | const happenings = await HappeningAPI.getHappeningsByType(0, HappeningType.EVENT);
22 |
23 | if (isErrorMessage(happenings)) throw new Error(happenings.message);
24 |
25 | const props: Props = {
26 | events: happenings,
27 | };
28 |
29 | return { props };
30 | };
31 |
32 | export default EventsCollectionPage;
33 |
--------------------------------------------------------------------------------
/src/pages/happenings-overview/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Divider,
4 | Flex,
5 | Heading,
6 | LinkBox,
7 | LinkOverlay,
8 | SimpleGrid,
9 | Spacer,
10 | Stack,
11 | Text,
12 | useColorModeValue,
13 | } from '@chakra-ui/react';
14 | import { addWeeks, getISOWeek, lastDayOfWeek, startOfWeek, subWeeks } from 'date-fns';
15 | import { GetStaticProps } from 'next';
16 | import NextLink from 'next/link';
17 | import React, { useState } from 'react';
18 | import { BiLeftArrow, BiRightArrow } from 'react-icons/bi';
19 | import Button from '../../components/button';
20 | import Section from '../../components/section';
21 | import SEO from '../../components/seo';
22 | import { Happening, HappeningAPI, HappeningType, isErrorMessage } from '../../lib/api';
23 |
24 | interface EventsStackProps {
25 | events: Array;
26 | date: Date;
27 | }
28 |
29 | const datesAreOnSameDay = (first: Date, second: Date) =>
30 | first.getFullYear() === second.getFullYear() &&
31 | first.getMonth() === second.getMonth() &&
32 | first.getDate() === second.getDate();
33 |
34 | const HappeningsColumn = ({ events, date }: EventsStackProps): React.ReactElement => {
35 | const eventsThisDay = events.filter((x) => datesAreOnSameDay(new Date(x.date), date));
36 | const formattedDate = date.toLocaleDateString('nb-NO', { weekday: 'long', month: 'short', day: 'numeric' });
37 | const titleColor = useColorModeValue('highlight.light.primary', 'highlight.dark.primary');
38 |
39 | return (
40 |
41 |
42 | {formattedDate}
43 |
44 |
45 | {eventsThisDay.map((event) => {
46 | return (
47 |
48 |
49 |
50 |
51 |
52 | {event.happeningType === HappeningType.BEDPRES ? 'Bedpres: ' : ''}
53 | {event.title}
54 |
55 |
56 |
57 |
58 |
59 | );
60 | })}
61 |
62 | );
63 | };
64 |
65 | const getDatesInRange = (startDate: Date, endDate: Date): Array => {
66 | const date = new Date(startDate.getTime());
67 |
68 | const dates = [];
69 |
70 | while (date <= endDate) {
71 | dates.push(new Date(date));
72 | date.setDate(date.getDate() + 1);
73 | }
74 |
75 | return dates;
76 | };
77 |
78 | const getWeekDatesFromDate = (date: Date): Array => {
79 | const firstWeekDay = startOfWeek(date, { weekStartsOn: 1 });
80 | const lastWeekDay = lastDayOfWeek(date, { weekStartsOn: 1 });
81 | return getDatesInRange(firstWeekDay, lastWeekDay);
82 | };
83 |
84 | interface Props {
85 | events: Array;
86 | }
87 | const HappeningsOverviewPage = ({ events }: Props): JSX.Element => {
88 | const [date, setDate] = useState(new Date());
89 | const currentWeek = getWeekDatesFromDate(date);
90 |
91 | return (
92 | <>
93 |
94 |
95 |
96 | Arrangementer uke {getISOWeek(date)}
97 |
98 |
99 |
100 | } onClick={() => setDate(subWeeks(date, 1))} marginRight="1rem">
101 | forrige uke
102 |
103 | } onClick={() => setDate(addWeeks(date, 1))}>
104 | neste uke
105 |
106 |
107 |
108 |
109 | {currentWeek.map((x) => {
110 | return ;
111 | })}
112 |
113 | >
114 | );
115 | };
116 |
117 | export const getStaticProps: GetStaticProps = async () => {
118 | const eventsResponse = await HappeningAPI.getHappeningsByType(0, HappeningType.EVENT);
119 | const bedpressesResponse = await HappeningAPI.getHappeningsByType(0, HappeningType.BEDPRES);
120 |
121 | if (isErrorMessage(eventsResponse)) throw new Error(eventsResponse.message);
122 | if (isErrorMessage(bedpressesResponse)) throw new Error(bedpressesResponse.message);
123 |
124 | const props: Props = {
125 | events: [...eventsResponse, ...bedpressesResponse],
126 | };
127 |
128 | return { props };
129 | };
130 |
131 | export default HappeningsOverviewPage;
132 |
--------------------------------------------------------------------------------
/src/pages/job/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { GetStaticProps } from 'next';
3 | import { JobAdvert, JobAdvertAPI, isErrorMessage } from '../../lib/api';
4 | import JobAdvertOverview from '../../components/job-advert-overview';
5 | import Section from '../../components/section';
6 |
7 | interface Props {
8 | jobAdverts: Array;
9 | }
10 |
11 | const JobPage = ({ jobAdverts }: Props) => {
12 | return (
13 |
16 | );
17 | };
18 |
19 | export const getStaticProps: GetStaticProps = async () => {
20 | if (process.env.ENABLE_JOB_ADVERTS?.toLowerCase() !== 'true') {
21 | return {
22 | notFound: true,
23 | };
24 | }
25 |
26 | const jobAdverts = await JobAdvertAPI.getJobAdverts(10);
27 |
28 | if (isErrorMessage(jobAdverts)) throw new Error(jobAdverts.message);
29 |
30 | const props: Props = {
31 | jobAdverts,
32 | };
33 |
34 | return {
35 | props,
36 | };
37 | };
38 |
39 | export default JobPage;
40 |
--------------------------------------------------------------------------------
/src/pages/om-echo/[slug].tsx:
--------------------------------------------------------------------------------
1 | import { ParsedUrlQuery } from 'querystring';
2 | import { Center, Spinner } from '@chakra-ui/react';
3 | import { GetStaticPaths, GetStaticProps } from 'next';
4 | import { useRouter } from 'next/router';
5 | import React from 'react';
6 | import Markdown from 'markdown-to-jsx';
7 | import SEO from '../../components/seo';
8 | import { isErrorMessage, StaticInfoAPI, StaticInfo } from '../../lib/api';
9 | import SidebarWrapper from '../../components/sidebar-wrapper';
10 | import MapMarkdownChakra from '../../markdown';
11 |
12 | interface Props {
13 | staticInfo: StaticInfo;
14 | }
15 |
16 | const StaticInfoPage = ({ staticInfo }: Props): JSX.Element => {
17 | const router = useRouter();
18 |
19 | return (
20 | <>
21 | {router.isFallback && (
22 |
23 |
24 |
25 | )}
26 | {!router.isFallback && (
27 | <>
28 |
29 |
30 | {staticInfo.info}
31 |
32 | >
33 | )}
34 | >
35 | );
36 | };
37 |
38 | const getStaticPaths: GetStaticPaths = async () => {
39 | const paths = await StaticInfoAPI.getPaths();
40 |
41 | return {
42 | paths: paths.map((slug: string) => ({
43 | params: {
44 | slug,
45 | },
46 | })),
47 | fallback: true,
48 | };
49 | };
50 |
51 | interface Params extends ParsedUrlQuery {
52 | slug: string;
53 | }
54 |
55 | const getStaticProps: GetStaticProps = async (context) => {
56 | const { slug } = context.params as Params;
57 | const staticInfo = await StaticInfoAPI.getStaticInfoBySlug(slug);
58 |
59 | if (isErrorMessage(staticInfo)) {
60 | if (staticInfo.message === '404') {
61 | return {
62 | notFound: true,
63 | };
64 | }
65 | throw new Error(staticInfo.message);
66 | }
67 |
68 | const props: Props = {
69 | staticInfo,
70 | };
71 |
72 | return {
73 | props,
74 | };
75 | };
76 |
77 | export default StaticInfoPage;
78 | export { getStaticPaths, getStaticProps };
79 |
--------------------------------------------------------------------------------
/src/pages/om-echo/moetereferat/index.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Spinner } from '@chakra-ui/react';
2 | import { GetStaticProps } from 'next';
3 | import { useRouter } from 'next/router';
4 | import React from 'react';
5 | import SEO from '../../../components/seo';
6 | import MinuteList from '../../../components/minute-list';
7 | import { isErrorMessage, MinuteAPI, Minute } from '../../../lib/api';
8 | import SidebarWrapper from '../../../components/sidebar-wrapper';
9 |
10 | interface Props {
11 | minutes: Array;
12 | }
13 |
14 | const MinutesPage = ({ minutes }: Props): JSX.Element => {
15 | const router = useRouter();
16 |
17 | return (
18 | <>
19 | {router.isFallback && (
20 |
21 |
22 |
23 | )}
24 | {!router.isFallback && (
25 | <>
26 |
27 |
28 |
29 |
30 | >
31 | )}
32 | >
33 | );
34 | };
35 |
36 | const getStaticProps: GetStaticProps = async () => {
37 | const minutes = await MinuteAPI.getMinutes();
38 |
39 | if (isErrorMessage(minutes)) throw new Error(minutes.message);
40 |
41 | return {
42 | props: { minutes },
43 | };
44 | };
45 |
46 | export default MinutesPage;
47 | export { getStaticProps };
48 |
--------------------------------------------------------------------------------
/src/pages/om-echo/studentgrupper/[slug].tsx:
--------------------------------------------------------------------------------
1 | import { ParsedUrlQuery } from 'querystring';
2 | import { Center, Divider, Heading, Spinner, Wrap, WrapItem, Image } from '@chakra-ui/react';
3 | import { GetStaticPaths, GetStaticProps } from 'next';
4 | import { useRouter } from 'next/router';
5 | import React from 'react';
6 | import Markdown from 'markdown-to-jsx';
7 | import SEO from '../../../components/seo';
8 | import { isErrorMessage, StudentGroup, StudentGroupAPI, Member } from '../../../lib/api';
9 | import SidebarWrapper from '../../../components/sidebar-wrapper';
10 | import MapMarkdownChakra from '../../../markdown';
11 | import MemberProfile from '../../../components/member-profile';
12 |
13 | interface Props {
14 | studentGroup: StudentGroup;
15 | }
16 |
17 | const StudentGroupPage = ({ studentGroup }: Props): JSX.Element => {
18 | const router = useRouter();
19 |
20 | return (
21 | <>
22 | {router.isFallback && (
23 |
24 |
25 |
26 | )}
27 | {!router.isFallback && (
28 | <>
29 |
30 |
31 |
32 | {studentGroup.name}
33 |
34 |
35 | {studentGroup.info}
36 | {studentGroup.imageUrl && (
37 |
38 |
45 |
46 | )}
47 |
48 |
49 | {studentGroup.members.map((member: Member) => (
50 |
51 |
52 |
53 | ))}
54 |
55 |
56 | >
57 | )}
58 | >
59 | );
60 | };
61 |
62 | const getStaticPaths: GetStaticPaths = async () => {
63 | const paths = await StudentGroupAPI.getPathsByType('subgroup');
64 |
65 | if (isErrorMessage(paths)) {
66 | throw new Error(paths.message);
67 | }
68 |
69 | return {
70 | paths: paths.map((slug: string) => ({
71 | params: {
72 | slug,
73 | },
74 | })),
75 | fallback: true,
76 | };
77 | };
78 |
79 | interface Params extends ParsedUrlQuery {
80 | slug: string;
81 | }
82 |
83 | const getStaticProps: GetStaticProps = async (context) => {
84 | const { slug } = context.params as Params;
85 | const studentGroup = await StudentGroupAPI.getStudentGroupBySlug(slug);
86 |
87 | if (isErrorMessage(studentGroup)) {
88 | if (studentGroup.message === '404') {
89 | return {
90 | notFound: true,
91 | };
92 | }
93 | throw new Error(studentGroup.message);
94 | }
95 |
96 | const props: Props = {
97 | studentGroup,
98 | };
99 |
100 | return {
101 | props,
102 | };
103 | };
104 |
105 | export default StudentGroupPage;
106 | export { getStaticPaths, getStaticProps };
107 |
--------------------------------------------------------------------------------
/src/pages/posts/[slug].tsx:
--------------------------------------------------------------------------------
1 | import { ParsedUrlQuery } from 'querystring';
2 | import { Box, Center, Divider, Heading, HStack, Spinner } from '@chakra-ui/react';
3 | import { format } from 'date-fns';
4 | import { nb } from 'date-fns/locale';
5 | import Markdown from 'markdown-to-jsx';
6 | import { GetStaticPaths, GetStaticProps } from 'next';
7 | import { useRouter } from 'next/router';
8 | import React from 'react';
9 | import { BiCalendar } from 'react-icons/bi';
10 | import IconText from '../../components/icon-text';
11 | import Section from '../../components/section';
12 | import SEO from '../../components/seo';
13 | import { isErrorMessage, Post, PostAPI } from '../../lib/api';
14 | import MapMarkdownChakra from '../../markdown';
15 |
16 | interface Props {
17 | post: Post;
18 | }
19 |
20 | const PostPage = ({ post }: Props): JSX.Element => {
21 | const router = useRouter();
22 |
23 | return (
24 | <>
25 | {router.isFallback && (
26 |
27 |
28 |
29 | )}
30 | {!router.isFallback && (
31 | <>
32 |
33 |
34 |
35 |
36 | {post.title}
37 |
38 |
39 |
40 |
41 |
42 | @{post.author}
43 |
47 |
48 |
49 |
50 |
51 | {post.body}
52 |
53 |
54 | >
55 | )}
56 | >
57 | );
58 | };
59 |
60 | const getStaticPaths: GetStaticPaths = async () => {
61 | const paths = await PostAPI.getPaths();
62 | return {
63 | paths: paths.map((slug: string) => ({
64 | params: {
65 | slug,
66 | },
67 | })),
68 | fallback: true,
69 | };
70 | };
71 |
72 | interface Params extends ParsedUrlQuery {
73 | slug: string;
74 | }
75 |
76 | const getStaticProps: GetStaticProps = async (context) => {
77 | const { slug } = context.params as Params;
78 | const post = await PostAPI.getPostBySlug(slug);
79 |
80 | if (isErrorMessage(post)) {
81 | if (post.message === '404') {
82 | return {
83 | notFound: true,
84 | };
85 | }
86 | throw new Error(post.message);
87 | }
88 |
89 | const props: Props = {
90 | post,
91 | };
92 |
93 | return {
94 | props,
95 | };
96 | };
97 |
98 | export default PostPage;
99 | export { getStaticProps, getStaticPaths };
100 |
--------------------------------------------------------------------------------
/src/pages/posts/index.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Divider } from '@chakra-ui/react';
2 | import { GetStaticProps } from 'next';
3 | import { useRouter } from 'next/router';
4 | import React from 'react';
5 | import ButtonLink from '../../components/button-link';
6 | import PostList from '../../components/post-list';
7 | import SEO from '../../components/seo';
8 | import { isErrorMessage, Post, PostAPI } from '../../lib/api';
9 |
10 | interface Props {
11 | posts: Array;
12 | }
13 |
14 | const PostCollectionPage = ({ posts }: Props): JSX.Element => {
15 | const router = useRouter();
16 | const { page } = router.query;
17 | const pageNumber = Number.parseInt(page as string) || 1;
18 | const postsPerPage = 6;
19 | const slicedPosts = posts.slice((pageNumber - 1) * postsPerPage, (pageNumber - 1) * 6 + postsPerPage);
20 |
21 | return (
22 | <>
23 |
24 |
25 |
26 |
27 | {pageNumber !== 1 && (
28 |
29 | Forrige
30 |
31 | )}
32 | {pageNumber * postsPerPage <= posts.length && (
33 |
40 | Neste
41 |
42 | )}
43 |
44 | >
45 | );
46 | };
47 |
48 | export const getStaticProps: GetStaticProps = async () => {
49 | const posts = await PostAPI.getPosts(0); // 0 for all posts
50 |
51 | if (isErrorMessage(posts)) throw new Error(posts.message);
52 |
53 | const props: Props = {
54 | posts,
55 | };
56 |
57 | return { props };
58 | };
59 |
60 | export default PostCollectionPage;
61 |
--------------------------------------------------------------------------------
/src/pages/profile.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { signIn, signOut, useSession } from 'next-auth/react';
3 | import { Spinner, Center, Text, Button, useColorModeValue, Link, Flex, Spacer } from '@chakra-ui/react';
4 | import { IoMdHome } from 'react-icons/io';
5 | import SEO from '../components/seo';
6 | import { UserAPI, UserWithName, isErrorMessage } from '../lib/api';
7 | import Section from '../components/section';
8 | import ProfileInfo from '../components/profile-info';
9 |
10 | const ProfilePage = (): JSX.Element => {
11 | const [user, setUser] = useState();
12 |
13 | const bg = useColorModeValue('button.light.primary', 'button.dark.primary');
14 | const hover = useColorModeValue('button.light.primaryHover', 'button.dark.primaryHover');
15 | const active = useColorModeValue('button.light.primaryActive', 'button.dark.primaryActive');
16 | const textColor = useColorModeValue('button.light.text', 'button.dark.text');
17 |
18 | useEffect(() => {
19 | const fetchUser = async () => {
20 | const result = await UserAPI.getUser();
21 |
22 | if (!isErrorMessage(result)) {
23 | setUser(result);
24 | }
25 | };
26 | void fetchUser();
27 | }, []);
28 |
29 | const { status } = useSession();
30 |
31 | return (
32 | <>
33 |
34 | {status === 'authenticated' && (
35 | <>
36 |
37 |
47 | {user && }
48 |
49 | >
50 | )}
51 | {status === 'loading' && (
52 |
53 |
54 |
55 | )}
56 | {status === 'unauthenticated' && (
57 |
58 |
59 |
60 |
61 | Du er ikke logget inn
62 |
63 |
64 |
77 |
78 |
79 | {} Hovedside
80 |
81 |
82 |
83 |
84 | )}
85 | >
86 | );
87 | };
88 |
89 | export default ProfilePage;
90 |
--------------------------------------------------------------------------------
/src/pages/valg/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Box, Divider, Heading } from '@chakra-ui/react';
3 | import Markdown from 'markdown-to-jsx';
4 | import Section from '../../components/section';
5 | import SEO from '../../components/seo';
6 | import MapMarkdownChakra from '../../markdown';
7 | import valg from '../../../public/static/valg.md';
8 |
9 | const ValgPage = () => {
10 | return (
11 | <>
12 |
13 |
14 |
15 |
16 | Husk å bruke stemmeretten din!
17 |
18 |
19 |
20 |
21 | {valg}
22 |
23 |
24 | >
25 | );
26 | };
27 |
28 | export default ValgPage;
29 |
--------------------------------------------------------------------------------
/src/styles/theme.ts:
--------------------------------------------------------------------------------
1 | import { extendTheme } from '@chakra-ui/react';
2 | import { getMonth } from 'date-fns';
3 | import { mainTheme, halloweenTheme, christmasTheme } from './themes';
4 |
5 | // global config
6 | const config = {
7 | useSystemColorMode: false,
8 | initialColorMode: 'light',
9 | };
10 |
11 | const currentTheme = () => {
12 | const month = getMonth(new Date());
13 |
14 | if (month === 9) return halloweenTheme;
15 | else if (month === 11) return christmasTheme;
16 |
17 | return mainTheme;
18 | };
19 |
20 | // theme
21 | const theme = extendTheme({
22 | ...currentTheme(),
23 | config,
24 | });
25 |
26 | export default theme;
27 |
--------------------------------------------------------------------------------
/src/styles/themes/christmas-theme.ts:
--------------------------------------------------------------------------------
1 | import mainTheme from './main-theme';
2 |
3 | /*
4 | * Add own colors for a Christmas theme here later.
5 |
6 | const christmasPalette = {
7 | ...defaultPalette,
8 |
9 | // etc...
10 |
11 | };
12 |
13 | *
14 | */
15 |
16 | const christmasTheme = {
17 | ...mainTheme,
18 | // colors: christmasPalette,
19 | };
20 |
21 | export default christmasTheme;
22 |
--------------------------------------------------------------------------------
/src/styles/themes/halloween-theme.ts:
--------------------------------------------------------------------------------
1 | import mainTheme, { defaultPalette } from './main-theme';
2 |
3 | const halloweenPalette = {
4 | ...defaultPalette,
5 | text: {
6 | dark: {
7 | primary: '#000000',
8 | secondary: '#ffffff',
9 | },
10 | light: {
11 | primary: '#000000',
12 | secondary: '#ffffff',
13 | },
14 | },
15 | button: {
16 | light: {
17 | primary: '#FC9E31',
18 | secondary: '',
19 | primaryHover: '#FDB35E',
20 | secondaryHover: '',
21 | primaryActive: '#FDB35E',
22 | secondaryActive: '',
23 | text: '#ffffff',
24 | },
25 | dark: {
26 | primary: '#FDB35E',
27 | secondary: '',
28 | primaryHover: '#FDC98B',
29 | secondaryHover: '',
30 | primaryActive: '#FDC98B',
31 | secondaryActive: '',
32 | text: '#000000',
33 | },
34 | },
35 | highlight: {
36 | light: {
37 | primary: '#FC9E31',
38 | secondary: '#A998C3',
39 | },
40 | dark: {
41 | primary: '#FDB35E',
42 | secondary: '#A998C3',
43 | },
44 | },
45 | orange: {
46 | 50: '#FFF3E6',
47 | 100: '#FEDEB9',
48 | 200: '#FDC98B',
49 | 300: '#FDB35E',
50 | 400: '#FC9E31',
51 | 500: '#FB8904',
52 | 600: '#C96D03',
53 | 700: '#975202',
54 | 800: '#653701',
55 | 900: '#321B01',
56 | },
57 | purple: {
58 | 50: '#F2EFF6',
59 | 100: '#D9D2E5',
60 | 200: '#C1B5D4',
61 | 300: '#A998C3',
62 | 400: '#917BB2',
63 | 500: '#785EA1',
64 | 600: '#604B81',
65 | 700: '#483861',
66 | 800: '#302640',
67 | 900: '#181320',
68 | },
69 | };
70 |
71 | const halloweenTheme = {
72 | ...mainTheme,
73 | colors: halloweenPalette,
74 | };
75 |
76 | export default halloweenTheme;
77 |
--------------------------------------------------------------------------------
/src/styles/themes/index.ts:
--------------------------------------------------------------------------------
1 | import christmasTheme from './christmas-theme';
2 | import halloweenTheme from './halloween-theme';
3 | import mainTheme from './main-theme';
4 |
5 | export { christmasTheme, halloweenTheme, mainTheme };
6 |
--------------------------------------------------------------------------------
/src/styles/themes/main-theme.ts:
--------------------------------------------------------------------------------
1 | import { useBreakpointValue } from '@chakra-ui/react';
2 |
3 | const defaultPalette = {
4 | transparent: 'transparent',
5 | black: '#000000',
6 | white: '#ffffff',
7 | bg: {
8 | light: {
9 | primary: '#e6e6e6',
10 | secondary: '#ffffff',
11 | tertiary: '#F5F5F5',
12 | hover: '#F5F5F5',
13 | border: '#ADADAD',
14 | },
15 | dark: {
16 | primary: '#1E1E1E',
17 | secondary: '#393939',
18 | tertiary: '#434343',
19 | hover: '#434343',
20 | border: '#808080',
21 | },
22 | },
23 | text: {
24 | dark: {
25 | primary: '#000000',
26 | secondary: '#000000',
27 | },
28 | light: {
29 | primary: '#000000',
30 | secondary: '#000000',
31 | },
32 | },
33 | button: {
34 | light: {
35 | primary: '#19A0B3',
36 | secondary: '',
37 | primaryHover: '#48D1E5',
38 | secondaryHover: '',
39 | primaryActive: '#049fb2',
40 | secondaryActive: '',
41 | text: '#ffffff',
42 | },
43 | dark: {
44 | primary: '#98E5F0',
45 | secondary: '',
46 | primaryHover: '#C0EFF6',
47 | secondaryHover: '',
48 | primaryActive: '#52afbe',
49 | secondaryActive: '',
50 | text: '#000000',
51 | },
52 | },
53 | highlight: {
54 | light: {
55 | primary: '#19A0B3',
56 | secondary: '#FDC42F',
57 | },
58 | dark: {
59 | primary: '#98E5F0',
60 | secondary: '#FEDE8B',
61 | },
62 | },
63 | vermillion: '#333a56',
64 | fresh: '#52658f',
65 | yellow: {
66 | 50: '#FFF8E6',
67 | 100: '#FEEBB8',
68 | 200: '#FEDE8B',
69 | 300: '#FED15D',
70 | 400: '#FDC42F',
71 | 500: '#FDB702',
72 | 600: '#CA9202',
73 | 700: '#986E01',
74 | 800: '#654901',
75 | 900: '#332500',
76 | },
77 | cyan: {
78 | 50: '#E9F9FC',
79 | 100: '#C0EFF6',
80 | 200: '#98E5F0',
81 | 300: '#70DBEB',
82 | 400: '#48D1E5',
83 | 500: '#20C8DF',
84 | 600: '#19A0B3',
85 | 700: '#137886',
86 | 800: '#0D5059',
87 | 900: '#06282D',
88 | },
89 | gray: {
90 | 50: '#F5F5F5',
91 | 100: '#DFDFDF',
92 | 200: '#C8C8C8',
93 | 300: '#B2B2B2',
94 | 400: '#9C9C9C',
95 | 500: '#868686',
96 | 600: '#6F6F6F',
97 | 700: '#595959',
98 | 800: '#434343',
99 | 900: '#2D2D2D',
100 | },
101 | };
102 |
103 | const mainTheme = {
104 | fonts: {
105 | heading: 'IBM Plex Serif',
106 | body: 'Raleway',
107 | },
108 | styles: {
109 | global: ({ colorMode }: { colorMode: string }) => ({
110 | body: {
111 | color: colorMode === 'light' ? 'black' : 'white',
112 | bg: colorMode === 'light' ? 'bg.light.primary' : 'bg.dark.primary',
113 | lineHeight: 'base',
114 | // eslint-disable-next-line react-hooks/rules-of-hooks
115 | fontSize: useBreakpointValue(['1rem', null, '1.25rem']),
116 | },
117 | }),
118 | },
119 | colors: defaultPalette,
120 | };
121 |
122 | export default mainTheme;
123 | export { defaultPalette };
124 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2018",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "strictNullChecks": true,
17 | "alwaysStrict": true,
18 | "incremental": true
19 | },
20 | "include": [
21 | "next-env.d.ts",
22 | "**/*.ts",
23 | "**/*.tsx",
24 | "src/components/__tests__/testing-utils.js",
25 | "jest.setup.js",
26 | "next.config.js"
27 | ],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------