Merge pull request #205 from GRA0007/chore/ci

CI and frontend upgrade
This commit is contained in:
Benjamin Grant 2022-08-19 15:59:48 +10:00 committed by GitHub
commit 0dca1a5eda
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
109 changed files with 4563 additions and 12631 deletions

30
.github/workflows/deploy_backend.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: Deploy Backend
on:
push:
branches: ['main']
paths: ['crabfit-backend/**']
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: crabfit-backend
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v3
- 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: crabfit-backend
version: v1

37
.github/workflows/deploy_frontend.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Deploy Frontend
on:
push:
branches: ['main']
paths: ['crabfit-frontend/**']
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: crabfit-frontend
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 17
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: crabfit-frontend
version: v1

View file

@ -1,2 +1,7 @@
runtime: nodejs10
runtime: nodejs16
service: api
handlers:
- url: /.*
secure: always
redirect_http_response_code: 301
script: auto

View file

@ -21,7 +21,7 @@ const taskRemoveOrphans = require('./routes/taskRemoveOrphans');
const app = express();
const port = 8080;
const corsOptions = {
origin: process.env.NODE_ENV === 'production' ? 'https://crab.fit' : 'http://localhost:3000',
origin: process.env.NODE_ENV === 'production' ? 'https://crab.fit' : 'http://localhost:5173',
};
const datastore = new Datastore({

View file

@ -0,0 +1,73 @@
/* 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'],
}
}

View file

@ -0,0 +1,10 @@
node_modules
.DS_Store
.git
.gitignore
.gcloudignore
src
public
.eslintrc.js
yarn.lock
package.json

View file

@ -1,22 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
node_modules
dist
build
npm-debug.log*
yarn-debug.log*

View file

@ -1,70 +0,0 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `yarn build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

View file

@ -1,15 +1,15 @@
runtime: nodejs12
runtime: nodejs16
handlers:
# Serve all static files with url ending with a file extension
- url: /(.*\..+)$
static_files: \1
static_files: dist/\1
upload: (.*\..+)$
secure: always
redirect_http_response_code: 301
# Catch all handler to index.html
- url: /.*
static_files: index.html
upload: index.html
static_files: dist/index.html
upload: dist/index.html
secure: always
redirect_http_response_code: 301

View file

@ -1,23 +0,0 @@
#!/usr/bin/env bash
yarn build
cd build
cat > app.yaml << EOF
runtime: nodejs12
handlers:
- url: /(.*\..+)$
static_files: \1
upload: (.*\..+)$
secure: always
redirect_http_response_code: 301
- url: /.*
static_files: index.html
upload: index.html
secure: always
redirect_http_response_code: 301
EOF
gcloud app deploy --project=crabfit --version=v1

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="icon" href="%PUBLIC_URL%/favicon.ico">
<link rel="icon" href="favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="theme-color" content="#F79E00">
<meta
@ -16,14 +16,14 @@
<meta name="monetization" content="$ilp.uphold.com/HjDULeBk9JnH">
<!--V1.0--><meta http-equiv="origin-trial" content="ApibM5tjM3kUQQ2EQrkRcdTdWJRGAEKaUFzNhFmx+Of5H/cRyWuecMxs//Bikgo3WMSKs5kntElcM+U8kDy9cAEAAABOeyJvcmlnaW4iOiJodHRwczovL2NyYWIuZml0OjQ0MyIsImZlYXR1cmUiOiJEaWdpdGFsR29vZHMiLCJleHBpcnkiOjE2Mzk1MjYzOTl9">
<!--V2.0--><meta http-equiv="origin-trial" content="AiZrT13ogLT63ah6Abb/aG6KhscY5PTf1HNTI2rcqpiFeqiQ3s6+xd+qCe3c+bp3udvvzh5QMHF4GqPAlG110gcAAABQeyJvcmlnaW4iOiJodHRwczovL2NyYWIuZml0OjQ0MyIsImZlYXR1cmUiOiJEaWdpdGFsR29vZHNWMiIsImV4cGlyeSI6MTY0Nzk5MzU5OX0=">
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="apple-touch-icon" href="logo192.png">
<link rel="manifest" href="manifest.json">
<meta property="og:title" content="Crab Fit">
<meta property="og:description" content="Enter your availability to find a time that works for everyone!">
<meta property="og:url" content="https://crab.fit">
<link rel="stylesheet" href="%PUBLIC_URL%/index.css">
<link rel="stylesheet" href="index.css">
<title>Crab Fit</title>
@ -38,9 +38,14 @@
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.jsx"></script>
<noscript>
<div style="font-family: Karla, sans-serif; text-align: center; margin: 20vh 0; display: block;"><h1>🦀 Crab Fit doesn't work without Javascript 🏋️</h1><p>Enable Javascript or try a different browser.</p></div>
<div style="font-family: Karla, sans-serif; text-align: center; margin: 20vh 0; display: block;">
<h1>🦀 Crab Fit doesn't work without Javascript 🏋️</h1>
<p>Enable Javascript or try a different browser.</p>
</div>
</noscript>
<div id="root"></div>
</body>
</html>

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"/*": ["./*"]
}
},
"exclude": [
"**/node_modules/*",
"**/dist/*",
"**/.git/*"
]
}

View file

@ -3,58 +3,47 @@
"version": "1.0.0",
"private": true,
"license": "GPL-3.0-only",
"dependencies": {
"@azure/msal-browser": "^2.14.2",
"@emotion/react": "^11.1.5",
"@emotion/styled": "^11.1.5",
"@microsoft/microsoft-graph-client": "^2.2.1",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.31",
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.1",
"axios": "^0.21.1",
"dayjs": "^1.10.4",
"gapi-script": "^1.2.0",
"i18next": "^20.2.4",
"i18next-browser-languagedetector": "^6.1.1",
"i18next-http-backend": "^1.2.4",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.8.1",
"react-i18next": "^11.8.15",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"typescript": "^4.2.2",
"web-vitals": "^1.0.1",
"workbox-background-sync": "^5.1.3",
"workbox-broadcast-update": "^5.1.3",
"workbox-cacheable-response": "^5.1.3",
"workbox-core": "^5.1.3",
"workbox-expiration": "^5.1.3",
"workbox-google-analytics": "^5.1.3",
"workbox-navigation-preload": "^5.1.3",
"workbox-precaching": "^5.1.3",
"workbox-range-requests": "^5.1.3",
"workbox-routing": "^5.1.3",
"workbox-strategies": "^5.1.3",
"workbox-streams": "^5.1.3",
"workbox-window": "^6.1.5",
"zustand": "^3.3.2"
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
"dev": "vite",
"build": "vite build",
"lint": "eslint --ext .js,.jsx ./src"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"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",
"i18next": "^21.9.0",
"i18next-browser-languagedetector": "^6.1.5",
"i18next-http-backend": "^1.4.1",
"lucide-react": "^0.84.0",
"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"
},
"devDependencies": {
"@vitejs/plugin-react": "^2.0.1",
"eslint": "^8.22.0",
"eslint-plugin-react": "^7.30.1",
"vite": "^3.0.7",
"workbox-webpack-plugin": "^6.5.4"
},
"browserslist": {
"production": [
@ -67,9 +56,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"react-app-rewired": "^2.1.8",
"workbox-webpack-plugin": "^5.1.3"
}
}

View file

@ -1,7 +1,7 @@
@font-face {
font-family: Karla;
font-family: 'Karla';
src: url('fonts/karla-variable.ttf') format('truetype');
font-weight: 1 999;
font-weight: 200 800;
}
@font-face {
@ -20,22 +20,154 @@
font-style: normal;
}
:root {
color-scheme: light dark;
--primary: #F79E00;
/* LIGHT */
--background-light: #FFFFFF;
--text-light: #000000;
--shadow-light: #F48600;
--highlight-light: #F4BB60;
--secondary-light: var(--shadow-light);
--tertiary-light: var(--highlight-light);
--surface-light: #FEF2DD;
--error-light: #D32F2F;
--loading-light: #DDDDDD;
--font-weight-light: 600;
/* DARK */
--background-dark: #111111;
--text-dark: #DDDDDD;
--shadow-dark: #CC7313;
--highlight-dark: #F4BB60;
--secondary-dark: var(--highlight-dark);
--tertiary-dark: var(--shadow-dark);
--surface-dark: #30240F;
--error-dark: #E53935;
--loading-dark: #444444;
--font-weight-dark: 500;
/* Define light defaults */
--background: var(--background-light);
--text: var(--text-light);
--shadow: var(--shadow-light);
--highlight: var(--highlight-light);
--secondary: var(--secondary-light);
--tertiary: var(--tertiary-light);
--surface: var(--surface-light);
--error: var(--error-light);
--loading: var(--loading-light);
--font-weight: var(--font-weight-light);
}
@media (prefers-color-scheme: dark) {
:root {
--background: var(--background-dark);
--text: var(--text-dark);
--shadow: var(--shadow-dark);
--highlight: var(--highlight-dark);
--secondary: var(--secondary-dark);
--tertiary: var(--tertiary-dark);
--surface: var(--surface-dark);
--error: var(--error-dark);
--loading: var(--loading-dark);
--font-weight: var(--font-weight-dark);
}
}
html {
scroll-behavior: smooth;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body {
margin: 0;
font-family: 'Karla', sans-serif;
background: var(--background);
color: var(--text);
font-weight: var(--font-weight);
}
.light {
color-scheme: light;
--background: var(--background-light);
--text: var(--text-light);
--shadow: var(--shadow-light);
--highlight: var(--highlight-light);
--secondary: var(--secondary-light);
--tertiary: var(--tertiary-light);
--surface: var(--surface-light);
--error: var(--error-light);
--loading: var(--loading-light);
--font-weight: var(--font-weight-light);
}
@media not print {
.dark {
color-scheme: dark;
--background: var(--background-dark);
--text: var(--text-dark);
--shadow: var(--shadow-dark);
--highlight: var(--highlight-dark);
--secondary: var(--secondary-dark);
--tertiary: var(--tertiary-dark);
--surface: var(--surface-dark);
--error: var(--error-dark);
--loading: var(--loading-dark);
--font-weight: var(--font-weight-dark);
}
}
@media print {
#app, .light, .dark {
--background: white;
}
}
a {
color: var(--primary);
}
*::-webkit-scrollbar {
width: 16px;
height: 16px;
}
*::-webkit-scrollbar-track {
background: var(--surface);
}
*::-webkit-scrollbar-thumb {
border-radius: 100px;
border: 4px solid var(--surface);
width: 12px;
background: var(--tertiary);
}
*::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
*::-webkit-scrollbar-thumb:active {
background: var(--secondary);
}
/* IE 10+ */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
#root {
font-family: Karla, sans-serif;
#app {
text-align: center;
margin: 20vh auto;
font-size: 1.3em;
font-weight: 600;
}
#root::before {
#app::before {
content: '🦀';
font-size: 1.5em;
display: block;
padding: 20px;
}
#root::after {
#app::after {
display: block;
content: 'Crab Fit doesn\'t work in Internet Explorer. Please try using a modern browser.';
}

View file

@ -0,0 +1,85 @@
import { useState, useEffect, useCallback, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { Workbox } from 'workbox-window'
import * as Pages from '/src/pages'
import { Settings, Loading, Egg, UpdateDialog, TranslateDialog } from '/src/components'
import { useSettingsStore, useTranslateStore } from '/src/stores'
const EGG_PATTERN = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']
const wb = new Workbox('sw.js')
const App = () => {
const [eggCount, setEggCount] = useState(0)
const [eggVisible, setEggVisible] = useState(false)
const [eggKey, setEggKey] = useState(0)
const [updateAvailable, setUpdateAvailable] = useState(false)
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(() => {
// Register service worker
if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') {
wb.addEventListener('installed', event => {
if (event.isUpdate) {
setUpdateAvailable(true)
}
})
wb.register()
}
}, [])
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 && <TranslateDialog />}
<Suspense fallback={<Loading />}>
<Settings />
<Routes>
<Route path="/" element={<Pages.Home />} />
<Route path="/how-to" element={<Pages.Help />} />
<Route path="/privacy" element={<Pages.Privacy />} />
<Route path="/create" element={<Pages.Create />} />
<Route path="/:id" element={<Pages.Event />} />
</Routes>
</Suspense>
{updateAvailable && (
<Suspense fallback={<Loading />}>
<UpdateDialog onClose={() => setUpdateAvailable(false)} />
</Suspense>
)}
{eggVisible && <Egg eggKey={eggKey} onClose={() => setEggVisible(false)} />}
</>
)
}
export default App

View file

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View file

@ -1,178 +0,0 @@
import { useState, useEffect, useCallback, Suspense, lazy } from 'react';
import { BrowserRouter, Switch, Route } from 'react-router-dom';
import { ThemeProvider, Global } from '@emotion/react';
import { Workbox } from 'workbox-window';
import { Settings, Loading, Egg, UpdateDialog, TranslateDialog } from 'components';
import { useSettingsStore, useTranslateStore } from 'stores';
import theme from 'theme';
const EGG_PATTERN = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
const Home = lazy(() => import('pages/Home/Home'));
const Event = lazy(() => import('pages/Event/Event'));
const Create = lazy(() => import('pages/Create/Create'));
const Help = lazy(() => import('pages/Help/Help'));
const Privacy = lazy(() => import('pages/Privacy/Privacy'));
const wb = new Workbox('sw.js');
const App = () => {
const colortheme = useSettingsStore(state => state.theme);
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
const [isDark, setIsDark] = useState(darkQuery.matches);
const [offline, setOffline] = useState(!window.navigator.onLine);
const [eggCount, setEggCount] = useState(0);
const [eggVisible, setEggVisible] = useState(false);
const [eggKey, setEggKey] = useState(0);
const [updateAvailable, setUpdateAvailable] = useState(false);
const languageSupported = useTranslateStore(state => state.navigatorSupported);
const translateDialogDismissed = useTranslateStore(state => state.translateDialogDismissed);
const eggHandler = useCallback(
event => {
if (EGG_PATTERN.indexOf(event.key) < 0 || event.key !== EGG_PATTERN[eggCount]) {
setEggCount(0);
return;
}
setEggCount(eggCount+1);
if (EGG_PATTERN.length === eggCount+1) {
setEggKey(eggKey+1);
setEggCount(0);
setEggVisible(true);
}
},
[eggCount, eggKey]
);
darkQuery.addListener(e => colortheme === 'System' && setIsDark(e.matches));
useEffect(() => {
const onOffline = () => setOffline(true);
const onOnline = () => setOffline(false);
window.addEventListener('offline', onOffline, false);
window.addEventListener('online', onOnline, false);
return () => {
window.removeEventListener('offline', onOffline, false);
window.removeEventListener('online', onOnline, false);
};
}, []);
useEffect(() => {
// Register service worker
if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') {
wb.addEventListener('installed', event => {
if (event.isUpdate) {
setUpdateAvailable(true);
}
});
wb.register();
}
}, []);
useEffect(() => {
document.addEventListener('keyup', eggHandler, false);
return () => {
document.removeEventListener('keyup', eggHandler, false);
};
}, [eggHandler]);
useEffect(() => {
setIsDark(colortheme === 'System' ? darkQuery.matches : colortheme === 'Dark');
}, [colortheme, darkQuery.matches]);
return (
<BrowserRouter>
<ThemeProvider theme={theme[isDark ? 'dark' : 'light']}>
<Global
styles={theme => ({
html: {
scrollBehavior: 'smooth',
WebkitPrintColorAdjust: 'exact',
},
body: {
backgroundColor: theme.background,
color: theme.text,
fontFamily: `'Karla', sans-serif`,
fontWeight: theme.mode === 'dark' ? 500 : 600,
margin: 0,
},
a: {
color: theme.primary,
},
'*::-webkit-scrollbar': {
width: 16,
height: 16,
},
'*::-webkit-scrollbar-track': {
background: `${theme.primaryBackground}`,
},
'*::-webkit-scrollbar-thumb': {
borderRadius: 100,
border: `4px solid ${theme.primaryBackground}`,
width: 12,
background: `${theme.mode === 'light' ? theme.primaryLight : theme.primaryDark}AA`,
},
'*::-webkit-scrollbar-thumb:hover': {
background: `${theme.mode === 'light' ? theme.primaryLight : theme.primaryDark}CC`,
},
'*::-webkit-scrollbar-thumb:active': {
background: `${theme.mode === 'light' ? theme.primaryLight : theme.primaryDark}`,
},
})}
/>
{!languageSupported && !translateDialogDismissed && <TranslateDialog />}
<Suspense fallback={<Loading />}>
<Settings />
</Suspense>
<Switch>
<Route path="/" exact render={props => (
<Suspense fallback={<Loading />}>
<Home offline={offline} {...props} />
</Suspense>
)} />
<Route path="/how-to" exact render={props => (
<Suspense fallback={<Loading />}>
<Help {...props} />
</Suspense>
)} />
<Route path="/privacy" exact render={props => (
<Suspense fallback={<Loading />}>
<Privacy {...props} />
</Suspense>
)} />
<Route path="/create" exact render={props => (
<Suspense fallback={<Loading />}>
<Create offline={offline} {...props} />
</Suspense>
)} />
<Route path="/:id" exact render={props => (
<Suspense fallback={<Loading />}>
<Event offline={offline} {...props} />
</Suspense>
)} />
</Switch>
{updateAvailable && (
<Suspense fallback={<Loading />}>
<UpdateDialog onClose={() => setUpdateAvailable(false)} />
</Suspense>
)}
{eggVisible && <Egg eggKey={eggKey} onClose={() => setEggVisible(false)} />}
</ThemeProvider>
</BrowserRouter>
);
}
export default App;

View file

@ -1,12 +1,13 @@
import { useState, useRef, Fragment, Suspense, lazy } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocaleUpdateStore } from 'stores';
import dayjs from 'dayjs';
import localeData from 'dayjs/plugin/localeData';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween';
import dayjs_timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { useState, useRef, Fragment, Suspense, lazy } from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import localeData from 'dayjs/plugin/localeData'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import isBetween from 'dayjs/plugin/isBetween'
import dayjs_timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { useLocaleUpdateStore } from '/src/stores'
import {
Wrapper,
@ -21,20 +22,20 @@ import {
TimeLabel,
TimeSpace,
StyledMain,
} from 'components/AvailabilityViewer/availabilityViewerStyle';
import { Time } from './availabilityEditorStyle';
} from '/src/components/AvailabilityViewer/AvailabilityViewer.styles'
import { Time } from './AvailabilityEditor.styles'
import { _GoogleCalendar, _OutlookCalendar, Center } from 'components';
import { Loader } from '../Loading/loadingStyle';
import { _GoogleCalendar, _OutlookCalendar, Center } from '/src/components'
import { Loader } from '../Loading/Loading.styles'
const GoogleCalendar = lazy(() => _GoogleCalendar());
const OutlookCalendar = lazy(() => _OutlookCalendar());
const GoogleCalendar = lazy(() => _GoogleCalendar())
const OutlookCalendar = lazy(() => _OutlookCalendar())
dayjs.extend(localeData);
dayjs.extend(customParseFormat);
dayjs.extend(isBetween);
dayjs.extend(utc);
dayjs.extend(dayjs_timezone);
dayjs.extend(localeData)
dayjs.extend(customParseFormat)
dayjs.extend(isBetween)
dayjs.extend(utc)
dayjs.extend(dayjs_timezone)
const AvailabilityEditor = ({
times,
@ -44,25 +45,24 @@ const AvailabilityEditor = ({
isSpecificDates,
value = [],
onChange,
...props
}) => {
const { t } = useTranslation('event');
const locale = useLocaleUpdateStore(state => state.locale);
const { t } = useTranslation('event')
const locale = useLocaleUpdateStore(state => state.locale)
const [selectingTimes, _setSelectingTimes] = useState([]);
const staticSelectingTimes = useRef([]);
const [selectingTimes, _setSelectingTimes] = useState([])
const staticSelectingTimes = useRef([])
const setSelectingTimes = newTimes => {
staticSelectingTimes.current = newTimes;
_setSelectingTimes(newTimes);
};
staticSelectingTimes.current = newTimes
_setSelectingTimes(newTimes)
}
const startPos = useRef({});
const staticMode = useRef(null);
const [mode, _setMode] = useState(staticMode.current);
const startPos = useRef({})
const staticMode = useRef(null)
const [mode, _setMode] = useState(staticMode.current)
const setMode = newMode => {
staticMode.current = newMode;
_setMode(newMode);
};
staticMode.current = newMode
_setMode(newMode)
}
return (
<>
@ -109,8 +109,8 @@ const AvailabilityEditor = ({
)}
</TimeLabels>
{dates.map((date, x) => {
const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date);
const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1;
const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date)
const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1
return (
<Fragment key={x}>
<Date>
@ -118,55 +118,55 @@ const AvailabilityEditor = ({
<DayLabel>{parsedDate.format('ddd')}</DayLabel>
<Times
borderRight={last}
borderLeft={x === 0 || (parsedDate).diff(isSpecificDates ? dayjs(dates[x-1], 'DDMMYYYY') : dayjs().day(dates[x-1]), 'day') > 1}
$borderRight={last}
$borderLeft={x === 0 || (parsedDate).diff(isSpecificDates ? dayjs(dates[x-1], 'DDMMYYYY') : dayjs().day(dates[x-1]), 'day') > 1}
>
{timeLabels.map((timeLabel, y) => {
if (!timeLabel.time) return null;
if (!timeLabel.time) return null
if (!times.includes(`${timeLabel.time}-${date}`)) {
return (
<TimeSpace key={x+y} className='timespace' title={t('event:greyed_times')} />
);
<TimeSpace key={x+y} className="timespace" title={t('event:greyed_times')} />
)
}
const time = `${timeLabel.time}-${date}`;
const time = `${timeLabel.time}-${date}`
return (
<Time
key={x+y}
time={time}
$time={time}
className="time"
selected={value.includes(time)}
selecting={selectingTimes.includes(time)}
mode={mode}
onPointerDown={(e) => {
e.preventDefault();
startPos.current = {x, y};
setMode(value.includes(time) ? 'remove' : 'add');
setSelectingTimes([time]);
e.currentTarget.releasePointerCapture(e.pointerId);
$selected={value.includes(time)}
$selecting={selectingTimes.includes(time)}
$mode={mode}
onPointerDown={e => {
e.preventDefault()
startPos.current = {x, y}
setMode(value.includes(time) ? 'remove' : 'add')
setSelectingTimes([time])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') {
onChange([...value, ...staticSelectingTimes.current]);
onChange([...value, ...staticSelectingTimes.current])
} else if (staticMode.current === 'remove') {
onChange(value.filter(t => !staticSelectingTimes.current.includes(t)));
onChange(value.filter(t => !staticSelectingTimes.current.includes(t)))
}
setMode(null);
}, { once: true });
setMode(null)
}, { once: true })
}}
onPointerEnter={() => {
if (staticMode.current) {
let found = [];
const found = []
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) {
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) {
found.push({y: cy, x: cx});
found.push({y: cy, x: cx})
}
}
setSelectingTimes(found.filter(d => timeLabels[d.y].time?.length === 4).map(d => `${timeLabels[d.y].time}-${dates[d.x]}`));
setSelectingTimes(found.filter(d => timeLabels[d.y].time?.length === 4).map(d => `${timeLabels[d.y].time}-${dates[d.x]}`))
}
}}
/>
);
)
})}
</Times>
</Date>
@ -174,13 +174,13 @@ const AvailabilityEditor = ({
<Spacer />
)}
</Fragment>
);
)
})}
</Container>
</ScrollWrapper>
</Wrapper>
</>
);
};
)
}
export default AvailabilityEditor;
export default AvailabilityEditor

View file

@ -0,0 +1,24 @@
import { styled } from 'goober'
export const Time = styled('div')`
height: 10px;
touch-action: none;
transition: background-color .1s;
${props => props.$time.slice(2, 4) === '00' && `
border-top: 2px solid var(--text);
`}
${props => props.$time.slice(2, 4) !== '00' && `
border-top: 2px solid transparent;
`}
${props => props.$time.slice(2, 4) === '30' && `
border-top: 2px dotted var(--text);
`}
${props => (props.$selected || (props.$mode === 'add' && props.$selecting)) && `
background-color: var(--primary);
`};
${props => props.$mode === 'remove' && props.$selecting && `
background-color: var(--background);
`};
`

View file

@ -1,24 +0,0 @@
import styled from '@emotion/styled';
export const Time = styled.div`
height: 10px;
touch-action: none;
transition: background-color .1s;
${props => props.time.slice(2, 4) === '00' && `
border-top: 2px solid ${props.theme.text};
`}
${props => props.time.slice(2, 4) !== '00' && `
border-top: 2px solid transparent;
`}
${props => props.time.slice(2, 4) === '30' && `
border-top: 2px dotted ${props.theme.text};
`}
${props => (props.selected || (props.mode === 'add' && props.selecting)) && `
background-color: ${props.theme.primary};
`};
${props => props.mode === 'remove' && props.selecting && `
background-color: ${props.theme.background};
`};
`;

View file

@ -1,13 +1,13 @@
import { useState, useEffect, useRef, useMemo, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import localeData from 'dayjs/plugin/localeData';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useState, useEffect, useRef, useMemo, Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import localeData from 'dayjs/plugin/localeData'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useSettingsStore, useLocaleUpdateStore } from 'stores';
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
import { Legend } from 'components';
import { Legend } from '/src/components'
import {
Wrapper,
ScrollWrapper,
@ -30,13 +30,13 @@ import {
Person,
StyledMain,
Info,
} from './availabilityViewerStyle';
} from './AvailabilityViewer.styles'
import locales from 'res/dayjs_locales';
import locales from '/src/i18n/locales'
dayjs.extend(localeData);
dayjs.extend(customParseFormat);
dayjs.extend(relativeTime);
dayjs.extend(localeData)
dayjs.extend(customParseFormat)
dayjs.extend(relativeTime)
const AvailabilityViewer = ({
times,
@ -46,25 +46,24 @@ const AvailabilityViewer = ({
people = [],
min = 0,
max = 0,
...props
}) => {
const [tooltip, setTooltip] = useState(null);
const timeFormat = useSettingsStore(state => state.timeFormat);
const highlight = useSettingsStore(state => state.highlight);
const [filteredPeople, setFilteredPeople] = useState([]);
const [touched, setTouched] = useState(false);
const [tempFocus, setTempFocus] = useState(null);
const [focusCount, setFocusCount] = useState(null);
const [tooltip, setTooltip] = useState(null)
const timeFormat = useSettingsStore(state => state.timeFormat)
const highlight = useSettingsStore(state => state.highlight)
const [filteredPeople, setFilteredPeople] = useState([])
const [touched, setTouched] = useState(false)
const [tempFocus, setTempFocus] = useState(null)
const [focusCount, setFocusCount] = useState(null)
const { t } = useTranslation('event');
const locale = useLocaleUpdateStore(state => state.locale);
const { t } = useTranslation('event')
const locale = useLocaleUpdateStore(state => state.locale)
const wrapper = useRef();
const wrapper = useRef()
useEffect(() => {
setFilteredPeople(people.map(p => p.name));
setTouched(people.length <= 1);
}, [people]);
setFilteredPeople(people.map(p => p.name))
setTouched(people.length <= 1)
}, [people])
const heatmap = useMemo(() => (
<Container>
@ -76,8 +75,8 @@ const AvailabilityViewer = ({
)}
</TimeLabels>
{dates.map((date, i) => {
const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date);
const last = dates.length === i+1 || (isSpecificDates ? dayjs(dates[i+1], 'DDMMYYYY') : dayjs().day(dates[i+1])).diff(parsedDate, 'day') > 1;
const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date)
const last = dates.length === i+1 || (isSpecificDates ? dayjs(dates[i+1], 'DDMMYYYY') : dayjs().day(dates[i+1])).diff(parsedDate, 'day') > 1
return (
<Fragment key={i}>
<Date>
@ -85,48 +84,48 @@ const AvailabilityViewer = ({
<DayLabel>{parsedDate.format('ddd')}</DayLabel>
<Times
borderRight={last}
borderLeft={i === 0 || (parsedDate).diff(isSpecificDates ? dayjs(dates[i-1], 'DDMMYYYY') : dayjs().day(dates[i-1]), 'day') > 1}
$borderRight={last}
$borderLeft={i === 0 || (parsedDate).diff(isSpecificDates ? dayjs(dates[i-1], 'DDMMYYYY') : dayjs().day(dates[i-1]), 'day') > 1}
>
{timeLabels.map((timeLabel, i) => {
if (!timeLabel.time) return null;
if (!timeLabel.time) return null
if (!times.includes(`${timeLabel.time}-${date}`)) {
return (
<TimeSpace className='timespace' key={i} title={t('event:greyed_times')} />
);
<TimeSpace className="timespace" key={i} title={t('event:greyed_times')} />
)
}
const time = `${timeLabel.time}-${date}`;
const time = `${timeLabel.time}-${date}`
const peopleHere = tempFocus !== null
? people.filter(person => person.availability.includes(time) && tempFocus === person.name).map(person => person.name)
: people.filter(person => person.availability.includes(time) && filteredPeople.includes(person.name)).map(person => person.name);
: people.filter(person => person.availability.includes(time) && filteredPeople.includes(person.name)).map(person => person.name)
return (
<Time
key={i}
time={time}
$time={time}
className="time"
peopleCount={focusCount !== null && focusCount !== peopleHere.length ? 0 : peopleHere.length}
$peopleCount={focusCount !== null && focusCount !== peopleHere.length ? 0 : peopleHere.length}
aria-label={peopleHere.join(', ')}
maxPeople={tempFocus !== null ? 1 : Math.min(max, filteredPeople.length)}
minPeople={tempFocus !== null ? 0 : Math.min(min, filteredPeople.length)}
highlight={highlight}
onMouseEnter={(e) => {
const cellBox = e.currentTarget.getBoundingClientRect();
const wrapperBox = wrapper?.current?.getBoundingClientRect() ?? { x: 0, y: 0 };
const timeText = timeFormat === '12h' ? `h${locales[locale].separator ?? ':'}mma` : `HH${locales[locale].separator ?? ':'}mm`;
$maxPeople={tempFocus !== null ? 1 : Math.min(max, filteredPeople.length)}
$minPeople={tempFocus !== null ? 0 : Math.min(min, filteredPeople.length)}
$highlight={highlight}
onMouseEnter={e => {
const cellBox = e.currentTarget.getBoundingClientRect()
const wrapperBox = wrapper?.current?.getBoundingClientRect() ?? { x: 0, y: 0 }
const timeText = timeFormat === '12h' ? `h${locales[locale].separator ?? ':'}mma` : `HH${locales[locale].separator ?? ':'}mm`
setTooltip({
x: Math.round(cellBox.x-wrapperBox.x + cellBox.width/2),
y: Math.round(cellBox.y-wrapperBox.y + cellBox.height)+6,
available: `${peopleHere.length} / ${people.length} ${t('event:available')}`,
date: parsedDate.hour(time.slice(0, 2)).minute(time.slice(2, 4)).format(isSpecificDates ? `${timeText} ddd, D MMM YYYY` : `${timeText} ddd`),
people: peopleHere,
});
})
}}
onMouseLeave={() => {
setTooltip(null);
setTooltip(null)
}}
/>
);
)
})}
</Times>
</Date>
@ -134,7 +133,7 @@ const AvailabilityViewer = ({
<Spacer />
)}
</Fragment>
);
)
})}
</Container>
), [
@ -152,7 +151,7 @@ const AvailabilityViewer = ({
timeFormat,
timeLabels,
times,
]);
])
return (
<>
@ -171,18 +170,18 @@ const AvailabilityViewer = ({
{people.map((person, i) =>
<Person
key={i}
filtered={filteredPeople.includes(person.name)}
$filtered={filteredPeople.includes(person.name)}
onClick={() => {
setTempFocus(null);
setTempFocus(null)
if (filteredPeople.includes(person.name)) {
if (!touched) {
setTouched(true);
setFilteredPeople([person.name]);
setTouched(true)
setFilteredPeople([person.name])
} else {
setFilteredPeople(filteredPeople.filter(n => n !== person.name));
setFilteredPeople(filteredPeople.filter(n => n !== person.name))
}
} else {
setFilteredPeople([...filteredPeople, person.name]);
setFilteredPeople([...filteredPeople, person.name])
}
}}
onMouseOver={() => setTempFocus(person.name)}
@ -201,8 +200,8 @@ const AvailabilityViewer = ({
{tooltip && (
<Tooltip
x={tooltip.x}
y={tooltip.y}
$x={tooltip.x}
$y={tooltip.y}
>
<TooltipTitle>{tooltip.available}</TooltipTitle>
<TooltipDate>{tooltip.date}</TooltipDate>
@ -221,7 +220,7 @@ const AvailabilityViewer = ({
</ScrollWrapper>
</Wrapper>
</>
);
};
)
}
export default AvailabilityViewer;
export default AvailabilityViewer

View file

@ -1,16 +1,17 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled.div`
export const Wrapper = styled('div', forwardRef)`
overflow-y: visible;
margin: 20px 0;
position: relative;
`;
`
export const ScrollWrapper = styled.div`
export const ScrollWrapper = styled('div')`
overflow-x: auto;
`;
`
export const Container = styled.div`
export const Container = styled('div')`
display: inline-flex;
box-sizing: border-box;
min-width: 100%;
@ -21,147 +22,147 @@ export const Container = styled.div`
@media (max-width: 660px) {
padding: 0 30px;
}
`;
`
export const Date = styled.div`
export const Date = styled('div')`
flex-shrink: 0;
display: flex;
flex-direction: column;
width: 60px;
min-width: 60px;
margin-bottom: 10px;
`;
`
export const Times = styled.div`
export const Times = styled('div')`
display: flex;
flex-direction: column;
border-bottom: 2px solid ${props => props.theme.text};
border-left: 1px solid ${props => props.theme.text};
border-right: 1px solid ${props => props.theme.text};
border-bottom: 2px solid var(--text);
border-left: 1px solid var(--text);
border-right: 1px solid var(--text);
${props => props.borderLeft && `
border-left: 2px solid ${props.theme.text};
${props => props.$borderLeft && `
border-left: 2px solid var(--text);
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
`}
${props => props.borderRight && `
border-right: 2px solid ${props.theme.text};
${props => props.$borderRight && `
border-right: 2px solid var(--text);
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
`}
& .time + .timespace, & .timespace:first-of-type {
border-top: 2px solid ${props => props.theme.text};
border-top: 2px solid var(--text);
}
`;
`
export const DateLabel = styled.label`
export const DateLabel = styled('label')`
display: block;
font-size: 12px;
text-align: center;
user-select: none;
`;
`
export const DayLabel = styled.label`
export const DayLabel = styled('label')`
display: block;
font-size: 15px;
text-align: center;
user-select: none;
`;
`
export const Time = styled.div`
export const Time = styled('div')`
height: 10px;
background-origin: border-box;
transition: background-color .1s;
${props => props.time.slice(2, 4) === '00' && `
border-top: 2px solid ${props.theme.text};
${props => props.$time.slice(2, 4) === '00' && `
border-top: 2px solid var(--text);
`}
${props => props.time.slice(2, 4) !== '00' && `
${props => props.$time.slice(2, 4) !== '00' && `
border-top: 2px solid transparent;
`}
${props => props.time.slice(2, 4) === '30' && `
border-top: 2px dotted ${props.theme.text};
${props => props.$time.slice(2, 4) === '30' && `
border-top: 2px dotted var(--text);
`}
background-color: ${props => `${props.theme.primary}${Math.round((props.peopleCount/props.maxPeople)*255).toString(16)}`};
background-color: ${props => `#F79E00${Math.round((props.$peopleCount/props.$maxPeople)*255).toString(16)}`};
${props => props.highlight && props.peopleCount === props.maxPeople && props.peopleCount > 0 && `
${props => props.$highlight && props.$peopleCount === props.$maxPeople && props.$peopleCount > 0 && `
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 4.3px,
${props.theme.primaryDark} 4.3px,
${props.theme.primaryDark} 8.6px
var(--shadow) 4.3px,
var(--shadow) 8.6px
);
`}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
`;
`
export const Spacer = styled.div`
export const Spacer = styled('div')`
width: 12px;
flex-shrink: 0;
`;
`
export const Tooltip = styled.div`
export const Tooltip = styled('div')`
position: absolute;
top: ${props => props.y}px;
left: ${props => props.x}px;
top: ${props => props.$y}px;
left: ${props => props.$x}px;
transform: translateX(-50%);
border: 1px solid ${props => props.theme.text};
border: 1px solid var(--text);
border-radius: 3px;
padding: 4px 8px;
background-color: ${props => props.theme.background}${props => props.theme.mode === 'light' ? 'EE' : 'DD'};
background-color: var(--background);
max-width: 200px;
pointer-events: none;
z-index: 100;
user-select: none;
`;
`
export const TooltipTitle = styled.span`
export const TooltipTitle = styled('span')`
font-size: 15px;
display: block;
font-weight: 700;
`;
`
export const TooltipDate = styled.span`
export const TooltipDate = styled('span')`
font-size: 13px;
display: block;
opacity: .8;
font-weight: 600;
`;
`
export const TooltipContent = styled.div`
export const TooltipContent = styled('div')`
font-size: 13px;
padding: 4px 0;
`;
`
export const TooltipPerson = styled.span`
export const TooltipPerson = styled('span')`
display: inline-block;
margin: 2px;
padding: 1px 4px;
border: 1px solid ${props => props.theme.primary};
border: 1px solid var(--primary);
border-radius: 3px;
${props => props.disabled && `
opacity: .5;
border-color: ${props.theme.text}
border-color: var(--text);
`}
`;
`
export const TimeLabels = styled.div`
export const TimeLabels = styled('div')`
flex-shrink: 0;
display: flex;
flex-direction: column;
width: 40px;
padding-right: 6px;
`;
`
export const TimeSpace = styled.div`
export const TimeSpace = styled('div')`
height: 10px;
position: relative;
border-top: 2px solid transparent;
@ -172,13 +173,13 @@ export const TimeSpace = styled.div`
45deg,
transparent,
transparent 4.3px,
${props => props.theme.loading} 4.3px,
${props => props.theme.loading} 8.6px
var(--loading) 4.3px,
var(--loading) 8.6px
);
}
`;
`
export const TimeLabel = styled.label`
export const TimeLabel = styled('label')`
display: block;
position: absolute;
top: -.7em;
@ -186,46 +187,46 @@ export const TimeLabel = styled.label`
text-align: right;
user-select: none;
width: 100%;
`;
`
export const StyledMain = styled.div`
export const StyledMain = styled('div')`
width: 600px;
margin: 20px auto;
max-width: calc(100% - 60px);
`;
`
export const People = styled.div`
export const People = styled('div')`
display: flex;
flex-wrap: wrap;
gap: 5px;
justify-content: center;
margin: 14px auto;
`;
`
export const Person = styled.button`
export const Person = styled('button')`
font: inherit;
font-size: 15px;
border-radius: 3px;
border: 1px solid ${props => props.theme.text};
color: ${props => props.theme.text};
border: 1px solid var(--text);
color: var(--text);
font-weight: 500;
background: transparent;
cursor: pointer;
padding: 2px 8px;
user-select: none;
${props => props.filtered && `
background: ${props.theme.primary};
${props => props.$filtered && `
background: var(--primary);
color: #FFFFFF;
border-color: ${props.theme.primary};
border-color: var(--primary);
`}
`;
`
export const Info = styled.span`
export const Info = styled('span')`
display: block;
text-align: center;
@media print {
display: none;
}
`;
`

View file

@ -0,0 +1,33 @@
import { Pressable } from './Button.styles'
const Button = ({
href,
type = 'button',
icon,
children,
secondary,
primaryColor,
secondaryColor,
small,
size,
isLoading,
...props
}) => (
<Pressable
type={type}
as={href ? 'a' : 'button'}
href={href}
$secondary={secondary}
$primaryColor={primaryColor}
$secondaryColor={secondaryColor}
$small={small}
$size={size}
$isLoading={isLoading}
{...props}
>
{icon}
{children}
</Pressable>
)
export default Button

View file

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Pressable = styled.button`
export const Pressable = styled('button')`
position: relative;
display: inline-flex;
align-items: center;
@ -11,12 +11,12 @@ export const Pressable = styled.button`
text-decoration: none;
font: inherit;
box-sizing: border-box;
background: ${props => props.primaryColor || props.theme.primary};
color: ${props => props.primaryColor ? '#FFF' : props.theme.background};
background: ${props => props.$primaryColor || 'var(--primary)'};
color: ${props => props.$primaryColor ? '#FFF' : 'var(--background)'};
font-weight: 600;
transition: transform 150ms cubic-bezier(0, 0, 0.58, 1);
border-radius: 3px;
padding: ${props => props.small ? '.4em 1.3em' : '.6em 1.5em'};
padding: ${props => props.$small ? '.4em 1.3em' : '.6em 1.5em'};
transform-style: preserve-3d;
margin-bottom: 5px;
@ -26,10 +26,10 @@ export const Pressable = styled.button`
margin-right: .5em;
}
${props => props.size && `
${props => props.$size && `
padding: 0;
height: ${props.size};
width: ${props.size};
height: ${props.$size};
width: ${props.$size};
`}
&::before {
@ -39,7 +39,7 @@ export const Pressable = styled.button`
width: 100%;
top: 0;
left: 0;
background: ${props => props.secondaryColor || props.theme.primaryDark};
background: ${props => props.$secondaryColor || 'var(--shadow)'};
border-radius: inherit;
transform: translate3d(0, 5px, -1em);
transition: transform 150ms cubic-bezier(0, 0, 0.58, 1), box-shadow 150ms cubic-bezier(0, 0, 0.58, 1);
@ -59,7 +59,7 @@ export const Pressable = styled.button`
}
}
${props => props.isLoading && `
${props => props.$isLoading && `
color: transparent;
cursor: wait;
@ -83,7 +83,7 @@ export const Pressable = styled.button`
left: calc(50% - 12px);
height: 18px;
width: 18px;
border: 3px solid ${props.primaryColor ? '#FFF' : props.theme.background};
border: 3px solid ${props.$primaryColor ? '#FFF' : 'var(--background)'};
border-left-color: transparent;
border-radius: 100px;
animation: load .5s linear infinite;
@ -92,7 +92,7 @@ export const Pressable = styled.button`
@media (prefers-reduced-motion: reduce) {
&:after {
content: 'loading...';
color: ${props.primaryColor ? '#FFF' : props.theme.background};
color: ${props.$primaryColor ? '#FFF' : 'var(--background)'};
animation: none;
width: initial;
height: initial;
@ -108,10 +108,10 @@ export const Pressable = styled.button`
}
`}
${props => props.secondary && `
${props => props.$secondary && `
background: transparent;
border: 1px solid ${props.primaryColor || props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
color: ${props.primaryColor || props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
border: 1px solid ${props.$primaryColor || 'var(--secondary)'};
color: ${props.$primaryColor || 'var(--secondary)'};
margin-bottom: 0;
&::before {
@ -123,12 +123,12 @@ export const Pressable = styled.button`
`}
@media print {
${props => !props.secondary && `
box-shadow: 0 4px 0 0 ${props.secondaryColor || props.theme.primaryDark};
${props => !props.$secondary && `
box-shadow: 0 4px 0 0 ${props.$secondaryColor || 'var(--secondary)'};
`}
&::before {
display: none;
}
}
`;
`

View file

@ -1,15 +0,0 @@
import { Pressable } from './buttonStyle';
const Button = ({ href, type = 'button', icon, children, ...props }) => (
<Pressable
type={type}
as={href ? 'a' : 'button'}
href={href}
{...props}
>
{icon}
{children}
</Pressable>
);
export default Button;

View file

@ -1,12 +1,12 @@
import { useState, useEffect, useRef, forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import localeData from 'dayjs/plugin/localeData';
import updateLocale from 'dayjs/plugin/updateLocale';
import { useState, useEffect, useRef, forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import isToday from 'dayjs/plugin/isToday'
import localeData from 'dayjs/plugin/localeData'
import updateLocale from 'dayjs/plugin/updateLocale'
import { Button, ToggleField } from 'components';
import { useSettingsStore, useLocaleUpdateStore } from 'stores';
import { Button, ToggleField } from '/src/components'
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
import {
Wrapper,
@ -17,35 +17,35 @@ import {
CalendarBody,
Date,
Day,
} from './calendarFieldStyle';
} from './CalendarField.styles'
dayjs.extend(isToday);
dayjs.extend(localeData);
dayjs.extend(updateLocale);
dayjs.extend(isToday)
dayjs.extend(localeData)
dayjs.extend(updateLocale)
const calculateMonth = (month, year, weekStart) => {
const date = dayjs().month(month).year(year);
const daysInMonth = date.daysInMonth();
const daysBefore = date.date(1).day() - weekStart;
const daysAfter = 6 - date.date(daysInMonth).day() + weekStart;
const date = dayjs().month(month).year(year)
const daysInMonth = date.daysInMonth()
const daysBefore = date.date(1).day() - weekStart
const daysAfter = 6 - date.date(daysInMonth).day() + weekStart
let dates = [];
let curDate = date.date(1).subtract(daysBefore, 'day');
let y = 0;
let x = 0;
const dates = []
let curDate = date.date(1).subtract(daysBefore, 'day')
let y = 0
let x = 0
for (let i = 0; i < daysBefore + daysInMonth + daysAfter; i++) {
if (x === 0) dates[y] = [];
dates[y][x] = curDate.clone();
curDate = curDate.add(1, 'day');
x++;
if (x === 0) dates[y] = []
dates[y][x] = curDate.clone()
curDate = curDate.add(1, 'day')
x++
if (x > 6) {
x = 0;
y++;
x = 0
y++
}
}
return dates;
};
return dates
}
const CalendarField = forwardRef(({
label,
@ -54,48 +54,48 @@ const CalendarField = forwardRef(({
setValue,
...props
}, ref) => {
const weekStart = useSettingsStore(state => state.weekStart);
const locale = useLocaleUpdateStore(state => state.locale);
const { t } = useTranslation('home');
const weekStart = useSettingsStore(state => state.weekStart)
const locale = useLocaleUpdateStore(state => state.locale)
const { t } = useTranslation('home')
const [type, setType] = useState(0);
const [type, setType] = useState(0)
const [dates, setDates] = useState(calculateMonth(dayjs().month(), dayjs().year(), weekStart));
const [month, setMonth] = useState(dayjs().month());
const [year, setYear] = useState(dayjs().year());
const [dates, setDates] = useState(calculateMonth(dayjs().month(), dayjs().year(), weekStart))
const [month, setMonth] = useState(dayjs().month())
const [year, setYear] = useState(dayjs().year())
const [selectedDates, setSelectedDates] = useState([]);
const [selectingDates, _setSelectingDates] = useState([]);
const staticSelectingDates = useRef([]);
const [selectedDates, setSelectedDates] = useState([])
const [selectingDates, _setSelectingDates] = useState([])
const staticSelectingDates = useRef([])
const setSelectingDates = newDates => {
staticSelectingDates.current = newDates;
_setSelectingDates(newDates);
};
staticSelectingDates.current = newDates
_setSelectingDates(newDates)
}
const [selectedDays, setSelectedDays] = useState([]);
const [selectingDays, _setSelectingDays] = useState([]);
const staticSelectingDays = useRef([]);
const [selectedDays, setSelectedDays] = useState([])
const [selectingDays, _setSelectingDays] = useState([])
const staticSelectingDays = useRef([])
const setSelectingDays = newDays => {
staticSelectingDays.current = newDays;
_setSelectingDays(newDays);
};
staticSelectingDays.current = newDays
_setSelectingDays(newDays)
}
const startPos = useRef({});
const staticMode = useRef(null);
const [mode, _setMode] = useState(staticMode.current);
const startPos = useRef({})
const staticMode = useRef(null)
const [mode, _setMode] = useState(staticMode.current)
const setMode = newMode => {
staticMode.current = newMode;
_setMode(newMode);
};
staticMode.current = newMode
_setMode(newMode)
}
useEffect(() => setValue(props.name, type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)), [type, selectedDays, selectedDates, setValue, props.name]);
useEffect(() => setValue(props.name, type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)), [type, selectedDays, selectedDates, setValue, props.name])
useEffect(() => {
if (dayjs.Ls.hasOwnProperty(locale) && weekStart !== dayjs.Ls[locale].weekStart) {
dayjs.updateLocale(locale, { weekStart });
if (dayjs.Ls?.[locale] && weekStart !== dayjs.Ls[locale].weekStart) {
dayjs.updateLocale(locale, { weekStart })
}
setDates(calculateMonth(month, year, weekStart));
}, [weekStart, month, year, locale]);
setDates(calculateMonth(month, year, weekStart))
}, [weekStart, month, year, locale])
return (
<Wrapper locale={locale}>
@ -128,10 +128,10 @@ const CalendarField = forwardRef(({
title={t('form.dates.tooltips.previous')}
onClick={() => {
if (month-1 < 0) {
setYear(year-1);
setMonth(11);
setYear(year-1)
setMonth(11)
} else {
setMonth(month-1);
setMonth(month-1)
}
}}
>&lt;</Button>
@ -141,10 +141,10 @@ const CalendarField = forwardRef(({
title={t('form.dates.tooltips.next')}
onClick={() => {
if (month+1 > 11) {
setYear(year+1);
setMonth(0);
setYear(year+1)
setMonth(0)
} else {
setMonth(month+1);
setMonth(month+1)
}
}}
>&gt;</Button>
@ -160,47 +160,47 @@ const CalendarField = forwardRef(({
dateRow.map((date, x) =>
<Date
key={y+x}
otherMonth={date.month() !== month}
isToday={date.isToday()}
$otherMonth={date.month() !== month}
$isToday={date.isToday()}
title={`${date.date()} ${dayjs.months()[date.month()]}${date.isToday() ? ` (${t('form.dates.tooltips.today')})` : ''}`}
selected={selectedDates.includes(date.format('DDMMYYYY'))}
selecting={selectingDates.includes(date)}
mode={mode}
$selected={selectedDates.includes(date.format('DDMMYYYY'))}
$selecting={selectingDates.includes(date)}
$mode={mode}
type="button"
onKeyPress={e => {
if (e.key === ' ' || e.key === 'Enter') {
if (selectedDates.includes(date.format('DDMMYYYY'))) {
setSelectedDates(selectedDates.filter(d => d !== date.format('DDMMYYYY')));
setSelectedDates(selectedDates.filter(d => d !== date.format('DDMMYYYY')))
} else {
setSelectedDates([...selectedDates, date.format('DDMMYYYY')]);
setSelectedDates([...selectedDates, date.format('DDMMYYYY')])
}
}
}}
onPointerDown={e => {
startPos.current = {x, y};
setMode(selectedDates.includes(date.format('DDMMYYYY')) ? 'remove' : 'add');
setSelectingDates([date]);
e.currentTarget.releasePointerCapture(e.pointerId);
startPos.current = {x, y}
setMode(selectedDates.includes(date.format('DDMMYYYY')) ? 'remove' : 'add')
setSelectingDates([date])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') {
setSelectedDates([...selectedDates, ...staticSelectingDates.current.map(d => d.format('DDMMYYYY'))]);
setSelectedDates([...selectedDates, ...staticSelectingDates.current.map(d => d.format('DDMMYYYY'))])
} else if (staticMode.current === 'remove') {
const toRemove = staticSelectingDates.current.map(d => d.format('DDMMYYYY'));
setSelectedDates(selectedDates.filter(d => !toRemove.includes(d)));
const toRemove = staticSelectingDates.current.map(d => d.format('DDMMYYYY'))
setSelectedDates(selectedDates.filter(d => !toRemove.includes(d)))
}
setMode(null);
}, { once: true });
setMode(null)
}, { once: true })
}}
onPointerEnter={() => {
if (staticMode.current) {
let found = [];
const found = []
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) {
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) {
found.push({y: cy, x: cx});
found.push({y: cy, x: cx})
}
}
setSelectingDates(found.map(d => dates[d.y][d.x]));
setSelectingDates(found.map(d => dates[d.y][d.x]))
}
}}
>{date.date()}</Date>
@ -213,44 +213,44 @@ const CalendarField = forwardRef(({
{(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map((name, i) =>
<Date
key={name}
isToday={(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort())[dayjs().day()-weekStart === -1 ? 6 : dayjs().day()-weekStart] === name}
$isToday={(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort())[dayjs().day()-weekStart === -1 ? 6 : dayjs().day()-weekStart] === name}
title={(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort())[dayjs().day()-weekStart === -1 ? 6 : dayjs().day()-weekStart] === name ? t('form.dates.tooltips.today') : ''}
selected={selectedDays.includes(((i + weekStart) % 7 + 7) % 7)}
selecting={selectingDays.includes(((i + weekStart) % 7 + 7) % 7)}
mode={mode}
$selected={selectedDays.includes(((i + weekStart) % 7 + 7) % 7)}
$selecting={selectingDays.includes(((i + weekStart) % 7 + 7) % 7)}
$mode={mode}
type="button"
onKeyPress={e => {
if (e.key === ' ' || e.key === 'Enter') {
if (selectedDays.includes(((i + weekStart) % 7 + 7) % 7)) {
setSelectedDays(selectedDays.filter(d => d !== ((i + weekStart) % 7 + 7) % 7));
setSelectedDays(selectedDays.filter(d => d !== ((i + weekStart) % 7 + 7) % 7))
} else {
setSelectedDays([...selectedDays, ((i + weekStart) % 7 + 7) % 7]);
setSelectedDays([...selectedDays, ((i + weekStart) % 7 + 7) % 7])
}
}
}}
onPointerDown={(e) => {
startPos.current = i;
setMode(selectedDays.includes(((i + weekStart) % 7 + 7) % 7) ? 'remove' : 'add');
setSelectingDays([((i + weekStart) % 7 + 7) % 7]);
e.currentTarget.releasePointerCapture(e.pointerId);
onPointerDown={e => {
startPos.current = i
setMode(selectedDays.includes(((i + weekStart) % 7 + 7) % 7) ? 'remove' : 'add')
setSelectingDays([((i + weekStart) % 7 + 7) % 7])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') {
setSelectedDays([...selectedDays, ...staticSelectingDays.current]);
setSelectedDays([...selectedDays, ...staticSelectingDays.current])
} else if (staticMode.current === 'remove') {
const toRemove = staticSelectingDays.current;
setSelectedDays(selectedDays.filter(d => !toRemove.includes(d)));
const toRemove = staticSelectingDays.current
setSelectedDays(selectedDays.filter(d => !toRemove.includes(d)))
}
setMode(null);
}, { once: true });
setMode(null)
}, { once: true })
}}
onPointerEnter={() => {
if (staticMode.current) {
let found = [];
const found = []
for (let ci = Math.min(startPos.current, i); ci < Math.max(startPos.current, i)+1; ci++) {
found.push(((ci + weekStart) % 7 + 7) % 7);
found.push(((ci + weekStart) % 7 + 7) % 7)
}
setSelectingDays(found);
setSelectingDays(found)
}
}}
>{name}</Date>
@ -258,7 +258,7 @@ const CalendarField = forwardRef(({
</CalendarBody>
)}
</Wrapper>
);
});
)
})
export default CalendarField;
export default CalendarField

View file

@ -1,22 +1,22 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
margin: 30px 0;
`;
`
export const StyledLabel = styled.label`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: 18px;
`;
`
export const StyledSubLabel = styled.label`
export const StyledSubLabel = styled('label')`
display: block;
font-size: 13px;
opacity: .6;
`;
`
export const CalendarHeader = styled.div`
export const CalendarHeader = styled('div')`
display: flex;
align-items: center;
justify-content: space-between;
@ -24,15 +24,15 @@ export const CalendarHeader = styled.div`
padding: 6px 0;
font-size: 1.2em;
font-weight: bold;
`;
`
export const CalendarDays = styled.div`
export const CalendarDays = styled('div')`
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
`;
`
export const Day = styled.div`
export const Day = styled('div')`
display: flex;
align-items: center;
justify-content: center;
@ -44,9 +44,9 @@ export const Day = styled.div`
@media (max-width: 350px) {
font-size: 12px;
}
`;
`
export const CalendarBody = styled.div`
export const CalendarBody = styled('div')`
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
@ -63,9 +63,9 @@ export const CalendarBody = styled.div`
& button:last-of-type {
border-bottom-right-radius: 3px;
}
`;
`
export const Date = styled.button`
export const Date = styled('button')`
font: inherit;
color: inherit;
background: none;
@ -77,8 +77,8 @@ export const Date = styled.button`
transition: none;
}
background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary};
background-color: var(--surface);
border: 1px solid var(--primary);
display: flex;
align-items: center;
justify-content: center;
@ -86,19 +86,19 @@ export const Date = styled.button`
user-select: none;
touch-action: none;
${props => props.otherMonth && `
color: ${props.theme.mode === 'light' ? props.theme.primaryLight : props.theme.primaryDark};
${props => props.$otherMonth && `
color: var(--tertiary);
`}
${props => props.isToday && `
${props => props.$isToday && `
font-weight: 900;
color: ${props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
color: var(--secondary);
`}
${props => (props.selected || (props.mode === 'add' && props.selecting)) && `
color: ${props.otherMonth ? 'rgba(255,255,255,.5)' : '#FFF'};
background-color: ${props.theme.primary};
${props => (props.$selected || (props.$mode === 'add' && props.$selecting)) && `
color: ${props.$otherMonth ? 'rgba(255,255,255,.5)' : '#FFF'};
background-color: var(--primary);
`}
${props => props.mode === 'remove' && props.selecting && `
background-color: ${props.theme.primaryBackground};
color: ${props.isToday ? props.theme.primaryDark : (props.otherMonth ? props.theme.primaryLight : 'inherit')};
${props => props.$mode === 'remove' && props.$selecting && `
background-color: var(--surface);
color: ${props.$isToday ? 'var(--secondary)' : (props.$otherMonth ? 'var(--tertiary)' : 'inherit')};
`}
`;
`

View file

@ -0,0 +1,9 @@
import { styled } from 'goober'
const Center = styled('div')`
display: flex;
align-items: center;
justify-content: center;
`
export default Center

View file

@ -1,9 +0,0 @@
import styled from '@emotion/styled';
const Center = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
export default Center;

View file

@ -1,106 +1,107 @@
import { useState, useEffect, useRef } from 'react';
import { Button } from 'components';
import { useTWAStore } from 'stores';
import { useTranslation } from 'react-i18next';
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '/src/components'
import { useTWAStore } from '/src/stores'
import {
Wrapper,
Options,
} from './donateStyle';
} from './Donate.styles'
import paypal_logo from 'res/paypal.svg';
import paypal_logo from '/src/res/paypal.svg'
const PAYMENT_METHOD = 'https://play.google.com/billing';
const SKU = 'crab_donation';
const PAYMENT_METHOD = 'https://play.google.com/billing'
const SKU = 'crab_donation'
const Donate = () => {
const store = useTWAStore();
const { t } = useTranslation('common');
const store = useTWAStore()
const { t } = useTranslation('common')
const firstLinkRef = useRef();
const modalRef = useRef();
const [isOpen, _setIsOpen] = useState(false);
const [closed, setClosed] = useState(false);
const firstLinkRef = useRef()
const modalRef = useRef()
const [isOpen, _setIsOpen] = useState(false)
const [closed, setClosed] = useState(false)
const setIsOpen = open => {
_setIsOpen(open);
_setIsOpen(open)
if (open) {
window.setTimeout(() => firstLinkRef.current.focus(), 150);
window.setTimeout(() => firstLinkRef.current.focus(), 150)
}
}
};
const linkPressed = () => {
setIsOpen(false);
gtag('event', 'donate', { 'event_category': 'donate' });
};
setIsOpen(false)
gtag('event', 'donate', { 'event_category': 'donate' })
}
useEffect(() => {
if (store.TWA === undefined) {
store.setTWA(document.referrer.includes('android-app://fit.crab'));
store.setTWA(document.referrer.includes('android-app://fit.crab'))
}
}, [store]);
}, [store])
const acknowledge = async (token, type='repeatable', onComplete = () => {}) => {
try {
let service = await window.getDigitalGoodsService(PAYMENT_METHOD);
await service.acknowledge(token, type);
const service = await window.getDigitalGoodsService(PAYMENT_METHOD)
await service.acknowledge(token, type)
if ('acknowledge' in service) {
// DGAPI 1.0
service.acknowledge(token, type);
service.acknowledge(token, type)
} else {
// DGAPI 2.0
service.consume(token);
service.consume(token)
}
onComplete();
onComplete()
} catch (error) {
console.error(error);
console.error(error)
}
}
const purchase = () => {
if (!window.PaymentRequest) return false;
if (!window.getDigitalGoodsService) return false;
if (!window.PaymentRequest) return false
if (!window.getDigitalGoodsService) return false
const supportedInstruments = [{
supportedMethods: PAYMENT_METHOD,
data: {
sku: SKU
}
}];
}]
const details = {
total: {
label: 'Total',
amount: { currency: 'AUD', value: '0' }
},
};
}
const request = new PaymentRequest(supportedInstruments, details);
const request = new PaymentRequest(supportedInstruments, details)
request.show()
.then(response => {
response
.complete('success')
.then(() => {
console.log(`Payment done: ${JSON.stringify(response, undefined, 2)}`);
console.log(`Payment done: ${JSON.stringify(response, undefined, 2)}`)
if (response.details && response.details.token) {
const token = response.details.token;
console.log(`Read Token: ${token.substring(0, 6)}...`);
alert(t('donate.messages.success'));
acknowledge(token);
const token = response.details.token
console.log(`Read Token: ${token.substring(0, 6)}...`)
alert(t('donate.messages.success'))
acknowledge(token)
}
})
.catch(e => {
console.error(e.message);
alert(t('donate.messages.error'));
});
console.error(e.message)
alert(t('donate.messages.error'))
})
})
.catch(e => {
console.error(e);
alert(t('donate.messages.error'));
});
};
console.error(e)
alert(t('donate.messages.error'))
})
}
return (
<Wrapper>
@ -109,20 +110,20 @@ const Donate = () => {
title={t('donate.title')}
onClick={event => {
if (closed) {
event.preventDefault();
return setClosed(false);
event.preventDefault()
return setClosed(false)
}
if (store.TWA) {
gtag('event', 'donate', { 'event_category': 'donate' });
event.preventDefault();
gtag('event', 'donate', { 'event_category': 'donate' })
event.preventDefault()
if (window.confirm(t('donate.messages.about'))) {
if (purchase() === false) {
alert(t('donate.messages.error'));
alert(t('donate.messages.error'))
}
}
} else {
event.preventDefault();
setIsOpen(true);
event.preventDefault()
setIsOpen(true)
}
}}
href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD&amount=5"
@ -135,13 +136,13 @@ const Donate = () => {
>{t('donate.button')}</Button>
<Options
isOpen={isOpen}
$isOpen={isOpen}
ref={modalRef}
onBlur={e => {
if (modalRef.current.contains(e.relatedTarget)) return;
setIsOpen(false);
if (modalRef.current?.contains(e.relatedTarget)) return
setIsOpen(false)
if (e.relatedTarget && e.relatedTarget.id === 'donate_button') {
setClosed(true);
setClosed(true)
}
}}
>
@ -152,7 +153,7 @@ const Donate = () => {
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation&currency_code=AUD" target="_blank" rel="noreferrer noopener payment">{t('donate.options.choose')}</a>
</Options>
</Wrapper>
);
)
}
export default Donate;
export default Donate

View file

@ -1,19 +1,18 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
margin-top: 6px;
margin-left: 12px;
position: relative;
`;
`
export const Options = styled.div`
export const Options = styled('div', forwardRef)`
position: absolute;
bottom: calc(100% + 20px);
right: 0;
background-color: ${props => props.theme.background};
${props => props.theme.mode === 'dark' && `
border: 1px solid ${props.theme.primaryBackground};
`}
background-color: var(--background);
border: 1px solid var(--surface);
z-index: 60;
padding: 4px 10px;
border-radius: 14px;
@ -27,7 +26,7 @@ export const Options = styled.div`
transform: translateY(5px);
transition: opacity .15s, transform .15s, visibility .15s;
${props => props.isOpen && `
${props => props.$isOpen && `
pointer-events: all;
opacity: 1;
transform: translateY(0);
@ -48,8 +47,8 @@ export const Options = styled.div`
margin: 6px 0;
text-decoration: none;
border-radius: 100px;
background-color: ${props => props.theme.primary};
color: ${props => props.theme.background};
background-color: var(--primary);
color: var(--background);
&:hover {
text-decoration: underline;
@ -62,4 +61,4 @@ export const Options = styled.div`
@media (prefers-reduced-motion: reduce) {
transition: none;
}
`;
`

View file

@ -1,10 +1,10 @@
import { useState } from 'react';
import { useState } from 'react'
import { Loading } from 'components';
import { Image, Wrapper } from './eggStyle';
import { Loading } from '/src/components'
import { Image, Wrapper } from './Egg.styles'
const Egg = ({ eggKey, onClose }) => {
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true)
return (
<Wrapper title="Click anywhere to close" onClick={() => onClose()}>
@ -15,7 +15,7 @@ const Egg = ({ eggKey, onClose }) => {
/>
{isLoading && <Loading />}
</Wrapper>
);
)
}
export default Egg;
export default Egg

View file

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
position: fixed;
background: rgba(0,0,0,.6);
top: 0;
@ -14,10 +14,10 @@ export const Wrapper = styled.div`
align-items: center;
z-index: 1000;
cursor: pointer;
`;
`
export const Image = styled.img`
export const Image = styled('img')`
max-width: 80%;
max-height: 80%;
position: absolute;
`;
`

View file

@ -0,0 +1,17 @@
import { X } from 'lucide-react'
import { Wrapper, CloseButton } from './Error.styles'
const Error = ({
children,
onClose,
open = true,
...props
}) => (
<Wrapper role="alert" open={open} {...props}>
{children}
<CloseButton type="button" onClick={onClose} title="Close error"><X /></CloseButton>
</Wrapper>
)
export default Error

View file

@ -1,8 +1,8 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
border-radius: 3px;
background-color: ${props => props.theme.error};
background-color: var(--error);
color: #FFFFFF;
padding: 0 16px;
display: flex;
@ -27,9 +27,9 @@ export const Wrapper = styled.div`
@media (prefers-reduced-motion: reduce) {
transition: none;
}
`;
`
export const CloseButton = styled.button`
export const CloseButton = styled('button')`
border: 0;
background: none;
height: 30px;
@ -40,4 +40,5 @@ export const CloseButton = styled.button`
align-items: center;
justify-content: center;
margin-left: 16px;
`;
padding: 0;
`

View file

@ -1,17 +0,0 @@
import { Wrapper, CloseButton } from './errorStyle';
const Error = ({
children,
onClose,
open = true,
...props
}) => (
<Wrapper role="alert" open={open} {...props}>
{children}
<CloseButton type="button" onClick={onClose} title="Close error">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</CloseButton>
</Wrapper>
);
export default Error;

View file

@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next'
import { Donate } from '/src/components'
import { Wrapper } from './Footer.styles'
const Footer = props => {
const { t } = useTranslation('common')
return (
<Wrapper id="donate" {...props}>
<span>{t('donate.info')}</span>
<Donate />
</Wrapper>
)
}
export default Footer

View file

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Wrapper = styled.footer`
export const Wrapper = styled('footer')`
width: 600px;
margin: 20px auto;
max-width: calc(100% - 60px);
@ -23,4 +23,4 @@ export const Wrapper = styled.footer`
@media print {
display: none;
}
`;
`

View file

@ -1,17 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Donate } from 'components';
import { Wrapper } from './footerStyle';
const Footer = (props) => {
const { t } = useTranslation('common');
return (
<Wrapper id="donate" {...props}>
<span>{t('donate.info')}</span>
<Donate />
</Wrapper>
);
};
export default Footer;

View file

@ -1,9 +1,9 @@
import { useState, useEffect } from 'react';
import { loadGapiInsideDOM } from 'gapi-script';
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from 'react'
import { loadGapiInsideDOM } from 'gapi-script'
import { useTranslation } from 'react-i18next'
import { Button, Center } from 'components';
import { Loader } from '../Loading/loadingStyle';
import { Button, Center } from '/src/components'
import { Loader } from '../Loading/Loading.styles'
import {
CalendarList,
CheckboxInput,
@ -14,22 +14,22 @@ import {
Title,
Icon,
LinkButton,
} from './googleCalendarStyle';
} from './GoogleCalendar.styles'
import googleLogo from 'res/google.svg';
import googleLogo from '/src/res/google.svg'
const signIn = () => window.gapi.auth2.getAuthInstance().signIn();
const signIn = () => window.gapi.auth2.getAuthInstance().signIn()
const signOut = () => window.gapi.auth2.getAuthInstance().signOut();
const signOut = () => window.gapi.auth2.getAuthInstance().signOut()
const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
const [signedIn, setSignedIn] = useState(undefined);
const [calendars, setCalendars] = useState(undefined);
const [freeBusyLoading, setFreeBusyLoading] = useState(false);
const { t } = useTranslation('event');
const [signedIn, setSignedIn] = useState(undefined)
const [calendars, setCalendars] = useState(undefined)
const [freeBusyLoading, setFreeBusyLoading] = useState(false)
const { t } = useTranslation('event')
const calendarLogin = async () => {
const gapi = await loadGapiInsideDOM();
const gapi = await loadGapiInsideDOM()
gapi.load('client:auth2', () => {
window.gapi.client.init({
clientId: '276505195333-9kjl7e48m272dljbspkobctqrpet0n8m.apps.googleusercontent.com',
@ -38,23 +38,23 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
})
.then(() => {
// Listen for state changes
window.gapi.auth2.getAuthInstance().isSignedIn.listen(isSignedIn => setSignedIn(isSignedIn));
window.gapi.auth2.getAuthInstance().isSignedIn.listen(isSignedIn => setSignedIn(isSignedIn))
// Handle initial sign-in state
setSignedIn(window.gapi.auth2.getAuthInstance().isSignedIn.get());
setSignedIn(window.gapi.auth2.getAuthInstance().isSignedIn.get())
})
.catch(e => {
console.error(e);
setSignedIn(false);
});
});
};
console.error(e)
setSignedIn(false)
})
})
}
const importAvailability = () => {
setFreeBusyLoading(true);
setFreeBusyLoading(true)
gtag('event', 'google_cal_sync', {
'event_category': 'event',
});
})
window.gapi.client.calendar.freebusy.query({
timeMin,
timeMax,
@ -62,15 +62,15 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
items: calendars.filter(c => c.checked).map(c => ({id: c.id})),
})
.then(response => {
onImport(response.result.calendars ? Object.values(response.result.calendars).reduce((busy, c) => [...busy, ...c.busy], []) : []);
setFreeBusyLoading(false);
onImport(response.result.calendars ? Object.values(response.result.calendars).reduce((busy, c) => [...busy, ...c.busy], []) : [])
setFreeBusyLoading(false)
}, e => {
console.error(e);
setFreeBusyLoading(false);
});
};
console.error(e)
setFreeBusyLoading(false)
})
}
useEffect(() => calendarLogin(), []);
useEffect(() => void calendarLogin(), [])
useEffect(() => {
if (signedIn) {
@ -83,15 +83,15 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
'description': item.description,
'id': item.id,
'color': item.backgroundColor,
'checked': item.hasOwnProperty('primary') && item.primary === true,
})));
'checked': item.primary === true,
})))
})
.catch(e => {
console.error(e);
signOut();
});
console.error(e)
signOut()
})
}
}, [signedIn]);
}, [signedIn])
return (
<>
@ -113,21 +113,21 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
<Icon src={googleLogo} alt="" />
<strong>{t('event:you.google_cal.login')}</strong>
(<LinkButton type="button" onClick={e => {
e.preventDefault();
signOut();
e.preventDefault()
signOut()
}}>{t('event:you.google_cal.logout')}</LinkButton>)
</Title>
<Options>
{calendars !== undefined && !calendars.every(c => c.checked) && (
<LinkButton type="button" onClick={e => {
e.preventDefault();
setCalendars(calendars.map(c => ({...c, checked: true})));
e.preventDefault()
setCalendars(calendars.map(c => ({...c, checked: true})))
}}>{t('event:you.google_cal.select_all')}</LinkButton>
)}
{calendars !== undefined && calendars.every(c => c.checked) && (
<LinkButton type="button" onClick={e => {
e.preventDefault();
setCalendars(calendars.map(c => ({...c, checked: false})));
e.preventDefault()
setCalendars(calendars.map(c => ({...c, checked: false})))
}}>{t('event:you.google_cal.select_none')}</LinkButton>
)}
</Options>
@ -139,7 +139,7 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
id={calendar.id}
color={calendar.color}
checked={calendar.checked}
onChange={e => setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))}
onChange={() => setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))}
/>
<CheckboxLabel htmlFor={calendar.id} color={calendar.color} />
<CalendarLabel htmlFor={calendar.id}>{calendar.name}</CalendarLabel>
@ -161,7 +161,7 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
</CalendarList>
)}
</>
);
};
)
}
export default GoogleCalendar;
export default GoogleCalendar

View file

@ -1,14 +1,14 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const CalendarList = styled.div`
export const CalendarList = styled('div')`
width: 100%;
& > div {
display: flex;
margin: 2px 0;
}
`;
`
export const CheckboxInput = styled.input`
export const CheckboxInput = styled('input')`
height: 0px;
width: 0px;
margin: 0;
@ -27,8 +27,8 @@ export const CheckboxInput = styled.input`
opacity: .6;
}
&[disabled] + label:after {
border: 2px solid ${props => props.theme.text};
background-color: ${props => props.theme.text};
border: 2px solid var(--text);
background-color: var(--text);
}
&:focus + label {
box-shadow: 0 0 0 2px ${props => props.theme.text}44;
@ -39,9 +39,9 @@ export const CheckboxInput = styled.input`
box-shadow: 0 0 0 2px ${props => props.color || props.theme.primary}44;
background-color: ${props => props.color || props.theme.primary}44;
}
`;
`
export const CheckboxLabel = styled.label`
export const CheckboxLabel = styled('label')`
display: inline-block;
height: 24px;
width: 24px;
@ -55,7 +55,7 @@ export const CheckboxLabel = styled.label`
display: inline-block;
height: 14px;
width: 14px;
border: 2px solid ${props => props.theme.text};
border: 2px solid var(--text);
border-radius: 2px;
position: absolute;
top: 3px;
@ -66,8 +66,8 @@ export const CheckboxLabel = styled.label`
display: inline-block;
height: 14px;
width: 14px;
border: 2px solid ${props => props.color || props.theme.primary};
background-color: ${props => props.color || props.theme.primary};
border: 2px solid ${props => props.color || 'var(--primary)'};
background-color: ${props => props.color || 'var(--primary)'};
border-radius: 2px;
position: absolute;
top: 3px;
@ -80,37 +80,37 @@ export const CheckboxLabel = styled.label`
transform: scale(.5);
transition: opacity 0.15s, transform 0.15s;
}
`;
`
export const CalendarLabel = styled.label`
export const CalendarLabel = styled('label')`
margin-left: .6em;
font-size: 15px;
font-weight: 500;
line-height: 24px;
`;
`
export const Info = styled.div`
export const Info = styled('div')`
font-size: 14px;
opacity: .6;
font-weight: 500;
padding: 14px 0 10px;
`;
`
export const Options = styled.div`
export const Options = styled('div')`
font-size: 14px;
padding: 0 0 5px;
`;
`
export const Title = styled.p`
export const Title = styled('p')`
display: flex;
align-items: center;
& strong {
margin-right: 1ex;
}
`;
`
export const Icon = styled.img`
export const Icon = styled('img')`
height: 24px;
width: 24px;
margin-right: 12px;
@ -118,11 +118,11 @@ export const Icon = styled.img`
${props => props.theme.mode === 'light' && `
filter: invert(1);
`}
`;
`
export const LinkButton = styled.button`
export const LinkButton = styled('button')`
font: inherit;
color: ${props => props.theme.primary};
color: var(--primary);
border: 0;
background: none;
text-decoration: underline;
@ -131,4 +131,4 @@ export const LinkButton = styled.button`
display: inline;
cursor: pointer;
appearance: none;
`;
`

View file

@ -1,25 +1,23 @@
import { useTheme } from '@emotion/react';
import { useSettingsStore } from 'stores';
import { useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '/src/stores'
import {
Wrapper,
Label,
Bar,
Grade,
} from './legendStyle';
} from './Legend.styles'
const Legend = ({
min,
max,
total,
onSegmentFocus,
...props
}) => {
const theme = useTheme();
const { t } = useTranslation('event');
const highlight = useSettingsStore(state => state.highlight);
const setHighlight = useSettingsStore(state => state.setHighlight);
const { t } = useTranslation('event')
const highlight = useSettingsStore(state => state.highlight)
const setHighlight = useSettingsStore(state => state.setHighlight)
return (
<Wrapper>
@ -33,8 +31,8 @@ const Legend = ({
{[...Array(max+1-min).keys()].map(i => i+min).map(i =>
<Grade
key={i}
color={`${theme.primary}${Math.round((i/(max))*255).toString(16)}`}
highlight={highlight && i === max && max > 0}
$color={`#F79E00${Math.round((i/(max))*255).toString(16)}`}
$highlight={highlight && i === max && max > 0}
onMouseOver={() => onSegmentFocus(i)}
/>
)}
@ -42,7 +40,7 @@ const Legend = ({
<Label>{max}/{total} {t('event:available')}</Label>
</Wrapper>
);
};
)
}
export default Legend;
export default Legend

View file

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
margin: 10px 0;
display: flex;
align-items: center;
@ -13,40 +13,40 @@ export const Wrapper = styled.div`
@media (max-width: 400px) {
display: block;
}
`;
`
export const Label = styled.label`
export const Label = styled('label')`
display: block;
font-size: 14px;
text-align: left;
`;
`
export const Bar = styled.div`
export const Bar = styled('div')`
display: flex;
width: 40%;
height: 20px;
border-radius: 3px;
overflow: hidden;
margin: 0 8px;
border: 1px solid ${props => props.theme.text};
border: 1px solid var(--text);
@media (max-width: 400px) {
width: 100%;
margin: 8px 0;
}
`;
`
export const Grade = styled.div`
export const Grade = styled('div')`
flex: 1;
background-color: ${props => props.color};
background-color: ${props => props.$color};
${props => props.highlight && `
${props => props.$highlight && `
background-image: repeating-linear-gradient(
45deg,
${props.theme.primary},
${props.theme.primary} 4.5px,
${props.theme.primaryDark} 4.5px,
${props.theme.primaryDark} 9px
var(--primary),
var(--primary) 4.5px,
var(--shadow) 4.5px,
var(--shadow) 9px
);
`}
`;
`

View file

@ -0,0 +1,5 @@
import { Wrapper, Loader } from './Loading.styles'
const Loading = () => <Wrapper><Loader /></Wrapper>
export default Loading

View file

@ -1,13 +1,13 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Wrapper = styled.main`
export const Wrapper = styled('main')`
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
`;
`
export const Loader = styled.div`
export const Loader = styled('div')`
@keyframes load {
from {
transform: rotate(0deg);
@ -19,7 +19,7 @@ export const Loader = styled.div`
height: 24px;
width: 24px;
border: 3px solid ${props => props.theme.primary};
border: 3px solid var(--primary);
border-left-color: transparent;
border-radius: 100px;
animation: load .5s linear infinite;
@ -32,4 +32,4 @@ export const Loader = styled.div`
content: 'loading...';
}
}
`;
`

View file

@ -1,12 +0,0 @@
import {
Wrapper,
Loader,
} from './loadingStyle';
const Loading = () => (
<Wrapper>
<Loader />
</Wrapper>
);
export default Loading;

View file

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import {
Wrapper,
@ -8,12 +8,12 @@ import {
Image,
Title,
Tagline,
} from './logoStyle';
} from './Logo.styles'
import image from 'res/logo.svg';
import image from '/src/res/logo.svg'
const Logo = () => {
const { t } = useTranslation('common');
const { t } = useTranslation('common')
return (
<Wrapper>
@ -25,7 +25,7 @@ const Logo = () => {
<Tagline>{t('common:tagline')}</Tagline>
</A>
</Wrapper>
);
};
)
}
export default Logo;
export default Logo

View file

@ -1,12 +1,12 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
display: flex;
align-items: center;
justify-content: center;
`;
`
export const A = styled.a`
export const A = styled('a')`
text-decoration: none;
@keyframes jelly {
@ -32,30 +32,30 @@ export const A = styled.a`
animation: none;
}
}
`;
`
export const Top = styled.div`
export const Top = styled('div')`
display: inline-flex;
justify-content: center;
align-items: center;
`;
`
export const Image = styled.img`
export const Image = styled('img')`
width: 2.5rem;
margin-right: 16px;
`;
`
export const Title = styled.span`
export const Title = styled('span')`
display: block;
font-size: 2rem;
color: ${props => props.theme.primary};
color: var(--primary);
font-family: 'Molot', sans-serif;
font-weight: 400;
text-shadow: 0 2px 0 ${props => props.theme.primaryDark};
text-shadow: 0 2px 0 var(--shadow);
line-height: 1em;
`;
`
export const Tagline = styled.span`
export const Tagline = styled('span')`
text-decoration: underline;
font-size: 14px;
padding-top: 2px;
@ -66,4 +66,4 @@ export const Tagline = styled.span`
@media print {
display: none;
}
`;
`

View file

@ -1,10 +1,10 @@
import { useState, useEffect } from 'react';
import { PublicClientApplication } from "@azure/msal-browser";
import { Client } from "@microsoft/microsoft-graph-client";
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from 'react'
import { PublicClientApplication } from '@azure/msal-browser'
import { Client } from '@microsoft/microsoft-graph-client'
import { useTranslation } from 'react-i18next'
import { Button, Center } from 'components';
import { Loader } from '../Loading/loadingStyle';
import { Button, Center } from '/src/components'
import { Loader } from '../Loading/Loading.styles'
import {
CalendarList,
CheckboxInput,
@ -15,11 +15,11 @@ import {
Title,
Icon,
LinkButton,
} from '../GoogleCalendar/googleCalendarStyle';
} from '../GoogleCalendar/GoogleCalendar.styles'
import outlookLogo from 'res/outlook.svg';
import outlookLogo from '/src/res/outlook.svg'
const scopes = ['Calendars.Read', 'Calendars.Read.Shared'];
const scopes = ['Calendars.Read', 'Calendars.Read.Shared']
// Initialise the MSAL object
const publicClientApplication = new PublicClientApplication({
@ -31,69 +31,69 @@ const publicClientApplication = new PublicClientApplication({
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: true,
},
});
})
const getAuthenticatedClient = accessToken => {
const client = Client.init({
authProvider: done => done(null, accessToken),
});
return client;
};
})
return client
}
const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
const [client, setClient] = useState(undefined);
const [calendars, setCalendars] = useState(undefined);
const [freeBusyLoading, setFreeBusyLoading] = useState(false);
const { t } = useTranslation('event');
const [client, setClient] = useState(undefined)
const [calendars, setCalendars] = useState(undefined)
const [freeBusyLoading, setFreeBusyLoading] = useState(false)
const { t } = useTranslation('event')
const checkLogin = async () => {
const accounts = publicClientApplication.getAllAccounts();
const accounts = publicClientApplication.getAllAccounts()
if (accounts && accounts.length > 0) {
try {
const accessToken = await getAccessToken();
setClient(getAuthenticatedClient(accessToken));
const accessToken = await getAccessToken()
setClient(getAuthenticatedClient(accessToken))
} catch (e) {
console.error(e);
signOut();
console.error(e)
signOut()
}
} else {
setClient(null);
setClient(null)
}
}
};
const signIn = async () => {
try {
await publicClientApplication.loginPopup({ scopes });
await publicClientApplication.loginPopup({ scopes })
} catch (e) {
console.error(e);
console.error(e)
} finally {
checkLogin();
checkLogin()
}
}
};
const signOut = async () => {
try {
await publicClientApplication.logoutRedirect({
onRedirectNavigate: () => false,
});
})
} catch (e) {
console.error(e);
console.error(e)
} finally {
checkLogin();
checkLogin()
}
}
};
const getAccessToken = async () => {
try {
const accounts = publicClientApplication.getAllAccounts();
if (accounts.length <= 0) throw new Error('login_required');
const accounts = publicClientApplication.getAllAccounts()
if (accounts.length <= 0) throw new Error('login_required')
// Try to get silently
const result = await publicClientApplication.acquireTokenSilent({
scopes,
account: accounts[0],
});
return result.accessToken;
})
return result.accessToken
} catch (e) {
if ([
'consent_required',
@ -102,19 +102,19 @@ const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
'no_account_in_silent_request'
].includes(e.message)) {
// Try to get with popup
const result = await publicClientApplication.acquireTokenPopup({ scopes });
return result.accessToken;
const result = await publicClientApplication.acquireTokenPopup({ scopes })
return result.accessToken
} else {
throw e;
throw e
}
}
}
};
const importAvailability = () => {
setFreeBusyLoading(true);
setFreeBusyLoading(true)
gtag('event', 'outlook_cal_sync', {
'event_category': 'event',
});
})
client.api('/me/calendar/getSchedule').post({
schedules: calendars.filter(c => c.checked).map(c => c.id),
startTime: {
@ -128,17 +128,16 @@ const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
availabilityViewInterval: 30,
})
.then(response => {
onImport(response.value.reduce((busy, c) => c.hasOwnProperty('error') ? busy : [...busy, ...c.scheduleItems.filter(item => item.status === 'busy' || item.status === 'tentative')], []));
onImport(response.value.reduce((busy, c) => c.error ? busy : [...busy, ...c.scheduleItems.filter(item => item.status === 'busy' || item.status === 'tentative')], []))
})
.catch(e => {
console.error(e);
signOut();
console.error(e)
signOut()
})
.finally(() => setFreeBusyLoading(false));
};
.finally(() => setFreeBusyLoading(false))
}
// eslint-disable-next-line
useEffect(() => checkLogin(), []);
useEffect(() => void checkLogin(), [])
useEffect(() => {
if (client) {
@ -150,15 +149,14 @@ const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
'id': item.owner.address,
'color': item.hexColor,
'checked': item.isDefaultCalendar === true,
})));
})))
})
.catch(e => {
console.error(e);
signOut();
});
console.error(e)
signOut()
})
}
// eslint-disable-next-line
}, [client]);
}, [client])
return (
<>
@ -170,9 +168,7 @@ const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
primaryColor="#0364B9"
secondaryColor="#02437D"
icon={<img aria-hidden="true" focusable="false" src={outlookLogo} alt="" />}
>
{t('event:you.outlook_cal')}
</Button>
>{t('event:you.outlook_cal')}</Button>
</Center>
) : (
<CalendarList>
@ -180,21 +176,21 @@ const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
<Icon src={outlookLogo} alt="" />
<strong>{t('event:you.outlook_cal')}</strong>
(<LinkButton type="button" onClick={e => {
e.preventDefault();
signOut();
e.preventDefault()
signOut()
}}>{t('event:you.google_cal.logout')}</LinkButton>)
</Title>
<Options>
{calendars !== undefined && !calendars.every(c => c.checked) && (
<LinkButton type="button" onClick={e => {
e.preventDefault();
setCalendars(calendars.map(c => ({...c, checked: true})));
e.preventDefault()
setCalendars(calendars.map(c => ({...c, checked: true})))
}}>{t('event:you.google_cal.select_all')}</LinkButton>
)}
{calendars !== undefined && calendars.every(c => c.checked) && (
<LinkButton type="button" onClick={e => {
e.preventDefault();
setCalendars(calendars.map(c => ({...c, checked: false})));
e.preventDefault()
setCalendars(calendars.map(c => ({...c, checked: false})))
}}>{t('event:you.google_cal.select_none')}</LinkButton>
)}
</Options>
@ -206,14 +202,12 @@ const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
id={calendar.id}
color={calendar.color}
checked={calendar.checked}
onChange={e => setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))}
onChange={() => setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))}
/>
<CheckboxLabel htmlFor={calendar.id} color={calendar.color} />
<CalendarLabel htmlFor={calendar.id}>{calendar.name}</CalendarLabel>
</div>
)) : (
<Loader />
)}
)) : <Loader />}
{calendars !== undefined && (
<>
<Info>{t('event:you.google_cal.info')}</Info>
@ -228,7 +222,7 @@ const OutlookCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
</CalendarList>
)}
</>
);
};
)
}
export default OutlookCalendar;
export default OutlookCalendar

View file

@ -1,17 +1,18 @@
import { useTranslation } from 'react-i18next';
import { useRecentsStore, useLocaleUpdateStore } from 'stores';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { AboutSection, StyledMain } from '../../pages/Home/homeStyle';
import { Wrapper, Recent } from './recentsStyle';
import { useRecentsStore, useLocaleUpdateStore } from '/src/stores'
dayjs.extend(relativeTime);
import { AboutSection, StyledMain } from '../../pages/Home/Home.styles'
import { Wrapper, Recent } from './Recents.styles'
dayjs.extend(relativeTime)
const Recents = ({ target }) => {
const recents = useRecentsStore(state => state.recents);
const locale = useLocaleUpdateStore(state => state.locale);
const { t } = useTranslation(['home', 'common']);
const recents = useRecentsStore(state => state.recents)
const locale = useLocaleUpdateStore(state => state.locale)
const { t } = useTranslation(['home', 'common'])
return !!recents.length && (
<Wrapper>
@ -27,7 +28,7 @@ const Recents = ({ target }) => {
</StyledMain>
</AboutSection>
</Wrapper>
);
};
)
}
export default Recents;
export default Recents

View file

@ -1,12 +1,12 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
@media print {
display: none;
}
`;
`
export const Recent = styled.a`
export const Recent = styled('a')`
text-decoration: none;
display: flex;
align-items: center;
@ -17,7 +17,7 @@ export const Recent = styled.a`
& .name {
font-weight: 700;
font-size: 1.1em;
color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
color: var(--secondary);
flex: 1;
display: block;
}
@ -25,7 +25,7 @@ export const Recent = styled.a`
font-weight: 400;
opacity: .8;
white-space: nowrap;
color: ${props => props.theme.text};
color: var(--text);
}
&:hover .name {
@ -39,4 +39,4 @@ export const Recent = styled.a`
white-space: normal;
}
}
`;
`

View file

@ -1,10 +1,11 @@
import { forwardRef } from 'react';
import { forwardRef } from 'react'
import {
Wrapper,
StyledLabel,
StyledSubLabel,
StyledSelect,
} from './selectFieldStyle';
} from './SelectField.styles'
const SelectField = forwardRef(({
label,
@ -16,13 +17,13 @@ const SelectField = forwardRef(({
defaultOption,
...props
}, ref) => (
<Wrapper inline={inline} small={small}>
{label && <StyledLabel htmlFor={id} inline={inline} small={small}>{label}</StyledLabel>}
<Wrapper $inline={inline} $small={small}>
{label && <StyledLabel htmlFor={id} $inline={inline} $small={small}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<StyledSelect
id={id}
small={small}
$small={small}
ref={ref}
{...props}
>
@ -38,6 +39,6 @@ const SelectField = forwardRef(({
)}
</StyledSelect>
</Wrapper>
));
))
export default SelectField;
export default SelectField

View file

@ -0,0 +1,61 @@
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled('div')`
margin: 30px 0;
${props => props.$inline && `
margin: 0;
`}
${props => props.$small && `
margin: 10px 0;
`}
`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: 18px;
${props => props.$inline && `
font-size: 16px;
`}
${props => props.$small && `
font-size: .9rem;
`}
`
export const StyledSubLabel = styled('label')`
display: block;
padding-bottom: 6px;
font-size: 13px;
opacity: .6;
`
export const StyledSelect = styled('select', forwardRef)`
width: 100%;
box-sizing: border-box;
font: inherit;
background: var(--surface);
color: inherit;
padding: 10px 14px;
border: 1px solid var(--primary);
box-shadow: inset 0 0 0 0 var(--primary);
border-radius: 3px;
outline: none;
transition: border-color .15s, box-shadow .15s;
appearance: none;
background-image: url('data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><foreignObject width="100px" height="100px"><div xmlns="http://www.w3.org/1999/xhtml" style="color:#F79E00;font-size:60px;display:flex;align-items:center;justify-content:center;height:100%;width:100%;"></div></foreignObject></svg>')}');
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 1em;
&:focus {
border: 1px solid var(--secondary);
box-shadow: inset 0 -3px 0 0 var(--secondary);
}
${props => props.$small && `
padding: 6px 8px;
`}
`

View file

@ -1,60 +0,0 @@
import styled from '@emotion/styled';
export const Wrapper = styled.div`
margin: 30px 0;
${props => props.inline && `
margin: 0;
`}
${props => props.small && `
margin: 10px 0;
`}
`;
export const StyledLabel = styled.label`
display: block;
padding-bottom: 4px;
font-size: 18px;
${props => props.inline && `
font-size: 16px;
`}
${props => props.small && `
font-size: .9rem;
`}
`;
export const StyledSubLabel = styled.label`
display: block;
padding-bottom: 6px;
font-size: 13px;
opacity: .6;
`;
export const StyledSelect = styled.select`
width: 100%;
box-sizing: border-box;
font: inherit;
background: ${props => props.theme.primaryBackground};
color: inherit;
padding: 10px 14px;
border: 1px solid ${props => props.theme.primary};
box-shadow: inset 0 0 0 0 ${props => props.theme.primary};
border-radius: 3px;
outline: none;
transition: border-color .15s, box-shadow .15s;
appearance: none;
background-image: url("data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><foreignObject width=%22100px%22 height=%22100px%22><div xmlns=%22http://www.w3.org/1999/xhtml%22 style=%22color:${props => encodeURIComponent(props.theme.primary)};font-size:60px;display:flex;align-items:center;justify-content:center;height:100%25;width:100%25%22>▼</div></foreignObject></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 1em;
&:focus {
border: 1px solid ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
box-shadow: inset 0 -3px 0 0 ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
}
${props => props.small && `
padding: 6px 8px;
`}
`;

View file

@ -1,93 +1,90 @@
import { useState, useEffect, useRef } from 'react';
import { useTheme } from '@emotion/react';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import { Settings as SettingsIcon } from 'lucide-react'
import { ToggleField, SelectField } from 'components';
import { ToggleField, SelectField } from '/src/components'
import { useSettingsStore, useLocaleUpdateStore } from 'stores';
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
import {
OpenButton,
Modal,
Heading,
Cover,
} from './settingsStyle';
} from './Settings.styles'
import locales from 'res/dayjs_locales';
import locales from '/src/i18n/locales'
// Language specific options
const setDefaults = (lang, store) => {
if (locales.hasOwnProperty(lang)) {
store.setWeekStart(locales[lang].weekStart);
store.setTimeFormat(locales[lang].timeFormat);
if (locales[lang]) {
store.setWeekStart(locales[lang].weekStart)
store.setTimeFormat(locales[lang].timeFormat)
}
};
}
const Settings = () => {
const { pathname } = useLocation();
const theme = useTheme();
const store = useSettingsStore();
const [isOpen, _setIsOpen] = useState(false);
const { t, i18n } = useTranslation('common');
const setLocale = useLocaleUpdateStore(state => state.setLocale);
const firstControlRef = useRef();
const { pathname } = useLocation()
const store = useSettingsStore()
const [isOpen, _setIsOpen] = useState(false)
const { t, i18n } = useTranslation('common')
const setLocale = useLocaleUpdateStore(state => state.setLocale)
const firstControlRef = useRef()
const onEsc = e => {
if (e.key === 'Escape') {
setIsOpen(false);
setIsOpen(false)
}
}
};
const setIsOpen = open => {
_setIsOpen(open);
_setIsOpen(open)
if (open) {
window.setTimeout(() => firstControlRef.current.focus(), 150);
document.addEventListener('keyup', onEsc, true);
window.setTimeout(() => firstControlRef.current?.focus(), 150)
document.addEventListener('keyup', onEsc, true)
} else {
document.removeEventListener('keyup', onEsc);
document.removeEventListener('keyup', onEsc)
}
}
};
useEffect(() => {
if (Object.keys(locales).includes(i18n.language)) {
locales[i18n.language].import().then(() => {
dayjs.locale(i18n.language);
setLocale(dayjs.locale());
document.documentElement.setAttribute('lang', i18n.language);
});
dayjs.locale(i18n.language)
setLocale(dayjs.locale())
document.documentElement.setAttribute('lang', i18n.language)
})
} else {
setLocale('en');
setLocale('en')
document.documentElement.setAttribute('lang', 'en')
}
}, [i18n.language, setLocale]);
}, [i18n.language, setLocale])
if (!i18n.options.storedLang) {
setDefaults(i18n.language, store);
i18n.options.storedLang = i18n.language;
setDefaults(i18n.language, store)
i18n.options.storedLang = i18n.language
}
i18n.on('languageChanged', lang => {
setDefaults(lang, store);
});
setDefaults(lang, store)
})
// Reset scroll on navigation
useEffect(() => window.scrollTo(0, 0), [pathname]);
useEffect(() => window.scrollTo(0, 0), [pathname])
return (
<>
<OpenButton
isOpen={isOpen}
$isOpen={isOpen}
type="button"
onClick={() => setIsOpen(!isOpen)} title={t('options.name')}
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke={theme.text} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</OpenButton>
><SettingsIcon /></OpenButton>
<Cover isOpen={isOpen} onClick={() => setIsOpen(false)} />
<Modal isOpen={isOpen}>
<Cover $isOpen={isOpen} onClick={() => setIsOpen(false)} />
<Modal $isOpen={isOpen}>
<Heading>{t('options.name')}</Heading>
<ToggleField
@ -147,8 +144,8 @@ const Settings = () => {
id="language"
options={{
...Object.keys(locales).reduce((ls, l) => {
ls[l] = locales[l].name;
return ls;
ls[l] = locales[l].name
return ls
}, {}),
...process.env.NODE_ENV !== 'production' && { 'cimode': 'DEV' },
}}
@ -158,7 +155,7 @@ const Settings = () => {
/>
</Modal>
</>
);
};
)
}
export default Settings;
export default Settings

View file

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const OpenButton = styled.button`
export const OpenButton = styled('button')`
border: 0;
background: none;
height: 50px;
@ -19,15 +19,8 @@ export const OpenButton = styled.button`
transition: transform .15s;
padding: 0;
&:focus {
outline: 0;
}
&:focus-visible {
background-color: ${props => props.theme.text}22;
}
${props => props.isOpen && `
transform: rotate(-45deg);
${props => props.$isOpen && `
transform: rotate(-60deg);
`}
@media (prefers-reduced-motion: reduce) {
@ -36,9 +29,9 @@ export const OpenButton = styled.button`
@media print {
display: none;
}
`;
`
export const Cover = styled.div`
export const Cover = styled('div')`
position: fixed;
top: 0;
left: 0;
@ -47,19 +40,17 @@ export const Cover = styled.div`
z-index: 100;
display: none;
${props => props.isOpen && `
${props => props.$isOpen && `
display: block;
`}
`;
`
export const Modal = styled.div`
export const Modal = styled('div')`
position: absolute;
top: 70px;
right: 12px;
background-color: ${props => props.theme.background};
${props => props.theme.mode === 'dark' && `
border: 1px solid ${props.theme.primaryBackground};
`}
background-color: var(--background);
border: 1px solid var(--surface);
z-index: 150;
padding: 10px 18px;
border-radius: 3px;
@ -74,7 +65,7 @@ export const Modal = styled.div`
visibility: hidden;
transition: opacity .15s, transform .15s, visibility .15s;
${props => props.isOpen && `
${props => props.$isOpen && `
pointer-events: all;
opacity: 1;
transform: translateY(0);
@ -87,11 +78,11 @@ export const Modal = styled.div`
@media print {
display: none;
}
`;
`
export const Heading = styled.span`
export const Heading = styled('span')`
font-size: 1.5rem;
display: block;
margin: 6px 0;
line-height: 1em;
`;
`

View file

@ -1,10 +1,11 @@
import { forwardRef } from 'react';
import { forwardRef } from 'react'
import {
Wrapper,
StyledLabel,
StyledSubLabel,
StyledInput,
} from './textFieldStyle';
} from './TextField.styles'
const TextField = forwardRef(({
label,
@ -13,11 +14,11 @@ const TextField = forwardRef(({
inline = false,
...props
}, ref) => (
<Wrapper inline={inline}>
{label && <StyledLabel htmlFor={id} inline={inline}>{label}</StyledLabel>}
<Wrapper $inline={inline}>
{label && <StyledLabel htmlFor={id} $inline={inline}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<StyledInput id={id} ref={ref} {...props} />
</Wrapper>
));
))
export default TextField;
export default TextField

View file

@ -0,0 +1,47 @@
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled('div')`
margin: 30px 0;
${props => props.$inline && `
margin: 0;
`}
`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: 18px;
${props => props.$inline && `
font-size: 16px;
`}
`
export const StyledSubLabel = styled('label')`
display: block;
padding-bottom: 6px;
font-size: 13px;
opacity: .6;
`
export const StyledInput = styled('input', forwardRef)`
width: 100%;
box-sizing: border-box;
font: inherit;
background: var(--surface);
color: inherit;
padding: 10px 14px;
border: 1px solid var(--primary);
box-shadow: inset 0 0 0 0 var(--primary);
border-radius: 3px;
font-size: 18px;
outline: none;
transition: border-color .15s, box-shadow .15s;
&:focus {
border: 1px solid var(--secondary);
box-shadow: inset 0 -3px 0 0 var(--secondary);
}
`

View file

@ -1,46 +0,0 @@
import styled from '@emotion/styled';
export const Wrapper = styled.div`
margin: 30px 0;
${props => props.inline && `
margin: 0;
`}
`;
export const StyledLabel = styled.label`
display: block;
padding-bottom: 4px;
font-size: 18px;
${props => props.inline && `
font-size: 16px;
`}
`;
export const StyledSubLabel = styled.label`
display: block;
padding-bottom: 6px;
font-size: 13px;
opacity: .6;
`;
export const StyledInput = styled.input`
width: 100%;
box-sizing: border-box;
font: inherit;
background: ${props => props.theme.primaryBackground};
color: inherit;
padding: 10px 14px;
border: 1px solid ${props => props.theme.primary};
box-shadow: inset 0 0 0 0 ${props => props.theme.primary};
border-radius: 3px;
font-size: 18px;
outline: none;
transition: border-color .15s, box-shadow .15s;
&:focus {
border: 1px solid ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
box-shadow: inset 0 -3px 0 0 ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
}
`;

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, forwardRef } from 'react';
import dayjs from 'dayjs';
import { useState, useEffect, useRef, forwardRef } from 'react'
import dayjs from 'dayjs'
import { useSettingsStore, useLocaleUpdateStore } from 'stores';
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
import {
Wrapper,
@ -10,9 +10,9 @@ import {
Range,
Handle,
Selected,
} from './timeRangeFieldStyle';
} from './TimeRangeField.styles'
const times = ['00','01','02','03','04','05','06','07','08','09','10','11','12','13','14','15','16','17','18','19','20','21','22','23','24'];
const times = ['00','01','02','03','04','05','06','07','08','09','10','11','12','13','14','15','16','17','18','19','20','21','22','23','24']
const TimeRangeField = forwardRef(({
label,
@ -21,39 +21,39 @@ const TimeRangeField = forwardRef(({
setValue,
...props
}, ref) => {
const timeFormat = useSettingsStore(state => state.timeFormat);
const locale = useLocaleUpdateStore(state => state.locale);
const timeFormat = useSettingsStore(state => state.timeFormat)
const locale = useLocaleUpdateStore(state => state.locale)
const [start, setStart] = useState(9);
const [end, setEnd] = useState(17);
const [start, setStart] = useState(9)
const [end, setEnd] = useState(17)
const isStartMoving = useRef(false);
const isEndMoving = useRef(false);
const rangeRef = useRef();
const rangeRect = useRef();
const isStartMoving = useRef(false)
const isEndMoving = useRef(false)
const rangeRef = useRef()
const rangeRect = useRef()
useEffect(() => {
if (rangeRef.current) {
rangeRect.current = rangeRef.current.getBoundingClientRect();
rangeRect.current = rangeRef.current.getBoundingClientRect()
}
}, [rangeRef]);
}, [rangeRef])
useEffect(() => setValue(props.name, JSON.stringify({start, end})), [start, end, setValue, props.name]);
useEffect(() => setValue(props.name, JSON.stringify({start, end})), [start, end, setValue, props.name])
const handleMouseMove = e => {
if (isStartMoving.current || isEndMoving.current) {
let step = Math.round(((e.pageX - rangeRect.current.left) / rangeRect.current.width) * 24);
if (step < 0) step = 0;
if (step > 24) step = 24;
step = Math.abs(step);
let step = Math.round(((e.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
if (isStartMoving.current) {
setStart(step);
setStart(step)
} else if (isEndMoving.current) {
setEnd(step);
setEnd(step)
}
}
}
};
return (
<Wrapper locale={locale}>
@ -68,79 +68,79 @@ const TimeRangeField = forwardRef(({
/>
<Range ref={rangeRef}>
<Selected start={start} end={start > end ? 24 : end} />
{start > end && <Selected start={start > end ? 0 : start} end={end} />}
<Selected $start={start} $end={start > end ? 24 : end} />
{start > end && <Selected $start={start > end ? 0 : start} $end={end} />}
<Handle
value={start}
$value={start}
label={timeFormat === '24h' ? times[start] : dayjs().hour(times[start]).format('ha')}
extraPadding={end - start === 1 ? 'padding-right: 20px;' : (start - end === 1 ? 'padding-left: 20px;' : '')}
$extraPadding={end - start === 1 ? 'padding-right: 20px;' : (start - end === 1 ? 'padding-left: 20px;' : '')}
onMouseDown={() => {
document.addEventListener('mousemove', handleMouseMove);
isStartMoving.current = true;
document.addEventListener('mousemove', handleMouseMove)
isStartMoving.current = true
document.addEventListener('mouseup', () => {
isStartMoving.current = false;
document.removeEventListener('mousemove', handleMouseMove);
}, { once: true });
isStartMoving.current = false
document.removeEventListener('mousemove', handleMouseMove)
}, { once: true })
}}
onTouchMove={(e) => {
const touch = e.targetTouches[0];
onTouchMove={e => {
const touch = e.targetTouches[0]
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24);
if (step < 0) step = 0;
if (step > 24) step = 24;
step = Math.abs(step);
setStart(step);
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
setStart(step)
}}
tabIndex="0"
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
setStart(Math.max(start-1, 0));
e.preventDefault()
setStart(Math.max(start-1, 0))
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
setStart(Math.min(start+1, 24));
e.preventDefault()
setStart(Math.min(start+1, 24))
}
}}
/>
<Handle
value={end}
$value={end}
label={timeFormat === '24h' ? times[end] : dayjs().hour(times[end]).format('ha')}
extraPadding={end - start === 1 ? 'padding-left: 20px;' : (start - end === 1 ? 'padding-right: 20px;' : '')}
$extraPadding={end - start === 1 ? 'padding-left: 20px;' : (start - end === 1 ? 'padding-right: 20px;' : '')}
onMouseDown={() => {
document.addEventListener('mousemove', handleMouseMove);
isEndMoving.current = true;
document.addEventListener('mousemove', handleMouseMove)
isEndMoving.current = true
document.addEventListener('mouseup', () => {
isEndMoving.current = false;
document.removeEventListener('mousemove', handleMouseMove);
}, { once: true });
isEndMoving.current = false
document.removeEventListener('mousemove', handleMouseMove)
}, { once: true })
}}
onTouchMove={(e) => {
const touch = e.targetTouches[0];
onTouchMove={e => {
const touch = e.targetTouches[0]
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24);
if (step < 0) step = 0;
if (step > 24) step = 24;
step = Math.abs(step);
setEnd(step);
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
setEnd(step)
}}
tabIndex="0"
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
setEnd(Math.max(end-1, 0));
e.preventDefault()
setEnd(Math.max(end-1, 0))
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
setEnd(Math.min(end+1, 24));
e.preventDefault()
setEnd(Math.min(end+1, 24))
}
}}
/>
</Range>
</Wrapper>
);
});
)
})
export default TimeRangeField;
export default TimeRangeField

View file

@ -1,41 +1,42 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
margin: 30px 0;
`;
`
export const StyledLabel = styled.label`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: 18px;
`;
`
export const StyledSubLabel = styled.label`
export const StyledSubLabel = styled('label')`
display: block;
padding-bottom: 6px;
font-size: 13px;
opacity: .6;
`;
`
export const Range = styled.div`
export const Range = styled('div', forwardRef)`
user-select: none;
background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary};
background-color: var(--surface);
border: 1px solid var(--primary);
border-radius: 3px;
height: 50px;
position: relative;
margin: 38px 6px 18px;
`;
`
export const Handle = styled.div`
export const Handle = styled('div')`
height: calc(100% + 20px);
width: 20px;
border: 1px solid ${props => props.theme.primary};
background-color: ${props => props.theme.primaryLight};
border: 1px solid var(--primary);
background-color: var(--highlight);
border-radius: 3px;
position: absolute;
top: -10px;
left: calc(${props => props.value * 4.1666666666666666}% - 11px);
left: calc(${props => props.$value * 4.166}% - 11px);
cursor: ew-resize;
touch-action: none;
transition: left .1s;
@ -54,7 +55,7 @@ export const Handle = styled.div`
display: flex;
align-items: center;
justify-content: center;
color: ${props => props.theme.primaryDark};
color: var(--shadow);
}
&:before {
@ -65,20 +66,20 @@ export const Handle = styled.div`
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
${props => props.extraPadding}
${props => props.$extraPadding}
}
`;
`
export const Selected = styled.div`
export const Selected = styled('div')`
position: absolute;
height: 100%;
left: ${props => props.start * 4.1666666666666666}%;
right: calc(100% - ${props => props.end * 4.1666666666666666}%);
left: ${props => props.$start * 4.166}%;
right: calc(100% - ${props => props.$end * 4.166}%);
top: 0;
background-color: ${props => props.theme.primary};
background-color: var(--primary);
border-radius: 2px;
transition: left .1s, right .1s;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
`;
`

View file

@ -1,3 +1,5 @@
import { Info } from 'lucide-react'
import {
Wrapper,
ToggleContainer,
@ -5,21 +7,19 @@ import {
Option,
HiddenInput,
LabelButton,
} from './toggleFieldStyle';
} from './ToggleField.styles'
const ToggleField = ({
label,
id,
name,
title = '',
options = [],
value,
onChange,
inputRef,
...props
}) => (
<Wrapper>
{label && <StyledLabel title={title}>{label} {title !== '' && <svg viewBox="0 0 24 24"><path fill="currentColor" d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z" /></svg>}</StyledLabel>}
{label && <StyledLabel title={title}>{label} {title !== '' && <Info />}</StyledLabel>}
<ToggleContainer>
{Object.entries(options).map(([key, label]) =>
@ -38,6 +38,6 @@ const ToggleField = ({
)}
</ToggleContainer>
</Wrapper>
);
)
export default ToggleField;
export default ToggleField

View file

@ -1,19 +1,20 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
margin: 10px 0;
`;
`
export const ToggleContainer = styled.div`
export const ToggleContainer = styled('div')`
display: flex;
border: 1px solid ${props => props.theme.primary};
border: 1px solid var(--primary);
border-radius: 3px;
overflow: hidden;
--focus-color: ${props => props.theme.primary};
--focus-color: var(--primary);
transition: border .15s;
&:focus-within {
--focus-color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
--focus-color: var(--secondary);
border: 1px solid var(--focus-color);
& label {
box-shadow: inset 0 -3px 0 0 var(--focus-color);
@ -26,9 +27,9 @@ export const ToggleContainer = styled.div`
& > div:last-of-type label {
border-end-end-radius: 2px;
}
`;
`
export const StyledLabel = styled.label`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: .9rem;
@ -38,14 +39,14 @@ export const StyledLabel = styled.label`
width: 1em;
vertical-align: middle;
}
`;
`
export const Option = styled.div`
export const Option = styled('div')`
flex: 1;
position: relative;
`;
`
export const HiddenInput = styled.input`
export const HiddenInput = styled('input', forwardRef)`
height: 0;
width: 0;
position: absolute;
@ -55,12 +56,12 @@ export const HiddenInput = styled.input`
appearance: none;
&:checked + label {
color: ${props => props.theme.background};
color: var(--background);
background-color: var(--focus-color);
}
`;
`
export const LabelButton = styled.label`
export const LabelButton = styled('label')`
padding: 6px;
display: flex;
text-align: center;
@ -71,4 +72,4 @@ export const LabelButton = styled.label`
align-items: center;
justify-content: center;
transition: box-shadow .15s, background-color .15s;
`;
`

View file

@ -1,15 +1,15 @@
import { Button } from 'components';
import { Button } from '/src/components'
import { useTranslateStore } from 'stores';
import { useTranslateStore } from '/src/stores'
import {
Wrapper,
ButtonWrapper,
} from './translateDialogStyle';
} from './TranslateDialog.styles'
const TranslateDialog = ({ onClose }) => {
const navigatorLang = useTranslateStore(state => state.navigatorLang);
const setDialogDismissed = useTranslateStore(state => state.setDialogDismissed);
const TranslateDialog = () => {
const navigatorLang = useTranslateStore(state => state.navigatorLang)
const setDialogDismissed = useTranslateStore(state => state.setDialogDismissed)
return (
<Wrapper>
@ -26,7 +26,7 @@ const TranslateDialog = ({ onClose }) => {
<Button secondary onClick={() => setDialogDismissed(true)}>Close</Button>
</ButtonWrapper>
</Wrapper>
);
)
}
export default TranslateDialog;
export default TranslateDialog

View file

@ -1,13 +1,11 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
position: fixed;
top: 20px;
left: 20px;
background-color: ${props => props.theme.background};
${props => props.theme.mode === 'dark' && `
border: 1px solid ${props.theme.primaryBackground};
`}
background-color: var(--background);
border: 1px solid var(--surface);
z-index: 900;
padding: 20px;
border-radius: 3px;
@ -30,9 +28,9 @@ export const Wrapper = styled.div`
@media (max-width: 400px) {
display: block;
}
`;
`
export const ButtonWrapper = styled.div`
export const ButtonWrapper = styled('div')`
display: flex;
flex-direction: column;
align-items: stretch;
@ -46,4 +44,4 @@ export const ButtonWrapper = styled.div`
margin: 20px 0 0;
white-space: normal;
}
`;
`

View file

@ -1,13 +1,14 @@
import { Button } from 'components';
import { useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next'
import { Button } from '/src/components'
import {
Wrapper,
ButtonWrapper,
} from './updateDialogStyle';
} from './UpdateDialog.styles'
const UpdateDialog = ({ onClose }) => {
const { t } = useTranslation('common');
const { t } = useTranslation('common')
return (
<Wrapper>
@ -18,7 +19,7 @@ const UpdateDialog = ({ onClose }) => {
<Button onClick={() => window.location.reload()}>{t('common:update.buttons.reload')}</Button>
</ButtonWrapper>
</Wrapper>
);
)
}
export default UpdateDialog;
export default UpdateDialog

View file

@ -1,13 +1,11 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Wrapper = styled.div`
export const Wrapper = styled('div')`
position: fixed;
bottom: 20px;
right: 20px;
background-color: ${props => props.theme.background};
${props => props.theme.mode === 'dark' && `
border: 1px solid ${props.theme.primaryBackground};
`}
background-color: var(--background);
border: 1px solid var(--surface);
z-index: 900;
padding: 20px 26px;
border-radius: 3px;
@ -24,12 +22,12 @@ export const Wrapper = styled.div`
margin: 16px 0 24px;
font-size: 1rem;
}
`;
`
export const ButtonWrapper = styled.div`
export const ButtonWrapper = styled('div')`
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
flex-wrap: wrap;
`;
`

View file

@ -0,0 +1,25 @@
export { default as TextField } from './TextField/TextField'
export { default as SelectField } from './SelectField/SelectField'
export { default as CalendarField } from './CalendarField/CalendarField'
export { default as TimeRangeField } from './TimeRangeField/TimeRangeField'
export { default as ToggleField } from './ToggleField/ToggleField'
export { default as Button } from './Button/Button'
export { default as Legend } from './Legend/Legend'
export { default as AvailabilityViewer } from './AvailabilityViewer/AvailabilityViewer'
export { default as AvailabilityEditor } from './AvailabilityEditor/AvailabilityEditor'
export { default as Error } from './Error/Error'
export { default as Loading } from './Loading/Loading'
export { default as Center } from './Center/Center'
export { default as Donate } from './Donate/Donate'
export { default as Settings } from './Settings/Settings'
export { default as Egg } from './Egg/Egg'
export { default as Footer } from './Footer/Footer'
export { default as Recents } from './Recents/Recents'
export { default as Logo } from './Logo/Logo'
export { default as UpdateDialog } from './UpdateDialog/UpdateDialog'
export { default as TranslateDialog } from './TranslateDialog/TranslateDialog'
export const _GoogleCalendar = () => import('./GoogleCalendar/GoogleCalendar')
export const _OutlookCalendar = () => import('./OutlookCalendar/OutlookCalendar')

View file

@ -1,25 +0,0 @@
export { default as TextField } from './TextField/TextField';
export { default as SelectField } from './SelectField/SelectField';
export { default as CalendarField } from './CalendarField/CalendarField';
export { default as TimeRangeField } from './TimeRangeField/TimeRangeField';
export { default as ToggleField } from './ToggleField/ToggleField';
export { default as Button } from './Button/Button';
export { default as Legend } from './Legend/Legend';
export { default as AvailabilityViewer } from './AvailabilityViewer/AvailabilityViewer';
export { default as AvailabilityEditor } from './AvailabilityEditor/AvailabilityEditor';
export { default as Error } from './Error/Error';
export { default as Loading } from './Loading/Loading';
export { default as Center } from './Center/Center';
export { default as Donate } from './Donate/Donate';
export { default as Settings } from './Settings/Settings';
export { default as Egg } from './Egg/Egg';
export { default as Footer } from './Footer/Footer';
export { default as Recents } from './Recents/Recents';
export { default as Logo } from './Logo/Logo';
export { default as UpdateDialog } from './UpdateDialog/UpdateDialog';
export { default as TranslateDialog } from './TranslateDialog/TranslateDialog';
export const _GoogleCalendar = () => import('./GoogleCalendar/GoogleCalendar');
export const _OutlookCalendar = () => import('./OutlookCalendar/OutlookCalendar');

View file

@ -1,11 +1,11 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import Backend from 'i18next-http-backend'
import locales from 'res/dayjs_locales';
import locales from './locales'
const storedLang = localStorage.getItem('i18nextLng');
const storedLang = localStorage.getItem('i18nextLng')
i18n
.use(LanguageDetector)
@ -15,7 +15,6 @@ i18n
fallbackLng: 'en',
supportedLngs: Object.keys(locales),
ns: 'common',
defaultNS: 'common',
debug: process.env.NODE_ENV !== 'production',
interpolation: {
escapeValue: false,
@ -24,6 +23,6 @@ i18n
loadPath: '/i18n/{{lng}}/{{ns}}.json',
},
storedLang,
}).then(() => document.documentElement.setAttribute('lang', i18n.language));
}).then(() => document.documentElement.setAttribute('lang', i18n.language))
export default i18n;
export default i18n

View file

@ -60,12 +60,24 @@ const locales = {
weekStart: 1,
timeFormat: '12h',
},
'pt-BR': { // Portuguese (Brazil)
name: 'Português (do Brasil)',
import: () => import('dayjs/locale/pt-br'),
weekStart: 0,
timeFormat: '24h',
},
'ru': { // Russian
name: 'Pусский',
import: () => import('dayjs/locale/ru'),
weekStart: 1,
timeFormat: '24h',
},
};
// 'zh-CN': { // Chinese
// name: '中文',
// import: () => import('dayjs/locale/zh-cn'),
// weekStart: 1,
// timeFormat: '12h',
// },
}
export default locales;
export default locales

View file

@ -0,0 +1,24 @@
import { StrictMode, createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { setup } from 'goober'
import { shouldForwardProp } from 'goober/should-forward-prop'
import { BrowserRouter } from 'react-router-dom'
import '/src/i18n'
import App from './App'
setup(
createElement,
undefined, undefined,
shouldForwardProp(prop => !prop.startsWith('$'))
)
const root = createRoot(document.getElementById('app'))
root.render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)

View file

@ -1,11 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import 'i18n';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);

View file

@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useTranslation, Trans } from 'react-i18next';
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation, Trans } from 'react-i18next'
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import {
TextField,
@ -17,7 +17,7 @@ import {
Error,
Recents,
Footer,
} from 'components';
} from '/src/components'
import {
StyledMain,
@ -27,80 +27,80 @@ import {
P,
OfflineMessage,
ShareInfo,
} from './createStyle';
} from './Create.styles'
import api from 'services';
import { useRecentsStore } from 'stores';
import api from '/src/services'
import { useRecentsStore } from '/src/stores'
import timezones from 'res/timezones.json';
import timezones from '/src/res/timezones.json'
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
const Create = ({ offline }) => {
const { register, handleSubmit, setValue } = useForm({
defaultValues: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [createdEvent, setCreatedEvent] = useState(null);
const [copied, setCopied] = useState(null);
const [showFooter, setShowFooter] = useState(true);
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const [createdEvent, setCreatedEvent] = useState(null)
const [copied, setCopied] = useState(null)
const [showFooter, setShowFooter] = useState(true)
const { push } = useHistory();
const { t } = useTranslation(['common', 'home', 'event']);
const navigate = useNavigate()
const { t } = useTranslation(['common', 'home', 'event'])
const addRecent = useRecentsStore(state => state.addRecent);
const addRecent = useRecentsStore(state => state.addRecent)
useEffect(() => {
if (window.self === window.top) {
push('/');
navigate('/')
}
document.title = 'Create a Crab Fit';
document.title = 'Create a Crab Fit'
if (window.parent) {
window.parent.postMessage('crabfit-create', '*');
window.parent.postMessage('crabfit-create', '*')
window.addEventListener('message', e => {
if (e.data === 'safari-extension') {
setShowFooter(false);
setShowFooter(false)
}
}, {
once: true
});
})
}
}, [push]);
}, [navigate])
const onSubmit = async data => {
setIsLoading(true);
setError(null);
setIsLoading(true)
setError(null)
try {
const { start, end } = JSON.parse(data.times);
const dates = JSON.parse(data.dates);
const { start, end } = JSON.parse(data.times)
const dates = JSON.parse(data.dates)
if (dates.length === 0) {
return setError(t('home:form.errors.no_dates'));
return setError(t('home:form.errors.no_dates'))
}
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8;
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8
if (start === end) {
return setError(t('home:form.errors.same_times'));
return setError(t('home:form.errors.same_times'))
}
let times = dates.reduce((times, date) => {
let day = [];
const times = dates.reduce((times, date) => {
const day = []
for (let i = start; i < (start > end ? 24 : end); i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
);
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
);
)
}
}
if (start > end) {
@ -109,45 +109,45 @@ const Create = ({ offline }) => {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
);
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
);
)
}
}
}
return [...times, ...day];
}, []);
return [...times, ...day]
}, [])
if (times.length === 0) {
return setError(t('home:form.errors.no_time'));
return setError(t('home:form.errors.no_time'))
}
const response = await api.post('/event', {
const event = await api.post('/event', {
event: {
name: data.name,
times: times,
timezone: data.timezone,
},
});
setCreatedEvent(response.data);
})
setCreatedEvent(event)
addRecent({
id: response.data.id,
created: response.data.created,
name: response.data.name,
});
id: event.id,
created: event.created,
name: event.name,
})
gtag('event', 'create_event', {
'event_category': 'create',
});
})
} catch (e) {
setError(t('home:form.errors.unknown'));
console.error(e);
setError(t('home:form.errors.unknown'))
console.error(e)
} finally {
setIsLoading(false);
setIsLoading(false)
}
}
};
return (
<>
@ -163,15 +163,15 @@ const Create = ({ offline }) => {
<ShareInfo
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${createdEvent.id}`)
.then(() => {
setCopied(t('event:nav.copied'));
setTimeout(() => setCopied(null), 1000);
setCopied(t('event:nav.copied'))
setTimeout(() => setCopied(null), 1000)
gtag('event', 'copy_link', {
'event_category': 'event',
});
})
.catch((e) => console.error('Failed to copy', e))
})
.catch(e => console.error('Failed to copy', e))
}
title={!!navigator.clipboard ? t('event:nav.title') : ''}
title={navigator.clipboard ? t('event:nav.title') : ''}
>{copied ?? `https://crab.fit/${createdEvent?.id}`}</ShareInfo>
<ShareInfo>
{/* eslint-disable-next-line */}
@ -236,7 +236,7 @@ const Create = ({ offline }) => {
</>
)}
</>
);
};
)
}
export default Create;
export default Create

View file

@ -1,50 +1,50 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const StyledMain = styled.div`
export const StyledMain = styled('div')`
width: 600px;
margin: 10px auto;
max-width: calc(100% - 30px);
`;
`
export const CreateForm = styled.form`
export const CreateForm = styled('form')`
margin: 0 0 30px;
`;
`
export const TitleSmall = styled.span`
export const TitleSmall = styled('span')`
display: block;
margin: 0;
font-size: 2rem;
text-align: center;
font-family: 'Samurai Bob', sans-serif;
font-weight: 400;
color: ${props => props.theme.primaryDark};
color: var(--secondary);
line-height: 1em;
text-transform: uppercase;
`;
`
export const TitleLarge = styled.h1`
export const TitleLarge = styled('h1')`
margin: 0;
font-size: 2rem;
text-align: center;
color: ${props => props.theme.primary};
color: var(--primary);
font-family: 'Molot', sans-serif;
font-weight: 400;
text-shadow: 0 3px 0 ${props => props.theme.primaryDark};
text-shadow: 0 3px 0 var(--secondary);
line-height: 1em;
text-transform: uppercase;
`;
`
export const P = styled.p`
export const P = styled('p')`
font-weight: 500;
line-height: 1.6em;
`;
`
export const OfflineMessage = styled.div`
export const OfflineMessage = styled('div')`
text-align: center;
margin: 50px 0 20px;
`;
`
export const ShareInfo = styled.p`
export const ShareInfo = styled('p')`
margin: 6px 0;
text-align: center;
font-size: 15px;
@ -54,7 +54,7 @@ export const ShareInfo = styled.p`
cursor: pointer;
&:hover {
color: ${props.theme.primaryDark};
color: var(--secondary);
}
`}
`;
`

View file

@ -1,12 +1,13 @@
import { useForm } from 'react-hook-form';
import { useState, useEffect } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useForm } from 'react-hook-form'
import { useState, useEffect } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { useParams } from 'react-router-dom'
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import relativeTime from 'dayjs/plugin/relativeTime'
import {
Footer,
@ -17,9 +18,9 @@ import {
AvailabilityEditor,
Error,
Logo,
} from 'components';
} from '/src/components'
import { StyledMain } from '../Home/homeStyle';
import { StyledMain } from '../Home/Home.styles'
import {
EventName,
@ -30,232 +31,226 @@ import {
ShareInfo,
Tabs,
Tab,
} from './eventStyle';
} from './Event.styles'
import api from 'services';
import { useSettingsStore, useRecentsStore, useLocaleUpdateStore } from 'stores';
import api from '/src/services'
import { useSettingsStore, useRecentsStore, useLocaleUpdateStore } from '/src/stores'
import timezones from 'res/timezones.json';
import timezones from '/src/res/timezones.json'
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
dayjs.extend(relativeTime);
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
dayjs.extend(relativeTime)
const Event = (props) => {
const timeFormat = useSettingsStore(state => state.timeFormat);
const weekStart = useSettingsStore(state => state.weekStart);
const Event = () => {
const timeFormat = useSettingsStore(state => state.timeFormat)
const weekStart = useSettingsStore(state => state.weekStart)
const addRecent = useRecentsStore(state => state.addRecent);
const removeRecent = useRecentsStore(state => state.removeRecent);
const locale = useLocaleUpdateStore(state => state.locale);
const addRecent = useRecentsStore(state => state.addRecent)
const removeRecent = useRecentsStore(state => state.removeRecent)
const locale = useLocaleUpdateStore(state => state.locale)
const { t } = useTranslation(['common', 'event']);
const { t } = useTranslation(['common', 'event'])
const { register, handleSubmit, setFocus, reset } = useForm();
const { id } = props.match.params;
const { offline } = props;
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone);
const [user, setUser] = useState(null);
const [password, setPassword] = useState(null);
const [tab, setTab] = useState(user ? 'you' : 'group');
const [isLoading, setIsLoading] = useState(true);
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [error, setError] = useState(null);
const [event, setEvent] = useState(null);
const [people, setPeople] = useState([]);
const { register, handleSubmit, setFocus, reset } = useForm()
const { id } = useParams()
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
const [user, setUser] = useState(null)
const [password, setPassword] = useState(null)
const [tab, setTab] = useState(user ? 'you' : 'group')
const [isLoading, setIsLoading] = useState(true)
const [isLoginLoading, setIsLoginLoading] = useState(false)
const [error, setError] = useState(null)
const [event, setEvent] = useState(null)
const [people, setPeople] = useState([])
const [times, setTimes] = useState([]);
const [timeLabels, setTimeLabels] = useState([]);
const [dates, setDates] = useState([]);
const [min, setMin] = useState(0);
const [max, setMax] = useState(0);
const [times, setTimes] = useState([])
const [timeLabels, setTimeLabels] = useState([])
const [dates, setDates] = useState([])
const [min, setMin] = useState(0)
const [max, setMax] = useState(0)
const [copied, setCopied] = useState(null);
const [copied, setCopied] = useState(null)
useEffect(() => {
const fetchEvent = async () => {
try {
const response = await api.get(`/event/${id}`);
const event = await api.get(`/event/${id}`)
setEvent(response.data);
setEvent(event)
addRecent({
id: response.data.id,
created: response.data.created,
name: response.data.name,
});
document.title = `${response.data.name} | Crab Fit`;
id: event.id,
created: event.created,
name: event.name,
})
document.title = `${event.name} | Crab Fit`
} catch (e) {
console.error(e);
console.error(e)
if (e.status === 404) {
removeRecent(id);
removeRecent(id)
}
} finally {
setIsLoading(false);
setIsLoading(false)
}
}
};
fetchEvent();
}, [id, addRecent, removeRecent]);
fetchEvent()
}, [id, addRecent, removeRecent])
useEffect(() => {
const fetchPeople = async () => {
try {
const response = await api.get(`/event/${id}/people`);
const adjustedPeople = response.data.people.map(person => ({
const { people } = await api.get(`/event/${id}/people`)
const adjustedPeople = people.map(person => ({
...person,
availability: (!!person.availability.length && person.availability[0].length === 13)
? person.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: person.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}));
setPeople(adjustedPeople);
}))
setPeople(adjustedPeople)
} catch (e) {
console.error(e);
console.error(e)
}
}
if (tab === 'group') {
fetchPeople();
fetchPeople()
}
}, [tab, id, timezone]);
}, [tab, id, timezone])
// Convert to timezone and expand minute segments
useEffect(() => {
if (event) {
const isSpecificDates = event.times[0].length === 13;
const isSpecificDates = event.times[0].length === 13
setTimes(event.times.reduce(
(allTimes, time) => {
const date = isSpecificDates ?
dayjs(time, 'HHmm-DDMMYYYY').utc(true).tz(timezone)
: dayjs(time, 'HHmm').day(time.substring(5)).utc(true).tz(timezone);
const format = isSpecificDates ? 'HHmm-DDMMYYYY' : 'HHmm-d';
: dayjs(time, 'HHmm').day(time.substring(5)).utc(true).tz(timezone)
const format = isSpecificDates ? 'HHmm-DDMMYYYY' : 'HHmm-d'
return [
...allTimes,
date.minute(0).format(format),
date.minute(15).format(format),
date.minute(30).format(format),
date.minute(45).format(format),
];
]
},
[]
).sort((a, b) => {
if (isSpecificDates) {
return dayjs(a, 'HHmm-DDMMYYYY').diff(dayjs(b, 'HHmm-DDMMYYYY'));
return dayjs(a, 'HHmm-DDMMYYYY').diff(dayjs(b, 'HHmm-DDMMYYYY'))
} else {
return dayjs(a, 'HHmm').day((parseInt(a.substring(5))-weekStart % 7 + 7) % 7)
.diff(dayjs(b, 'HHmm').day((parseInt(b.substring(5))-weekStart % 7 + 7) % 7));
.diff(dayjs(b, 'HHmm').day((parseInt(b.substring(5))-weekStart % 7 + 7) % 7))
}
}));
}))
}
}, [event, timezone, weekStart]);
}, [event, timezone, weekStart])
useEffect(() => {
if (!!times.length && !!people.length) {
setMin(times.reduce((min, time) => {
let total = people.reduce(
const total = people.reduce(
(total, person) => person.availability.includes(time) ? total+1 : total,
0
);
return total < min ? total : min;
},
Infinity
));
)
return total < min ? total : min
}, Infinity))
setMax(times.reduce((max, time) => {
let total = people.reduce(
const total = people.reduce(
(total, person) => person.availability.includes(time) ? total+1 : total,
0
);
return total > max ? total : max;
},
-Infinity
));
)
return total > max ? total : max
}, -Infinity))
}
}, [times, people]);
}, [times, people])
useEffect(() => {
if (!!times.length) {
if (times.length) {
setTimeLabels(times.reduce((labels, datetime) => {
const time = datetime.substring(0, 4);
if (labels.includes(time)) return labels;
return [...labels, time];
const time = datetime.substring(0, 4)
if (labels.includes(time)) return labels
return [...labels, time]
}, [])
.sort((a, b) => parseInt(a) - parseInt(b))
.reduce((labels, time, i, allTimes) => {
if (time.substring(2) === '30') return [...labels, { label: '', time }];
if (time.substring(2) === '30') return [...labels, { label: '', time }]
if (allTimes.length - 1 === i) return [
...labels,
{ label: '', time },
{ label: dayjs(time, 'HHmm').add(1, 'hour').format(timeFormat === '12h' ? 'h A' : 'HH'), time: null }
];
]
if (allTimes.length - 1 > i && parseInt(allTimes[i+1].substring(0, 2))-1 > parseInt(time.substring(0, 2))) return [
...labels,
{ label: '', time },
{ label: dayjs(time, 'HHmm').add(1, 'hour').format(timeFormat === '12h' ? 'h A' : 'HH'), time: 'space' },
{ label: '', time: 'space' },
{ label: '', time: 'space' },
];
if (time.substring(2) !== '00') return [...labels, { label: '', time }];
return [...labels, { label: dayjs(time, 'HHmm').format(timeFormat === '12h' ? 'h A' : 'HH'), time }];
}, []));
]
if (time.substring(2) !== '00') return [...labels, { label: '', time }]
return [...labels, { label: dayjs(time, 'HHmm').format(timeFormat === '12h' ? 'h A' : 'HH'), time }]
}, []))
setDates(times.reduce((allDates, time) => {
if (time.substring(2, 4) !== '00') return allDates;
const date = time.substring(5);
if (allDates.includes(date)) return allDates;
return [...allDates, date];
}, []));
if (time.substring(2, 4) !== '00') return allDates
const date = time.substring(5)
if (allDates.includes(date)) return allDates
return [...allDates, date]
}, []))
}
}, [times, timeFormat, locale]);
}, [times, timeFormat, locale])
useEffect(() => {
const fetchUser = async () => {
try {
const response = await api.post(`/event/${id}/people/${user.name}`, { person: { password } });
const resUser = await api.post(`/event/${id}/people/${user.name}`, { person: { password } })
const adjustedUser = {
...response.data,
availability: (!!response.data.availability.length && response.data.availability[0].length === 13)
? response.data.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: response.data.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
};
setUser(adjustedUser);
} catch (e) {
console.log(e);
...resUser,
availability: (!!resUser.availability.length && resUser.availability[0].length === 13)
? resUser.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: resUser.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}
setUser(adjustedUser)
} catch (e) {
console.log(e)
}
}
};
if (user) {
fetchUser();
fetchUser()
}
// eslint-disable-next-line
}, [timezone]);
}, [timezone])
const onSubmit = async data => {
if (!data.name || data.name.length === 0) {
setFocus('name');
return setError(t('event:form.errors.name_required'));
setFocus('name')
return setError(t('event:form.errors.name_required'))
}
setIsLoginLoading(true);
setError(null);
setIsLoginLoading(true)
setError(null)
try {
const response = await api.post(`/event/${id}/people/${data.name}`, {
const resUser = await api.post(`/event/${id}/people/${data.name}`, {
person: {
password: data.password,
},
});
setPassword(data.password);
})
setPassword(data.password)
const adjustedUser = {
...response.data,
availability: (!!response.data.availability.length && response.data.availability[0].length === 13)
? response.data.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: response.data.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
};
setUser(adjustedUser);
setTab('you');
...resUser,
availability: (!!resUser.availability.length && resUser.availability[0].length === 13)
? resUser.availability.map(date => dayjs(date, 'HHmm-DDMMYYYY').utc(true).tz(timezone).format('HHmm-DDMMYYYY'))
: resUser.availability.map(date => dayjs(date, 'HHmm').day(date.substring(5)).utc(true).tz(timezone).format('HHmm-d')),
}
setUser(adjustedUser)
setTab('you')
} catch (e) {
if (e.status === 401) {
setError(t('event:form.errors.password_incorrect'));
setError(t('event:form.errors.password_incorrect'))
} else if (e.status === 404) {
// Create user
try {
@ -264,25 +259,25 @@ const Event = (props) => {
name: data.name,
password: data.password,
},
});
setPassword(data.password);
})
setPassword(data.password)
setUser({
name: data.name,
availability: [],
});
setTab('you');
})
setTab('you')
} catch (e) {
setError(t('event:form.errors.unknown'));
setError(t('event:form.errors.unknown'))
}
}
} finally {
setIsLoginLoading(false);
setIsLoginLoading(false)
gtag('event', 'login', {
'event_category': 'event',
});
reset();
})
reset()
}
}
};
return (
<>
@ -291,39 +286,32 @@ const Event = (props) => {
{(!!event || isLoading) ? (
<>
<EventName isLoading={isLoading}>{event?.name}</EventName>
<EventDate isLoading={isLoading} locale={locale} title={event?.created && dayjs.unix(event?.created).format('D MMMM, YYYY')}>{event?.created && t('common:created', { date: dayjs.unix(event?.created).fromNow() })}</EventDate>
<EventName $isLoading={isLoading}>{event?.name}</EventName>
<EventDate $isLoading={isLoading} locale={locale} title={event?.created && dayjs.unix(event?.created).format('D MMMM, YYYY')}>{event?.created && t('common:created', { date: dayjs.unix(event?.created).fromNow() })}</EventDate>
<ShareInfo
onClick={() => navigator.clipboard?.writeText(`https://crab.fit/${id}`)
.then(() => {
setCopied(t('event:nav.copied'));
setTimeout(() => setCopied(null), 1000);
setCopied(t('event:nav.copied'))
setTimeout(() => setCopied(null), 1000)
gtag('event', 'copy_link', {
'event_category': 'event',
});
})
.catch((e) => console.error('Failed to copy', e))
})
.catch(e => console.error('Failed to copy', e))
}
title={!!navigator.clipboard ? t('event:nav.title') : ''}
title={navigator.clipboard ? t('event:nav.title') : ''}
>{copied ?? `https://crab.fit/${id}`}</ShareInfo>
<ShareInfo isLoading={isLoading} className="instructions">
<ShareInfo $isLoading={isLoading} className="instructions">
{!!event?.name &&
<Trans i18nKey="event:nav.shareinfo">Copy the link to this page, or share via <a onClick={() => gtag('event', 'send_email', { 'event_category': 'event' })} href={`mailto:?subject=${encodeURIComponent(t('event:nav.email_subject', { event_name: event?.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${id}`)}`}>email</a>.</Trans>
}
</ShareInfo>
</>
) : (
offline ? (
<div style={{ margin: '100px 0' }}>
<EventName>{t('event:offline.title')}</EventName>
<ShareInfo><Trans i18nKey="event:offline.body" /></ShareInfo>
</div>
) : (
<div style={{ margin: '100px 0' }}>
<EventName>{t('event:error.title')}</EventName>
<ShareInfo>{t('event:error.body')}</ShareInfo>
</div>
)
)}
</StyledMain>
@ -335,9 +323,9 @@ const Event = (props) => {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '20px 0', flexWrap: 'wrap', gap: '10px' }}>
<h2 style={{ margin: 0 }}>{t('event:form.signed_in', { name: user.name })}</h2>
<Button small onClick={() => {
setTab('group');
setUser(null);
setPassword(null);
setTab('group')
setUser(null)
setPassword(null)
}}>{t('event:form.logout_button')}</Button>
</div>
) : (
@ -363,7 +351,7 @@ const Event = (props) => {
<Button
type="submit"
isLoading={isLoginLoading}
$isLoading={isLoginLoading}
disabled={isLoginLoading || isLoading}
>{t('event:form.button')}</Button>
</LoginForm>
@ -383,8 +371,8 @@ const Event = (props) => {
/>
{/* eslint-disable-next-line */}
{event?.timezone && event.timezone !== timezone && <p><Trans i18nKey="event:form.created_in_timezone">This event was created in the timezone <strong>{{timezone: event.timezone}}</strong>. <a href="#" onClick={e => {
e.preventDefault();
setTimezone(event.timezone);
e.preventDefault()
setTimezone(event.timezone)
}}>Click here</a> to use it.</Trans></p>}
{((
Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
@ -395,8 +383,8 @@ const Event = (props) => {
)) && (
/* eslint-disable-next-line */
<p><Trans i18nKey="event:form.local_timezone">Your local timezone is detected to be <strong>{{timezone: Intl.DateTimeFormat().resolvedOptions().timeZone}}</strong>. <a href="#" onClick={e => {
e.preventDefault();
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
e.preventDefault()
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
}}>Click here</a> to use it.</Trans></p>
)}
</StyledMain>
@ -407,24 +395,24 @@ const Event = (props) => {
<Tab
href="#you"
onClick={e => {
e.preventDefault();
e.preventDefault()
if (user) {
setTab('you');
setTab('you')
} else {
setFocus('name');
setFocus('name')
}
}}
selected={tab === 'you'}
$selected={tab === 'you'}
disabled={!user}
title={user ? '' : t('event:tabs.you_tooltip')}
>{t('event:tabs.you')}</Tab>
<Tab
href="#group"
onClick={e => {
e.preventDefault();
setTab('group');
e.preventDefault()
setTab('group')
}}
selected={tab === 'group'}
$selected={tab === 'group'}
>{t('event:tabs.group')}</Tab>
</Tabs>
</StyledMain>
@ -451,21 +439,21 @@ const Event = (props) => {
isSpecificDates={!!dates.length && dates[0].length === 8}
value={user.availability}
onChange={async availability => {
const oldAvailability = [...user.availability];
const oldAvailability = [...user.availability]
const utcAvailability = (!!availability.length && availability[0].length === 13)
? availability.map(date => dayjs.tz(date, 'HHmm-DDMMYYYY', timezone).utc().format('HHmm-DDMMYYYY'))
: availability.map(date => dayjs.tz(date, 'HHmm', timezone).day(date.substring(5)).utc().format('HHmm-d'));
setUser({ ...user, availability });
: availability.map(date => dayjs.tz(date, 'HHmm', timezone).day(date.substring(5)).utc().format('HHmm-d'))
setUser({ ...user, availability })
try {
await api.patch(`/event/${id}/people/${user.name}`, {
person: {
password,
availability: utcAvailability,
},
});
})
} catch (e) {
console.log(e);
setUser({ ...user, oldAvailability });
console.log(e)
setUser({ ...user, oldAvailability })
}
}}
/>
@ -476,7 +464,7 @@ const Event = (props) => {
<Footer />
</>
);
};
)
}
export default Event;
export default Event

View file

@ -1,24 +1,24 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const EventName = styled.h1`
export const EventName = styled('h1')`
text-align: center;
font-weight: 800;
margin: 20px 0 5px;
${props => props.isLoading && `
${props => props.$isLoading && `
&:after {
content: '';
display: inline-block;
height: 1em;
width: 400px;
max-width: 100%;
background-color: ${props.theme.loading};
background-color: var(--loading);
border-radius: 3px;
}
`}
`;
`
export const EventDate = styled.span`
export const EventDate = styled('span')`
display: block;
text-align: center;
font-size: 14px;
@ -27,14 +27,14 @@ export const EventDate = styled.span`
font-weight: 500;
letter-spacing: .01em;
${props => props.isLoading && `
${props => props.$isLoading && `
&:after {
content: '';
display: inline-block;
height: 1em;
width: 200px;
max-width: 100%;
background-color: ${props.theme.loading};
background-color: var(--loading);
border-radius: 3px;
}
`}
@ -44,9 +44,9 @@ export const EventDate = styled.span`
content: ' - ' attr(title);
}
}
`;
`
export const LoginForm = styled.form`
export const LoginForm = styled('form')`
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: flex-end;
@ -62,36 +62,36 @@ export const LoginForm = styled.form`
--btn-width: 100%;
}
}
`;
`
export const LoginSection = styled.section`
background-color: ${props => props.theme.primaryBackground};
export const LoginSection = styled('section')`
background-color: var(--surface);
padding: 10px 0;
@media print {
display: none;
}
`;
`
export const Info = styled.p`
export const Info = styled('p')`
margin: 18px 0;
opacity: .6;
font-size: 12px;
`;
`
export const ShareInfo = styled.p`
export const ShareInfo = styled('p')`
margin: 6px 0;
text-align: center;
font-size: 15px;
${props => props.isLoading && `
${props => props.$isLoading && `
&:after {
content: '';
display: inline-block;
height: 1em;
width: 300px;
max-width: 100%;
background-color: ${props.theme.loading};
background-color: var(--loading);
border-radius: 3px;
}
`}
@ -100,7 +100,7 @@ export const ShareInfo = styled.p`
cursor: pointer;
&:hover {
color: ${props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
color: var(--secondary);
}
`}
@ -109,9 +109,9 @@ export const ShareInfo = styled.p`
display: none;
}
}
`;
`
export const Tabs = styled.div`
export const Tabs = styled('div')`
display: flex;
align-items: center;
justify-content: center;
@ -120,29 +120,29 @@ export const Tabs = styled.div`
@media print {
display: none;
}
`;
`
export const Tab = styled.a`
export const Tab = styled('a')`
user-select: none;
text-decoration: none;
display: block;
color: ${props => props.theme.text};
color: var(--text);
padding: 8px 18px;
background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary};
background-color: var(--surface);
border: 1px solid var(--primary);
border-bottom: 0;
margin: 0 4px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
${props => props.selected && `
${props => props.$selected && `
color: #FFF;
background-color: ${props.theme.primary};
border-color: ${props.theme.primary};
background-color: var(--primary);
border-color: var(--primary);
`}
${props => props.disabled && `
opacity: .5;
cursor: not-allowed;
`}
`;
`

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,13 @@
import styled from '@emotion/styled';
import { styled } from 'goober'
export const Step = styled.h2`
text-decoration-color: ${props => props.theme.primary};
export const Step = styled('h2')`
text-decoration-color: var(--primary);
text-decoration-style: solid;
text-decoration-line: underline;
margin-top: 30px;
`;
`
export const FakeCalendar = styled.div`
export const FakeCalendar = styled('div')`
user-select: none;
& div {
@ -28,8 +28,8 @@ export const FakeCalendar = styled.div`
}
}
& .dates span {
background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary};
background-color: var(--surface);
border: 1px solid var(--primary);
display: flex;
align-items: center;
justify-content: center;
@ -37,7 +37,7 @@ export const FakeCalendar = styled.div`
&.selected {
color: #FFF;
background-color: ${props => props.theme.primary};
background-color: var(--primary);
}
}
& .dates span:first-of-type {
@ -48,12 +48,12 @@ export const FakeCalendar = styled.div`
border-end-end-radius: 3px;
border-start-end-radius: 3px;
}
`;
`
export const FakeTimeRange = styled.div`
export const FakeTimeRange = styled('div')`
user-select: none;
background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary};
background-color: var(--surface);
border: 1px solid var(--primary);
border-radius: 3px;
height: 50px;
position: relative;
@ -62,8 +62,8 @@ export const FakeTimeRange = styled.div`
& div {
height: calc(100% + 20px);
width: 20px;
border: 1px solid ${props => props.theme.primary};
background-color: ${props => props.theme.primaryLight};
border: 1px solid var(--primary);
background-color: var(--highlight);
border-radius: 3px;
position: absolute;
top: -10px;
@ -79,7 +79,7 @@ export const FakeTimeRange = styled.div`
display: flex;
align-items: center;
justify-content: center;
color: ${props => props.theme.primaryDark};
color: var(--shadow);
}
&:before {
@ -92,25 +92,25 @@ export const FakeTimeRange = styled.div`
}
}
& .start {
left: calc(${11 * 4.1666666666666666}% - 11px);
left: calc(${11 * 4.166}% - 11px);
}
& .end {
left: calc(${17 * 4.1666666666666666}% - 11px);
left: calc(${17 * 4.166}% - 11px);
}
&:before {
content: '';
position: absolute;
height: 100%;
left: ${11 * 4.1666666666666666}%;
right: calc(100% - ${17 * 4.1666666666666666}%);
left: ${11 * 4.166}%;
right: calc(100% - ${17 * 4.166}%);
top: 0;
background-color: ${props => props.theme.primary};
background-color: var(--primary);
border-radius: 2px;
}
`;
`
export const ButtonArea = styled.div`
export const ButtonArea = styled('div')`
@media print {
display: none;
}
`;
`

File diff suppressed because one or more lines are too long

View file

@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { useHistory, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useTranslation, Trans } from 'react-i18next';
import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useTranslation, Trans } from 'react-i18next'
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import {
TextField,
@ -18,7 +18,7 @@ import {
Error,
Footer,
Recents,
} from 'components';
} from '/src/components'
import {
StyledMain,
@ -37,82 +37,82 @@ import {
ButtonArea,
VideoWrapper,
VideoLink,
} from './homeStyle';
} from './Home.styles'
import api from 'services';
import { detect_browser } from 'utils';
import { useTWAStore } from 'stores';
import api from '/src/services'
import { detect_browser } from '/src/utils'
import { useTWAStore } from '/src/stores'
import logo from 'res/logo.svg';
import video_thumb from 'res/video_thumb.jpg';
import timezones from 'res/timezones.json';
import logo from '/src/res/logo.svg'
import video_thumb from '/src/res/video_thumb.jpg'
import timezones from '/src/res/timezones.json'
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
const Home = ({ offline }) => {
const { register, handleSubmit, setValue } = useForm({
defaultValues: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const [stats, setStats] = useState({
eventCount: null,
personCount: null,
version: 'loading...',
});
const [browser, setBrowser] = useState(undefined);
const [videoPlay, setVideoPlay] = useState(false);
const { push } = useHistory();
const { t } = useTranslation(['common', 'home']);
const isTWA = useTWAStore(state => state.TWA);
})
const [browser, setBrowser] = useState(undefined)
const [videoPlay, setVideoPlay] = useState(false)
const navigate = useNavigate()
const { t } = useTranslation(['common', 'home'])
const isTWA = useTWAStore(state => state.TWA)
useEffect(() => {
const fetch = async () => {
try {
const response = await api.get('/stats');
setStats(response.data);
const response = await api.get('/stats')
setStats(response)
} catch (e) {
console.error(e);
console.error(e)
}
}
};
fetch();
document.title = 'Crab Fit';
setBrowser(detect_browser());
}, []);
fetch()
document.title = 'Crab Fit'
setBrowser(detect_browser())
}, [])
const onSubmit = async data => {
setIsLoading(true);
setError(null);
setIsLoading(true)
setError(null)
try {
const { start, end } = JSON.parse(data.times);
const dates = JSON.parse(data.dates);
const { start, end } = JSON.parse(data.times)
const dates = JSON.parse(data.dates)
if (dates.length === 0) {
return setError(t('home:form.errors.no_dates'));
return setError(t('home:form.errors.no_dates'))
}
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8;
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8
if (start === end) {
return setError(t('home:form.errors.same_times'));
return setError(t('home:form.errors.same_times'))
}
let times = dates.reduce((times, date) => {
let day = [];
const times = dates.reduce((times, date) => {
const day = []
for (let i = start; i < (start > end ? 24 : end); i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
);
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
);
)
}
}
if (start > end) {
@ -121,20 +121,20 @@ const Home = ({ offline }) => {
day.push(
dayjs.tz(date, 'DDMMYYYY', data.timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
);
)
} else {
day.push(
dayjs().tz(data.timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
);
)
}
}
}
return [...times, ...day];
}, []);
return [...times, ...day]
}, [])
if (times.length === 0) {
return setError(t('home:form.errors.no_time'));
return setError(t('home:form.errors.no_time'))
}
const response = await api.post('/event', {
@ -143,18 +143,18 @@ const Home = ({ offline }) => {
times: times,
timezone: data.timezone,
},
});
push(`/${response.data.id}`);
})
navigate(`/${response.id}`)
gtag('event', 'create_event', {
'event_category': 'home',
});
})
} catch (e) {
setError(t('home:form.errors.unknown'));
console.error(e);
setError(t('home:form.errors.unknown'))
console.error(e)
} finally {
setIsLoading(false);
setIsLoading(false)
}
}
};
return (
<>
@ -162,7 +162,7 @@ const Home = ({ offline }) => {
<Center>
<Logo src={logo} alt="" />
</Center>
<TitleSmall altChars={/^[A-Za-z ]+$/.test(t('home:create'))}>{t('home:create')}</TitleSmall>
<TitleSmall $altChars={/^[A-Za-z ]+$/.test(t('home:create'))}>{t('home:create')}</TitleSmall>
<TitleLarge>CRAB FIT</TitleLarge>
<Links>
<a href="#about">{t('home:nav.about')}</a> / <a href="#donate">{t('home:nav.donate')}</a>
@ -228,11 +228,11 @@ const Home = ({ offline }) => {
<h2>{t('home:about.name')}</h2>
<Stats>
<Stat>
<StatNumber>{stats.eventCount ?? '1100+'}</StatNumber>
<StatNumber>{new Intl.NumberFormat().format(stats.eventCount ?? 7000)}{!stats.eventCount && '+'}</StatNumber>
<StatLabel>{t('home:about.events')}</StatLabel>
</Stat>
<Stat>
<StatNumber>{stats.personCount ?? '3700+'}</StatNumber>
<StatNumber>{new Intl.NumberFormat().format(stats.personCount ?? 25000)}{!stats.personCount && '+'}</StatNumber>
<StatLabel>{t('home:about.availabilities')}</StatLabel>
</Stat>
</Stats>
@ -240,14 +240,14 @@ const Home = ({ offline }) => {
{videoPlay ? (
<VideoWrapper>
<iframe width="560" height="315" src="https://www.youtube.com/embed/yXGd4VXZzcY?modestbranding=1&rel=0&autoplay=1" title={t('common:video.title')} frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
<iframe width="560" height="315" src="https://www.youtube.com/embed/yXGd4VXZzcY?modestbranding=1&rel=0&autoplay=1" title={t('common:video.title')} frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen></iframe>
</VideoWrapper>
) : (
<VideoLink
href="https://www.youtube.com/watch?v=yXGd4VXZzcY"
onClick={e => {
e.preventDefault();
setVideoPlay(true);
e.preventDefault()
setVideoPlay(true)
}}
>
<img src={video_thumb} alt={t('common:video.button')} />
@ -298,7 +298,7 @@ const Home = ({ offline }) => {
<Footer />
</>
);
};
)
}
export default Home;
export default Home

View file

@ -1,71 +1,71 @@
import styled from '@emotion/styled';
import { keyframes, styled } from 'goober'
export const StyledMain = styled.div`
export const StyledMain = styled('div')`
width: 600px;
margin: 20px auto;
max-width: calc(100% - 60px);
`;
`
export const CreateForm = styled.form`
export const CreateForm = styled('form')`
margin: 0 0 60px;
`;
`
export const TitleSmall = styled.span`
export const TitleSmall = styled('span')`
display: block;
margin: 0;
font-size: 3rem;
text-align: center;
font-family: 'Samurai Bob', sans-serif;
font-weight: 400;
color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
color: var(--secondary);
line-height: 1em;
text-transform: uppercase;
${props => !props.altChars && `
${props => !props.$altChars && `
font-family: sans-serif;
font-size: 2rem;
font-weight: 600;
line-height: 1.2em;
padding-top: .3em;
`}
`;
`
export const TitleLarge = styled.h1`
export const TitleLarge = styled('h1')`
margin: 0;
font-size: 4rem;
text-align: center;
color: ${props => props.theme.primary};
color: var(--primary);
font-family: 'Molot', sans-serif;
font-weight: 400;
text-shadow: 0 4px 0 ${props => props.theme.primaryDark};
text-shadow: 0 4px 0 var(--shadow);
line-height: 1em;
text-transform: uppercase;
@media (max-width: 350px) {
font-size: 3.5rem;
}
`;
`
export const Logo = styled.img`
width: 80px;
transition: transform .15s;
animation: jelly .5s 1 .05s;
user-select: none;
@keyframes jelly {
const jelly = keyframes`
from,to {
transform: scale(1,1)
transform: scale(1,1);
}
25% {
transform: scale(.9,1.1)
transform: scale(.9,1.1);
}
50% {
transform: scale(1.1,.9)
transform: scale(1.1,.9);
}
75% {
transform: scale(.95,1.05)
}
transform: scale(.95,1.05);
}
`
export const Logo = styled('img')`
width: 80px;
transition: transform .15s;
animation: ${jelly} .5s 1 .05s;
user-select: none;
&:active {
animation: none;
@ -79,68 +79,68 @@ export const Logo = styled.img`
transform: none;
}
}
`;
`
export const Links = styled.nav`
export const Links = styled('nav')`
text-align: center;
margin: 20px 0;
`;
`
export const AboutSection = styled.section`
export const AboutSection = styled('section')`
margin: 30px 0 0;
background-color: ${props => props.theme.primaryBackground};
background-color: var(--surface);
padding: 20px 0;
& a {
color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
color: var(--secondary);
}
`;
`
export const P = styled.p`
export const P = styled('p')`
font-weight: 500;
line-height: 1.6em;
`;
`
export const Stats = styled.div`
export const Stats = styled('div')`
display: flex;
justify-content: space-around;
align-items: flex-start;
flex-wrap: wrap;
`;
`
export const Stat = styled.div`
export const Stat = styled('div')`
text-align: center;
padding: 0 6px;
min-width: 160px;
margin: 10px 0;
`;
`
export const StatNumber = styled.span`
export const StatNumber = styled('span')`
display: block;
font-weight: 900;
color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
color: var(--secondary);
font-size: 2em;
`;
`
export const StatLabel = styled.span`
export const StatLabel = styled('span')`
display: block;
`;
`
export const OfflineMessage = styled.div`
export const OfflineMessage = styled('div')`
text-align: center;
margin: 50px 0 20px;
`;
`
export const ButtonArea = styled.div`
export const ButtonArea = styled('div')`
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
margin: 30px 0;
`;
`
export const VideoWrapper = styled.div`
export const VideoWrapper = styled('div')`
margin: 0 auto;
position: relative;
padding-bottom: 56.4%;
@ -152,9 +152,9 @@ export const VideoWrapper = styled.div`
height: 100%;
border-radius: 10px;
}
`;
`
export const VideoLink = styled.a`
export const VideoLink = styled('a')`
display: block;
text-decoration: none;
position: relative;
@ -163,10 +163,10 @@ export const VideoLink = styled.a`
margin: 0 auto;
transition: transform .15s;
:hover, :focus {
&:hover, &:focus {
transform: translateY(-2px);
}
:active {
&:active {
transform: translateY(-1px);
}
@ -188,7 +188,7 @@ export const VideoLink = styled.a`
text-shadow: 0 0 20px rgba(0,0,0,.8);
user-select: none;
::before {
&::before {
content: '';
display: block;
height: 2em;
@ -196,11 +196,11 @@ export const VideoLink = styled.a`
background: currentColor;
border-radius: 100%;
margin: 0 auto .4em;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='${props => encodeURIComponent(props.theme.primaryDark)}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-play'%3E%3Cpolygon points='5 3 19 12 5 21 5 3'%3E%3C/polygon%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23F79E00' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-play'%3E%3Cpolygon points='5 3 19 12 5 21 5 3'%3E%3C/polygon%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 1em;
box-shadow: 0 0 20px 0 rgba(0,0,0,.3);
}
}
`;
`

View file

@ -0,0 +1,103 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Button, Center, Footer, Logo } from '/src/components'
import { StyledMain, AboutSection, P } from '../Home/Home.styles'
import { Note, ButtonArea } from './Privacy.styles'
const translationDisclaimer = 'While the translated document is provided for your convenience, the English version as displayed at https://crab.fit is legally binding.'
const Privacy = () => {
const navigate = useNavigate()
const { t, i18n } = useTranslation(['common', 'privacy'])
const contentRef = useRef()
const [content, setContent] = useState('')
useEffect(() => {
document.title = `${t('privacy:name')} - Crab Fit`
}, [t])
useEffect(() => setContent(contentRef.current?.innerText || ''), [contentRef])
return <>
<StyledMain>
<Logo />
</StyledMain>
<StyledMain>
<h1>{t('privacy:name')}</h1>
{!i18n.language.startsWith('en') && (
<p>
<a
href={`https://translate.google.com/?sl=en&tl=${i18n.language.substring(0, 2)}&text=${encodeURIComponent(`${translationDisclaimer}\n\n${content}`)}&op=translate`}
target="_blank"
rel="noreferrer noopener"
>{t('privacy:translate')}</a>
</p>
)}
<h3>Crab Fit</h3>
<div ref={contentRef}>
<P>This SERVICE is provided by Benjamin Grant at no cost and is intended for use as is.</P>
<P>This page is used to inform visitors regarding the policies of the collection, use, and disclosure of Personal Information if using the Service.</P>
<P>If you choose to use the Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that is collected is used for providing and improving the Service. Your information will not be used or shared with anyone except as described in this Privacy Policy.</P>
<h2>Information Collection and Use</h2>
<P>The Service uses third party services that may collect information used to identify you.</P>
<P>Links to privacy policies of the third party service providers used by the Service:</P>
<P as="ul">
<li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a></li>
</P>
<h2>Log Data</h2>
<P>When you use the Service, in the case of an error, data and information is collected to improve the Service, which may include your IP address, device name, operating system version, app configuration and the time and date of the error.</P>
<h2>Cookies</h2>
<P>Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory.</P>
<P>Cookies are used by Google Analytics to track you across the web and provide anonymous statistics to improve the Service.</P>
<h2>Service Providers</h2>
<P>Third-party companies may be employed for the following reasons:</P>
<P as="ul">
<li>To facilitate the Service</li>
<li>To provide the Service on our behalf</li>
<li>To perform Service-related services</li>
<li>To assist in analyzing how the Service is used</li>
</P>
<P>To perform these tasks, the third parties may have access to your Personal Information, but are obligated not to disclose or use this information for any purpose except the above.</P>
<h2>Security</h2>
<P>Personal Information that is shared via the Service is protected, however remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, so take care when sharing Personal Information.</P>
<Note>Events that are created will be automatically permanently erased from storage after <strong>3 months</strong> of inactivity.</Note>
<h2>Links to Other Sites</h2>
<P>The Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by the Service. Therefore, you are advised to review the Privacy Policy of these websites.</P>
<h2>Children's Privacy</h2>
<P>The Service does not address anyone under the age of 13. Personally identifiable information is not knowingly collected from children under 13. If discovered that a child under 13 has provided the Service with personal information, such information will be immediately deleted from the servers. If you are a parent or guardian and you are aware that your child has provided the Service with personal information, please contact us using the details below so that this information can be removed.</P>
<h2>Changes to This Privacy Policy</h2>
<P>This Privacy Policy may be updated from time to time. Thus, you are advised to review this page periodically for any changes.</P>
<P>Last updated: 2021-06-16</P>
<h2>Contact Us</h2>
<P>If you have any questions or suggestions about the Privacy Policy, do not hesitate to contact us at <a href="mailto:contact@crab.fit">contact@crab.fit</a>.</P>
</div>
</StyledMain>
<ButtonArea>
<AboutSection>
<StyledMain>
<Center><Button onClick={() => navigate('/')}>{t('common:cta')}</Button></Center>
</StyledMain>
</AboutSection>
</ButtonArea>
<Footer />
</>
}
export default Privacy

View file

@ -0,0 +1,22 @@
import { styled } from 'goober'
export const Note = styled('p')`
background-color: var(--surface);
border: 1px solid var(--primary);
border-radius: 10px;
padding: 12px 16px;
margin: 16px 0;
box-sizing: border-box;
font-weight: 500;
line-height: 1.6em;
& a {
color: var(--secondary);
}
`
export const ButtonArea = styled('div')`
@media print {
display: none;
}
`

View file

@ -1,114 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Button,
Center,
Footer,
Logo,
} from 'components';
import {
StyledMain,
AboutSection,
P,
} from '../Home/homeStyle';
import { Note, ButtonArea } from './privacyStyle';
const translationDisclaimer = 'While the translated document is provided for your convenience, the English version as displayed at https://crab.fit is legally binding.';
const Privacy = () => {
const { push } = useHistory();
const { t, i18n } = useTranslation(['common', 'privacy']);
const contentRef = useRef();
const [content, setContent] = useState('');
useEffect(() => {
document.title = `${t('privacy:name')} - Crab Fit`;
}, [t]);
useEffect(() => setContent(contentRef.current?.innerText || ''), [contentRef]);
return (
<>
<StyledMain>
<Logo />
</StyledMain>
<StyledMain>
<h1>{t('privacy:name')}</h1>
{!i18n.language.startsWith('en') && (
<p>
<a
href={`https://translate.google.com/?sl=en&tl=${i18n.language.substring(0, 2)}&text=${encodeURIComponent(`${translationDisclaimer}\n\n${content}`)}&op=translate`}
target="_blank"
rel="noreferrer noopener"
>{t('privacy:translate')}</a>
</p>
)}
<h3>Crab Fit</h3>
<div ref={contentRef}>
<P>This SERVICE is provided by Benjamin Grant at no cost and is intended for use as is.</P>
<P>This page is used to inform visitors regarding the policies of the collection, use, and disclosure of Personal Information if using the Service.</P>
<P>If you choose to use the Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that is collected is used for providing and improving the Service. Your information will not be used or shared with anyone except as described in this Privacy Policy.</P>
<h2>Information Collection and Use</h2>
<P>The Service uses third party services that may collect information used to identify you.</P>
<P>Links to privacy policies of the third party service providers used by the Service:</P>
<P as="ul">
<li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a></li>
</P>
<h2>Log Data</h2>
<P>When you use the Service, in the case of an error, data and information is collected to improve the Service, which may include your IP address, device name, operating system version, app configuration and the time and date of the error.</P>
<h2>Cookies</h2>
<P>Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory.</P>
<P>Cookies are used by Google Analytics to track you across the web and provide anonymous statistics to improve the Service.</P>
<h2>Service Providers</h2>
<P>Third-party companies may be employed for the following reasons:</P>
<P as="ul">
<li>To facilitate the Service</li>
<li>To provide the Service on our behalf</li>
<li>To perform Service-related services</li>
<li>To assist in analyzing how the Service is used</li>
</P>
<P>To perform these tasks, the third parties may have access to your Personal Information, but are obligated not to disclose or use this information for any purpose except the above.</P>
<h2>Security</h2>
<P>Personal Information that is shared via the Service is protected, however remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, so take care when sharing Personal Information.</P>
<Note>Events that are created will be automatically permanently erased from storage after <strong>3 months</strong> of inactivity.</Note>
<h2>Links to Other Sites</h2>
<P>The Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by the Service. Therefore, you are advised to review the Privacy Policy of these websites.</P>
<h2>Children's Privacy</h2>
<P>The Service does not address anyone under the age of 13. Personally identifiable information is not knowingly collected from children under 13. If discovered that a child under 13 has provided the Service with personal information, such information will be immediately deleted from the servers. If you are a parent or guardian and you are aware that your child has provided the Service with personal information, please contact us using the details below so that this information can be removed.</P>
<h2>Changes to This Privacy Policy</h2>
<P>This Privacy Policy may be updated from time to time. Thus, you are advised to review this page periodically for any changes.</P>
<P>Last updated: 2021-06-16</P>
<h2>Contact Us</h2>
<P>If you have any questions or suggestions about the Privacy Policy, do not hesitate to contact us at <a href="mailto:contact@crab.fit">contact@crab.fit</a>.</P>
</div>
</StyledMain>
<ButtonArea>
<AboutSection>
<StyledMain>
<Center><Button onClick={() => push('/')}>{t('common:cta')}</Button></Center>
</StyledMain>
</AboutSection>
</ButtonArea>
<Footer />
</>
);
};
export default Privacy;

View file

@ -1,22 +0,0 @@
import styled from '@emotion/styled';
export const Note = styled.p`
background-color: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.primary};
border-radius: 10px;
padding: 12px 16px;
margin: 16px 0;
box-sizing: border-box;
font-weight: 500;
line-height: 1.6em;
& a {
color: ${props => props.theme.mode === 'light' ? props.theme.primaryDark : props.theme.primaryLight};
}
`;
export const ButtonArea = styled.div`
@media print {
display: none;
}
`;

View file

@ -0,0 +1,7 @@
import { lazy } from 'react'
export const Home = lazy(() => import('./Home/Home'))
export const Event = lazy(() => import('./Event/Event'))
export const Create = lazy(() => import('./Create/Create'))
export const Help = lazy(() => import('./Help/Help'))
export const Privacy = lazy(() => import('./Privacy/Privacy'))

View file

@ -1,5 +0,0 @@
export { default as Home } from './Home/Home';
export { default as Event } from './Event/Event';
export { default as Create } from './Create/Create';
export { default as Help } from './Help/Help';
export { default as Privacy } from './Privacy/Privacy';

View file

@ -1 +0,0 @@
/// <reference types="react-scripts" />

View file

@ -1,13 +0,0 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View file

@ -1 +0,0 @@
["af","am","ar-dz","ar-kw","ar-ly","ar-ma","ar-sa","ar-tn","ar","az","be","bg","bi","bm","bn","bo","br","bs","ca","cs","cv","cy","da","de-at","de-ch","de","dv","el","en-au","en-ca","en-gb","en-ie","en-il","en-in","en-nz","en-sg","en-tt","en","eo","es-do","es-pr","es-us","es","et","eu","fa","fi","fo","fr-ca","fr-ch","fr","fy","ga","gd","gl","gom-latn","gu","he","hi","hr","ht","hu","hy-am","id","is","it-ch","it","ja","jv","ka","kk","km","kn","ko","ku","ky","lb","lo","lt","lv","me","mi","mk","ml","mn","mr","ms-my","ms","mt","my","nb","ne","nl-be","nl","nn","oc-lnc","pa-in","pl","pt-br","pt","ro","ru","rw","sd","se","si","sk","sl","sq","sr-cyrl","sr","ss","sv","sw","ta","te","tet","tg","th","tk","tl-ph","tlh","tr","tzl","tzm-latn","tzm","ug-cn","uk","ur","uz-latn","uz","vi","x-pseudo","yo","zh-cn","zh-hk","zh-tw","zh"]

View file

@ -1 +1,20 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.cls-1,.cls-4{fill:#f79e00;}.cls-2,.cls-4{isolation:isolate;}.cls-3{fill:#f48600;}</style></defs><path class="cls-1" d="M181.11,311a123,123,0,0,1-123-123h0a123,123,0,0,1,123-123h64.32L211.66,98.73h32.46c0,79.47-108.36,32.47-136.43,50.75C73.65,171.64,181.11,311,181.11,311Z"/><path class="cls-1" d="M404,149.48C375.94,131.2,267.58,178.2,267.58,98.73H300L266.27,65h64.32a123,123,0,0,1,123,123h0a123,123,0,0,1-123,123S438.05,171.64,404,149.48Z"/><rect class="cls-1" x="266.27" y="200.39" width="20.89" height="57.44" rx="10.44"/><rect class="cls-1" x="224.49" y="200.39" width="20.89" height="57.44" rx="10.44"/><path class="cls-1" d="M190.55,229.11H321.11A108.35,108.35,0,0,1,429.47,337.47h0A108.36,108.36,0,0,1,321.11,445.83H190.55A108.37,108.37,0,0,1,82.19,337.47h0A108.36,108.36,0,0,1,190.55,229.11Z"/><g class="cls-2"><path class="cls-3" d="M293.69,268.29a68.83,68.83,0,0,0-37.86,11.29,69.19,69.19,0,1,0,0,115.8,69.19,69.19,0,1,0,37.86-127.09Z"/></g><ellipse class="cls-4" cx="255.83" cy="337.48" rx="31.32" ry="57.9"/><path class="cls-1" d="M402.77,386.23c36,12.31,66,40.07,59.44,60.75C440,431.17,413.11,416.91,389,409.83Z"/><path class="cls-1" d="M420.05,345.89c37.37,7.15,71,30.45,67.35,51.85-24.24-12.55-52.82-22.92-77.67-26.57Z"/><path class="cls-1" d="M417.26,303.44c38.05.39,75.26,17.34,75.5,39-26.08-8-56.05-13.16-81.15-12.32Z"/><path class="cls-1" d="M109.23,386.23c-36,12.31-66,40.07-59.44,60.75C72,431.17,98.89,416.91,123,409.83Z"/><path class="cls-1" d="M92,345.89C54.58,353,21,376.34,24.6,397.74c24.24-12.55,52.82-22.92,77.67-26.57Z"/><path class="cls-1" d="M94.74,303.44c-38,.39-75.26,17.34-75.5,39,26.08-8,56.05-13.16,81.15-12.32Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<style>.cls-1,.cls-4{fill:#f79e00;}.cls-2,.cls-4{isolation:isolate;}.cls-3{fill:#f48600;}</style>
</defs>
<path class="cls-1" d="M181.11,311a123,123,0,0,1-123-123h0a123,123,0,0,1,123-123h64.32L211.66,98.73h32.46c0,79.47-108.36,32.47-136.43,50.75C73.65,171.64,181.11,311,181.11,311Z"/>
<path class="cls-1" d="M404,149.48C375.94,131.2,267.58,178.2,267.58,98.73H300L266.27,65h64.32a123,123,0,0,1,123,123h0a123,123,0,0,1-123,123S438.05,171.64,404,149.48Z"/>
<rect class="cls-1" x="266.27" y="200.39" width="20.89" height="57.44" rx="10.44"/>
<rect class="cls-1" x="224.49" y="200.39" width="20.89" height="57.44" rx="10.44"/>
<path class="cls-1" d="M190.55,229.11H321.11A108.35,108.35,0,0,1,429.47,337.47h0A108.36,108.36,0,0,1,321.11,445.83H190.55A108.37,108.37,0,0,1,82.19,337.47h0A108.36,108.36,0,0,1,190.55,229.11Z"/>
<g class="cls-2">
<path class="cls-3" d="M293.69,268.29a68.83,68.83,0,0,0-37.86,11.29,69.19,69.19,0,1,0,0,115.8,69.19,69.19,0,1,0,37.86-127.09Z"/>
</g>
<ellipse class="cls-4" cx="255.83" cy="337.48" rx="31.32" ry="57.9"/>
<path class="cls-1" d="M402.77,386.23c36,12.31,66,40.07,59.44,60.75C440,431.17,413.11,416.91,389,409.83Z"/>
<path class="cls-1" d="M420.05,345.89c37.37,7.15,71,30.45,67.35,51.85-24.24-12.55-52.82-22.92-77.67-26.57Z"/>
<path class="cls-1" d="M417.26,303.44c38.05.39,75.26,17.34,75.5,39-26.08-8-56.05-13.16-81.15-12.32Z"/>
<path class="cls-1" d="M109.23,386.23c-36,12.31-66,40.07-59.44,60.75C72,431.17,98.89,416.91,123,409.83Z"/>
<path class="cls-1" d="M92,345.89C54.58,353,21,376.34,24.6,397.74c24.24-12.55,52.82-22.92,77.67-26.57Z"/>
<path class="cls-1" d="M94.74,303.44c-38,.39-75.26,17.34-75.5,39,26.08-8,56.05-13.16,81.15-12.32Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,45 +1,75 @@
import axios from 'axios';
export const instance = axios.create({
baseURL: process.env.NODE_ENV === 'production' ? 'https://api-dot-crabfit.uc.r.appspot.com' : 'http://localhost:8080',
timeout: 1000 * 300,
headers: {
'Content-Type': 'application/json',
},
});
const API_URL = process.env.NODE_ENV === 'production' ? 'https://api.crab.fit' : 'http://localhost:8080'
const handleError = error => {
if (error.response && error.response.status) {
console.log('[Error handler] res:', error.response);
if (error && error.status) {
console.error('[Error handler] res:', error)
}
return Promise.reject(error.response);
};
return Promise.reject(error)
}
const api = {
get: async (endpoint, data) => {
get: async endpoint => {
try {
const response = await instance.get(endpoint, data);
return Promise.resolve(response);
const response = await fetch(API_URL + endpoint)
if (!response.ok) {
throw response
}
const json = await response.json()
return Promise.resolve(json)
} catch (error) {
return handleError(error);
return handleError(error)
}
},
post: async (endpoint, data, options = {}) => {
try {
const response = await instance.post(endpoint, data, options);
return Promise.resolve(response);
} catch (error) {
return handleError(error);
}
const response = await fetch(API_URL + endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
patch: async (endpoint, data) => {
try {
const response = await instance.patch(endpoint, data);
return Promise.resolve(response);
} catch (error) {
return handleError(error);
body: JSON.stringify(data),
...options
})
if (!response.ok) {
throw response
}
},
};
export default api;
//TODO: hack until api update
try {
const json = await response.json()
return Promise.resolve(json)
} catch (e) {
return Promise.resolve(response)
}
} catch (error) {
return handleError(error)
}
},
patch: async (endpoint, data, options = {}) => {
try {
const response = await fetch(API_URL + endpoint, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
...options
})
if (!response.ok) {
throw response
}
//TODO: hack until api update
try {
const json = await response.json()
return Promise.resolve(json)
} catch (e) {
return Promise.resolve(response)
}
} catch (error) {
return handleError(error)
}
},
}
export default api

View file

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View file

@ -0,0 +1,5 @@
export { default as useSettingsStore } from './settingsStore'
export { default as useRecentsStore } from './recentsStore'
export { default as useTWAStore } from './twaStore'
export { default as useLocaleUpdateStore } from './localeUpdateStore'
export { default as useTranslateStore } from './translateStore'

View file

@ -1,64 +0,0 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import locales from 'res/dayjs_locales';
export const useSettingsStore = create(persist(
set => ({
weekStart: 0,
timeFormat: '12h',
theme: 'System',
highlight: false,
setWeekStart: weekStart => set({ weekStart }),
setTimeFormat: timeFormat => set({ timeFormat }),
setTheme: theme => set({ theme }),
setHighlight: highlight => set({ highlight }),
}),
{ name: 'crabfit-settings' },
));
export const useRecentsStore = create(persist(
set => ({
recents: [],
addRecent: event => set(state => {
const recents = state.recents.filter(e => e.id !== event.id);
recents.unshift(event);
recents.length = Math.min(recents.length, 5);
return { recents };
}),
removeRecent: id => set(state => {
const recents = state.recents.filter(e => e.id !== id);
return { recents };
}),
clearRecents: () => set({ recents: [] }),
}),
{ name: 'crabfit-recent' },
));
export const useTWAStore = create(set => ({
TWA: undefined,
setTWA: TWA => set({ TWA }),
}));
export const useLocaleUpdateStore = create(set => ({
locale: 'en',
setLocale: locale => set({ locale }),
}));
export const useTranslateStore = create(persist(
set => ({
navigatorLang: navigator.language,
navigatorSupported: Object.keys(locales).includes(navigator.language.substring(0, 2)),
translateDialogDismissed: false,
setDialogDismissed: value => set({ translateDialogDismissed: value }),
}),
{
name: 'crabfit-translate',
blacklist: [
'navigatorLang',
'navigatorSupported',
],
},
));

View file

@ -0,0 +1,8 @@
import create from 'zustand'
const useLocaleUpdateStore = create(set => ({
locale: 'en',
setLocale: locale => set({ locale }),
}))
export default useLocaleUpdateStore

View file

@ -0,0 +1,23 @@
import create from 'zustand'
import { persist } from 'zustand/middleware'
const useRecentsStore = create(persist(
set => ({
recents: [],
addRecent: event => set(state => {
const recents = state.recents.filter(e => e.id !== event.id)
recents.unshift(event)
recents.length = Math.min(recents.length, 5)
return { recents }
}),
removeRecent: id => set(state => {
const recents = state.recents.filter(e => e.id !== id)
return { recents }
}),
clearRecents: () => set({ recents: [] }),
}),
{ name: 'crabfit-recent' },
))
export default useRecentsStore

View file

@ -0,0 +1,19 @@
import create from 'zustand'
import { persist } from 'zustand/middleware'
const useSettingsStore = create(persist(
set => ({
weekStart: 0,
timeFormat: '12h',
theme: 'System',
highlight: false,
setWeekStart: weekStart => set({ weekStart }),
setTimeFormat: timeFormat => set({ timeFormat }),
setTheme: theme => set({ theme }),
setHighlight: highlight => set({ highlight }),
}),
{ name: 'crabfit-settings' },
))
export default useSettingsStore

View file

@ -0,0 +1,23 @@
import create from 'zustand'
import { persist } from 'zustand/middleware'
import locales from '/src/i18n/locales'
const useTranslateStore = create(persist(
set => ({
navigatorLang: navigator.language,
navigatorSupported: Object.keys(locales).includes(navigator.language.substring(0, 2)),
translateDialogDismissed: false,
setDialogDismissed: value => set({ translateDialogDismissed: value }),
}),
{
name: 'crabfit-translate',
blacklist: [
'navigatorLang',
'navigatorSupported',
],
},
))
export default useTranslateStore

Some files were not shown because too many files have changed in this diff Show more