diff --git a/.github/workflows/check_api.yml b/.github/workflows/check_api.yml new file mode 100644 index 0000000..69beb63 --- /dev/null +++ b/.github/workflows/check_api.yml @@ -0,0 +1,23 @@ +name: API Checks + +on: + pull_request: + paths: + - api/** + - .github/workflows/check_api.yml + +# Fail on warnings +env: + RUSTFLAGS: "-Dwarnings" + +jobs: + clippy: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: api + + steps: + - uses: actions/checkout@v3 + - run: cargo clippy diff --git a/.github/workflows/check_frontend.yml b/.github/workflows/check_frontend.yml new file mode 100644 index 0000000..02c5938 --- /dev/null +++ b/.github/workflows/check_frontend.yml @@ -0,0 +1,42 @@ +name: Frontend Checks + +on: + pull_request: + paths: + - frontend/** + - .github/workflows/check_frontend.yml + +jobs: + lint: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + cache: 'yarn' + cache-dependency-path: '**/yarn.lock' + - run: yarn install --immutable + - run: yarn lint --max-warnings 0 + + typecheck: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + cache: 'yarn' + cache-dependency-path: '**/yarn.lock' + - run: yarn install --immutable + - run: yarn tsc diff --git a/.github/workflows/deploy_frontend.yml b/.github/workflows/deploy_frontend.yml index e495b49..242c757 100644 --- a/.github/workflows/deploy_frontend.yml +++ b/.github/workflows/deploy_frontend.yml @@ -2,36 +2,37 @@ name: Deploy Frontend on: push: - branches: ['main'] - paths: ['frontend/**'] + branches: + - main + paths: + - frontend/** + - .github/workflows/deploy_frontend.yml + +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} jobs: deploy: + name: Deploy to Vercel runs-on: ubuntu-latest defaults: run: working-directory: frontend - permissions: - contents: read - id-token: write - steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 17 + node-version: 18 cache: yarn cache-dependency-path: '**/yarn.lock' - - run: yarn install --immutable - - run: yarn build - - id: auth - uses: google-github-actions/auth@v0 - with: - credentials_json: '${{ secrets.GCP_SA_KEY }}' - - id: deploy - uses: google-github-actions/deploy-appengine@v0 - with: - working_directory: frontend - version: v1 + - name: Install Vercel CLI + run: yarn global install vercel@latest + - name: Pull Vercel Environment Information + run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} + - name: Build Project Artifacts + run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} + - name: Deploy Project Artifacts to Vercel + run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} diff --git a/README.md b/README.md index 8526b8b..3f5f1b5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Crab Fit avatar +[![Frontend Checks](https://github.com/GRA0007/crab.fit/actions/workflows/check_frontend.yml/badge.svg)](https://github.com/GRA0007/crab.fit/actions/workflows/check_frontend.yml) +[![API Checks](https://github.com/GRA0007/crab.fit/actions/workflows/check_api.yml/badge.svg)](https://github.com/GRA0007/crab.fit/actions/workflows/check_api.yml) + Align your schedules to find the perfect time that works for everyone. Licensed under the GNU GPLv3. @@ -29,9 +32,9 @@ The browser extension in `browser-extension` can be tested by first running the ## Deploy -Deployments are managed with GitHub Workflows. +Deployments are managed with GitHub Workflows. The frontend is deployed using Vercel, and the API uses Fly.io. -To deploy cron jobs (i.e. monthly cleanup of old events), run `gcloud app deploy cron.yaml`. +More detailed instructions on setting up your own deployment are coming soon. ### 🔌 Browser extension diff --git a/api/src/main.rs b/api/src/main.rs index e2f46aa..da3e2c3 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -3,7 +3,10 @@ use std::{env, net::SocketAddr, sync::Arc}; use axum::{ error_handling::HandleErrorLayer, extract, - http::{HeaderValue, Method}, + http::{ + header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, + HeaderValue, Method, + }, routing::{get, patch, post}, BoxError, Router, Server, }; @@ -44,6 +47,8 @@ async fn main() { // CORS configuration let cors = CorsLayer::new() + .allow_credentials(true) + .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]) .allow_methods([Method::GET, Method::POST, Method::PATCH]) .allow_origin( if cfg!(debug_assertions) { @@ -56,9 +61,14 @@ async fn main() { ); // Rate limiting configuration (using tower_governor) - // From the docs: Allows bursts with up to eight requests and replenishes + // From the docs: Allows bursts with up to 20 requests and replenishes // one element after 500ms, based on peer IP. - let governor_config = Box::new(GovernorConfigBuilder::default().finish().unwrap()); + let governor_config = Box::new( + GovernorConfigBuilder::default() + .burst_size(20) + .finish() + .unwrap(), + ); let rate_limit = ServiceBuilder::new() // Handle errors from governor and convert into HTTP responses .layer(HandleErrorLayer::new(|e: BoxError| async move { diff --git a/api/src/routes/person.rs b/api/src/routes/person.rs index 974a519..2ea4e82 100644 --- a/api/src/routes/person.rs +++ b/api/src/routes/person.rs @@ -38,7 +38,18 @@ pub async fn get_people( .map_err(ApiError::AdaptorError)?; match people { - Some(people) => Ok(Json(people.into_iter().map(|p| p.into()).collect())), + Some(people) => Ok(Json( + people + .into_iter() + .filter_map(|p| { + if !p.availability.is_empty() { + Some(p.into()) + } else { + None + } + }) + .collect(), + )), None => Err(ApiError::NotFound), } } diff --git a/dispatch.yaml b/dispatch.yaml deleted file mode 100644 index 3b94851..0000000 --- a/dispatch.yaml +++ /dev/null @@ -1,5 +0,0 @@ -dispatch: - - url: "api.crab.fit/*" - service: api - - url: "crab.fit/*" - service: default diff --git a/frontend/.env.local b/frontend/.env.local new file mode 100644 index 0000000..ee43468 --- /dev/null +++ b/frontend/.env.local @@ -0,0 +1,5 @@ +NEXT_PUBLIC_API_URL="http://127.0.0.1:3000" + +# Google auth for calendar syncing, feature will be disabled if these aren't set +# NEXT_PUBLIC_GOOGLE_CLIENT_ID="" +# NEXT_PUBLIC_GOOGLE_API_KEY="" diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js deleted file mode 100644 index 0dfd57e..0000000 --- a/frontend/.eslintrc.js +++ /dev/null @@ -1,73 +0,0 @@ -/* eslint-env node */ -module.exports = { - 'settings': { - 'react': { - 'version': 'detect' - } - }, - 'env': { - 'browser': true, - 'es2021': true - }, - 'globals': { - 'process': true, - 'require': true, - 'gtag': true, - }, - 'extends': [ - 'eslint:recommended', - 'plugin:react/recommended' - ], - 'parserOptions': { - 'ecmaFeatures': { - 'jsx': true - }, - 'ecmaVersion': 12, - 'sourceType': 'module' - }, - 'plugins': [ - 'react' - ], - 'rules': { - 'react/display-name': 'off', - 'react/prop-types': 'off', - 'react/no-unescaped-entities': 'off', - 'react/react-in-jsx-scope': 'off', - 'eqeqeq': 2, - 'no-return-await': 1, - 'no-var': 2, - 'prefer-const': 1, - 'yoda': 2, - 'no-trailing-spaces': 1, - 'eol-last': [1, 'always'], - 'no-unused-vars': [ - 1, - { - 'args': 'all', - 'argsIgnorePattern': '^_', - 'ignoreRestSiblings': true - } - ], - 'indent': [ - 'error', - 2 - ], - 'linebreak-style': [ - 'error', - 'unix' - ], - 'quotes': [ - 'error', - 'single' - ], - 'semi': [ - 'error', - 'never' - ], - 'arrow-parens': [ - 'error', - 'as-needed' - ], - 'jsx-quotes': [1, 'prefer-double'], - } -} diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..64e2189 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,32 @@ +{ + "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "simple-import-sort"], + "rules": { + "react/no-unescaped-entities": "off", + "simple-import-sort/imports": "warn", + "@next/next/no-img-element": "off", + "react/display-name": "off", + "react-hooks/exhaustive-deps": "off", + "space-infix-ops": "warn", + "comma-spacing": "warn", + "react-hooks/rules-of-hooks": "off" + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "rules": { + "simple-import-sort/imports": [ + "warn", + { + "groups": [ + ["^react", "^next", "^@", "^[a-z]"], + ["^/src/"], + ["^./", "^.", "^../"] + ] + } + ] + } + } + ] +} diff --git a/frontend/.gcloudignore b/frontend/.gcloudignore deleted file mode 100644 index f970c16..0000000 --- a/frontend/.gcloudignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules -.DS_Store -.git -.gitignore -.gcloudignore -src -public -.eslintrc.js -yarn.lock -package.json diff --git a/frontend/.gitignore b/frontend/.gitignore index 8b79af8..24927b9 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,8 +1,10 @@ node_modules dist -build -dev-dist +.next -npm-debug.log* yarn-debug.log* yarn-error.log* +tsconfig.tsbuildinfo + +.env +.vercel diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 0000000..25fa621 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/frontend/app.yaml b/frontend/app.yaml deleted file mode 100644 index b3ee472..0000000 --- a/frontend/app.yaml +++ /dev/null @@ -1,15 +0,0 @@ -runtime: nodejs16 -handlers: -# Serve all static files with url ending with a file extension -- url: /(.*\..+)$ - static_files: dist/\1 - upload: (.*\..+)$ - secure: always - redirect_http_response_code: 301 - -# Catch all handler to index.html -- url: /.* - static_files: dist/index.html - upload: dist/index.html - secure: always - redirect_http_response_code: 301 diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index b0f99ac..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Crab Fit - - - - - - -
- - - - - diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json deleted file mode 100644 index 064fef1..0000000 --- a/frontend/jsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": "./", - "paths": { - "/*": ["./*"] - } - }, - "exclude": [ - "**/node_modules/*", - "**/dist/*", - "**/.git/*" - ] -} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/frontend/package.json b/frontend/package.json index a7482ba..8a155e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,51 +1,51 @@ { "name": "crabfit-frontend", - "version": "1.0.0", + "version": "2.0.0", "private": true, "license": "GPL-3.0-only", "scripts": { - "dev": "vite", - "build": "vite build", - "lint": "eslint --ext .js,.jsx ./src" + "dev": "next dev --port 1234", + "build": "next build", + "start": "next start", + "lint": "next lint" }, "dependencies": { - "@azure/msal-browser": "^2.28.1", - "@microsoft/microsoft-graph-client": "^3.0.2", - "dayjs": "^1.11.5", - "gapi-script": "^1.2.0", - "goober": "^2.1.10", + "@azure/msal-browser": "^2.37.1", + "@giraugh/tools": "^1.6.0", + "@js-temporal/polyfill": "^0.4.4", + "@microsoft/microsoft-graph-client": "^3.0.5", + "@vercel/analytics": "^1.0.1", + "accept-language": "^3.0.18", + "chroma.ts": "^1.0.10", "hue-map": "^1.0.0", - "i18next": "^21.9.0", - "i18next-browser-languagedetector": "^6.1.5", - "i18next-http-backend": "^1.4.1", - "lucide-react": "^0.84.0", + "i18next": "^22.5.1", + "i18next-browser-languagedetector": "^7.0.2", + "i18next-http-backend": "^2.2.1", + "i18next-resources-to-backend": "^1.1.4", + "lucide-react": "^0.241.0", + "next": "^13.4.4", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-hook-form": "^7.34.1", - "react-i18next": "^11.18.4", - "react-router-dom": "^6.3.0", - "workbox-background-sync": "^6.5.4", - "workbox-broadcast-update": "^6.5.4", - "workbox-cacheable-response": "^6.5.4", - "workbox-core": "^6.5.4", - "workbox-expiration": "^6.5.4", - "workbox-google-analytics": "^6.5.4", - "workbox-navigation-preload": "^6.5.4", - "workbox-precaching": "^6.5.4", - "workbox-range-requests": "^6.5.4", - "workbox-routing": "^6.5.4", - "workbox-strategies": "^6.5.4", - "workbox-streams": "^6.5.4", - "workbox-window": "^6.5.4", - "zustand": "^4.0.0" + "react-hook-form": "^7.44.3", + "react-i18next": "^12.3.1", + "zod": "^3.21.4", + "zustand": "^4.3.8" }, "devDependencies": { - "@vitejs/plugin-react": "^2.0.1", - "eslint": "^8.22.0", - "eslint-plugin-react": "^7.30.1", - "vite": "^3.0.7", - "vite-plugin-pwa": "^0.12.3", - "workbox-webpack-plugin": "^6.5.4" + "@types/gapi": "^0.0.44", + "@types/gapi.calendar": "^3.0.6", + "@types/google.accounts": "^0.0.7", + "@types/node": "^20.2.5", + "@types/react": "^18.2.9", + "@types/react-dom": "^18.2.4", + "@typescript-eslint/eslint-plugin": "^5.59.9", + "@typescript-eslint/parser": "^5.59.9", + "eslint": "^8.42.0", + "eslint-config-next": "^13.4.4", + "eslint-plugin-simple-import-sort": "^10.0.0", + "sass": "^1.63.2", + "typescript": "^5.1.3", + "typescript-plugin-css-modules": "^5.0.1" }, "browserslist": { "production": [ diff --git a/frontend/public/fonts/karla-italic-variable.ttf b/frontend/public/fonts/karla-italic-variable.ttf deleted file mode 100644 index aecc468..0000000 Binary files a/frontend/public/fonts/karla-italic-variable.ttf and /dev/null differ diff --git a/frontend/public/fonts/karla-variable.ttf b/frontend/public/fonts/karla-variable.ttf deleted file mode 100644 index 172b500..0000000 Binary files a/frontend/public/fonts/karla-variable.ttf and /dev/null differ diff --git a/frontend/public/fonts/molot.woff b/frontend/public/fonts/molot.woff deleted file mode 100644 index a73dbb1..0000000 Binary files a/frontend/public/fonts/molot.woff and /dev/null differ diff --git a/frontend/public/fonts/samuraibob.woff b/frontend/public/fonts/samuraibob.woff deleted file mode 100644 index d33d9a2..0000000 Binary files a/frontend/public/fonts/samuraibob.woff and /dev/null differ diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 6d547b2..a1de5a7 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,67 +1,19 @@ -/* eslint-disable no-restricted-globals */ +// TODO: This is temporary, as I've made the decision to move away +// from a PWA, so must remove all existing service workers -import { clientsClaim, skipWaiting } from 'workbox-core' -import { ExpirationPlugin } from 'workbox-expiration' -import { precacheAndRoute, createHandlerBoundToURL, cleanupOutdatedCaches } from 'workbox-precaching' -import { registerRoute } from 'workbox-routing' -import { StaleWhileRevalidate, NetworkFirst } from 'workbox-strategies' +self.addEventListener("install", () => { + self.skipWaiting() +}) -skipWaiting() -clientsClaim() - -// Injection point -precacheAndRoute(self.__WB_MANIFEST) - -cleanupOutdatedCaches() - -const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$') -registerRoute( - // Return false to exempt requests from being fulfilled by index.html. - ({ request, url }) => { - // If this isn't a navigation, skip. - if (request.mode !== 'navigate') { - return false - } // If this is a URL that starts with /_, skip. - - if (url.pathname.startsWith('/_')) { - return false - } // If this looks like a URL for a resource, because it contains // a file extension, skip. - - if (url.pathname.match(fileExtensionRegexp)) { - return false - } // Return true to signal that we want to use the handler. - - return true - }, - createHandlerBoundToURL('index.html') -) - -registerRoute( - // Add in any other file extensions or routing criteria as needed. - ({ url }) => url.origin === self.location.origin && ( - url.pathname.endsWith('.png') - || url.pathname.endsWith('.svg') - || url.pathname.endsWith('.jpg') - || url.pathname.endsWith('.jpeg') - || url.pathname.endsWith('.ico') - || url.pathname.endsWith('.ttf') - || url.pathname.endsWith('.woff') - || url.pathname.endsWith('.woff2') - ), // Customize this strategy as needed, e.g., by changing to CacheFirst. - new StaleWhileRevalidate({ - cacheName: 'res', - plugins: [ - // Ensure that once this runtime cache reaches a maximum size the - // least-recently used images are removed. - new ExpirationPlugin({ maxEntries: 50 }), - ], - }) -) - -registerRoute( - // Add in any other file extensions or routing criteria as needed. - ({ url }) => url.origin === self.location.origin && url.pathname.includes('i18n'), - new NetworkFirst({ - cacheName: 'i18n', - }) -) +self.addEventListener("activate", () => { + self.registration + .unregister() + .then(() => self.clients.matchAll()) + .then((clients) => { + clients.forEach((client) => { + if (client.url && "navigate" in client) { + client.navigate(client.url) + } + }) + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx deleted file mode 100644 index 7491131..0000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useState, useEffect, useCallback, Suspense } from 'react' -import { Route, Routes } from 'react-router-dom' - -import * as Pages from '/src/pages' -import { Settings, Loading, Egg, TranslateDialog } from '/src/components' - -import { useSettingsStore, useTranslateStore } from '/src/stores' - -const EGG_PATTERN = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'] - -const App = () => { - const [eggCount, setEggCount] = useState(0) - const [eggVisible, setEggVisible] = useState(false) - const [eggKey, setEggKey] = useState(0) - - const languageSupported = useTranslateStore(state => state.navigatorSupported) - const translateDialogDismissed = useTranslateStore(state => state.translateDialogDismissed) - - const eggHandler = useCallback(e => { - if (EGG_PATTERN.indexOf(e.key) < 0 || e.key !== EGG_PATTERN[eggCount]) return setEggCount(0) - setEggCount(eggCount+1) - if (EGG_PATTERN.length === eggCount+1) { - setEggKey(eggKey+1) - setEggCount(0) - setEggVisible(true) - } - }, [eggCount, eggKey]) - - useEffect(() => { - document.addEventListener('keyup', eggHandler, false) - return () => document.removeEventListener('keyup', eggHandler, false) - }, [eggHandler]) - - // Use user theme preference - const theme = useSettingsStore(state => state.theme) - useEffect(() => { - document.body.classList.toggle('light', theme === 'Light') - document.body.classList.toggle('dark', theme === 'Dark') - }, [theme]) - - return ( - <> - {!languageSupported && !translateDialogDismissed && } - - }> - - - - } /> - } /> - } /> - } /> - } /> - - - - {eggVisible && setEggVisible(false)} />} - - ) -} - -export default App diff --git a/frontend/src/app/[id]/EventAvailabilities.tsx b/frontend/src/app/[id]/EventAvailabilities.tsx new file mode 100644 index 0000000..8450029 --- /dev/null +++ b/frontend/src/app/[id]/EventAvailabilities.tsx @@ -0,0 +1,159 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { Trans } from 'react-i18next/TransWithoutContext' + +import AvailabilityEditor from '/src/components/AvailabilityEditor/AvailabilityEditor' +import AvailabilityViewer from '/src/components/AvailabilityViewer/AvailabilityViewer' +import Content from '/src/components/Content/Content' +import Login from '/src/components/Login/Login' +import Section from '/src/components/Section/Section' +import SelectField from '/src/components/SelectField/SelectField' +import { EventResponse, getPeople, PersonResponse, updatePerson } from '/src/config/api' +import { useTranslation } from '/src/i18n/client' +import timezones from '/src/res/timezones.json' +import useRecentsStore from '/src/stores/recentsStore' +import { expandTimes, makeClass } from '/src/utils' + +import styles from './page.module.scss' + +interface EventAvailabilitiesProps { + event: EventResponse + people: PersonResponse[] +} + +const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => { + const { t, i18n } = useTranslation('event') + + const [people, setPeople] = useState(data.people) + const expandedTimes = useMemo(() => expandTimes(event.times), [event.times]) + + const [user, setUser] = useState() + const [password, setPassword] = useState() + + const [tab, setTab] = useState<'group' | 'you'>('group') + const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone) + + // Add this event to recents + const addRecent = useRecentsStore(state => state.addRecent) + useEffect(() => { + addRecent({ + id: event.id, + name: event.name, + created_at: event.created_at, + }) + }, [addRecent]) + + // Refetch availabilities + useEffect(() => { + if (tab === 'group') { + getPeople(event.id) + .then(setPeople) + .catch(console.warn) + } + }, [tab]) + + return <> +
+ + { + setUser(u) + setPassword(p) + setTab(u ? 'you' : 'group') + }} /> + + setTimezone(event.currentTarget.value)} + options={timezones} + /> + + {event?.timezone && event.timezone !== timezone &&

+ + {/* eslint-disable-next-line */} + {/* @ts-ignore */} + _{{timezone: event.timezone}} + _ { + e.preventDefault() + setTimezone(event.timezone) + }}>__ + +

} + + {(( + Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone + && (event?.timezone && event.timezone !== Intl.DateTimeFormat().resolvedOptions().timeZone) + ) || ( + event?.timezone === undefined + && Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone + )) && ( +

+ + {/* eslint-disable-next-line */} + {/* @ts-ignore */} + _{{timezone: Intl.DateTimeFormat().resolvedOptions().timeZone}} + _ { + e.preventDefault() + setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone) + }}>__ + +

+ )} +
+
+ + +
+ + +
+
+ + {tab === 'group' ? : user && { + const oldAvailability = [...user.availability] + setUser({ ...user, availability }) + updatePerson(event.id, user.name, { availability }, password) + .catch(e => { + console.warn(e) + setUser({ ...user, availability: oldAvailability }) + }) + }} + />} + +} + +export default EventAvailabilities diff --git a/frontend/src/app/[id]/layout.tsx b/frontend/src/app/[id]/layout.tsx new file mode 100644 index 0000000..f07140f --- /dev/null +++ b/frontend/src/app/[id]/layout.tsx @@ -0,0 +1,15 @@ +import Content from '/src/components/Content/Content' +import Footer from '/src/components/Footer/Footer' +import Header from '/src/components/Header/Header' + +const Layout = async ({ children }: { children: React.ReactNode }) => <> + +
+ + + {children} + +