Merge pull request #259 from GRA0007/refactor/nextjs-frontend
Next.js frontend refactor
This commit is contained in:
commit
d250ef67ee
23
.github/workflows/check_api.yml
vendored
Normal file
23
.github/workflows/check_api.yml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
name: API Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- api/**
|
||||||
|
- .github/workflows/check_api.yml
|
||||||
|
|
||||||
|
# Fail on warnings
|
||||||
|
env:
|
||||||
|
RUSTFLAGS: "-Dwarnings"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
clippy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: api
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- run: cargo clippy
|
||||||
42
.github/workflows/check_frontend.yml
vendored
Normal file
42
.github/workflows/check_frontend.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
name: Frontend Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- frontend/**
|
||||||
|
- .github/workflows/check_frontend.yml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'yarn'
|
||||||
|
cache-dependency-path: '**/yarn.lock'
|
||||||
|
- run: yarn install --immutable
|
||||||
|
- run: yarn lint --max-warnings 0
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'yarn'
|
||||||
|
cache-dependency-path: '**/yarn.lock'
|
||||||
|
- run: yarn install --immutable
|
||||||
|
- run: yarn tsc
|
||||||
37
.github/workflows/deploy_frontend.yml
vendored
37
.github/workflows/deploy_frontend.yml
vendored
|
|
@ -2,36 +2,37 @@ name: Deploy Frontend
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['main']
|
branches:
|
||||||
paths: ['frontend/**']
|
- main
|
||||||
|
paths:
|
||||||
|
- frontend/**
|
||||||
|
- .github/workflows/deploy_frontend.yml
|
||||||
|
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
name: Deploy to Vercel
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 17
|
node-version: 18
|
||||||
cache: yarn
|
cache: yarn
|
||||||
cache-dependency-path: '**/yarn.lock'
|
cache-dependency-path: '**/yarn.lock'
|
||||||
- run: yarn install --immutable
|
- name: Install Vercel CLI
|
||||||
- run: yarn build
|
run: yarn global install vercel@latest
|
||||||
- id: auth
|
- name: Pull Vercel Environment Information
|
||||||
uses: google-github-actions/auth@v0
|
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
with:
|
- name: Build Project Artifacts
|
||||||
credentials_json: '${{ secrets.GCP_SA_KEY }}'
|
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
- id: deploy
|
- name: Deploy Project Artifacts to Vercel
|
||||||
uses: google-github-actions/deploy-appengine@v0
|
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
with:
|
|
||||||
working_directory: frontend
|
|
||||||
version: v1
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
# Crab Fit <img width="100" align="right" src="frontend/src/res/logo.svg" alt="avatar">
|
# Crab Fit <img width="100" align="right" src="frontend/src/res/logo.svg" alt="avatar">
|
||||||
|
|
||||||
|
[](https://github.com/GRA0007/crab.fit/actions/workflows/check_frontend.yml)
|
||||||
|
[](https://github.com/GRA0007/crab.fit/actions/workflows/check_api.yml)
|
||||||
|
|
||||||
Align your schedules to find the perfect time that works for everyone.
|
Align your schedules to find the perfect time that works for everyone.
|
||||||
Licensed under the GNU GPLv3.
|
Licensed under the GNU GPLv3.
|
||||||
|
|
||||||
|
|
@ -29,9 +32,9 @@ The browser extension in `browser-extension` can be tested by first running the
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Deployments are managed with GitHub Workflows.
|
Deployments are managed with GitHub Workflows. The frontend is deployed using Vercel, and the API uses Fly.io.
|
||||||
|
|
||||||
To deploy cron jobs (i.e. monthly cleanup of old events), run `gcloud app deploy cron.yaml`.
|
More detailed instructions on setting up your own deployment are coming soon.
|
||||||
|
|
||||||
### 🔌 Browser extension
|
### 🔌 Browser extension
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ use std::{env, net::SocketAddr, sync::Arc};
|
||||||
use axum::{
|
use axum::{
|
||||||
error_handling::HandleErrorLayer,
|
error_handling::HandleErrorLayer,
|
||||||
extract,
|
extract,
|
||||||
http::{HeaderValue, Method},
|
http::{
|
||||||
|
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||||
|
HeaderValue, Method,
|
||||||
|
},
|
||||||
routing::{get, patch, post},
|
routing::{get, patch, post},
|
||||||
BoxError, Router, Server,
|
BoxError, Router, Server,
|
||||||
};
|
};
|
||||||
|
|
@ -44,6 +47,8 @@ async fn main() {
|
||||||
|
|
||||||
// CORS configuration
|
// CORS configuration
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
|
.allow_credentials(true)
|
||||||
|
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE])
|
||||||
.allow_methods([Method::GET, Method::POST, Method::PATCH])
|
.allow_methods([Method::GET, Method::POST, Method::PATCH])
|
||||||
.allow_origin(
|
.allow_origin(
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
|
|
@ -56,9 +61,14 @@ async fn main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rate limiting configuration (using tower_governor)
|
// Rate limiting configuration (using tower_governor)
|
||||||
// From the docs: Allows bursts with up to eight requests and replenishes
|
// From the docs: Allows bursts with up to 20 requests and replenishes
|
||||||
// one element after 500ms, based on peer IP.
|
// one element after 500ms, based on peer IP.
|
||||||
let governor_config = Box::new(GovernorConfigBuilder::default().finish().unwrap());
|
let governor_config = Box::new(
|
||||||
|
GovernorConfigBuilder::default()
|
||||||
|
.burst_size(20)
|
||||||
|
.finish()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
let rate_limit = ServiceBuilder::new()
|
let rate_limit = ServiceBuilder::new()
|
||||||
// Handle errors from governor and convert into HTTP responses
|
// Handle errors from governor and convert into HTTP responses
|
||||||
.layer(HandleErrorLayer::new(|e: BoxError| async move {
|
.layer(HandleErrorLayer::new(|e: BoxError| async move {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,18 @@ pub async fn get_people<A: Adaptor>(
|
||||||
.map_err(ApiError::AdaptorError)?;
|
.map_err(ApiError::AdaptorError)?;
|
||||||
|
|
||||||
match people {
|
match people {
|
||||||
Some(people) => Ok(Json(people.into_iter().map(|p| p.into()).collect())),
|
Some(people) => Ok(Json(
|
||||||
|
people
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|p| {
|
||||||
|
if !p.availability.is_empty() {
|
||||||
|
Some(p.into())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)),
|
||||||
None => Err(ApiError::NotFound),
|
None => Err(ApiError::NotFound),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
dispatch:
|
|
||||||
- url: "api.crab.fit/*"
|
|
||||||
service: api
|
|
||||||
- url: "crab.fit/*"
|
|
||||||
service: default
|
|
||||||
5
frontend/.env.local
Normal file
5
frontend/.env.local
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
NEXT_PUBLIC_API_URL="http://127.0.0.1:3000"
|
||||||
|
|
||||||
|
# Google auth for calendar syncing, feature will be disabled if these aren't set
|
||||||
|
# NEXT_PUBLIC_GOOGLE_CLIENT_ID=""
|
||||||
|
# NEXT_PUBLIC_GOOGLE_API_KEY=""
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
/* eslint-env node */
|
|
||||||
module.exports = {
|
|
||||||
'settings': {
|
|
||||||
'react': {
|
|
||||||
'version': 'detect'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'env': {
|
|
||||||
'browser': true,
|
|
||||||
'es2021': true
|
|
||||||
},
|
|
||||||
'globals': {
|
|
||||||
'process': true,
|
|
||||||
'require': true,
|
|
||||||
'gtag': true,
|
|
||||||
},
|
|
||||||
'extends': [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:react/recommended'
|
|
||||||
],
|
|
||||||
'parserOptions': {
|
|
||||||
'ecmaFeatures': {
|
|
||||||
'jsx': true
|
|
||||||
},
|
|
||||||
'ecmaVersion': 12,
|
|
||||||
'sourceType': 'module'
|
|
||||||
},
|
|
||||||
'plugins': [
|
|
||||||
'react'
|
|
||||||
],
|
|
||||||
'rules': {
|
|
||||||
'react/display-name': 'off',
|
|
||||||
'react/prop-types': 'off',
|
|
||||||
'react/no-unescaped-entities': 'off',
|
|
||||||
'react/react-in-jsx-scope': 'off',
|
|
||||||
'eqeqeq': 2,
|
|
||||||
'no-return-await': 1,
|
|
||||||
'no-var': 2,
|
|
||||||
'prefer-const': 1,
|
|
||||||
'yoda': 2,
|
|
||||||
'no-trailing-spaces': 1,
|
|
||||||
'eol-last': [1, 'always'],
|
|
||||||
'no-unused-vars': [
|
|
||||||
1,
|
|
||||||
{
|
|
||||||
'args': 'all',
|
|
||||||
'argsIgnorePattern': '^_',
|
|
||||||
'ignoreRestSiblings': true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'indent': [
|
|
||||||
'error',
|
|
||||||
2
|
|
||||||
],
|
|
||||||
'linebreak-style': [
|
|
||||||
'error',
|
|
||||||
'unix'
|
|
||||||
],
|
|
||||||
'quotes': [
|
|
||||||
'error',
|
|
||||||
'single'
|
|
||||||
],
|
|
||||||
'semi': [
|
|
||||||
'error',
|
|
||||||
'never'
|
|
||||||
],
|
|
||||||
'arrow-parens': [
|
|
||||||
'error',
|
|
||||||
'as-needed'
|
|
||||||
],
|
|
||||||
'jsx-quotes': [1, 'prefer-double'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
32
frontend/.eslintrc.json
Normal file
32
frontend/.eslintrc.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint", "simple-import-sort"],
|
||||||
|
"rules": {
|
||||||
|
"react/no-unescaped-entities": "off",
|
||||||
|
"simple-import-sort/imports": "warn",
|
||||||
|
"@next/next/no-img-element": "off",
|
||||||
|
"react/display-name": "off",
|
||||||
|
"react-hooks/exhaustive-deps": "off",
|
||||||
|
"space-infix-ops": "warn",
|
||||||
|
"comma-spacing": "warn",
|
||||||
|
"react-hooks/rules-of-hooks": "off"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx"],
|
||||||
|
"rules": {
|
||||||
|
"simple-import-sort/imports": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"groups": [
|
||||||
|
["^react", "^next", "^@", "^[a-z]"],
|
||||||
|
["^/src/"],
|
||||||
|
["^./", "^.", "^../"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
node_modules
|
|
||||||
.DS_Store
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.gcloudignore
|
|
||||||
src
|
|
||||||
public
|
|
||||||
.eslintrc.js
|
|
||||||
yarn.lock
|
|
||||||
package.json
|
|
||||||
8
frontend/.gitignore
vendored
8
frontend/.gitignore
vendored
|
|
@ -1,8 +1,10 @@
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
build
|
.next
|
||||||
dev-dist
|
|
||||||
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
.env
|
||||||
|
.vercel
|
||||||
|
|
|
||||||
3
frontend/.vscode/settings.json
vendored
Normal file
3
frontend/.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
runtime: nodejs16
|
|
||||||
handlers:
|
|
||||||
# Serve all static files with url ending with a file extension
|
|
||||||
- url: /(.*\..+)$
|
|
||||||
static_files: dist/\1
|
|
||||||
upload: (.*\..+)$
|
|
||||||
secure: always
|
|
||||||
redirect_http_response_code: 301
|
|
||||||
|
|
||||||
# Catch all handler to index.html
|
|
||||||
- url: /.*
|
|
||||||
static_files: dist/index.html
|
|
||||||
upload: dist/index.html
|
|
||||||
secure: always
|
|
||||||
redirect_http_response_code: 301
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<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
|
|
||||||
name="keywords"
|
|
||||||
content="crab, fit, crabfit, schedule, availability, availabilities, when2meet, doodle, meet, plan, time, timezone"
|
|
||||||
>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Enter your availability to find a time that works for everyone!"
|
|
||||||
>
|
|
||||||
<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="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="index.css">
|
|
||||||
|
|
||||||
<title>Crab Fit</title>
|
|
||||||
|
|
||||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-E6S1CDFBCD"></script>
|
|
||||||
<script>
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag(){dataLayer.push(arguments);}
|
|
||||||
gtag('js', new Date());
|
|
||||||
|
|
||||||
gtag('config', 'G-E6S1CDFBCD');
|
|
||||||
</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>
|
|
||||||
</noscript>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": "./",
|
|
||||||
"paths": {
|
|
||||||
"/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"**/node_modules/*",
|
|
||||||
"**/dist/*",
|
|
||||||
"**/.git/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
|
|
@ -1,51 +1,51 @@
|
||||||
{
|
{
|
||||||
"name": "crabfit-frontend",
|
"name": "crabfit-frontend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "next dev --port 1234",
|
||||||
"build": "vite build",
|
"build": "next build",
|
||||||
"lint": "eslint --ext .js,.jsx ./src"
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^2.28.1",
|
"@azure/msal-browser": "^2.37.1",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.2",
|
"@giraugh/tools": "^1.6.0",
|
||||||
"dayjs": "^1.11.5",
|
"@js-temporal/polyfill": "^0.4.4",
|
||||||
"gapi-script": "^1.2.0",
|
"@microsoft/microsoft-graph-client": "^3.0.5",
|
||||||
"goober": "^2.1.10",
|
"@vercel/analytics": "^1.0.1",
|
||||||
|
"accept-language": "^3.0.18",
|
||||||
|
"chroma.ts": "^1.0.10",
|
||||||
"hue-map": "^1.0.0",
|
"hue-map": "^1.0.0",
|
||||||
"i18next": "^21.9.0",
|
"i18next": "^22.5.1",
|
||||||
"i18next-browser-languagedetector": "^6.1.5",
|
"i18next-browser-languagedetector": "^7.0.2",
|
||||||
"i18next-http-backend": "^1.4.1",
|
"i18next-http-backend": "^2.2.1",
|
||||||
"lucide-react": "^0.84.0",
|
"i18next-resources-to-backend": "^1.1.4",
|
||||||
|
"lucide-react": "^0.241.0",
|
||||||
|
"next": "^13.4.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.34.1",
|
"react-hook-form": "^7.44.3",
|
||||||
"react-i18next": "^11.18.4",
|
"react-i18next": "^12.3.1",
|
||||||
"react-router-dom": "^6.3.0",
|
"zod": "^3.21.4",
|
||||||
"workbox-background-sync": "^6.5.4",
|
"zustand": "^4.3.8"
|
||||||
"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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^2.0.1",
|
"@types/gapi": "^0.0.44",
|
||||||
"eslint": "^8.22.0",
|
"@types/gapi.calendar": "^3.0.6",
|
||||||
"eslint-plugin-react": "^7.30.1",
|
"@types/google.accounts": "^0.0.7",
|
||||||
"vite": "^3.0.7",
|
"@types/node": "^20.2.5",
|
||||||
"vite-plugin-pwa": "^0.12.3",
|
"@types/react": "^18.2.9",
|
||||||
"workbox-webpack-plugin": "^6.5.4"
|
"@types/react-dom": "^18.2.4",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.59.9",
|
||||||
|
"@typescript-eslint/parser": "^5.59.9",
|
||||||
|
"eslint": "^8.42.0",
|
||||||
|
"eslint-config-next": "^13.4.4",
|
||||||
|
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||||
|
"sass": "^1.63.2",
|
||||||
|
"typescript": "^5.1.3",
|
||||||
|
"typescript-plugin-css-modules": "^5.0.1"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,67 +1,19 @@
|
||||||
/* eslint-disable no-restricted-globals */
|
// TODO: This is temporary, as I've made the decision to move away
|
||||||
|
// from a PWA, so must remove all existing service workers
|
||||||
|
|
||||||
import { clientsClaim, skipWaiting } from 'workbox-core'
|
self.addEventListener("install", () => {
|
||||||
import { ExpirationPlugin } from 'workbox-expiration'
|
self.skipWaiting()
|
||||||
import { precacheAndRoute, createHandlerBoundToURL, cleanupOutdatedCaches } from 'workbox-precaching'
|
|
||||||
import { registerRoute } from 'workbox-routing'
|
|
||||||
import { StaleWhileRevalidate, NetworkFirst } from 'workbox-strategies'
|
|
||||||
|
|
||||||
skipWaiting()
|
|
||||||
clientsClaim()
|
|
||||||
|
|
||||||
// Injection point
|
|
||||||
precacheAndRoute(self.__WB_MANIFEST)
|
|
||||||
|
|
||||||
cleanupOutdatedCaches()
|
|
||||||
|
|
||||||
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
|
|
||||||
registerRoute(
|
|
||||||
// Return false to exempt requests from being fulfilled by index.html.
|
|
||||||
({ request, url }) => {
|
|
||||||
// If this isn't a navigation, skip.
|
|
||||||
if (request.mode !== 'navigate') {
|
|
||||||
return false
|
|
||||||
} // If this is a URL that starts with /_, skip.
|
|
||||||
|
|
||||||
if (url.pathname.startsWith('/_')) {
|
|
||||||
return false
|
|
||||||
} // If this looks like a URL for a resource, because it contains // a file extension, skip.
|
|
||||||
|
|
||||||
if (url.pathname.match(fileExtensionRegexp)) {
|
|
||||||
return false
|
|
||||||
} // Return true to signal that we want to use the handler.
|
|
||||||
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
createHandlerBoundToURL('index.html')
|
|
||||||
)
|
|
||||||
|
|
||||||
registerRoute(
|
|
||||||
// Add in any other file extensions or routing criteria as needed.
|
|
||||||
({ url }) => url.origin === self.location.origin && (
|
|
||||||
url.pathname.endsWith('.png')
|
|
||||||
|| url.pathname.endsWith('.svg')
|
|
||||||
|| url.pathname.endsWith('.jpg')
|
|
||||||
|| url.pathname.endsWith('.jpeg')
|
|
||||||
|| url.pathname.endsWith('.ico')
|
|
||||||
|| url.pathname.endsWith('.ttf')
|
|
||||||
|| url.pathname.endsWith('.woff')
|
|
||||||
|| url.pathname.endsWith('.woff2')
|
|
||||||
), // Customize this strategy as needed, e.g., by changing to CacheFirst.
|
|
||||||
new StaleWhileRevalidate({
|
|
||||||
cacheName: 'res',
|
|
||||||
plugins: [
|
|
||||||
// Ensure that once this runtime cache reaches a maximum size the
|
|
||||||
// least-recently used images are removed.
|
|
||||||
new ExpirationPlugin({ maxEntries: 50 }),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
)
|
|
||||||
|
|
||||||
registerRoute(
|
self.addEventListener("activate", () => {
|
||||||
// Add in any other file extensions or routing criteria as needed.
|
self.registration
|
||||||
({ url }) => url.origin === self.location.origin && url.pathname.includes('i18n'),
|
.unregister()
|
||||||
new NetworkFirst({
|
.then(() => self.clients.matchAll())
|
||||||
cacheName: 'i18n',
|
.then((clients) => {
|
||||||
|
clients.forEach((client) => {
|
||||||
|
if (client.url && "navigate" in client) {
|
||||||
|
client.navigate(client.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import { useState, useEffect, useCallback, Suspense } from 'react'
|
|
||||||
import { Route, Routes } from 'react-router-dom'
|
|
||||||
|
|
||||||
import * as Pages from '/src/pages'
|
|
||||||
import { Settings, Loading, Egg, TranslateDialog } from '/src/components'
|
|
||||||
|
|
||||||
import { useSettingsStore, useTranslateStore } from '/src/stores'
|
|
||||||
|
|
||||||
const EGG_PATTERN = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const [eggCount, setEggCount] = useState(0)
|
|
||||||
const [eggVisible, setEggVisible] = useState(false)
|
|
||||||
const [eggKey, setEggKey] = useState(0)
|
|
||||||
|
|
||||||
const languageSupported = useTranslateStore(state => state.navigatorSupported)
|
|
||||||
const translateDialogDismissed = useTranslateStore(state => state.translateDialogDismissed)
|
|
||||||
|
|
||||||
const eggHandler = useCallback(e => {
|
|
||||||
if (EGG_PATTERN.indexOf(e.key) < 0 || e.key !== EGG_PATTERN[eggCount]) return setEggCount(0)
|
|
||||||
setEggCount(eggCount+1)
|
|
||||||
if (EGG_PATTERN.length === eggCount+1) {
|
|
||||||
setEggKey(eggKey+1)
|
|
||||||
setEggCount(0)
|
|
||||||
setEggVisible(true)
|
|
||||||
}
|
|
||||||
}, [eggCount, eggKey])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('keyup', eggHandler, false)
|
|
||||||
return () => document.removeEventListener('keyup', eggHandler, false)
|
|
||||||
}, [eggHandler])
|
|
||||||
|
|
||||||
// Use user theme preference
|
|
||||||
const theme = useSettingsStore(state => state.theme)
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.classList.toggle('light', theme === 'Light')
|
|
||||||
document.body.classList.toggle('dark', theme === 'Dark')
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!languageSupported && !translateDialogDismissed && <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>
|
|
||||||
|
|
||||||
{eggVisible && <Egg eggKey={eggKey} onClose={() => setEggVisible(false)} />}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
159
frontend/src/app/[id]/EventAvailabilities.tsx
Normal file
159
frontend/src/app/[id]/EventAvailabilities.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
|
||||||
|
import AvailabilityEditor from '/src/components/AvailabilityEditor/AvailabilityEditor'
|
||||||
|
import AvailabilityViewer from '/src/components/AvailabilityViewer/AvailabilityViewer'
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import Login from '/src/components/Login/Login'
|
||||||
|
import Section from '/src/components/Section/Section'
|
||||||
|
import SelectField from '/src/components/SelectField/SelectField'
|
||||||
|
import { EventResponse, getPeople, PersonResponse, updatePerson } from '/src/config/api'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import timezones from '/src/res/timezones.json'
|
||||||
|
import useRecentsStore from '/src/stores/recentsStore'
|
||||||
|
import { expandTimes, makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
|
interface EventAvailabilitiesProps {
|
||||||
|
event: EventResponse
|
||||||
|
people: PersonResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => {
|
||||||
|
const { t, i18n } = useTranslation('event')
|
||||||
|
|
||||||
|
const [people, setPeople] = useState(data.people)
|
||||||
|
const expandedTimes = useMemo(() => expandTimes(event.times), [event.times])
|
||||||
|
|
||||||
|
const [user, setUser] = useState<PersonResponse>()
|
||||||
|
const [password, setPassword] = useState<string>()
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<'group' | 'you'>('group')
|
||||||
|
const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||||
|
|
||||||
|
// Add this event to recents
|
||||||
|
const addRecent = useRecentsStore(state => state.addRecent)
|
||||||
|
useEffect(() => {
|
||||||
|
addRecent({
|
||||||
|
id: event.id,
|
||||||
|
name: event.name,
|
||||||
|
created_at: event.created_at,
|
||||||
|
})
|
||||||
|
}, [addRecent])
|
||||||
|
|
||||||
|
// Refetch availabilities
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'group') {
|
||||||
|
getPeople(event.id)
|
||||||
|
.then(setPeople)
|
||||||
|
.catch(console.warn)
|
||||||
|
}
|
||||||
|
}, [tab])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Section id="login">
|
||||||
|
<Content>
|
||||||
|
<Login eventId={event.id} user={user} onChange={(u, p) => {
|
||||||
|
setUser(u)
|
||||||
|
setPassword(p)
|
||||||
|
setTab(u ? 'you' : 'group')
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<SelectField
|
||||||
|
label={t('form.timezone')}
|
||||||
|
name="timezone"
|
||||||
|
id="timezone"
|
||||||
|
isInline
|
||||||
|
value={timezone}
|
||||||
|
onChange={event => setTimezone(event.currentTarget.value)}
|
||||||
|
options={timezones}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{event?.timezone && event.timezone !== timezone && <p>
|
||||||
|
<Trans i18nKey="form.created_in_timezone" t={t} i18n={i18n}>
|
||||||
|
{/* eslint-disable-next-line */}
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
_<strong>{{timezone: event.timezone}}</strong>
|
||||||
|
_<a href="#" onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
setTimezone(event.timezone)
|
||||||
|
}}>_</a>_
|
||||||
|
</Trans>
|
||||||
|
</p>}
|
||||||
|
|
||||||
|
{((
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
|
||||||
|
&& (event?.timezone && event.timezone !== Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||||
|
) || (
|
||||||
|
event?.timezone === undefined
|
||||||
|
&& Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
|
||||||
|
)) && (
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="form.local_timezone" t={t} i18n={i18n}>
|
||||||
|
{/* eslint-disable-next-line */}
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
_<strong>{{timezone: Intl.DateTimeFormat().resolvedOptions().timeZone}}</strong>
|
||||||
|
_<a href="#" onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||||
|
}}>_</a>_
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Content>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Content>
|
||||||
|
<div className={styles.tabs}>
|
||||||
|
<button
|
||||||
|
className={makeClass(
|
||||||
|
styles.tab,
|
||||||
|
tab === 'you' && styles.tabSelected,
|
||||||
|
!user && styles.tabDisabled,
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (user) {
|
||||||
|
setTab('you')
|
||||||
|
} else {
|
||||||
|
document.dispatchEvent(new CustomEvent('focusName'))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={user ? '' : t<string>('tabs.you_tooltip')}
|
||||||
|
>{t('tabs.you')}</button>
|
||||||
|
<button
|
||||||
|
className={makeClass(
|
||||||
|
styles.tab,
|
||||||
|
tab === 'group' && styles.tabSelected,
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('group')}
|
||||||
|
>{t('tabs.group')}</button>
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
{tab === 'group' ? <AvailabilityViewer
|
||||||
|
times={expandedTimes}
|
||||||
|
people={people}
|
||||||
|
timezone={timezone}
|
||||||
|
/> : user && <AvailabilityEditor
|
||||||
|
times={expandedTimes}
|
||||||
|
timezone={timezone}
|
||||||
|
value={user.availability}
|
||||||
|
onChange={availability => {
|
||||||
|
const oldAvailability = [...user.availability]
|
||||||
|
setUser({ ...user, availability })
|
||||||
|
updatePerson(event.id, user.name, { availability }, password)
|
||||||
|
.catch(e => {
|
||||||
|
console.warn(e)
|
||||||
|
setUser({ ...user, availability: oldAvailability })
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventAvailabilities
|
||||||
15
frontend/src/app/[id]/layout.tsx
Normal file
15
frontend/src/app/[id]/layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import Footer from '/src/components/Footer/Footer'
|
||||||
|
import Header from '/src/components/Header/Header'
|
||||||
|
|
||||||
|
const Layout = async ({ children }: { children: React.ReactNode }) => <>
|
||||||
|
<Content>
|
||||||
|
<Header />
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
|
||||||
|
export default Layout
|
||||||
29
frontend/src/app/[id]/not-found.tsx
Normal file
29
frontend/src/app/[id]/not-found.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import useRecentsStore from '/src/stores/recentsStore'
|
||||||
|
|
||||||
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
|
const NotFound = () => {
|
||||||
|
const { t } = useTranslation('event')
|
||||||
|
|
||||||
|
// Remove this event from recents if it was in there
|
||||||
|
const removeRecent = useRecentsStore(state => state.removeRecent)
|
||||||
|
useEffect(() => {
|
||||||
|
// Note: Next.js doesn't expose path params to the 404 page
|
||||||
|
removeRecent(window.location.pathname.replace('/', ''))
|
||||||
|
}, [removeRecent])
|
||||||
|
|
||||||
|
return <Content>
|
||||||
|
<div style={{ marginBlock: 100 }}>
|
||||||
|
<h1 className={styles.name}>{t('error.title')}</h1>
|
||||||
|
<p className={styles.info}>{t('error.body')}</p>
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotFound
|
||||||
71
frontend/src/app/[id]/page.module.scss
Normal file
71
frontend/src/app/[id]/page.module.scss
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
.name {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 20px 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin: 6px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noPrint {
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: .8;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
&::after {
|
||||||
|
content: ' - ' attr(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 30px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
user-select: none;
|
||||||
|
display: block;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 18px;
|
||||||
|
background-color: var(--surface);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-bottom: 0;
|
||||||
|
margin: 0 4px;
|
||||||
|
font: inherit;
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabSelected {
|
||||||
|
color: #FFF;
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabDisabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
55
frontend/src/app/[id]/page.tsx
Normal file
55
frontend/src/app/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import Copyable from '/src/components/Copyable/Copyable'
|
||||||
|
import { getEvent, getPeople } from '/src/config/api'
|
||||||
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
import { makeClass, relativeTimeFormat } from '/src/utils'
|
||||||
|
|
||||||
|
import EventAvailabilities from './EventAvailabilities'
|
||||||
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: { id: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateMetadata = async ({ params }: PageProps): Promise<Metadata> => {
|
||||||
|
const event = await getEvent(params.id).catch(() => undefined)
|
||||||
|
const { t } = await useTranslation('event')
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: event?.name ?? t('error.title'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Page = async ({ params }: PageProps) => {
|
||||||
|
const event = await getEvent(params.id).catch(() => undefined)
|
||||||
|
const people = await getPeople(params.id).catch(() => undefined)
|
||||||
|
if (!event || !people) notFound()
|
||||||
|
|
||||||
|
const { t, i18n } = await useTranslation(['common', 'event'])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Content>
|
||||||
|
<h1 className={styles.name}>{event.name}</h1>
|
||||||
|
<span
|
||||||
|
className={styles.date}
|
||||||
|
title={Temporal.Instant.fromEpochSeconds(event.created_at).toLocaleString(i18n.language, { dateStyle: 'long' })}
|
||||||
|
>{t('common:created', { date: relativeTimeFormat(Temporal.Instant.fromEpochSeconds(event.created_at), i18n.language) })}</span>
|
||||||
|
|
||||||
|
<Copyable className={styles.info}>
|
||||||
|
{`https://crab.fit/${event.id}`}
|
||||||
|
</Copyable>
|
||||||
|
<p className={makeClass(styles.info, styles.noPrint)}>
|
||||||
|
<Trans i18nKey="event:nav.shareinfo" t={t} i18n={i18n}>_<a href={`mailto:?subject=${encodeURIComponent(t<string>('event:nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('event:nav.email_body')} https://crab.fit/${event.id}`)}`}>_</a>_</Trans>
|
||||||
|
</p>
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<EventAvailabilities event={event} people={people} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page
|
||||||
19
frontend/src/app/create/Redirect.tsx
Normal file
19
frontend/src/app/create/Redirect.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
/** Check if the current page is running in an iframe, otherwise redirect home */
|
||||||
|
const Redirect = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.self === window.top) {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Redirect
|
||||||
28
frontend/src/app/create/page.tsx
Normal file
28
frontend/src/app/create/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import CreateForm from '/src/components/CreateForm/CreateForm'
|
||||||
|
import Header from '/src/components/Header/Header'
|
||||||
|
|
||||||
|
import Redirect from './Redirect'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Create a Crab Fit',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used in the Crab Fit browser extension, to be rendered only in an iframe
|
||||||
|
*/
|
||||||
|
const Page = async () => <>
|
||||||
|
<Content isSlim>
|
||||||
|
<Header isFull isSmall />
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<Content isSlim>
|
||||||
|
<CreateForm noRedirect />
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<Redirect />
|
||||||
|
</>
|
||||||
|
|
||||||
|
export default Page
|
||||||
|
|
@ -1,25 +1,3 @@
|
||||||
@font-face {
|
|
||||||
font-family: 'Karla';
|
|
||||||
src: url('fonts/karla-variable.ttf') format('truetype');
|
|
||||||
font-weight: 200 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Samurai Bob';
|
|
||||||
src: url('fonts/samuraibob.woff2') format('woff2'),
|
|
||||||
url('fonts/samuraibob.woff') format('woff');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Molot';
|
|
||||||
src: url('fonts/molot.woff2') format('woff2'),
|
|
||||||
url('fonts/molot.woff') format('woff');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
|
@ -85,10 +63,10 @@ html {
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Karla', sans-serif;
|
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: var(--font-weight);
|
font-weight: var(--font-weight);
|
||||||
|
--focus-ring: 2px solid var(--secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.light {
|
.light {
|
||||||
|
|
@ -131,6 +109,11 @@ body {
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
|
border-radius: .2em;
|
||||||
|
}
|
||||||
|
a:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
|
|
@ -152,23 +135,3 @@ a {
|
||||||
*::-webkit-scrollbar-thumb:active {
|
*::-webkit-scrollbar-thumb:active {
|
||||||
background: var(--secondary);
|
background: var(--secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* IE 10+ */
|
|
||||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
|
||||||
#app {
|
|
||||||
text-align: center;
|
|
||||||
margin: 20vh auto;
|
|
||||||
font-size: 1.3em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
#app::before {
|
|
||||||
content: '🦀';
|
|
||||||
font-size: 1.5em;
|
|
||||||
display: block;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
#app::after {
|
|
||||||
display: block;
|
|
||||||
content: 'Crab Fit doesn\'t work in Internet Explorer. Please try using a modern browser.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
50
frontend/src/app/how-to/page.module.scss
Normal file
50
frontend/src/app/how-to/page.module.scss
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
.step {
|
||||||
|
text-decoration-color: var(--primary);
|
||||||
|
text-decoration-style: solid;
|
||||||
|
text-decoration-line: underline;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fakeCalendar {
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
& div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-gap: 2px;
|
||||||
|
}
|
||||||
|
& div:first-of-type span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
user-select: none;
|
||||||
|
opacity: .7;
|
||||||
|
@media (max-width: 350px) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& div:last-of-type span {
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #FFF;
|
||||||
|
background-color: var(--primary);
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-start-start-radius: 3px;
|
||||||
|
border-end-start-radius: 3px;
|
||||||
|
color: inherit;
|
||||||
|
background-color: var(--surface);
|
||||||
|
}
|
||||||
|
&:last-of-type {
|
||||||
|
border-end-end-radius: 3px;
|
||||||
|
border-start-end-radius: 3px;
|
||||||
|
color: inherit;
|
||||||
|
background-color: var(--surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
frontend/src/app/how-to/page.tsx
Normal file
88
frontend/src/app/how-to/page.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { range, rotateArray } from '@giraugh/tools'
|
||||||
|
|
||||||
|
import AvailabilityViewer from '/src/components/AvailabilityViewer/AvailabilityViewer'
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import Footer from '/src/components/Footer/Footer'
|
||||||
|
import Header from '/src/components/Header/Header'
|
||||||
|
import { P } from '/src/components/Paragraph/Text'
|
||||||
|
import Section from '/src/components/Section/Section'
|
||||||
|
import TimeRangeField from '/src/components/TimeRangeField/TimeRangeField'
|
||||||
|
import Video from '/src/components/Video/Video'
|
||||||
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
import { getWeekdayNames } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
|
export const generateMetadata = async (): Promise<Metadata> => {
|
||||||
|
const { t } = await useTranslation('help')
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('name'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Page = async () => {
|
||||||
|
const { t, i18n } = await useTranslation(['common', 'help'])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Content>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<h1>{t('help:name')}</h1>
|
||||||
|
|
||||||
|
<Video />
|
||||||
|
|
||||||
|
<P>{t('help:p1')}</P>
|
||||||
|
<P>{t('help:p2')}</P>
|
||||||
|
|
||||||
|
<h2 className={styles.step}>{t('help:s1')}</h2>
|
||||||
|
<P><Trans i18nKey="help:p3" t={t} i18n={i18n}>_<Link href="/">_</Link>_</Trans></P>
|
||||||
|
<P>{t('help:p4')}</P>
|
||||||
|
<div className={styles.fakeCalendar}>
|
||||||
|
<div>{rotateArray(getWeekdayNames(i18n.language, 'short')).map(d => <span key={d}>{d}</span>)}</div>
|
||||||
|
<div>{range(11, 17).map(d => <span key={d}>{d}</span>)}</div>
|
||||||
|
</div>
|
||||||
|
<P>{t('help:p5')}</P>
|
||||||
|
<TimeRangeField name="time" staticValue={{ start: 11, end: 17 }} />
|
||||||
|
|
||||||
|
<h2 className={styles.step}>{t('help:s2')}</h2>
|
||||||
|
<P>{t('help:p6')}</P>
|
||||||
|
<P>{t('help:p7')}</P>
|
||||||
|
<AvailabilityViewer
|
||||||
|
times={['1100-12042021', '1115-12042021', '1130-12042021', '1145-12042021', '1200-12042021', '1215-12042021', '1230-12042021', '1245-12042021', '1300-12042021', '1315-12042021', '1330-12042021', '1345-12042021', '1400-12042021', '1415-12042021', '1430-12042021', '1445-12042021', '1500-12042021', '1515-12042021', '1530-12042021', '1545-12042021', '1600-12042021', '1615-12042021', '1630-12042021', '1645-12042021', '1100-13042021', '1115-13042021', '1130-13042021', '1145-13042021', '1200-13042021', '1215-13042021', '1230-13042021', '1245-13042021', '1300-13042021', '1315-13042021', '1330-13042021', '1345-13042021', '1400-13042021', '1415-13042021', '1430-13042021', '1445-13042021', '1500-13042021', '1515-13042021', '1530-13042021', '1545-13042021', '1600-13042021', '1615-13042021', '1630-13042021', '1645-13042021', '1100-14042021', '1115-14042021', '1130-14042021', '1145-14042021', '1200-14042021', '1215-14042021', '1230-14042021', '1245-14042021', '1300-14042021', '1315-14042021', '1330-14042021', '1345-14042021', '1400-14042021', '1415-14042021', '1430-14042021', '1445-14042021', '1500-14042021', '1515-14042021', '1530-14042021', '1545-14042021', '1600-14042021', '1615-14042021', '1630-14042021', '1645-14042021', '1100-15042021', '1115-15042021', '1130-15042021', '1145-15042021', '1200-15042021', '1215-15042021', '1230-15042021', '1245-15042021', '1300-15042021', '1315-15042021', '1330-15042021', '1345-15042021', '1400-15042021', '1415-15042021', '1430-15042021', '1445-15042021', '1500-15042021', '1515-15042021', '1530-15042021', '1545-15042021', '1600-15042021', '1615-15042021', '1630-15042021', '1645-15042021', '1100-16042021', '1115-16042021', '1130-16042021', '1145-16042021', '1200-16042021', '1215-16042021', '1230-16042021', '1245-16042021', '1300-16042021', '1315-16042021', '1330-16042021', '1345-16042021', '1400-16042021', '1415-16042021', '1430-16042021', '1445-16042021', '1500-16042021', '1515-16042021', '1530-16042021', '1545-16042021', '1600-16042021', '1615-16042021', '1630-16042021', '1645-16042021']}
|
||||||
|
people={[{ name: 'Jenny', created_at: 1618232400, availability: ['1100-12042021', '1100-13042021', '1100-14042021', '1100-15042021', '1115-12042021', '1115-13042021', '1115-14042021', '1115-15042021', '1130-12042021', '1130-13042021', '1130-14042021', '1130-15042021', '1145-12042021', '1145-13042021', '1145-14042021', '1145-15042021', '1200-12042021', '1200-13042021', '1200-14042021', '1200-15042021', '1215-12042021', '1215-13042021', '1215-14042021', '1215-15042021', '1230-12042021', '1230-13042021', '1230-14042021', '1230-15042021', '1245-12042021', '1245-13042021', '1245-14042021', '1245-15042021', '1300-12042021', '1300-13042021', '1300-14042021', '1300-15042021', '1300-16042021', '1315-12042021', '1315-13042021', '1315-14042021', '1315-15042021', '1315-16042021', '1330-12042021', '1330-13042021', '1330-14042021', '1330-15042021', '1330-16042021', '1345-12042021', '1345-13042021', '1345-14042021', '1345-15042021', '1345-16042021', '1400-12042021', '1400-13042021', '1400-14042021', '1400-15042021', '1400-16042021', '1415-12042021', '1415-13042021', '1415-14042021', '1415-15042021', '1415-16042021', '1430-12042021', '1430-13042021', '1430-14042021', '1430-15042021', '1430-16042021', '1445-12042021', '1445-13042021', '1445-14042021', '1445-15042021', '1445-16042021', '1500-12042021', '1500-15042021', '1500-16042021', '1515-12042021', '1515-15042021', '1515-16042021', '1530-12042021', '1530-15042021', '1530-16042021', '1545-12042021', '1545-15042021', '1545-16042021', '1600-12042021', '1600-15042021', '1600-16042021', '1615-12042021', '1615-15042021', '1615-16042021', '1630-12042021', '1630-15042021', '1630-16042021', '1645-12042021', '1645-15042021', '1645-16042021'] }]}
|
||||||
|
timezone="UTC"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 className={styles.step}>{t('help:s3')}</h2>
|
||||||
|
<P>{t('help:p8')}</P>
|
||||||
|
<P>{t('help:p9')}</P>
|
||||||
|
<P>{t('help:p10')}</P>
|
||||||
|
<AvailabilityViewer
|
||||||
|
times={['1100-12042021', '1115-12042021', '1130-12042021', '1145-12042021', '1200-12042021', '1215-12042021', '1230-12042021', '1245-12042021', '1300-12042021', '1315-12042021', '1330-12042021', '1345-12042021', '1400-12042021', '1415-12042021', '1430-12042021', '1445-12042021', '1500-12042021', '1515-12042021', '1530-12042021', '1545-12042021', '1600-12042021', '1615-12042021', '1630-12042021', '1645-12042021', '1100-13042021', '1115-13042021', '1130-13042021', '1145-13042021', '1200-13042021', '1215-13042021', '1230-13042021', '1245-13042021', '1300-13042021', '1315-13042021', '1330-13042021', '1345-13042021', '1400-13042021', '1415-13042021', '1430-13042021', '1445-13042021', '1500-13042021', '1515-13042021', '1530-13042021', '1545-13042021', '1600-13042021', '1615-13042021', '1630-13042021', '1645-13042021', '1100-14042021', '1115-14042021', '1130-14042021', '1145-14042021', '1200-14042021', '1215-14042021', '1230-14042021', '1245-14042021', '1300-14042021', '1315-14042021', '1330-14042021', '1345-14042021', '1400-14042021', '1415-14042021', '1430-14042021', '1445-14042021', '1500-14042021', '1515-14042021', '1530-14042021', '1545-14042021', '1600-14042021', '1615-14042021', '1630-14042021', '1645-14042021', '1100-15042021', '1115-15042021', '1130-15042021', '1145-15042021', '1200-15042021', '1215-15042021', '1230-15042021', '1245-15042021', '1300-15042021', '1315-15042021', '1330-15042021', '1345-15042021', '1400-15042021', '1415-15042021', '1430-15042021', '1445-15042021', '1500-15042021', '1515-15042021', '1530-15042021', '1545-15042021', '1600-15042021', '1615-15042021', '1630-15042021', '1645-15042021', '1100-16042021', '1115-16042021', '1130-16042021', '1145-16042021', '1200-16042021', '1215-16042021', '1230-16042021', '1245-16042021', '1300-16042021', '1315-16042021', '1330-16042021', '1345-16042021', '1400-16042021', '1415-16042021', '1430-16042021', '1445-16042021', '1500-16042021', '1515-16042021', '1530-16042021', '1545-16042021', '1600-16042021', '1615-16042021', '1630-16042021', '1645-16042021']}
|
||||||
|
people={[
|
||||||
|
{ name: 'Jenny', created_at: 1618232400, availability: ['1100-12042021', '1100-13042021', '1100-14042021', '1100-15042021', '1115-12042021', '1115-13042021', '1115-14042021', '1115-15042021', '1130-12042021', '1130-13042021', '1130-14042021', '1130-15042021', '1145-12042021', '1145-13042021', '1145-14042021', '1145-15042021', '1200-12042021', '1200-13042021', '1200-14042021', '1200-15042021', '1215-12042021', '1215-13042021', '1215-14042021', '1215-15042021', '1230-12042021', '1230-13042021', '1230-14042021', '1230-15042021', '1245-12042021', '1245-13042021', '1245-14042021', '1245-15042021', '1300-12042021', '1300-13042021', '1300-14042021', '1300-15042021', '1300-16042021', '1315-12042021', '1315-13042021', '1315-14042021', '1315-15042021', '1315-16042021', '1330-12042021', '1330-13042021', '1330-14042021', '1330-15042021', '1330-16042021', '1345-12042021', '1345-13042021', '1345-14042021', '1345-15042021', '1345-16042021', '1400-12042021', '1400-13042021', '1400-14042021', '1400-15042021', '1400-16042021', '1415-12042021', '1415-13042021', '1415-14042021', '1415-15042021', '1415-16042021', '1430-12042021', '1430-13042021', '1430-14042021', '1430-15042021', '1430-16042021', '1445-12042021', '1445-13042021', '1445-14042021', '1445-15042021', '1445-16042021', '1500-12042021', '1500-15042021', '1500-16042021', '1515-12042021', '1515-15042021', '1515-16042021', '1530-12042021', '1530-15042021', '1530-16042021', '1545-12042021', '1545-15042021', '1545-16042021', '1600-12042021', '1600-15042021', '1600-16042021', '1615-12042021', '1615-15042021', '1615-16042021', '1630-12042021', '1630-15042021', '1630-16042021', '1645-12042021', '1645-15042021', '1645-16042021'] },
|
||||||
|
{ name: 'Dakota', created_at: 1618232400, availability: ['1300-14042021', '1300-15042021', '1300-16042021', '1315-13042021', '1315-14042021', '1315-15042021', '1315-16042021', '1330-13042021', '1330-14042021', '1330-15042021', '1330-16042021', '1345-13042021', '1345-14042021', '1345-15042021', '1345-16042021', '1400-13042021', '1400-14042021', '1400-15042021', '1400-16042021', '1415-13042021', '1415-14042021', '1415-15042021', '1415-16042021', '1430-13042021', '1430-14042021', '1430-15042021', '1430-16042021', '1445-13042021', '1445-14042021', '1445-15042021', '1445-16042021', '1300-13042021', '1100-12042021', '1100-13042021', '1115-12042021', '1115-13042021', '1130-12042021', '1130-13042021', '1145-12042021', '1145-13042021'] },
|
||||||
|
{ name: 'Samson', created_at: 1618232400, availability: ['1100-16042021', '1115-16042021', '1130-16042021', '1145-16042021', '1200-16042021', '1215-16042021', '1230-16042021', '1245-16042021', '1300-16042021', '1315-16042021', '1330-16042021', '1345-16042021', '1400-16042021', '1415-16042021', '1430-16042021', '1445-16042021', '1500-16042021', '1515-16042021', '1530-16042021', '1545-16042021', '1600-16042021', '1615-16042021', '1630-16042021', '1645-16042021'] },
|
||||||
|
{ name: 'Mark', created_at: 1618232400, availability: ['1200-12042021', '1200-13042021', '1200-14042021', '1200-16042021', '1215-12042021', '1215-13042021', '1215-14042021', '1215-16042021', '1230-12042021', '1230-13042021', '1230-14042021', '1230-16042021', '1245-12042021', '1245-13042021', '1245-14042021', '1245-16042021', '1300-12042021', '1300-13042021', '1300-14042021', '1300-16042021', '1315-12042021', '1315-13042021', '1315-14042021', '1315-16042021', '1330-12042021', '1330-13042021', '1330-14042021', '1330-16042021', '1345-12042021', '1345-13042021', '1345-14042021', '1345-16042021', '1400-12042021', '1400-13042021', '1400-14042021', '1400-16042021', '1415-12042021', '1415-13042021', '1415-14042021', '1415-16042021', '1430-12042021', '1430-13042021', '1430-14042021', '1430-16042021', '1445-12042021', '1445-13042021', '1445-14042021', '1445-16042021', '1500-12042021', '1500-13042021', '1500-14042021', '1500-16042021', '1515-12042021', '1515-13042021', '1515-14042021', '1515-16042021', '1530-12042021', '1530-13042021', '1530-14042021', '1530-16042021', '1545-12042021', '1545-13042021', '1545-14042021', '1545-16042021'] },
|
||||||
|
{ name: 'Alex', created_at: 1618232400, availability: ['1200-13042021', '1200-14042021', '1215-13042021', '1215-14042021', '1230-13042021', '1230-14042021', '1245-13042021', '1245-14042021', '1300-13042021', '1300-14042021', '1315-13042021', '1315-14042021', '1330-13042021', '1330-14042021', '1345-13042021', '1345-14042021', '1400-13042021', '1400-14042021', '1415-13042021', '1415-14042021', '1430-13042021', '1430-14042021', '1445-13042021', '1445-14042021', '1500-13042021', '1500-14042021', '1515-13042021', '1515-14042021', '1530-13042021', '1530-14042021', '1545-13042021', '1545-14042021', '1200-12042021', '1215-12042021', '1545-12042021', '1230-12042021', '1245-12042021', '1300-12042021', '1315-12042021', '1330-12042021', '1345-12042021', '1400-12042021', '1415-12042021', '1430-12042021', '1445-12042021', '1500-12042021', '1515-12042021', '1530-12042021', '1100-15042021', '1100-16042021', '1115-15042021', '1115-16042021', '1130-15042021', '1130-16042021', '1145-15042021', '1145-16042021', '1200-15042021', '1200-16042021', '1215-15042021', '1215-16042021', '1230-15042021', '1230-16042021', '1245-15042021', '1245-16042021', '1300-15042021', '1300-16042021', '1315-15042021', '1315-16042021', '1330-15042021', '1330-16042021', '1345-15042021', '1345-16042021', '1400-15042021', '1400-16042021', '1415-15042021', '1415-16042021', '1430-15042021', '1430-16042021', '1445-15042021', '1445-16042021', '1500-15042021', '1500-16042021', '1515-15042021', '1515-16042021', '1530-15042021', '1530-16042021', '1545-15042021', '1545-16042021', '1600-15042021', '1600-16042021', '1615-15042021', '1615-16042021', '1630-15042021', '1630-16042021', '1645-15042021', '1645-16042021'] },
|
||||||
|
]}
|
||||||
|
timezone="UTC"
|
||||||
|
/>
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Content isCentered>
|
||||||
|
<Button href="/">{t('common:cta')}</Button>
|
||||||
|
</Content>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page
|
||||||
52
frontend/src/app/layout.tsx
Normal file
52
frontend/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
import { Karla } from 'next/font/google'
|
||||||
|
import { Analytics } from '@vercel/analytics/react'
|
||||||
|
|
||||||
|
import Egg from '/src/components/Egg/Egg'
|
||||||
|
import Settings from '/src/components/Settings/Settings'
|
||||||
|
import TranslateDialog from '/src/components/TranslateDialog/TranslateDialog'
|
||||||
|
import { fallbackLng } from '/src/i18n/options'
|
||||||
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
|
||||||
|
import './global.css'
|
||||||
|
|
||||||
|
const karla = Karla({ subsets: ['latin'] })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL('https://crab.fit'),
|
||||||
|
title: {
|
||||||
|
absolute: 'Crab Fit',
|
||||||
|
template: '%s - Crab Fit',
|
||||||
|
},
|
||||||
|
keywords: ['crab', 'fit', 'crabfit', 'schedule', 'availability', 'availabilities', 'when2meet', 'doodle', 'meet', 'plan', 'time', 'timezone'],
|
||||||
|
description: 'Enter your availability to find a time that works for everyone!',
|
||||||
|
themeColor: '#F79E00',
|
||||||
|
manifest: 'manifest.json',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Crab Fit',
|
||||||
|
description: 'Enter your availability to find a time that works for everyone!',
|
||||||
|
url: '/',
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: 'favicon.ico',
|
||||||
|
apple: 'logo192.png',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { resolvedLanguage } = await useTranslation([])
|
||||||
|
|
||||||
|
return <html lang={resolvedLanguage ?? fallbackLng}>
|
||||||
|
<body className={karla.className}>
|
||||||
|
<Settings />
|
||||||
|
<Egg />
|
||||||
|
<TranslateDialog />
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<Analytics />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RootLayout
|
||||||
53
frontend/src/app/page.tsx
Normal file
53
frontend/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import CreateForm from '/src/components/CreateForm/CreateForm'
|
||||||
|
import DownloadButtons from '/src/components/DownloadButtons/DownloadButtons'
|
||||||
|
import Footer from '/src/components/Footer/Footer'
|
||||||
|
import Header from '/src/components/Header/Header'
|
||||||
|
import { P } from '/src/components/Paragraph/Text'
|
||||||
|
import Recents from '/src/components/Recents/Recents'
|
||||||
|
import Section from '/src/components/Section/Section'
|
||||||
|
import Stats from '/src/components/Stats/Stats'
|
||||||
|
import Video from '/src/components/Video/Video'
|
||||||
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
|
||||||
|
const Page = async () => {
|
||||||
|
const { t, i18n } = await useTranslation('home')
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Content>
|
||||||
|
<Header isFull />
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<Recents />
|
||||||
|
|
||||||
|
<Content>
|
||||||
|
<CreateForm />
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<Section id="about">
|
||||||
|
<Content>
|
||||||
|
<h2>{t('about.name')}</h2>
|
||||||
|
|
||||||
|
<Stats />
|
||||||
|
|
||||||
|
<P><Trans i18nKey="about.content.p1" t={t} i18n={i18n}>_<br /><Link href="/how-to" rel="help">_</Link>_</Trans></P>
|
||||||
|
|
||||||
|
<Video />
|
||||||
|
|
||||||
|
<DownloadButtons />
|
||||||
|
|
||||||
|
<P><Trans i18nKey="about.content.p3" t={t} i18n={i18n}>_<a href="https://bengrant.dev" target="_blank" rel="noreferrer noopener author">_</a>_</Trans></P>
|
||||||
|
<P><Trans i18nKey="about.content.p4" t={t} i18n={i18n}>_<a href="https://github.com/GRA0007/crab.fit" target="_blank" rel="noreferrer noopener">_</a>_<Link href="/privacy" rel="license">_</Link>_</Trans></P>
|
||||||
|
<P>{t('about.content.p6')}</P>
|
||||||
|
<P>{t('about.content.p5')}</P>
|
||||||
|
</Content>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page
|
||||||
29
frontend/src/app/privacy/GoogleTranslate.tsx
Normal file
29
frontend/src/app/privacy/GoogleTranslate.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const TRANSLATION_DISCLAIMER = 'While the translated document is provided for your convenience, the English version as displayed at https://crab.fit is legally binding.'
|
||||||
|
|
||||||
|
interface GoogleTranslateProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
language: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a link to translate the privacy policy to the user's preferred language
|
||||||
|
const GoogleTranslate = ({ language, children }: GoogleTranslateProps) => {
|
||||||
|
const [content, setContent] = useState<string>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setContent(document.querySelector<HTMLDivElement>('#policy')?.innerText)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return content ? <p>
|
||||||
|
<a
|
||||||
|
href={`https://translate.google.com/?sl=en&tl=${language.substring(0, 2)}&text=${encodeURIComponent(`${TRANSLATION_DISCLAIMER}\n\n${content}`)}&op=translate`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>{children}</a>
|
||||||
|
</p> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GoogleTranslate
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { styled } from 'goober'
|
.note {
|
||||||
|
|
||||||
export const Note = styled('p')`
|
|
||||||
background-color: var(--surface);
|
background-color: var(--surface);
|
||||||
border: 1px solid var(--primary);
|
border: 1px solid var(--primary);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|
@ -13,10 +11,4 @@ export const Note = styled('p')`
|
||||||
& a {
|
& a {
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
}
|
}
|
||||||
`
|
|
||||||
|
|
||||||
export const ButtonArea = styled('div')`
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
`
|
|
||||||
|
|
@ -1,46 +1,37 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { Metadata } from 'next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
import { Button, Center, Footer, Logo } from '/src/components'
|
import GoogleTranslate from '/src/app/privacy/GoogleTranslate'
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import Footer from '/src/components/Footer/Footer'
|
||||||
|
import Header from '/src/components/Header/Header'
|
||||||
|
import { P, Ul } from '/src/components/Paragraph/Text'
|
||||||
|
import Section from '/src/components/Section/Section'
|
||||||
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
|
||||||
import { StyledMain, AboutSection, P } from '../Home/Home.styles'
|
import styles from './page.module.scss'
|
||||||
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.'
|
export const generateMetadata = async (): Promise<Metadata> => {
|
||||||
|
const { t } = await useTranslation('privacy')
|
||||||
|
|
||||||
const Privacy = () => {
|
return {
|
||||||
const navigate = useNavigate()
|
title: t('name'),
|
||||||
const { t, i18n } = useTranslation(['common', 'privacy'])
|
}
|
||||||
const contentRef = useRef()
|
}
|
||||||
const [content, setContent] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
const Page = async () => {
|
||||||
document.title = `${t('privacy:name')} - Crab Fit`
|
const { t, i18n } = await useTranslation(['common', 'privacy'])
|
||||||
}, [t])
|
|
||||||
|
|
||||||
useEffect(() => setContent(contentRef.current?.innerText || ''), [contentRef])
|
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<StyledMain>
|
<Content>
|
||||||
<Logo />
|
<Header />
|
||||||
</StyledMain>
|
|
||||||
|
|
||||||
<StyledMain>
|
|
||||||
<h1>{t('privacy:name')}</h1>
|
<h1>{t('privacy:name')}</h1>
|
||||||
|
|
||||||
{!i18n.language.startsWith('en') && (
|
{!i18n.language.startsWith('en') && <GoogleTranslate language={i18n.language}>{t('privacy:translate')}</GoogleTranslate>}
|
||||||
<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>
|
<h3>Crab Fit</h3>
|
||||||
<div ref={contentRef}>
|
<div id="policy">
|
||||||
<P>This SERVICE is provided by Benjamin Grant at no cost and is intended for use as is.</P>
|
<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>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>
|
<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>
|
||||||
|
|
@ -48,9 +39,10 @@ const Privacy = () => {
|
||||||
<h2>Information Collection and Use</h2>
|
<h2>Information Collection and Use</h2>
|
||||||
<P>The Service uses third party services that may collect information used to identify you.</P>
|
<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>Links to privacy policies of the third party service providers used by the Service:</P>
|
||||||
<P as="ul">
|
<Ul>
|
||||||
<li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a></li>
|
<li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a> (only used for Google Calendar sync)</li>
|
||||||
</P>
|
<li><a href="https://vercel.com/docs/concepts/analytics/privacy-policy" target="blank">Vercel Analytics</a></li>
|
||||||
|
</Ul>
|
||||||
|
|
||||||
<h2>Log Data</h2>
|
<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>
|
<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>
|
||||||
|
|
@ -61,17 +53,17 @@ const Privacy = () => {
|
||||||
|
|
||||||
<h2>Service Providers</h2>
|
<h2>Service Providers</h2>
|
||||||
<P>Third-party companies may be employed for the following reasons:</P>
|
<P>Third-party companies may be employed for the following reasons:</P>
|
||||||
<P as="ul">
|
<Ul>
|
||||||
<li>To facilitate the Service</li>
|
<li>To facilitate the Service</li>
|
||||||
<li>To provide the Service on our behalf</li>
|
<li>To provide the Service on our behalf</li>
|
||||||
<li>To perform Service-related services</li>
|
<li>To perform Service-related services</li>
|
||||||
<li>To assist in analyzing how the Service is used</li>
|
<li>To assist in analyzing how the Service is used</li>
|
||||||
</P>
|
</Ul>
|
||||||
<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>
|
<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>
|
<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>
|
<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>
|
<p className={styles.note}>Events that are created will be automatically permanently erased from storage after <strong>3 months</strong> of inactivity.</p>
|
||||||
|
|
||||||
<h2>Links to Other Sites</h2>
|
<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>
|
<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>
|
||||||
|
|
@ -81,23 +73,21 @@ const Privacy = () => {
|
||||||
|
|
||||||
<h2>Changes to This Privacy Policy</h2>
|
<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>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>
|
<P>Last updated: 2023-06-10</P>
|
||||||
|
|
||||||
<h2>Contact Us</h2>
|
<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>
|
<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>
|
</div>
|
||||||
</StyledMain>
|
</Content>
|
||||||
|
|
||||||
<ButtonArea>
|
<Section>
|
||||||
<AboutSection>
|
<Content isCentered>
|
||||||
<StyledMain>
|
<Button href="/">{t('common:cta')}</Button>
|
||||||
<Center><Button onClick={() => navigate('/')}>{t('common:cta')}</Button></Center>
|
</Content>
|
||||||
</StyledMain>
|
</Section>
|
||||||
</AboutSection>
|
|
||||||
</ButtonArea>
|
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Privacy
|
export default Page
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
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,
|
|
||||||
ScrollWrapper,
|
|
||||||
Container,
|
|
||||||
Date,
|
|
||||||
Times,
|
|
||||||
DateLabel,
|
|
||||||
DayLabel,
|
|
||||||
Spacer,
|
|
||||||
TimeLabels,
|
|
||||||
TimeLabel,
|
|
||||||
TimeSpace,
|
|
||||||
StyledMain,
|
|
||||||
} from '/src/components/AvailabilityViewer/AvailabilityViewer.styles'
|
|
||||||
import { Time } from './AvailabilityEditor.styles'
|
|
||||||
|
|
||||||
import { _GoogleCalendar, _OutlookCalendar, Center } from '/src/components'
|
|
||||||
import { Loader } from '../Loading/Loading.styles'
|
|
||||||
|
|
||||||
const GoogleCalendar = lazy(() => _GoogleCalendar())
|
|
||||||
const OutlookCalendar = lazy(() => _OutlookCalendar())
|
|
||||||
|
|
||||||
dayjs.extend(localeData)
|
|
||||||
dayjs.extend(customParseFormat)
|
|
||||||
dayjs.extend(isBetween)
|
|
||||||
dayjs.extend(utc)
|
|
||||||
dayjs.extend(dayjs_timezone)
|
|
||||||
|
|
||||||
const AvailabilityEditor = ({
|
|
||||||
times,
|
|
||||||
timeLabels,
|
|
||||||
dates,
|
|
||||||
timezone,
|
|
||||||
isSpecificDates,
|
|
||||||
value = [],
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('event')
|
|
||||||
const locale = useLocaleUpdateStore(state => state.locale)
|
|
||||||
|
|
||||||
const [selectingTimes, _setSelectingTimes] = useState([])
|
|
||||||
const staticSelectingTimes = useRef([])
|
|
||||||
const setSelectingTimes = newTimes => {
|
|
||||||
staticSelectingTimes.current = newTimes
|
|
||||||
_setSelectingTimes(newTimes)
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPos = useRef({})
|
|
||||||
const staticMode = useRef(null)
|
|
||||||
const [mode, _setMode] = useState(staticMode.current)
|
|
||||||
const setMode = newMode => {
|
|
||||||
staticMode.current = newMode
|
|
||||||
_setMode(newMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StyledMain>
|
|
||||||
<Center style={{textAlign: 'center'}}>{t('event:you.info')}</Center>
|
|
||||||
</StyledMain>
|
|
||||||
{isSpecificDates && (
|
|
||||||
<StyledMain>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
|
||||||
<Suspense fallback={<Loader />}>
|
|
||||||
<GoogleCalendar
|
|
||||||
timeMin={dayjs(times[0], 'HHmm-DDMMYYYY').toISOString()}
|
|
||||||
timeMax={dayjs(times[times.length-1], 'HHmm-DDMMYYYY').add(15, 'm').toISOString()}
|
|
||||||
timeZone={timezone}
|
|
||||||
onImport={busyArray => onChange(
|
|
||||||
times.filter(time => !busyArray.some(busy =>
|
|
||||||
dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)')
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<OutlookCalendar
|
|
||||||
timeMin={dayjs(times[0], 'HHmm-DDMMYYYY').toISOString()}
|
|
||||||
timeMax={dayjs(times[times.length-1], 'HHmm-DDMMYYYY').add(15, 'm').toISOString()}
|
|
||||||
timeZone={timezone}
|
|
||||||
onImport={busyArray => onChange(
|
|
||||||
times.filter(time => !busyArray.some(busy =>
|
|
||||||
dayjs(time, 'HHmm-DDMMYYYY').isBetween(dayjs.tz(busy.start.dateTime, busy.start.timeZone), dayjs.tz(busy.end.dateTime, busy.end.timeZone), null, '[)')
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</StyledMain>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Wrapper locale={locale}>
|
|
||||||
<ScrollWrapper>
|
|
||||||
<Container>
|
|
||||||
<TimeLabels>
|
|
||||||
{!!timeLabels.length && timeLabels.map((label, i) =>
|
|
||||||
<TimeSpace key={i}>
|
|
||||||
{label.label?.length !== '' && <TimeLabel>{label.label}</TimeLabel>}
|
|
||||||
</TimeSpace>
|
|
||||||
)}
|
|
||||||
</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
|
|
||||||
return (
|
|
||||||
<Fragment key={x}>
|
|
||||||
<Date>
|
|
||||||
{isSpecificDates && <DateLabel>{parsedDate.format('MMM D')}</DateLabel>}
|
|
||||||
<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}
|
|
||||||
>
|
|
||||||
{timeLabels.map((timeLabel, y) => {
|
|
||||||
if (!timeLabel.time) return null
|
|
||||||
if (!times.includes(`${timeLabel.time}-${date}`)) {
|
|
||||||
return (
|
|
||||||
<TimeSpace key={x+y} className="timespace" title={t('event:greyed_times')} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const time = `${timeLabel.time}-${date}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Time
|
|
||||||
key={x+y}
|
|
||||||
$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)
|
|
||||||
|
|
||||||
document.addEventListener('pointerup', () => {
|
|
||||||
if (staticMode.current === 'add') {
|
|
||||||
onChange([...value, ...staticSelectingTimes.current])
|
|
||||||
} else if (staticMode.current === 'remove') {
|
|
||||||
onChange(value.filter(t => !staticSelectingTimes.current.includes(t)))
|
|
||||||
}
|
|
||||||
setMode(null)
|
|
||||||
}, { once: true })
|
|
||||||
}}
|
|
||||||
onPointerEnter={() => {
|
|
||||||
if (staticMode.current) {
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSelectingTimes(found.filter(d => timeLabels[d.y].time?.length === 4).map(d => `${timeLabels[d.y].time}-${dates[d.x]}`))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Times>
|
|
||||||
</Date>
|
|
||||||
{last && dates.length !== x+1 && (
|
|
||||||
<Spacer />
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Container>
|
|
||||||
</ScrollWrapper>
|
|
||||||
</Wrapper>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AvailabilityEditor
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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);
|
|
||||||
`};
|
|
||||||
`
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { Fragment, useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import GoogleCalendar from '/src/components/GoogleCalendar/GoogleCalendar'
|
||||||
|
import { usePalette } from '/src/hooks/usePalette'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import { useStore } from '/src/stores'
|
||||||
|
import useSettingsStore from '/src/stores/settingsStore'
|
||||||
|
import { calculateTable, makeClass, parseSpecificDate } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from '../AvailabilityViewer/AvailabilityViewer.module.scss'
|
||||||
|
|
||||||
|
interface AvailabilityEditorProps {
|
||||||
|
times: string[]
|
||||||
|
timezone: string
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvailabilityEditor = ({
|
||||||
|
times,
|
||||||
|
timezone,
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
}: AvailabilityEditorProps) => {
|
||||||
|
const { t, i18n } = useTranslation('event')
|
||||||
|
|
||||||
|
const timeFormat = useStore(useSettingsStore, state => state.timeFormat) ?? '12h'
|
||||||
|
|
||||||
|
// Calculate table
|
||||||
|
const { rows, columns } = useMemo(() =>
|
||||||
|
calculateTable(times, i18n.language, timeFormat, timezone),
|
||||||
|
[times, i18n.language, timeFormat, timezone])
|
||||||
|
|
||||||
|
// Ref and state required to rerender but also access static version in callbacks
|
||||||
|
const selectingRef = useRef<string[]>([])
|
||||||
|
const [selecting, _setSelecting] = useState<string[]>([])
|
||||||
|
const setSelecting = useCallback((v: string[]) => {
|
||||||
|
selectingRef.current = v
|
||||||
|
_setSelecting(v)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startPos = useRef({ x: 0, y: 0 })
|
||||||
|
const mode = useRef<'add' | 'remove'>()
|
||||||
|
|
||||||
|
// Create the colour palette
|
||||||
|
const palette = usePalette(2)
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Content isCentered>{t('you.info')}</Content>
|
||||||
|
{times[0].length === 13 && <Content>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||||
|
<GoogleCalendar
|
||||||
|
timezone={timezone}
|
||||||
|
timeStart={parseSpecificDate(times[0])}
|
||||||
|
timeEnd={parseSpecificDate(times[times.length - 1]).add({ minutes: 15 })}
|
||||||
|
times={times}
|
||||||
|
onImport={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Content>}
|
||||||
|
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<div>
|
||||||
|
<div className={styles.heatmap}>
|
||||||
|
<div className={styles.timeLabels}>
|
||||||
|
{rows.map((row, i) =>
|
||||||
|
<div className={styles.timeSpace} key={i}>
|
||||||
|
{row && <label className={styles.timeLabel}>
|
||||||
|
{row.label}
|
||||||
|
</label>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{columns.map((column, x) => <Fragment key={x}>
|
||||||
|
{column ? <div className={styles.dateColumn}>
|
||||||
|
{column.header.dateLabel && <label className={styles.dateLabel}>{column.header.dateLabel}</label>}
|
||||||
|
<label className={styles.dayLabel}>{column.header.weekdayLabel}</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.times}
|
||||||
|
data-border-left={x === 0 || columns.at(x - 1) === null}
|
||||||
|
data-border-right={x === columns.length - 1 || columns.at(x + 1) === null}
|
||||||
|
>
|
||||||
|
{column.cells.map((cell, y) => {
|
||||||
|
if (y === column.cells.length - 1) return null
|
||||||
|
|
||||||
|
if (!cell) return <div
|
||||||
|
className={makeClass(styles.timeSpace, styles.grey)}
|
||||||
|
key={y}
|
||||||
|
title={t<string>('greyed_times')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
const isSelected = (
|
||||||
|
(!(mode.current === 'remove' && selecting.includes(cell.serialized)) && value.includes(cell.serialized))
|
||||||
|
|| (mode.current === 'add' && selecting.includes(cell.serialized))
|
||||||
|
)
|
||||||
|
|
||||||
|
return <div
|
||||||
|
key={y}
|
||||||
|
className={makeClass(styles.time, selecting.length === 0 && styles.editable)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? palette[1].string : palette[0].string,
|
||||||
|
'--hover-color': isSelected ? palette[0].highlight : palette[1].highlight,
|
||||||
|
...cell.minute !== 0 && cell.minute !== 30 && { borderTopColor: 'transparent' },
|
||||||
|
...cell.minute === 30 && { borderTopStyle: 'dotted' },
|
||||||
|
} as React.CSSProperties}
|
||||||
|
onPointerDown={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
startPos.current = { x, y }
|
||||||
|
mode.current = value.includes(cell.serialized) ? 'remove' : 'add'
|
||||||
|
setSelecting([cell.serialized])
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
|
||||||
|
document.addEventListener('pointerup', () => {
|
||||||
|
if (mode.current === 'add') {
|
||||||
|
onChange([...value, ...selectingRef.current])
|
||||||
|
} else if (mode.current === 'remove') {
|
||||||
|
onChange(value.filter(t => !selectingRef.current.includes(t)))
|
||||||
|
}
|
||||||
|
setSelecting([])
|
||||||
|
mode.current = undefined
|
||||||
|
}, { once: true })
|
||||||
|
}}
|
||||||
|
onPointerEnter={() => {
|
||||||
|
if (mode.current) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelecting(found.flatMap(d => {
|
||||||
|
const serialized = columns[d.x]?.cells[d.y]?.serialized
|
||||||
|
if (serialized && times.includes(serialized)) {
|
||||||
|
return [serialized]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div> : <div className={styles.columnSpacer} />}
|
||||||
|
</Fragment>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AvailabilityEditor
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
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 { createPalette } from 'hue-map'
|
|
||||||
|
|
||||||
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
|
|
||||||
|
|
||||||
import { Legend } from '/src/components'
|
|
||||||
import {
|
|
||||||
Wrapper,
|
|
||||||
ScrollWrapper,
|
|
||||||
Container,
|
|
||||||
Date,
|
|
||||||
Times,
|
|
||||||
DateLabel,
|
|
||||||
DayLabel,
|
|
||||||
Time,
|
|
||||||
Spacer,
|
|
||||||
Tooltip,
|
|
||||||
TooltipTitle,
|
|
||||||
TooltipDate,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipPerson,
|
|
||||||
TimeLabels,
|
|
||||||
TimeLabel,
|
|
||||||
TimeSpace,
|
|
||||||
People,
|
|
||||||
Person,
|
|
||||||
StyledMain,
|
|
||||||
Info,
|
|
||||||
} from './AvailabilityViewer.styles'
|
|
||||||
|
|
||||||
import locales from '/src/i18n/locales'
|
|
||||||
|
|
||||||
dayjs.extend(localeData)
|
|
||||||
dayjs.extend(customParseFormat)
|
|
||||||
dayjs.extend(relativeTime)
|
|
||||||
|
|
||||||
const AvailabilityViewer = ({
|
|
||||||
times,
|
|
||||||
timeLabels,
|
|
||||||
dates,
|
|
||||||
isSpecificDates,
|
|
||||||
people = [],
|
|
||||||
min = 0,
|
|
||||||
max = 0,
|
|
||||||
}) => {
|
|
||||||
const [tooltip, setTooltip] = useState(null)
|
|
||||||
const timeFormat = useSettingsStore(state => state.timeFormat)
|
|
||||||
const highlight = useSettingsStore(state => state.highlight)
|
|
||||||
const colormap = useSettingsStore(state => state.colormap)
|
|
||||||
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 wrapper = useRef()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFilteredPeople(people.map(p => p.name))
|
|
||||||
setTouched(people.length <= 1)
|
|
||||||
}, [people])
|
|
||||||
|
|
||||||
const [palette, setPalette] = useState([])
|
|
||||||
|
|
||||||
useEffect(() => setPalette(createPalette({
|
|
||||||
map: colormap === 'crabfit' ? [[0, [247,158,0,0]], [1, [247,158,0,255]]] : colormap,
|
|
||||||
steps: tempFocus !== null ? 2 : Math.min(max, filteredPeople.length)+1,
|
|
||||||
}).format()), [tempFocus, filteredPeople, max, colormap])
|
|
||||||
|
|
||||||
const heatmap = useMemo(() => (
|
|
||||||
<Container>
|
|
||||||
<TimeLabels>
|
|
||||||
{!!timeLabels.length && timeLabels.map((label, i) =>
|
|
||||||
<TimeSpace key={i}>
|
|
||||||
{label.label?.length !== '' && <TimeLabel>{label.label}</TimeLabel>}
|
|
||||||
</TimeSpace>
|
|
||||||
)}
|
|
||||||
</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
|
|
||||||
return (
|
|
||||||
<Fragment key={i}>
|
|
||||||
<Date>
|
|
||||||
{isSpecificDates && <DateLabel locale={locale}>{parsedDate.format('MMM D')}</DateLabel>}
|
|
||||||
<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}
|
|
||||||
>
|
|
||||||
{timeLabels.map((timeLabel, i) => {
|
|
||||||
if (!timeLabel.time) return null
|
|
||||||
if (!times.includes(`${timeLabel.time}-${date}`)) {
|
|
||||||
return (
|
|
||||||
<TimeSpace className="timespace" key={i} title={t('event:greyed_times')} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Time
|
|
||||||
key={i}
|
|
||||||
$time={time}
|
|
||||||
className="time"
|
|
||||||
$peopleCount={focusCount !== null && focusCount !== peopleHere.length ? null : peopleHere.length}
|
|
||||||
$palette={palette}
|
|
||||||
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`
|
|
||||||
setTooltip({
|
|
||||||
x: Math.round(cellBox.x-wrapperBox.x + cellBox.width/2),
|
|
||||||
y: Math.round(cellBox.y-wrapperBox.y + cellBox.height)+6,
|
|
||||||
available: `${peopleHere.length} / ${filteredPeople.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)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Times>
|
|
||||||
</Date>
|
|
||||||
{last && dates.length !== i+1 && <Spacer />}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Container>
|
|
||||||
), [
|
|
||||||
people,
|
|
||||||
filteredPeople,
|
|
||||||
tempFocus,
|
|
||||||
focusCount,
|
|
||||||
highlight,
|
|
||||||
locale,
|
|
||||||
dates,
|
|
||||||
isSpecificDates,
|
|
||||||
max,
|
|
||||||
min,
|
|
||||||
t,
|
|
||||||
timeFormat,
|
|
||||||
timeLabels,
|
|
||||||
times,
|
|
||||||
palette,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StyledMain>
|
|
||||||
<Legend
|
|
||||||
min={Math.min(min, filteredPeople.length)}
|
|
||||||
max={Math.min(max, filteredPeople.length)}
|
|
||||||
total={filteredPeople.length}
|
|
||||||
onSegmentFocus={count => setFocusCount(count)}
|
|
||||||
/>
|
|
||||||
<Info>{t('event:group.info1')}</Info>
|
|
||||||
{people.length > 1 && (
|
|
||||||
<>
|
|
||||||
<Info>{t('event:group.info2')}</Info>
|
|
||||||
<People>
|
|
||||||
{people.map((person, i) =>
|
|
||||||
<Person
|
|
||||||
key={i}
|
|
||||||
$filtered={filteredPeople.includes(person.name)}
|
|
||||||
onClick={() => {
|
|
||||||
setTempFocus(null)
|
|
||||||
if (filteredPeople.includes(person.name)) {
|
|
||||||
if (!touched) {
|
|
||||||
setTouched(true)
|
|
||||||
setFilteredPeople([person.name])
|
|
||||||
} else {
|
|
||||||
setFilteredPeople(filteredPeople.filter(n => n !== person.name))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setFilteredPeople([...filteredPeople, person.name])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseOver={() => setTempFocus(person.name)}
|
|
||||||
onMouseOut={() => setTempFocus(null)}
|
|
||||||
title={person.created && dayjs.unix(person.created).fromNow()}
|
|
||||||
>{person.name}</Person>
|
|
||||||
)}
|
|
||||||
</People>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</StyledMain>
|
|
||||||
|
|
||||||
<Wrapper ref={wrapper}>
|
|
||||||
<ScrollWrapper>
|
|
||||||
{heatmap}
|
|
||||||
|
|
||||||
{tooltip && (
|
|
||||||
<Tooltip
|
|
||||||
$x={tooltip.x}
|
|
||||||
$y={tooltip.y}
|
|
||||||
>
|
|
||||||
<TooltipTitle>{tooltip.available}</TooltipTitle>
|
|
||||||
<TooltipDate>{tooltip.date}</TooltipDate>
|
|
||||||
{!!filteredPeople.length && (
|
|
||||||
<TooltipContent>
|
|
||||||
{tooltip.people.map(person =>
|
|
||||||
<TooltipPerson key={person}>{person}</TooltipPerson>
|
|
||||||
)}
|
|
||||||
{filteredPeople.filter(p => !tooltip.people.includes(p)).map(person =>
|
|
||||||
<TooltipPerson key={person} disabled>{person}</TooltipPerson>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</ScrollWrapper>
|
|
||||||
</Wrapper>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AvailabilityViewer
|
|
||||||
|
|
@ -1,17 +1,4 @@
|
||||||
import { styled } from 'goober'
|
.heatmap {
|
||||||
import { forwardRef } from 'react'
|
|
||||||
|
|
||||||
export const Wrapper = styled('div', forwardRef)`
|
|
||||||
overflow-y: visible;
|
|
||||||
margin: 20px 0;
|
|
||||||
position: relative;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const ScrollWrapper = styled('div')`
|
|
||||||
overflow-x: auto;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Container = styled('div')`
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
|
|
@ -22,152 +9,22 @@ export const Container = styled('div')`
|
||||||
@media (max-width: 660px) {
|
@media (max-width: 660px) {
|
||||||
padding: 0 30px;
|
padding: 0 30px;
|
||||||
}
|
}
|
||||||
`
|
|
||||||
|
|
||||||
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')`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
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 var(--text);
|
|
||||||
border-top-left-radius: 3px;
|
|
||||||
border-bottom-left-radius: 3px;
|
|
||||||
`}
|
|
||||||
${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 var(--text);
|
|
||||||
}
|
}
|
||||||
`
|
|
||||||
|
|
||||||
export const DateLabel = styled('label')`
|
.timeLabels {
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
user-select: none;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const DayLabel = styled('label')`
|
|
||||||
display: block;
|
|
||||||
font-size: 15px;
|
|
||||||
text-align: center;
|
|
||||||
user-select: none;
|
|
||||||
`
|
|
||||||
|
|
||||||
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 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);
|
|
||||||
`}
|
|
||||||
|
|
||||||
background-color: ${props => props.$palette[props.$peopleCount] ?? 'transparent'};
|
|
||||||
|
|
||||||
${props => props.$highlight && props.$peopleCount === props.$maxPeople && props.$peopleCount > 0 && `
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent,
|
|
||||||
transparent 4.3px,
|
|
||||||
rgba(0,0,0,.5) 4.3px,
|
|
||||||
rgba(0,0,0,.5) 8.6px
|
|
||||||
);
|
|
||||||
`}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Spacer = styled('div')`
|
|
||||||
width: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Tooltip = styled('div')`
|
|
||||||
position: absolute;
|
|
||||||
top: ${props => props.$y}px;
|
|
||||||
left: ${props => props.$x}px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
border: 1px solid var(--text);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background-color: var(--background);
|
|
||||||
max-width: 200px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 100;
|
|
||||||
user-select: none;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const TooltipTitle = styled('span')`
|
|
||||||
font-size: 15px;
|
|
||||||
display: block;
|
|
||||||
font-weight: 700;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const TooltipDate = styled('span')`
|
|
||||||
font-size: 13px;
|
|
||||||
display: block;
|
|
||||||
opacity: .8;
|
|
||||||
font-weight: 600;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const TooltipContent = styled('div')`
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 4px 0;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const TooltipPerson = styled('span')`
|
|
||||||
display: inline-block;
|
|
||||||
margin: 2px;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
${props => props.disabled && `
|
|
||||||
opacity: .5;
|
|
||||||
border-color: var(--text);
|
|
||||||
`}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const TimeLabels = styled('div')`
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
`
|
}
|
||||||
|
|
||||||
export const TimeSpace = styled('div')`
|
.timeSpace {
|
||||||
height: 10px;
|
height: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
border-top: 2px solid transparent;
|
border-top: 2px solid transparent;
|
||||||
|
|
||||||
&.timespace {
|
&.grey {
|
||||||
background-origin: border-box;
|
background-origin: border-box;
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
45deg,
|
45deg,
|
||||||
|
|
@ -177,9 +34,9 @@ export const TimeSpace = styled('div')`
|
||||||
var(--loading) 8.6px
|
var(--loading) 8.6px
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
`
|
}
|
||||||
|
|
||||||
export const TimeLabel = styled('label')`
|
.timeLabel {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -.7em;
|
top: -.7em;
|
||||||
|
|
@ -187,23 +44,107 @@ export const TimeLabel = styled('label')`
|
||||||
text-align: right;
|
text-align: right;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
`
|
}
|
||||||
|
|
||||||
export const StyledMain = styled('div')`
|
.dateColumn {
|
||||||
width: 600px;
|
flex-shrink: 0;
|
||||||
margin: 20px auto;
|
display: flex;
|
||||||
max-width: calc(100% - 60px);
|
flex-direction: column;
|
||||||
`
|
width: 60px;
|
||||||
|
min-width: 60px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
export const People = styled('div')`
|
.dateLabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayLabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.times {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
border-bottom: 2px solid var(--text);
|
||||||
|
border-left: 1px solid var(--text);
|
||||||
|
border-right: 1px solid var(--text);
|
||||||
|
|
||||||
|
&[data-border-left=true] {
|
||||||
|
border-left: 2px solid var(--text);
|
||||||
|
border-top-left-radius: 3px;
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
&[data-border-right=true] {
|
||||||
|
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 var(--text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
height: 10px;
|
||||||
|
background-origin: border-box;
|
||||||
|
transition: background-color .1s;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
border-top-width: 2px;
|
||||||
|
border-top-style: solid;
|
||||||
|
border-top-color: var(--text);
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable {
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover:not(:active) {
|
||||||
|
opacity: .8;
|
||||||
|
background-image: linear-gradient(var(--hover-color), var(--hover-color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent,
|
||||||
|
transparent 4.3px,
|
||||||
|
var(--highlight-color, rgba(0,0,0,.5)) 4.3px,
|
||||||
|
var(--highlight-color, rgba(0,0,0,.5)) 8.6px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.people {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 14px auto;
|
margin: 14px auto;
|
||||||
`
|
}
|
||||||
|
|
||||||
export const Person = styled('button')`
|
.person {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|
@ -215,18 +156,71 @@ export const Person = styled('button')`
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
${props => props.$filtered && `
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.personSelected {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
`}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Info = styled('span')`
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
`
|
|
||||||
|
.wrapper {
|
||||||
|
overflow-y: visible;
|
||||||
|
margin: 20px 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnSpacer {
|
||||||
|
width: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 1px solid var(--text);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: var(--background);
|
||||||
|
max-width: 200px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
& > span {
|
||||||
|
font-size: 13px;
|
||||||
|
display: block;
|
||||||
|
opacity: .8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
& > div {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 2px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&[data-disabled=true] {
|
||||||
|
opacity: .5;
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import Legend from '/src/components/Legend/Legend'
|
||||||
|
import { PersonResponse } from '/src/config/api'
|
||||||
|
import { usePalette } from '/src/hooks/usePalette'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import { useStore } from '/src/stores'
|
||||||
|
import useSettingsStore from '/src/stores/settingsStore'
|
||||||
|
import { calculateAvailability, calculateTable, makeClass, relativeTimeFormat } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './AvailabilityViewer.module.scss'
|
||||||
|
|
||||||
|
interface AvailabilityViewerProps {
|
||||||
|
times: string[]
|
||||||
|
timezone: string
|
||||||
|
people: PersonResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps) => {
|
||||||
|
const { t, i18n } = useTranslation('event')
|
||||||
|
|
||||||
|
const timeFormat = useStore(useSettingsStore, state => state.timeFormat) ?? '12h'
|
||||||
|
const highlight = useStore(useSettingsStore, state => state.highlight)
|
||||||
|
const [filteredPeople, setFilteredPeople] = useState(people.map(p => p.name))
|
||||||
|
const [tempFocus, setTempFocus] = useState<string>()
|
||||||
|
const [focusCount, setFocusCount] = useState<number>()
|
||||||
|
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [tooltip, setTooltip] = useState<{
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
available: string
|
||||||
|
date: string
|
||||||
|
people: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Calculate table
|
||||||
|
const { rows, columns } = useMemo(() =>
|
||||||
|
calculateTable(times, i18n.language, timeFormat, timezone),
|
||||||
|
[times, i18n.language, timeFormat, timezone])
|
||||||
|
|
||||||
|
// Calculate availabilities
|
||||||
|
const { availabilities, min, max } = useMemo(() =>
|
||||||
|
calculateAvailability(times, people.filter(p => filteredPeople.includes(p.name))),
|
||||||
|
[times, filteredPeople, people])
|
||||||
|
|
||||||
|
// Create the colour palette
|
||||||
|
const palette = usePalette(Math.max((max - min) + 1, 2))
|
||||||
|
|
||||||
|
// Reselect everyone if the amount of people changes
|
||||||
|
useEffect(() => {
|
||||||
|
setFilteredPeople(people.map(p => p.name))
|
||||||
|
}, [people.length])
|
||||||
|
|
||||||
|
const heatmap = useMemo(() => columns.map((column, x) => <Fragment key={x}>
|
||||||
|
{column ? <div className={styles.dateColumn}>
|
||||||
|
{column.header.dateLabel && <label className={styles.dateLabel}>{column.header.dateLabel}</label>}
|
||||||
|
<label className={styles.dayLabel}>{column.header.weekdayLabel}</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.times}
|
||||||
|
data-border-left={x === 0 || columns.at(x - 1) === null}
|
||||||
|
data-border-right={x === columns.length - 1 || columns.at(x + 1) === null}
|
||||||
|
>
|
||||||
|
{column.cells.map((cell, y) => {
|
||||||
|
if (y === column.cells.length - 1) return null
|
||||||
|
|
||||||
|
if (!cell) return <div
|
||||||
|
className={makeClass(styles.timeSpace, styles.grey)}
|
||||||
|
key={y}
|
||||||
|
title={t<string>('greyed_times')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
let peopleHere = availabilities.find(a => a.date === cell.serialized)?.people ?? []
|
||||||
|
if (tempFocus) {
|
||||||
|
peopleHere = peopleHere.filter(p => p === tempFocus)
|
||||||
|
}
|
||||||
|
const color = palette[tempFocus && peopleHere.length ? max : peopleHere.length - min]
|
||||||
|
|
||||||
|
return <div
|
||||||
|
key={y}
|
||||||
|
className={makeClass(
|
||||||
|
styles.time,
|
||||||
|
(focusCount === undefined || focusCount === peopleHere.length) && highlight && (peopleHere.length === max || tempFocus) && peopleHere.length > 0 && styles.highlight,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: (focusCount === undefined || focusCount === peopleHere.length) ? color.string : 'transparent',
|
||||||
|
'--highlight-color': color.highlight,
|
||||||
|
...cell.minute !== 0 && cell.minute !== 30 && { borderTopColor: 'transparent' },
|
||||||
|
...cell.minute === 30 && { borderTopStyle: 'dotted' },
|
||||||
|
} as React.CSSProperties}
|
||||||
|
aria-label={peopleHere.join(', ')}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
const cellBox = e.currentTarget.getBoundingClientRect()
|
||||||
|
const wrapperBox = wrapperRef.current?.getBoundingClientRect() ?? { x: 0, y: 0 }
|
||||||
|
setTooltip({
|
||||||
|
x: Math.round(cellBox.x - wrapperBox.x + cellBox.width / 2),
|
||||||
|
y: Math.round(cellBox.y - wrapperBox.y + cellBox.height) + 6,
|
||||||
|
available: `${peopleHere.length} / ${filteredPeople.length} ${t('available')}`,
|
||||||
|
date: cell.label,
|
||||||
|
people: peopleHere,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setTooltip(undefined)}
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div> : <div className={styles.columnSpacer} />}
|
||||||
|
</Fragment>), [
|
||||||
|
availabilities,
|
||||||
|
columns,
|
||||||
|
highlight,
|
||||||
|
max,
|
||||||
|
min,
|
||||||
|
t,
|
||||||
|
palette,
|
||||||
|
tempFocus,
|
||||||
|
focusCount,
|
||||||
|
filteredPeople,
|
||||||
|
])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Content>
|
||||||
|
<Legend
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
total={filteredPeople.length}
|
||||||
|
palette={palette}
|
||||||
|
onSegmentFocus={setFocusCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={styles.info}>{t('group.info1')}</span>
|
||||||
|
|
||||||
|
{people.length > 1 && <>
|
||||||
|
<span className={styles.info}>{t('group.info2')}</span>
|
||||||
|
<div className={styles.people}>
|
||||||
|
{people.map(person =>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={makeClass(
|
||||||
|
styles.person,
|
||||||
|
filteredPeople.includes(person.name) && styles.personSelected,
|
||||||
|
)}
|
||||||
|
key={person.name}
|
||||||
|
onClick={() => {
|
||||||
|
setTempFocus(undefined)
|
||||||
|
if (filteredPeople.includes(person.name)) {
|
||||||
|
setFilteredPeople(filteredPeople.filter(n => n !== person.name))
|
||||||
|
} else {
|
||||||
|
setFilteredPeople([...filteredPeople, person.name])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseOver={() => setTempFocus(person.name)}
|
||||||
|
onMouseOut={() => setTempFocus(undefined)}
|
||||||
|
title={relativeTimeFormat(Temporal.Instant.fromEpochSeconds(person.created_at), i18n.language)}
|
||||||
|
>{person.name}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<div className={styles.wrapper} ref={wrapperRef}>
|
||||||
|
<div>
|
||||||
|
<div className={styles.heatmap}>
|
||||||
|
{useMemo(() => <div className={styles.timeLabels}>
|
||||||
|
{rows.map((row, i) =>
|
||||||
|
<div className={styles.timeSpace} key={i}>
|
||||||
|
{row && <label className={styles.timeLabel}>
|
||||||
|
{row.label}
|
||||||
|
</label>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>, [rows])}
|
||||||
|
|
||||||
|
{heatmap}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tooltip && <div
|
||||||
|
className={styles.tooltip}
|
||||||
|
style={{ top: tooltip.y, left: tooltip.x }}
|
||||||
|
>
|
||||||
|
<h3>{tooltip.available}</h3>
|
||||||
|
<span>{tooltip.date}</span>
|
||||||
|
{!!filteredPeople.length && <div>
|
||||||
|
{tooltip.people.map(person => <span key={person}>{person}</span>)}
|
||||||
|
{filteredPeople.filter(p => !tooltip.people.includes(p)).map(person =>
|
||||||
|
<span key={person} data-disabled>{person}</span>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AvailabilityViewer
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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
|
|
||||||
157
frontend/src/components/Button/Button.module.scss
Normal file
157
frontend/src/components/Button/Button.module.scss
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
.button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--override-surface-color, var(--primary));
|
||||||
|
color: var(--override-text-color, var(--background));
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 150ms cubic-bezier(0, 0, 0.58, 1);
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: .6em 1.5em;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
|
||||||
|
& svg, & img {
|
||||||
|
height: 1.2em;
|
||||||
|
width: 1.2em;
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: var(--override-shadow-color, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover > div, &:focus > div {
|
||||||
|
transform: translate(0, 1px);
|
||||||
|
&::before {
|
||||||
|
transform: translate3d(0, 4px, -1em);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active > div {
|
||||||
|
transform: translate(0, 5px);
|
||||||
|
&::before {
|
||||||
|
transform: translate3d(0, 0, -1em);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
& > div::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton > div {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
& svg, & img {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.small > div {
|
||||||
|
padding: .4em 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
cursor: wait;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
& img {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes load {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > div::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% - 12px);
|
||||||
|
left: calc(50% - 12px);
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
border: 3px solid var(--override-text-color, var(--background));
|
||||||
|
border-left-color: transparent;
|
||||||
|
border-radius: 100px;
|
||||||
|
animation: load .5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
& > div::after {
|
||||||
|
content: 'loading...';
|
||||||
|
color: var(--override-text-color, var(--background));
|
||||||
|
animation: none;
|
||||||
|
width: initial;
|
||||||
|
height: initial;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
& > div {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--override-surface-color, var(--secondary));
|
||||||
|
color: var(--override-surface-color, var(--secondary));
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
box-shadow: 0 4px 0 0 var(--override-shadow-color, var(--secondary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > div::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
&:hover > div, &:active > div, &:focus > div {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
export const Pressable = styled('button')`
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 0;
|
|
||||||
text-decoration: none;
|
|
||||||
font: inherit;
|
|
||||||
box-sizing: border-box;
|
|
||||||
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'};
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
|
|
||||||
& svg, & img {
|
|
||||||
height: 1.2em;
|
|
||||||
width: 1.2em;
|
|
||||||
margin-right: .5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
${props => props.$size && `
|
|
||||||
padding: 0;
|
|
||||||
height: ${props.$size};
|
|
||||||
width: ${props.$size};
|
|
||||||
`}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover, &:focus {
|
|
||||||
transform: translate(0, 1px);
|
|
||||||
&::before {
|
|
||||||
transform: translate3d(0, 4px, -1em);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translate(0, 5px);
|
|
||||||
&::before {
|
|
||||||
transform: translate3d(0, 0, -1em);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
${props => props.$isLoading && `
|
|
||||||
color: transparent;
|
|
||||||
cursor: wait;
|
|
||||||
|
|
||||||
& img {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes load {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: calc(50% - 12px);
|
|
||||||
left: calc(50% - 12px);
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
border: 3px solid ${props.$primaryColor ? '#FFF' : 'var(--background)'};
|
|
||||||
border-left-color: transparent;
|
|
||||||
border-radius: 100px;
|
|
||||||
animation: load .5s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
&:after {
|
|
||||||
content: 'loading...';
|
|
||||||
color: ${props.$primaryColor ? '#FFF' : 'var(--background)'};
|
|
||||||
animation: none;
|
|
||||||
width: initial;
|
|
||||||
height: initial;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
border: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
|
|
||||||
${props => props.$secondary && `
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid ${props.$primaryColor || 'var(--secondary)'};
|
|
||||||
color: ${props.$primaryColor || 'var(--secondary)'};
|
|
||||||
margin-bottom: 0;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
&:hover, &:active, &:focus {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
${props => !props.$secondary && `
|
|
||||||
box-shadow: 0 4px 0 0 ${props.$secondaryColor || 'var(--secondary)'};
|
|
||||||
`}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
56
frontend/src/components/Button/Button.tsx
Normal file
56
frontend/src/components/Button/Button.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './Button.module.scss'
|
||||||
|
|
||||||
|
type ButtonProps = {
|
||||||
|
/** If provided, will render a link that looks like a button */
|
||||||
|
href?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
children?: React.ReactNode
|
||||||
|
isSecondary?: boolean
|
||||||
|
isSmall?: boolean
|
||||||
|
isLoading?: boolean
|
||||||
|
/** Override the surface color of the button. Will force the text to #FFFFFF. */
|
||||||
|
surfaceColor?: string
|
||||||
|
/** Override the shadow color of the button */
|
||||||
|
shadowColor?: string
|
||||||
|
} & Omit<React.ComponentProps<'button'> & React.ComponentProps<'a'>, 'ref'>
|
||||||
|
|
||||||
|
const Button: React.FC<ButtonProps> = ({
|
||||||
|
href,
|
||||||
|
type = 'button',
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
isSecondary,
|
||||||
|
isSmall,
|
||||||
|
isLoading,
|
||||||
|
surfaceColor,
|
||||||
|
shadowColor,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const sharedProps = {
|
||||||
|
className: makeClass(
|
||||||
|
styles.button,
|
||||||
|
isSecondary && styles.secondary,
|
||||||
|
isSmall && styles.small,
|
||||||
|
isLoading && styles.loading,
|
||||||
|
!children && icon && styles.iconButton,
|
||||||
|
),
|
||||||
|
style: {
|
||||||
|
...surfaceColor && { '--override-surface-color': surfaceColor, '--override-text-color': '#FFFFFF' },
|
||||||
|
...shadowColor && { '--override-shadow-color': shadowColor },
|
||||||
|
...style,
|
||||||
|
},
|
||||||
|
children: <div>{icon}{children}</div>,
|
||||||
|
...props,
|
||||||
|
}
|
||||||
|
|
||||||
|
return href
|
||||||
|
? <Link href={href} {...sharedProps} />
|
||||||
|
: <button type={type} {...sharedProps} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Button
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
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 '/src/components'
|
|
||||||
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Wrapper,
|
|
||||||
StyledLabel,
|
|
||||||
StyledSubLabel,
|
|
||||||
CalendarHeader,
|
|
||||||
CalendarDays,
|
|
||||||
CalendarBody,
|
|
||||||
Date,
|
|
||||||
Day,
|
|
||||||
} from './CalendarField.styles'
|
|
||||||
|
|
||||||
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 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 > 6) {
|
|
||||||
x = 0
|
|
||||||
y++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dates
|
|
||||||
}
|
|
||||||
|
|
||||||
const CalendarField = forwardRef(({
|
|
||||||
label,
|
|
||||||
subLabel,
|
|
||||||
id,
|
|
||||||
setValue,
|
|
||||||
...props
|
|
||||||
}, ref) => {
|
|
||||||
const weekStart = useSettingsStore(state => state.weekStart)
|
|
||||||
const locale = useLocaleUpdateStore(state => state.locale)
|
|
||||||
const { t } = useTranslation('home')
|
|
||||||
|
|
||||||
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 [selectedDates, setSelectedDates] = useState([])
|
|
||||||
const [selectingDates, _setSelectingDates] = useState([])
|
|
||||||
const staticSelectingDates = useRef([])
|
|
||||||
const setSelectingDates = newDates => {
|
|
||||||
staticSelectingDates.current = newDates
|
|
||||||
_setSelectingDates(newDates)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [selectedDays, setSelectedDays] = useState([])
|
|
||||||
const [selectingDays, _setSelectingDays] = useState([])
|
|
||||||
const staticSelectingDays = useRef([])
|
|
||||||
const setSelectingDays = newDays => {
|
|
||||||
staticSelectingDays.current = newDays
|
|
||||||
_setSelectingDays(newDays)
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPos = useRef({})
|
|
||||||
const staticMode = useRef(null)
|
|
||||||
const [mode, _setMode] = useState(staticMode.current)
|
|
||||||
const 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(() => {
|
|
||||||
if (dayjs.Ls?.[locale] && weekStart !== dayjs.Ls[locale].weekStart) {
|
|
||||||
dayjs.updateLocale(locale, { weekStart })
|
|
||||||
}
|
|
||||||
setDates(calculateMonth(month, year, weekStart))
|
|
||||||
}, [weekStart, month, year, locale])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper locale={locale}>
|
|
||||||
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
|
|
||||||
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="hidden"
|
|
||||||
ref={ref}
|
|
||||||
value={type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ToggleField
|
|
||||||
id="calendarMode"
|
|
||||||
name="calendarMode"
|
|
||||||
options={{
|
|
||||||
'specific': t('form.dates.options.specific'),
|
|
||||||
'week': t('form.dates.options.week'),
|
|
||||||
}}
|
|
||||||
value={type === 0 ? 'specific' : 'week'}
|
|
||||||
onChange={value => setType(value === 'specific' ? 0 : 1)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{type === 0 ? (
|
|
||||||
<>
|
|
||||||
<CalendarHeader>
|
|
||||||
<Button
|
|
||||||
size="30px"
|
|
||||||
title={t('form.dates.tooltips.previous')}
|
|
||||||
onClick={() => {
|
|
||||||
if (month-1 < 0) {
|
|
||||||
setYear(year-1)
|
|
||||||
setMonth(11)
|
|
||||||
} else {
|
|
||||||
setMonth(month-1)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
><</Button>
|
|
||||||
<span>{dayjs.months()[month]} {year}</span>
|
|
||||||
<Button
|
|
||||||
size="30px"
|
|
||||||
title={t('form.dates.tooltips.next')}
|
|
||||||
onClick={() => {
|
|
||||||
if (month+1 > 11) {
|
|
||||||
setYear(year+1)
|
|
||||||
setMonth(0)
|
|
||||||
} else {
|
|
||||||
setMonth(month+1)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>></Button>
|
|
||||||
</CalendarHeader>
|
|
||||||
|
|
||||||
<CalendarDays>
|
|
||||||
{(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map(name =>
|
|
||||||
<Day key={name}>{name}</Day>
|
|
||||||
)}
|
|
||||||
</CalendarDays>
|
|
||||||
<CalendarBody>
|
|
||||||
{dates.length > 0 && dates.map((dateRow, y) =>
|
|
||||||
dateRow.map((date, x) =>
|
|
||||||
<Date
|
|
||||||
key={y+x}
|
|
||||||
$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}
|
|
||||||
type="button"
|
|
||||||
onKeyPress={e => {
|
|
||||||
if (e.key === ' ' || e.key === 'Enter') {
|
|
||||||
if (selectedDates.includes(date.format('DDMMYYYY'))) {
|
|
||||||
setSelectedDates(selectedDates.filter(d => d !== date.format('DDMMYYYY')))
|
|
||||||
} else {
|
|
||||||
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)
|
|
||||||
|
|
||||||
document.addEventListener('pointerup', () => {
|
|
||||||
if (staticMode.current === 'add') {
|
|
||||||
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)))
|
|
||||||
}
|
|
||||||
setMode(null)
|
|
||||||
}, { once: true })
|
|
||||||
}}
|
|
||||||
onPointerEnter={() => {
|
|
||||||
if (staticMode.current) {
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSelectingDates(found.map(d => dates[d.y][d.x]))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>{date.date()}</Date>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</CalendarBody>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<CalendarBody>
|
|
||||||
{(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}
|
|
||||||
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}
|
|
||||||
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))
|
|
||||||
} else {
|
|
||||||
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)
|
|
||||||
|
|
||||||
document.addEventListener('pointerup', () => {
|
|
||||||
if (staticMode.current === 'add') {
|
|
||||||
setSelectedDays([...selectedDays, ...staticSelectingDays.current])
|
|
||||||
} else if (staticMode.current === 'remove') {
|
|
||||||
const toRemove = staticSelectingDays.current
|
|
||||||
setSelectedDays(selectedDays.filter(d => !toRemove.includes(d)))
|
|
||||||
}
|
|
||||||
setMode(null)
|
|
||||||
}, { once: true })
|
|
||||||
}}
|
|
||||||
onPointerEnter={() => {
|
|
||||||
if (staticMode.current) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
setSelectingDays(found)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>{name}</Date>
|
|
||||||
)}
|
|
||||||
</CalendarBody>
|
|
||||||
)}
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default CalendarField
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
margin: 30px 0;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const StyledLabel = styled('label')`
|
|
||||||
display: block;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
font-size: 18px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const StyledSubLabel = styled('label')`
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: .6;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const CalendarHeader = styled('div')`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
user-select: none;
|
|
||||||
padding: 6px 0;
|
|
||||||
font-size: 1.2em;
|
|
||||||
font-weight: bold;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const CalendarDays = styled('div')`
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
grid-gap: 2px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Day = styled('div')`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 3px 0;
|
|
||||||
font-weight: bold;
|
|
||||||
user-select: none;
|
|
||||||
opacity: .7;
|
|
||||||
|
|
||||||
@media (max-width: 350px) {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const CalendarBody = styled('div')`
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
grid-gap: 2px;
|
|
||||||
|
|
||||||
& button:first-of-type {
|
|
||||||
border-top-left-radius: 3px;
|
|
||||||
}
|
|
||||||
& button:nth-of-type(7) {
|
|
||||||
border-top-right-radius: 3px;
|
|
||||||
}
|
|
||||||
& button:nth-last-of-type(7) {
|
|
||||||
border-bottom-left-radius: 3px;
|
|
||||||
}
|
|
||||||
& button:last-of-type {
|
|
||||||
border-bottom-right-radius: 3px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Date = styled('button')`
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
margin: 0;
|
|
||||||
appearance: none;
|
|
||||||
transition: background-color .1s;
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
background-color: var(--surface);
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 10px 0;
|
|
||||||
user-select: none;
|
|
||||||
touch-action: none;
|
|
||||||
|
|
||||||
${props => props.$otherMonth && `
|
|
||||||
color: var(--tertiary);
|
|
||||||
`}
|
|
||||||
${props => props.$isToday && `
|
|
||||||
font-weight: 900;
|
|
||||||
color: var(--secondary);
|
|
||||||
`}
|
|
||||||
${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: var(--surface);
|
|
||||||
color: ${props.$isToday ? 'var(--secondary)' : (props.$otherMonth ? 'var(--tertiary)' : 'inherit')};
|
|
||||||
`}
|
|
||||||
`
|
|
||||||
62
frontend/src/components/CalendarField/CalendarField.tsx
Normal file
62
frontend/src/components/CalendarField/CalendarField.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FieldValues, useController, UseControllerProps } from 'react-hook-form'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
import { Description, Label, Wrapper } from '/src/components/Field/Field'
|
||||||
|
import ToggleField from '/src/components/ToggleField/ToggleField'
|
||||||
|
|
||||||
|
import Month from './components/Month/Month'
|
||||||
|
import Weekdays from './components/Weekdays/Weekdays'
|
||||||
|
|
||||||
|
interface CalendarFieldProps<TValues extends FieldValues> extends UseControllerProps<TValues> {
|
||||||
|
label?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarField = <TValues extends FieldValues>({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
...props
|
||||||
|
}: CalendarFieldProps<TValues>) => {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
|
||||||
|
const { field } = useController(props)
|
||||||
|
|
||||||
|
const [type, setType] = useState<'specific' | 'week'>('specific')
|
||||||
|
|
||||||
|
const [innerValue, setInnerValue] = useState({
|
||||||
|
specific: [],
|
||||||
|
week: [],
|
||||||
|
} satisfies Record<typeof type, Temporal.PlainDate[]>)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInnerValue({ ...innerValue, [type]: field.value })
|
||||||
|
}, [type, field.value])
|
||||||
|
|
||||||
|
return <Wrapper>
|
||||||
|
{label && <Label htmlFor={props.name}>{label}</Label>}
|
||||||
|
{description && <Description htmlFor={props.name}>{description}</Description>}
|
||||||
|
|
||||||
|
<ToggleField
|
||||||
|
name="calendarMode"
|
||||||
|
options={{
|
||||||
|
specific: t('form.dates.options.specific'),
|
||||||
|
week: t('form.dates.options.week'),
|
||||||
|
}}
|
||||||
|
value={type}
|
||||||
|
onChange={t => {
|
||||||
|
setType(t)
|
||||||
|
field.onChange(innerValue[t])
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{type === 'specific' ? (
|
||||||
|
<Month value={innerValue.specific} onChange={field.onChange} />
|
||||||
|
) : (
|
||||||
|
<Weekdays value={innerValue.week} onChange={field.onChange} />
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarField
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
user-select: none;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayLabels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-gap: 2px;
|
||||||
|
|
||||||
|
& label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
user-select: none;
|
||||||
|
opacity: .7;
|
||||||
|
|
||||||
|
@media (max-width: 350px) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-gap: 2px;
|
||||||
|
|
||||||
|
& button:first-of-type {
|
||||||
|
border-top-left-radius: 3px;
|
||||||
|
}
|
||||||
|
& button:nth-of-type(7) {
|
||||||
|
border-top-right-radius: 3px;
|
||||||
|
}
|
||||||
|
& button:nth-last-of-type(7) {
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
& button:last-of-type {
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
appearance: none;
|
||||||
|
transition: background-color .1s;
|
||||||
|
background-color: var(--surface);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.otherMonth {
|
||||||
|
color: var(--tertiary);
|
||||||
|
}
|
||||||
|
.today {
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
.selected {
|
||||||
|
color: #FFF;
|
||||||
|
background-color: var(--primary);
|
||||||
|
|
||||||
|
.otherMonth {
|
||||||
|
color: rgba(255,255,255,.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
frontend/src/components/CalendarField/components/Month/Month.tsx
Normal file
156
frontend/src/components/CalendarField/components/Month/Month.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import { rotateArray } from '@giraugh/tools'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import { useStore } from '/src/stores'
|
||||||
|
import useSettingsStore from '/src/stores/settingsStore'
|
||||||
|
import { getWeekdayNames, makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './Month.module.scss'
|
||||||
|
|
||||||
|
interface MonthProps {
|
||||||
|
/** Stringified PlainDate `YYYY-MM-DD` */
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Month = ({ value, onChange }: MonthProps) => {
|
||||||
|
const { t, i18n } = useTranslation('home')
|
||||||
|
|
||||||
|
const weekStart = useStore(useSettingsStore, state => state.weekStart) ?? 0
|
||||||
|
|
||||||
|
const [page, setPage] = useState<Temporal.PlainYearMonth>(Temporal.Now.plainDateISO().toPlainYearMonth())
|
||||||
|
const dates = useMemo(() => calculateMonth(page, weekStart, i18n.language), [page, weekStart, i18n.language])
|
||||||
|
|
||||||
|
// Ref and state required to rerender but also access static version in callbacks
|
||||||
|
const selectingRef = useRef<string[]>([])
|
||||||
|
const [selecting, _setSelecting] = useState<string[]>([])
|
||||||
|
const setSelecting = useCallback((v: string[]) => {
|
||||||
|
selectingRef.current = v
|
||||||
|
_setSelecting(v)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startPos = useRef({ x: 0, y: 0 })
|
||||||
|
const mode = useRef<'add' | 'remove'>()
|
||||||
|
|
||||||
|
const handleFinishSelection = useCallback(() => {
|
||||||
|
if (mode.current === 'add') {
|
||||||
|
onChange([...value, ...selectingRef.current])
|
||||||
|
} else {
|
||||||
|
onChange(value.filter(d => !selectingRef.current.includes(d)))
|
||||||
|
}
|
||||||
|
mode.current = undefined
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{useMemo(() => <div className={styles.header}>
|
||||||
|
<Button
|
||||||
|
title={t<string>('form.dates.tooltips.previous')}
|
||||||
|
onClick={() => setPage(page.subtract({ months: 1 }))}
|
||||||
|
icon={<ChevronLeft />}
|
||||||
|
/>
|
||||||
|
<span>{page.toPlainDate({ day: 1 }).toLocaleString(i18n.language, { month: 'long', year: 'numeric' })}</span>
|
||||||
|
<Button
|
||||||
|
title={t<string>('form.dates.tooltips.next')}
|
||||||
|
onClick={() => setPage(page.add({ months: 1 }))}
|
||||||
|
icon={<ChevronRight />}
|
||||||
|
/>
|
||||||
|
</div>, [page, i18n.language])}
|
||||||
|
|
||||||
|
{useMemo(() => <div className={styles.dayLabels}>
|
||||||
|
{(rotateArray(getWeekdayNames(i18n.language, 'short'), weekStart ? 0 : 1)).map(name =>
|
||||||
|
<label key={name}>{name}</label>
|
||||||
|
)}
|
||||||
|
</div>, [i18n.language, weekStart])}
|
||||||
|
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{dates.length > 0 && dates.map((dateRow, y) =>
|
||||||
|
dateRow.map((date, x) => <button
|
||||||
|
type="button"
|
||||||
|
className={makeClass(
|
||||||
|
styles.date,
|
||||||
|
date.month !== page.month && styles.otherMonth,
|
||||||
|
date.isToday && styles.today,
|
||||||
|
(
|
||||||
|
(!(mode.current === 'remove' && selecting.includes(date.string)) && value.includes(date.string))
|
||||||
|
|| (mode.current === 'add' && selecting.includes(date.string))
|
||||||
|
) && styles.selected,
|
||||||
|
)}
|
||||||
|
key={date.string}
|
||||||
|
title={`${date.title}${date.isToday ? ` (${t('form.dates.tooltips.today')})` : ''}`}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
if (value.includes(date.string)) {
|
||||||
|
onChange(value.filter(d => d !== date.string))
|
||||||
|
} else {
|
||||||
|
onChange([...value, date.string])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={e => {
|
||||||
|
startPos.current = { x, y }
|
||||||
|
mode.current = value.includes(date.string) ? 'remove' : 'add'
|
||||||
|
setSelecting([date.string])
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
|
||||||
|
document.addEventListener('pointerup', handleFinishSelection, { once: true })
|
||||||
|
}}
|
||||||
|
onPointerEnter={() => {
|
||||||
|
if (mode) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelecting(found.map(d => dates[d.y][d.x].string))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>{date.label}</button>)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Month
|
||||||
|
|
||||||
|
interface Day {
|
||||||
|
month: number
|
||||||
|
isToday: boolean
|
||||||
|
string: string
|
||||||
|
title: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate the dates to show for the month in a 2d array */
|
||||||
|
const calculateMonth = (month: Temporal.PlainYearMonth, weekStart: 0 | 1, locale: string) => {
|
||||||
|
const today = Temporal.Now.plainDateISO()
|
||||||
|
const daysBefore = month.toPlainDate({ day: 1 }).dayOfWeek - weekStart
|
||||||
|
const daysAfter = 6 - month.toPlainDate({ day: month.daysInMonth }).dayOfWeek + weekStart
|
||||||
|
|
||||||
|
const dates: Day[][] = []
|
||||||
|
let curDate = month.toPlainDate({ day: 1 }).subtract({ days: daysBefore })
|
||||||
|
let y = 0
|
||||||
|
let x = 0
|
||||||
|
for (let i = 0; i < daysBefore + month.daysInMonth + daysAfter; i++) {
|
||||||
|
if (x === 0) dates[y] = []
|
||||||
|
dates[y][x] = {
|
||||||
|
month: curDate.month,
|
||||||
|
isToday: curDate.equals(today),
|
||||||
|
string: curDate.toString(),
|
||||||
|
title: curDate.toLocaleString(locale, { day: 'numeric', month: 'long' }),
|
||||||
|
label: curDate.toLocaleString(locale, { day: 'numeric' }),
|
||||||
|
}
|
||||||
|
curDate = curDate.add({ days: 1 })
|
||||||
|
x++
|
||||||
|
if (x > 6) {
|
||||||
|
x = 0
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import { range, rotateArray } from '@giraugh/tools'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import { useStore } from '/src/stores'
|
||||||
|
import useSettingsStore from '/src/stores/settingsStore'
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
// Use styles from Month picker
|
||||||
|
import styles from '../Month/Month.module.scss'
|
||||||
|
|
||||||
|
interface WeekdaysProps {
|
||||||
|
/** dayOfWeek 1-7 as a string */
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Weekdays = ({ value, onChange }: WeekdaysProps) => {
|
||||||
|
const { t, i18n } = useTranslation('home')
|
||||||
|
|
||||||
|
const weekStart = useStore(useSettingsStore, state => state.weekStart) ?? 0
|
||||||
|
|
||||||
|
const weekdays = useMemo(() => rotateArray(range(1, 7).map(i => Temporal.Now.plainDateISO().add({ days: i - Temporal.Now.plainDateISO().dayOfWeek })), weekStart ? 0 : 1), [weekStart])
|
||||||
|
|
||||||
|
// Ref and state required to rerender but also access static version in callbacks
|
||||||
|
const selectingRef = useRef<string[]>([])
|
||||||
|
const [selecting, _setSelecting] = useState<string[]>([])
|
||||||
|
const setSelecting = useCallback((v: string[]) => {
|
||||||
|
selectingRef.current = v
|
||||||
|
_setSelecting(v)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startPos = useRef(0)
|
||||||
|
const mode = useRef<'add' | 'remove'>()
|
||||||
|
|
||||||
|
const handleFinishSelection = useCallback(() => {
|
||||||
|
if (mode.current === 'add') {
|
||||||
|
onChange([...value, ...selectingRef.current])
|
||||||
|
} else {
|
||||||
|
onChange(value.filter(d => !selectingRef.current.includes(d)))
|
||||||
|
}
|
||||||
|
mode.current = undefined
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return <div className={styles.grid}>
|
||||||
|
{weekdays.map((day, i) =>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={makeClass(
|
||||||
|
styles.date,
|
||||||
|
day.equals(Temporal.Now.plainDateISO()) && styles.today,
|
||||||
|
(
|
||||||
|
(!(mode.current === 'remove' && selecting.includes(day.dayOfWeek.toString())) && value.includes(day.dayOfWeek.toString()))
|
||||||
|
|| (mode.current === 'add' && selecting.includes(day.dayOfWeek.toString()))
|
||||||
|
) && styles.selected,
|
||||||
|
)}
|
||||||
|
key={day.toString()}
|
||||||
|
title={day.equals(Temporal.Now.plainDateISO()) ? t<string>('form.dates.tooltips.today') : undefined}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
if (value.includes(day.dayOfWeek.toString())) {
|
||||||
|
onChange(value.filter(d => d !== day.dayOfWeek.toString()))
|
||||||
|
} else {
|
||||||
|
onChange([...value, day.dayOfWeek.toString()])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={e => {
|
||||||
|
startPos.current = i
|
||||||
|
mode.current = value.includes(day.dayOfWeek.toString()) ? 'remove' : 'add'
|
||||||
|
setSelecting([day.dayOfWeek.toString()])
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
|
||||||
|
document.addEventListener('pointerup', handleFinishSelection, { once: true })
|
||||||
|
}}
|
||||||
|
onPointerEnter={() => {
|
||||||
|
if (mode.current) {
|
||||||
|
const found = []
|
||||||
|
for (let ci = Math.min(startPos.current, i); ci < Math.max(startPos.current, i) + 1; ci++) {
|
||||||
|
found.push(weekdays[ci].dayOfWeek.toString())
|
||||||
|
}
|
||||||
|
setSelecting(found)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>{day.toLocaleString(i18n.language, { weekday: 'short' })}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Weekdays
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
const Center = styled('div')`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`
|
|
||||||
|
|
||||||
export default Center
|
|
||||||
17
frontend/src/components/Content/Content.module.scss
Normal file
17
frontend/src/components/Content/Content.module.scss
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
.content {
|
||||||
|
width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
max-width: calc(100% - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slim {
|
||||||
|
margin-block: 10px;
|
||||||
|
max-width: calc(100% - 30px);
|
||||||
|
}
|
||||||
21
frontend/src/components/Content/Content.tsx
Normal file
21
frontend/src/components/Content/Content.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './Content.module.scss'
|
||||||
|
|
||||||
|
interface ContentProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
isCentered?: boolean
|
||||||
|
isSlim?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Content = ({ isCentered, isSlim, ...props }: ContentProps) =>
|
||||||
|
<div
|
||||||
|
className={makeClass(
|
||||||
|
styles.content,
|
||||||
|
isCentered && styles.centered,
|
||||||
|
isSlim && styles.slim,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
export default Content
|
||||||
7
frontend/src/components/Copyable/Copyable.module.scss
Normal file
7
frontend/src/components/Copyable/Copyable.module.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.copyable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/src/components/Copyable/Copyable.tsx
Normal file
33
frontend/src/components/Copyable/Copyable.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './Copyable.module.scss'
|
||||||
|
|
||||||
|
interface CopyableProps extends Omit<React.ComponentProps<'p'>, 'children'> {
|
||||||
|
children: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Copyable = ({ children, className, ...props }: CopyableProps) => {
|
||||||
|
const { t } = useTranslation('event')
|
||||||
|
|
||||||
|
const [copied, setCopied] = useState<React.ReactNode>()
|
||||||
|
|
||||||
|
return <p
|
||||||
|
onClick={() => navigator.clipboard?.writeText(children)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(t('nav.copied'))
|
||||||
|
setTimeout(() => setCopied(undefined), 1000)
|
||||||
|
})
|
||||||
|
.catch(e => console.error('Failed to copy', e))
|
||||||
|
}
|
||||||
|
title={'clipboard' in navigator ? t<string>('nav.title') : undefined}
|
||||||
|
className={makeClass(className, 'clipboard' in navigator && styles.copyable)}
|
||||||
|
{...props}
|
||||||
|
>{copied ?? children}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Copyable
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
.buttonWrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
169
frontend/src/components/CreateForm/CreateForm.tsx
Normal file
169
frontend/src/components/CreateForm/CreateForm.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { range } from '@giraugh/tools'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import CalendarField from '/src/components/CalendarField/CalendarField'
|
||||||
|
import { default as ErrorAlert } from '/src/components/Error/Error'
|
||||||
|
import SelectField from '/src/components/SelectField/SelectField'
|
||||||
|
import TextField from '/src/components/TextField/TextField'
|
||||||
|
import TimeRangeField from '/src/components/TimeRangeField/TimeRangeField'
|
||||||
|
import { createEvent, EventResponse } from '/src/config/api'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import timezones from '/src/res/timezones.json'
|
||||||
|
import useRecentsStore from '/src/stores/recentsStore'
|
||||||
|
|
||||||
|
import EventInfo from './components/EventInfo/EventInfo'
|
||||||
|
import styles from './CreateForm.module.scss'
|
||||||
|
|
||||||
|
interface Fields {
|
||||||
|
name: string
|
||||||
|
/** As `YYYY-MM-DD` or `d` */
|
||||||
|
dates: string[]
|
||||||
|
time: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
timezone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValues: Fields = {
|
||||||
|
name: '',
|
||||||
|
dates: [],
|
||||||
|
time: { start: 9, end: 17 },
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateForm = ({ noRedirect }: { noRedirect?: boolean }) => {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
const { push } = useRouter()
|
||||||
|
|
||||||
|
const addRecent = useRecentsStore(state => state.addRecent)
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
} = useForm({ defaultValues })
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [createdEvent, setCreatedEvent] = useState<EventResponse>()
|
||||||
|
const [error, setError] = useState<React.ReactNode>()
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<Fields> = async values => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(undefined)
|
||||||
|
|
||||||
|
const { name, dates, time, timezone } = values
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dates.length === 0) {
|
||||||
|
return setError(t('form.errors.no_dates'))
|
||||||
|
}
|
||||||
|
if (time.start === time.end) {
|
||||||
|
return setError(t('form.errors.same_times'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If format is `YYYY-MM-DD` or `d`
|
||||||
|
const isSpecificDates = dates[0].length !== 1
|
||||||
|
|
||||||
|
const times = dates.flatMap(dateStr => {
|
||||||
|
const date = isSpecificDates
|
||||||
|
? Temporal.PlainDate.from(dateStr)
|
||||||
|
: Temporal.Now.plainDateISO().add({ days: Number(dateStr) - Temporal.Now.plainDateISO().dayOfWeek })
|
||||||
|
|
||||||
|
const hours = time.start > time.end ? [...range(0, time.end - 1), ...range(time.start, 23)] : range(time.start, time.end - 1)
|
||||||
|
|
||||||
|
return hours.map(hour => {
|
||||||
|
const dateTime = date.toZonedDateTime({ timeZone: timezone, plainTime: Temporal.PlainTime.from({ hour }) }).withTimeZone('UTC')
|
||||||
|
if (isSpecificDates) {
|
||||||
|
// Format as `HHmm-DDMMYYYY`
|
||||||
|
return `${dateTime.hour.toString().padStart(2, '0')}${dateTime.minute.toString().padStart(2, '0')}-${dateTime.day.toString().padStart(2, '0')}${dateTime.month.toString().padStart(2, '0')}${dateTime.year.toString().padStart(4, '0')}`
|
||||||
|
} else {
|
||||||
|
// Format as `HHmm-d`
|
||||||
|
return `${dateTime.hour.toString().padStart(2, '0')}${dateTime.minute.toString().padStart(2, '0')}-${String(dateTime.dayOfWeek === 7 ? 0 : dateTime.dayOfWeek)}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (times.length === 0) {
|
||||||
|
return setError(t('form.errors.no_time'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEvent = await createEvent({ name, times, timezone }).catch(e => {
|
||||||
|
console.error(e)
|
||||||
|
throw new Error('Failed to create event')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (noRedirect) {
|
||||||
|
// Show event link
|
||||||
|
setCreatedEvent(newEvent)
|
||||||
|
addRecent({
|
||||||
|
id: newEvent.id,
|
||||||
|
name: newEvent.name,
|
||||||
|
created_at: newEvent.created_at,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Navigate to the new event
|
||||||
|
push(`/${newEvent.id}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(t('form.errors.unknown'))
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdEvent ? <EventInfo event={createdEvent} /> : <form
|
||||||
|
style={{ marginBlockEnd: noRedirect ? 30 : 60 }}
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
id="create"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label={t('form.name.label')}
|
||||||
|
description={t('form.name.sublabel')}
|
||||||
|
type="text"
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CalendarField
|
||||||
|
label={t('form.dates.label')}
|
||||||
|
description={t('form.dates.sublabel')}
|
||||||
|
control={control}
|
||||||
|
name="dates"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TimeRangeField
|
||||||
|
label={t('form.times.label')}
|
||||||
|
description={t('form.times.sublabel')}
|
||||||
|
control={control}
|
||||||
|
name="time"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectField
|
||||||
|
label={t('form.timezone.label')}
|
||||||
|
options={timezones}
|
||||||
|
required
|
||||||
|
{...register('timezone')}
|
||||||
|
defaultOption={t('form.timezone.defaultOption')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ErrorAlert onClose={() => setError(undefined)}>{error}</ErrorAlert>
|
||||||
|
|
||||||
|
<div className={styles.buttonWrapper}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={noRedirect ? { width: '100%' } : undefined}
|
||||||
|
>{t('form.button')}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateForm
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
.wrapper {
|
||||||
|
text-align: center;
|
||||||
|
margin: 50px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin: 6px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
|
||||||
|
import Copyable from '/src/components/Copyable/Copyable'
|
||||||
|
import { EventResponse } from '/src/config/api'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
|
||||||
|
import styles from './EventInfo.module.scss'
|
||||||
|
|
||||||
|
interface EventInfoProps {
|
||||||
|
event: EventResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
const EventInfo = ({ event }: EventInfoProps) => {
|
||||||
|
const { t, i18n } = useTranslation('event')
|
||||||
|
|
||||||
|
return <div className={styles.wrapper}>
|
||||||
|
<h2>{event.name}</h2>
|
||||||
|
<Copyable className={styles.info}>
|
||||||
|
{`https://crab.fit/${event.id}`}
|
||||||
|
</Copyable>
|
||||||
|
<p className={styles.info}>
|
||||||
|
<Trans i18nKey="event:nav.shareinfo_alt" t={t} i18n={i18n}>_<a href={`mailto:?subject=${encodeURIComponent(t<string>('nav.email_subject', { event_name: event.name }))}&body=${encodeURIComponent(`${t('nav.email_body')} https://crab.fit/${event.id}`)}`} target="_blank">_</a>_</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventInfo
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
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 './Donate.styles'
|
|
||||||
|
|
||||||
import paypal_logo from '/src/res/paypal.svg'
|
|
||||||
|
|
||||||
const PAYMENT_METHOD = 'https://play.google.com/billing'
|
|
||||||
const SKU = 'crab_donation'
|
|
||||||
|
|
||||||
const Donate = () => {
|
|
||||||
const store = useTWAStore()
|
|
||||||
const { t } = useTranslation('common')
|
|
||||||
|
|
||||||
const firstLinkRef = useRef()
|
|
||||||
const modalRef = useRef()
|
|
||||||
const [isOpen, _setIsOpen] = useState(false)
|
|
||||||
const [closed, setClosed] = useState(false)
|
|
||||||
|
|
||||||
const setIsOpen = open => {
|
|
||||||
_setIsOpen(open)
|
|
||||||
|
|
||||||
if (open) {
|
|
||||||
window.setTimeout(() => firstLinkRef.current.focus(), 150)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkPressed = () => {
|
|
||||||
setIsOpen(false)
|
|
||||||
gtag('event', 'donate', { 'event_category': 'donate' })
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (store.TWA === undefined) {
|
|
||||||
store.setTWA(document.referrer.includes('android-app://fit.crab'))
|
|
||||||
}
|
|
||||||
}, [store])
|
|
||||||
|
|
||||||
const acknowledge = async (token, type='repeatable', onComplete = () => {}) => {
|
|
||||||
try {
|
|
||||||
const service = await window.getDigitalGoodsService(PAYMENT_METHOD)
|
|
||||||
await service.acknowledge(token, type)
|
|
||||||
if ('acknowledge' in service) {
|
|
||||||
// DGAPI 1.0
|
|
||||||
service.acknowledge(token, type)
|
|
||||||
} else {
|
|
||||||
// DGAPI 2.0
|
|
||||||
service.consume(token)
|
|
||||||
}
|
|
||||||
onComplete()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const purchase = () => {
|
|
||||||
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)
|
|
||||||
|
|
||||||
request.show()
|
|
||||||
.then(response => {
|
|
||||||
response
|
|
||||||
.complete('success')
|
|
||||||
.then(() => {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e.message)
|
|
||||||
alert(t('donate.messages.error'))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e)
|
|
||||||
alert(t('donate.messages.error'))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper>
|
|
||||||
<Button
|
|
||||||
small
|
|
||||||
title={t('donate.title')}
|
|
||||||
onClick={event => {
|
|
||||||
if (closed) {
|
|
||||||
event.preventDefault()
|
|
||||||
return setClosed(false)
|
|
||||||
}
|
|
||||||
if (store.TWA) {
|
|
||||||
gtag('event', 'donate', { 'event_category': 'donate' })
|
|
||||||
event.preventDefault()
|
|
||||||
if (window.confirm(t('donate.messages.about'))) {
|
|
||||||
if (purchase() === false) {
|
|
||||||
alert(t('donate.messages.error'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
event.preventDefault()
|
|
||||||
setIsOpen(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD&amount=5"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener payment"
|
|
||||||
id="donate_button"
|
|
||||||
role="button"
|
|
||||||
aria-expanded={isOpen ? 'true' : 'false'}
|
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
|
||||||
>{t('donate.button')}</Button>
|
|
||||||
|
|
||||||
<Options
|
|
||||||
$isOpen={isOpen}
|
|
||||||
ref={modalRef}
|
|
||||||
onBlur={e => {
|
|
||||||
if (modalRef.current?.contains(e.relatedTarget)) return
|
|
||||||
setIsOpen(false)
|
|
||||||
if (e.relatedTarget && e.relatedTarget.id === 'donate_button') {
|
|
||||||
setClosed(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img src={paypal_logo} alt="Donate with PayPal" />
|
|
||||||
<a onClick={linkPressed} ref={firstLinkRef} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD&amount=2" target="_blank" rel="noreferrer noopener payment">{t('donate.options.$2')}</a>
|
|
||||||
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD&amount=5" target="_blank" rel="noreferrer noopener payment"><strong>{t('donate.options.$5')}</strong></a>
|
|
||||||
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD&amount=10" target="_blank" rel="noreferrer noopener payment">{t('donate.options.$10')}</a>
|
|
||||||
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD" target="_blank" rel="noreferrer noopener payment">{t('donate.options.choose')}</a>
|
|
||||||
</Options>
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Donate
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
import { forwardRef } from 'react'
|
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
margin-top: 6px;
|
|
||||||
margin-left: 12px;
|
|
||||||
position: relative;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Options = styled('div', forwardRef)`
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 20px);
|
|
||||||
right: 0;
|
|
||||||
background-color: var(--background);
|
|
||||||
border: 1px solid var(--surface);
|
|
||||||
z-index: 60;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 14px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-width: calc(100vw - 20px);
|
|
||||||
box-shadow: 0 3px 6px 0 rgba(0,0,0,.3);
|
|
||||||
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(5px);
|
|
||||||
transition: opacity .15s, transform .15s, visibility .15s;
|
|
||||||
|
|
||||||
${props => props.$isOpen && `
|
|
||||||
pointer-events: all;
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
visibility: visible;
|
|
||||||
`}
|
|
||||||
|
|
||||||
& img {
|
|
||||||
width: 80px;
|
|
||||||
margin: 10px auto 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
& a {
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-align: center;
|
|
||||||
padding: 4px 20px;
|
|
||||||
margin: 6px 0;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 100px;
|
|
||||||
background-color: var(--primary);
|
|
||||||
color: var(--background);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
& strong {
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
.buttonWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
61
frontend/src/components/DownloadButtons/DownloadButtons.tsx
Normal file
61
frontend/src/components/DownloadButtons/DownloadButtons.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import { detectBrowser } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './DownloadButtons.module.scss'
|
||||||
|
|
||||||
|
const DownloadButtons = () => {
|
||||||
|
const { t } = useTranslation('home')
|
||||||
|
|
||||||
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
const [browser, setBrowser] = useState<ReturnType<typeof detectBrowser>>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't show buttons in the Android app
|
||||||
|
if (document.referrer.includes('android-app://fit.crab')) {
|
||||||
|
setIsVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect which browser the user is using
|
||||||
|
setBrowser(detectBrowser())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return isVisible ? <div className={styles.buttonWrapper}>
|
||||||
|
{(browser === 'firefox' || browser === 'safari') && (
|
||||||
|
<Button
|
||||||
|
href={{
|
||||||
|
// TODO: Chrome extension was removed due to iframe policies
|
||||||
|
// chrome: 'https://chrome.google.com/webstore/detail/crab-fit/pnafiibmjbiljofcpjlbonpgdofjhhkj',
|
||||||
|
firefox: 'https://addons.mozilla.org/en-US/firefox/addon/crab-fit/',
|
||||||
|
safari: 'https://apps.apple.com/us/app/crab-fit/id1570803259',
|
||||||
|
}[browser]}
|
||||||
|
icon={{
|
||||||
|
// chrome: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /></svg>,
|
||||||
|
firefox: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M9.27 7.94C9.27 7.94 9.27 7.94 9.27 7.94M6.85 6.74C6.86 6.74 6.86 6.74 6.85 6.74M21.28 8.6C20.85 7.55 19.96 6.42 19.27 6.06C19.83 7.17 20.16 8.28 20.29 9.1L20.29 9.12C19.16 6.3 17.24 5.16 15.67 2.68C15.59 2.56 15.5 2.43 15.43 2.3C15.39 2.23 15.36 2.16 15.32 2.09C15.26 1.96 15.2 1.83 15.17 1.69C15.17 1.68 15.16 1.67 15.15 1.67H15.13L15.12 1.67L15.12 1.67L15.12 1.67C12.9 2.97 11.97 5.26 11.74 6.71C11.05 6.75 10.37 6.92 9.75 7.22C9.63 7.27 9.58 7.41 9.62 7.53C9.67 7.67 9.83 7.74 9.96 7.68C10.5 7.42 11.1 7.27 11.7 7.23L11.75 7.23C11.83 7.22 11.92 7.22 12 7.22C12.5 7.21 12.97 7.28 13.44 7.42L13.5 7.44C13.6 7.46 13.67 7.5 13.75 7.5C13.8 7.54 13.86 7.56 13.91 7.58L14.05 7.64C14.12 7.67 14.19 7.7 14.25 7.73C14.28 7.75 14.31 7.76 14.34 7.78C14.41 7.82 14.5 7.85 14.54 7.89C14.58 7.91 14.62 7.94 14.66 7.96C15.39 8.41 16 9.03 16.41 9.77C15.88 9.4 14.92 9.03 14 9.19C17.6 11 16.63 17.19 11.64 16.95C11.2 16.94 10.76 16.85 10.34 16.7C10.24 16.67 10.14 16.63 10.05 16.58C10 16.56 9.93 16.53 9.88 16.5C8.65 15.87 7.64 14.68 7.5 13.23C7.5 13.23 8 11.5 10.83 11.5C11.14 11.5 12 10.64 12.03 10.4C12.03 10.31 10.29 9.62 9.61 8.95C9.24 8.59 9.07 8.42 8.92 8.29C8.84 8.22 8.75 8.16 8.66 8.1C8.43 7.3 8.42 6.45 8.63 5.65C7.6 6.12 6.8 6.86 6.22 7.5H6.22C5.82 7 5.85 5.35 5.87 5C5.86 5 5.57 5.16 5.54 5.18C5.19 5.43 4.86 5.71 4.56 6C4.21 6.37 3.9 6.74 3.62 7.14C3 8.05 2.5 9.09 2.28 10.18C2.28 10.19 2.18 10.59 2.11 11.1L2.08 11.33C2.06 11.5 2.04 11.65 2 11.91L2 11.94L2 12.27L2 12.32C2 17.85 6.5 22.33 12 22.33C16.97 22.33 21.08 18.74 21.88 14C21.9 13.89 21.91 13.76 21.93 13.63C22.13 11.91 21.91 10.11 21.28 8.6Z" /></svg>,
|
||||||
|
safari: <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,14.09 4.8,16 6.11,17.41L9.88,9.88L17.41,6.11C16,4.8 14.09,4 12,4M12,20A8,8 0 0,0 20,12C20,9.91 19.2,8 17.89,6.59L14.12,14.12L6.59,17.89C8,19.2 9.91,20 12,20M12,12L11.23,11.23L9.7,14.3L12.77,12.77L12,12M12,17.5H13V19H12V17.5M15.88,15.89L16.59,15.18L17.65,16.24L16.94,16.95L15.88,15.89M17.5,12V11H19V12H17.5M12,6.5H11V5H12V6.5M8.12,8.11L7.41,8.82L6.35,7.76L7.06,7.05L8.12,8.11M6.5,12V13H5V12H6.5Z" /></svg>,
|
||||||
|
}[browser]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
isSecondary
|
||||||
|
>{{
|
||||||
|
// chrome: t('about.chrome_extension'),
|
||||||
|
firefox: t('about.firefox_extension'),
|
||||||
|
safari: t('about.safari_extension'),
|
||||||
|
}[browser]}</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
href="https://play.google.com/store/apps/details?id=fit.crab"
|
||||||
|
icon={<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path fill="currentColor" d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z" /></svg>}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
isSecondary
|
||||||
|
>{t('about.android_app')}</Button>
|
||||||
|
</div> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DownloadButtons
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import { Loading } from '/src/components'
|
|
||||||
import { Image, Wrapper } from './Egg.styles'
|
|
||||||
|
|
||||||
const Egg = ({ eggKey, onClose }) => {
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper title="Click anywhere to close" onClick={() => onClose()}>
|
|
||||||
<Image
|
|
||||||
src={`https://us-central1-flour-app-services.cloudfunctions.net/charliAPI?v=${eggKey}`}
|
|
||||||
onLoadStart={() => setIsLoading(true)}
|
|
||||||
onLoad={() => setIsLoading(false)}
|
|
||||||
/>
|
|
||||||
{isLoading && <Loading />}
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Egg
|
|
||||||
55
frontend/src/components/Egg/Egg.module.scss
Normal file
55
frontend/src/components/Egg/Egg.module.scss
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
.modal {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
&[open] {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::backdrop {
|
||||||
|
background: rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
max-width: 80vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes load {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border: 3px solid var(--primary);
|
||||||
|
border-left-color: transparent;
|
||||||
|
border-radius: 100px;
|
||||||
|
animation: load .5s linear infinite;
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
animation: none;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: 'loading...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
position: fixed;
|
|
||||||
background: rgba(0,0,0,.6);
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
cursor: pointer;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Image = styled('img')`
|
|
||||||
max-width: 80%;
|
|
||||||
max-height: 80%;
|
|
||||||
position: absolute;
|
|
||||||
`
|
|
||||||
60
frontend/src/components/Egg/Egg.tsx
Normal file
60
frontend/src/components/Egg/Egg.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import styles from './Egg.module.scss'
|
||||||
|
|
||||||
|
const PATTERN = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']
|
||||||
|
const API_URL = 'https://us-central1-flour-app-services.cloudfunctions.net/charliAPI?v='
|
||||||
|
|
||||||
|
const Egg = () => {
|
||||||
|
const ref = useRef<HTMLDialogElement>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [patternCompletion, setPatternCompletion] = useState(0)
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [key, setKey] = useState(0)
|
||||||
|
|
||||||
|
const keyHandler = useCallback((e: KeyboardEvent) => {
|
||||||
|
// Key pressed not next in pattern
|
||||||
|
if (PATTERN.indexOf(e.key) < 0 || e.key !== PATTERN[patternCompletion]) {
|
||||||
|
return setPatternCompletion(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPatternCompletion(patternCompletion + 1)
|
||||||
|
|
||||||
|
// Pattern completed
|
||||||
|
if (PATTERN.length === patternCompletion + 1) {
|
||||||
|
setUrl(`${API_URL}${key}`)
|
||||||
|
setKey(key + 1)
|
||||||
|
setPatternCompletion(0)
|
||||||
|
setIsLoading(true)
|
||||||
|
ref.current?.showModal()
|
||||||
|
}
|
||||||
|
}, [patternCompletion, key])
|
||||||
|
|
||||||
|
// Listen to key presses
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keyup', keyHandler)
|
||||||
|
return () => document.removeEventListener('keyup', keyHandler)
|
||||||
|
}, [keyHandler])
|
||||||
|
|
||||||
|
return <dialog
|
||||||
|
onClick={e => {
|
||||||
|
e.currentTarget.close()
|
||||||
|
setUrl('')
|
||||||
|
}}
|
||||||
|
className={styles.modal}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className={styles.image}
|
||||||
|
src={url}
|
||||||
|
alt="A cute picture of Charli"
|
||||||
|
onLoadStart={() => setIsLoading(true)}
|
||||||
|
onLoad={() => setIsLoading(false)}
|
||||||
|
/>
|
||||||
|
{isLoading && <div className={styles.loader} />}
|
||||||
|
</dialog>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Egg
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { styled } from 'goober'
|
.error {
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: var(--error);
|
background-color: var(--error);
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
|
|
@ -15,21 +13,21 @@ export const Wrapper = styled('div')`
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: margin .2s, padding .2s, max-height .2s;
|
transition: margin .2s, padding .2s, max-height .2s;
|
||||||
|
|
||||||
${props => props.open && `
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.open {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
max-height: 60px;
|
max-height: 60px;
|
||||||
transition: opacity .15s .2s, max-height .2s, margin .2s, padding .2s, visibility .2s;
|
transition: opacity .15s .2s, max-height .2s, margin .2s, padding .2s, visibility .2s;
|
||||||
`}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
transition: none;
|
|
||||||
}
|
}
|
||||||
`
|
|
||||||
|
|
||||||
export const CloseButton = styled('button')`
|
.closeButton {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
|
@ -41,4 +39,4 @@ export const CloseButton = styled('button')`
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
`
|
}
|
||||||
25
frontend/src/components/Error/Error.tsx
Normal file
25
frontend/src/components/Error/Error.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './Error.module.scss'
|
||||||
|
|
||||||
|
interface ErrorProps {
|
||||||
|
children?: React.ReactNode
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Error = ({ children, onClose }: ErrorProps) =>
|
||||||
|
<div role="alert" className={makeClass(styles.error, children && styles.open)}>
|
||||||
|
{children}
|
||||||
|
<button
|
||||||
|
className={styles.closeButton}
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title="Dismiss error"
|
||||||
|
><X /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
export default Error
|
||||||
16
frontend/src/components/Field/Field.module.scss
Normal file
16
frontend/src/components/Field/Field.module.scss
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
.wrapper {
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
22
frontend/src/components/Field/Field.tsx
Normal file
22
frontend/src/components/Field/Field.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import styles from './Field.module.scss'
|
||||||
|
|
||||||
|
interface WrapperProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Wrapper = (props: WrapperProps) =>
|
||||||
|
<div className={styles.wrapper} {...props} />
|
||||||
|
|
||||||
|
interface LabelProps {
|
||||||
|
htmlFor?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
style?: React.CSSProperties
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Label = (props: LabelProps) =>
|
||||||
|
<label className={styles.label} {...props} />
|
||||||
|
|
||||||
|
export const Description = (props: LabelProps) =>
|
||||||
|
<label className={styles.description} {...props} />
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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
|
|
||||||
24
frontend/src/components/Footer/Footer.module.scss
Normal file
24
frontend/src/components/Footer/Footer.module.scss
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
.footer {
|
||||||
|
width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
max-width: calc(100% - 60px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
margin: 60px auto 0;
|
||||||
|
width: 250px;
|
||||||
|
max-width: initial;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
& span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
export const Wrapper = styled('footer')`
|
|
||||||
width: 600px;
|
|
||||||
margin: 20px auto;
|
|
||||||
max-width: calc(100% - 60px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
${props => props.small && `
|
|
||||||
margin: 60px auto 0;
|
|
||||||
width: 250px;
|
|
||||||
max-width: initial;
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
& span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
35
frontend/src/components/Footer/Footer.tsx
Normal file
35
frontend/src/components/Footer/Footer.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './Footer.module.scss'
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
isSmall?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Footer = async ({ isSmall }: FooterProps) => {
|
||||||
|
const { t } = await useTranslation('common')
|
||||||
|
const isRunningInApp = headers().get('referer')?.includes('android-app://fit.crab')
|
||||||
|
|
||||||
|
return isRunningInApp
|
||||||
|
? null // Cannot show external donation link in an Android app
|
||||||
|
: <footer
|
||||||
|
id="donate" // Required to allow scrolling directly to the footer
|
||||||
|
className={makeClass(styles.footer, isSmall && styles.small)}
|
||||||
|
>
|
||||||
|
<span>{t('donate.info')}</span>
|
||||||
|
<Button
|
||||||
|
isSmall
|
||||||
|
title={t<string>('donate.title')}
|
||||||
|
href="https://ko-fi.com/A06841WZ"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener payment"
|
||||||
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
|
>{t('donate.button')}</Button>
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { loadGapiInsideDOM } from 'gapi-script'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
import { Button, Center } from '/src/components'
|
|
||||||
import { Loader } from '../Loading/Loading.styles'
|
|
||||||
import {
|
|
||||||
CalendarList,
|
|
||||||
CheckboxInput,
|
|
||||||
CheckboxLabel,
|
|
||||||
CalendarLabel,
|
|
||||||
Info,
|
|
||||||
Options,
|
|
||||||
Title,
|
|
||||||
Icon,
|
|
||||||
LinkButton,
|
|
||||||
} from './GoogleCalendar.styles'
|
|
||||||
|
|
||||||
import googleLogo from '/src/res/google.svg'
|
|
||||||
|
|
||||||
const signIn = () => window.gapi.auth2.getAuthInstance().signIn()
|
|
||||||
|
|
||||||
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 calendarLogin = async () => {
|
|
||||||
const gapi = await loadGapiInsideDOM()
|
|
||||||
gapi.load('client:auth2', () => {
|
|
||||||
window.gapi.client.init({
|
|
||||||
clientId: '276505195333-9kjl7e48m272dljbspkobctqrpet0n8m.apps.googleusercontent.com',
|
|
||||||
discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest'],
|
|
||||||
scope: 'https://www.googleapis.com/auth/calendar.readonly',
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// Listen for state changes
|
|
||||||
window.gapi.auth2.getAuthInstance().isSignedIn.listen(isSignedIn => setSignedIn(isSignedIn))
|
|
||||||
|
|
||||||
// Handle initial sign-in state
|
|
||||||
setSignedIn(window.gapi.auth2.getAuthInstance().isSignedIn.get())
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e)
|
|
||||||
setSignedIn(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const importAvailability = () => {
|
|
||||||
setFreeBusyLoading(true)
|
|
||||||
gtag('event', 'google_cal_sync', {
|
|
||||||
'event_category': 'event',
|
|
||||||
})
|
|
||||||
window.gapi.client.calendar.freebusy.query({
|
|
||||||
timeMin,
|
|
||||||
timeMax,
|
|
||||||
timeZone,
|
|
||||||
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)
|
|
||||||
}, e => {
|
|
||||||
console.error(e)
|
|
||||||
setFreeBusyLoading(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => void calendarLogin(), [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (signedIn) {
|
|
||||||
window.gapi.client.calendar.calendarList.list({
|
|
||||||
'minAccessRole': 'freeBusyReader'
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
setCalendars(response.result.items.map(item => ({
|
|
||||||
'name': item.summary,
|
|
||||||
'description': item.description,
|
|
||||||
'id': item.id,
|
|
||||||
'color': item.backgroundColor,
|
|
||||||
'checked': item.primary === true,
|
|
||||||
})))
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e)
|
|
||||||
signOut()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [signedIn])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!signedIn ? (
|
|
||||||
<Center>
|
|
||||||
<Button
|
|
||||||
onClick={() => signIn()}
|
|
||||||
isLoading={signedIn === undefined}
|
|
||||||
primaryColor="#4286F5"
|
|
||||||
secondaryColor="#3367BD"
|
|
||||||
icon={<img aria-hidden="true" focusable="false" src={googleLogo} alt="" />}
|
|
||||||
>
|
|
||||||
{t('event:you.google_cal.login')}
|
|
||||||
</Button>
|
|
||||||
</Center>
|
|
||||||
) : (
|
|
||||||
<CalendarList>
|
|
||||||
<Title>
|
|
||||||
<Icon src={googleLogo} alt="" />
|
|
||||||
<strong>{t('event:you.google_cal.login')}</strong>
|
|
||||||
(<LinkButton type="button" onClick={e => {
|
|
||||||
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})))
|
|
||||||
}}>{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})))
|
|
||||||
}}>{t('event:you.google_cal.select_none')}</LinkButton>
|
|
||||||
)}
|
|
||||||
</Options>
|
|
||||||
{calendars !== undefined ? calendars.map(calendar => (
|
|
||||||
<div key={calendar.id}>
|
|
||||||
<CheckboxInput
|
|
||||||
type="checkbox"
|
|
||||||
role="checkbox"
|
|
||||||
id={calendar.id}
|
|
||||||
color={calendar.color}
|
|
||||||
checked={calendar.checked}
|
|
||||||
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 />
|
|
||||||
)}
|
|
||||||
{calendars !== undefined && (
|
|
||||||
<>
|
|
||||||
<Info>{t('event:you.google_cal.info')}</Info>
|
|
||||||
<Button
|
|
||||||
small
|
|
||||||
isLoading={freeBusyLoading}
|
|
||||||
disabled={freeBusyLoading}
|
|
||||||
onClick={() => importAvailability()}
|
|
||||||
>{t('event:you.google_cal.button')}</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CalendarList>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GoogleCalendar
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
display: flex;
|
||||||
|
margin-block: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& strong {
|
||||||
|
margin-right: 1ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
margin-right: 12px;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
:global(.light) & {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkButton {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--primary);
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: inline;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: .2em;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
height: 0px;
|
||||||
|
width: 0px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: 0;
|
||||||
|
font-size: 0;
|
||||||
|
transform: scale(0);
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
&:checked + label::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
&[disabled] + label {
|
||||||
|
opacity: .6;
|
||||||
|
}
|
||||||
|
&[disabled] + label::after {
|
||||||
|
border: 2px solid var(--text);
|
||||||
|
background-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
& + label {
|
||||||
|
display: inline-block;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background-color 0.2s, box-shadow 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
border: 2px solid var(--text);
|
||||||
|
border-radius: 2px;
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
border: 2px solid var(--cal-color, var(--primary));
|
||||||
|
background-color: var(--cal-color, var(--primary));
|
||||||
|
border-radius: 2px;
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMjEsN0w5LDE5TDMuNSwxMy41TDQuOTEsMTIuMDlMOSwxNi4xN0wxOS41OSw1LjU5TDIxLDdaIiAvPjwvc3ZnPg==');
|
||||||
|
background-size: 16px;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(.5);
|
||||||
|
transition: opacity 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendarName {
|
||||||
|
margin-left: .6em;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: .6;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 14px 0 10px;
|
||||||
|
}
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
export const CalendarList = styled('div')`
|
|
||||||
width: 100%;
|
|
||||||
& > div {
|
|
||||||
display: flex;
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const CheckboxInput = styled('input')`
|
|
||||||
height: 0px;
|
|
||||||
width: 0px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: 0;
|
|
||||||
font-size: 0;
|
|
||||||
transform: scale(0);
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
&:checked + label::after {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
&[disabled] + label {
|
|
||||||
opacity: .6;
|
|
||||||
}
|
|
||||||
&[disabled] + label:after {
|
|
||||||
border: 2px solid var(--text);
|
|
||||||
background-color: var(--text);
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const CheckboxLabel = styled('label')`
|
|
||||||
display: inline-block;
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
min-width: 24px;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: background-color 0.2s, box-shadow 0.2s;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
height: 14px;
|
|
||||||
width: 14px;
|
|
||||||
border: 2px solid var(--text);
|
|
||||||
border-radius: 2px;
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
height: 14px;
|
|
||||||
width: 14px;
|
|
||||||
border: 2px solid ${props => props.color || 'var(--primary)'};
|
|
||||||
background-color: ${props => props.color || 'var(--primary)'};
|
|
||||||
border-radius: 2px;
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMjEsN0w5LDE5TDMuNSwxMy41TDQuOTEsMTIuMDlMOSwxNi4xN0wxOS41OSw1LjU5TDIxLDdaIiAvPjwvc3ZnPg==');
|
|
||||||
background-size: 16px;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(.5);
|
|
||||||
transition: opacity 0.15s, transform 0.15s;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const CalendarLabel = styled('label')`
|
|
||||||
margin-left: .6em;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 24px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Info = styled('div')`
|
|
||||||
font-size: 14px;
|
|
||||||
opacity: .6;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 14px 0 10px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Options = styled('div')`
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 0 0 5px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Title = styled('p')`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
& strong {
|
|
||||||
margin-right: 1ex;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Icon = styled('img')`
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
margin-right: 12px;
|
|
||||||
filter: invert(1);
|
|
||||||
`
|
|
||||||
|
|
||||||
export const LinkButton = styled('button')`
|
|
||||||
font: inherit;
|
|
||||||
color: var(--primary);
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
text-decoration: underline;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: inline;
|
|
||||||
cursor: pointer;
|
|
||||||
appearance: none;
|
|
||||||
`
|
|
||||||
194
frontend/src/components/GoogleCalendar/GoogleCalendar.tsx
Normal file
194
frontend/src/components/GoogleCalendar/GoogleCalendar.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import Script from 'next/script'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import googleLogo from '/src/res/google.svg'
|
||||||
|
import { allowUrlToWrap, parseSpecificDate } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './GoogleCalendar.module.scss'
|
||||||
|
|
||||||
|
const [clientId, apiKey] = [process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, process.env.NEXT_PUBLIC_GOOGLE_API_KEY]
|
||||||
|
|
||||||
|
interface Calendar {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
color?: string
|
||||||
|
isChecked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = (callback: (tokenResponse: google.accounts.oauth2.TokenResponse) => void) => {
|
||||||
|
if (!clientId) return
|
||||||
|
|
||||||
|
const client = google.accounts.oauth2.initTokenClient({
|
||||||
|
client_id: clientId,
|
||||||
|
scope: 'https://www.googleapis.com/auth/calendar.readonly',
|
||||||
|
callback,
|
||||||
|
})
|
||||||
|
if (gapi?.client?.getToken()) {
|
||||||
|
// Skip dialog for existing session
|
||||||
|
client.requestAccessToken({ prompt: '' })
|
||||||
|
} else {
|
||||||
|
client.requestAccessToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoogleCalendarProps {
|
||||||
|
timezone: string
|
||||||
|
timeStart: Temporal.ZonedDateTime
|
||||||
|
timeEnd: Temporal.ZonedDateTime
|
||||||
|
times: string[]
|
||||||
|
onImport: (availability: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const GoogleCalendar = ({ timezone, timeStart, timeEnd, times, onImport }: GoogleCalendarProps) => {
|
||||||
|
if (!clientId || !apiKey) return null
|
||||||
|
|
||||||
|
const { t } = useTranslation('event')
|
||||||
|
|
||||||
|
// Prevent Google scripts from loading until button pressed
|
||||||
|
const [canLoad, setCanLoad] = useState(false)
|
||||||
|
const [calendars, setCalendars] = useState<Calendar[]>()
|
||||||
|
|
||||||
|
// Clear calendars if logged out
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canLoad) setCalendars(undefined)
|
||||||
|
}, [canLoad])
|
||||||
|
|
||||||
|
const fetchCalendars = useCallback((res: google.accounts.oauth2.TokenResponse) => {
|
||||||
|
if (res.error !== undefined) return setCanLoad(false)
|
||||||
|
if ('gapi' in window) {
|
||||||
|
gapi.client.calendar.calendarList.list({
|
||||||
|
'minAccessRole': 'freeBusyReader'
|
||||||
|
})
|
||||||
|
.then(res => setCalendars(res.result.items.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.summary,
|
||||||
|
description: item.description,
|
||||||
|
color: item.backgroundColor,
|
||||||
|
isChecked: item.primary === true,
|
||||||
|
}))))
|
||||||
|
.catch(console.warn)
|
||||||
|
} else {
|
||||||
|
setCanLoad(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Process times so they can be checked quickly
|
||||||
|
const epochTimes = useMemo(() => times.map(t => parseSpecificDate(t).epochMilliseconds), [times])
|
||||||
|
|
||||||
|
const [isLoadingAvailability, setIsLoadingAvailability] = useState(false)
|
||||||
|
const importAvailability = useCallback(() => {
|
||||||
|
if (!calendars) return
|
||||||
|
|
||||||
|
setIsLoadingAvailability(true)
|
||||||
|
gapi.client.calendar.freebusy.query({
|
||||||
|
timeMin: timeStart.toPlainDateTime().toString({ smallestUnit: 'millisecond' }) + 'Z',
|
||||||
|
timeMax: timeEnd.toPlainDateTime().toString({ smallestUnit: 'millisecond' }) + 'Z',
|
||||||
|
timeZone: timezone,
|
||||||
|
items: calendars.filter(c => c.isChecked).map(c => ({ id: c.id })),
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
const availabilities = response.result.calendars ? Object.values(response.result.calendars).flatMap(cal => cal.busy.map(a => ({
|
||||||
|
start: new Date(a.start).valueOf(),
|
||||||
|
end: new Date(a.end).valueOf(),
|
||||||
|
}))) : []
|
||||||
|
|
||||||
|
onImport(times.filter((_, i) => !availabilities.some(a => epochTimes[i] >= a.start && epochTimes[i] < a.end)))
|
||||||
|
setIsLoadingAvailability(false)
|
||||||
|
}, e => {
|
||||||
|
console.error(e)
|
||||||
|
setIsLoadingAvailability(false)
|
||||||
|
})
|
||||||
|
}, [calendars])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{!calendars && <Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!canLoad) {
|
||||||
|
setCanLoad(true)
|
||||||
|
if ('google' in window) {
|
||||||
|
login(fetchCalendars)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCanLoad(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={canLoad}
|
||||||
|
surfaceColor="#4286F5"
|
||||||
|
shadowColor="#3367BD"
|
||||||
|
icon={<img aria-hidden="true" src={googleLogo.src} alt="" />}
|
||||||
|
>
|
||||||
|
{t('you.google_cal.login')}
|
||||||
|
</Button>}
|
||||||
|
|
||||||
|
{calendars && <div className={styles.wrapper}>
|
||||||
|
<p className={styles.title}>
|
||||||
|
<img src={googleLogo.src} alt="" className={styles.icon} />
|
||||||
|
<strong>{t('you.google_cal.login')}</strong>
|
||||||
|
(<button
|
||||||
|
className={styles.linkButton}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCanLoad(false)}
|
||||||
|
>{t('you.google_cal.logout')}</button>)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.options}>
|
||||||
|
{!calendars.every(c => c.isChecked) && <button
|
||||||
|
className={styles.linkButton}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCalendars(calendars.map(c => ({ ...c, isChecked: true })))}
|
||||||
|
>{t('event:you.google_cal.select_all')}</button>}
|
||||||
|
{calendars.every(c => c.isChecked) && <button
|
||||||
|
className={styles.linkButton}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCalendars(calendars.map(c => ({ ...c, isChecked: false })))}
|
||||||
|
>{t('event:you.google_cal.select_none')}</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{calendars.map(calendar => <div key={calendar.id}>
|
||||||
|
<input
|
||||||
|
className={styles.checkbox}
|
||||||
|
type="checkbox"
|
||||||
|
id={calendar.id}
|
||||||
|
color={calendar.color}
|
||||||
|
checked={calendar.isChecked}
|
||||||
|
onChange={() => setCalendars(calendars.map(c => c.id === calendar.id ? {...c, isChecked: !c.isChecked} : c))}
|
||||||
|
/>
|
||||||
|
<label htmlFor={calendar.id} style={{ '--cal-color': calendar.color } as React.CSSProperties} />
|
||||||
|
<label className={styles.calendarName} htmlFor={calendar.id} title={calendar.description}>{allowUrlToWrap(calendar.name)}</label>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
<div className={styles.info}>{t('you.google_cal.info')}</div>
|
||||||
|
<Button
|
||||||
|
isSmall
|
||||||
|
isLoading={isLoadingAvailability}
|
||||||
|
disabled={isLoadingAvailability}
|
||||||
|
onClick={() => importAvailability()}
|
||||||
|
>{t('you.google_cal.button')}</Button>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{/* Load google api scripts */}
|
||||||
|
{canLoad && <>
|
||||||
|
<Script
|
||||||
|
src="https://accounts.google.com/gsi/client"
|
||||||
|
onError={() => setCanLoad(false)}
|
||||||
|
onLoad={() => login(fetchCalendars)}
|
||||||
|
/>
|
||||||
|
<Script
|
||||||
|
src="https://apis.google.com/js/api.js"
|
||||||
|
onError={() => setCanLoad(false)}
|
||||||
|
onLoad={() => gapi.load('client', () => {
|
||||||
|
gapi.client.init({
|
||||||
|
apiKey,
|
||||||
|
discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest'],
|
||||||
|
}).catch(() => setCanLoad(false))
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GoogleCalendar
|
||||||
132
frontend/src/components/Header/Header.module.scss
Normal file
132
frontend/src/components/Header/Header.module.scss
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jelly {
|
||||||
|
from,to {
|
||||||
|
transform: scale(1,1)
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: scale(.9,1.1)
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1,.9)
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: scale(.95,1.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover img {
|
||||||
|
animation: jelly .5s 1;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
&:hover img {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 2.5rem;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 400;
|
||||||
|
text-shadow: 0 2px 0 var(--shadow);
|
||||||
|
line-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: 14px;
|
||||||
|
padding-top: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--secondary);
|
||||||
|
line-height: 1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
[data-small=true] & {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hasAltChars {
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2em;
|
||||||
|
padding-top: .3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bigTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 4rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 400;
|
||||||
|
text-shadow: 0 4px 0 var(--shadow);
|
||||||
|
line-height: 1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
@media (max-width: 350px) {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-small=true] & {
|
||||||
|
font-size: 2rem;
|
||||||
|
@media (max-width: 350px) {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bigLogo {
|
||||||
|
width: 80px;
|
||||||
|
transition: transform .15s;
|
||||||
|
animation: jelly .5s 1 .05s;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
animation: none;
|
||||||
|
transform: scale(.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
animation: none;
|
||||||
|
transition: none;
|
||||||
|
&:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
frontend/src/components/Header/Header.tsx
Normal file
43
frontend/src/components/Header/Header.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import localFont from 'next/font/local'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
import logo from '/src/res/logo.svg'
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './Header.module.scss'
|
||||||
|
|
||||||
|
const samuraiBob = localFont({
|
||||||
|
src: './samuraibob.woff2',
|
||||||
|
fallback: ['sans-serif'],
|
||||||
|
})
|
||||||
|
const molot = localFont({
|
||||||
|
src: './molot.woff2',
|
||||||
|
fallback: ['sans-serif'],
|
||||||
|
})
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
/** Show the full header */
|
||||||
|
isFull?: boolean
|
||||||
|
isSmall?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header = async ({ isFull, isSmall }: HeaderProps) => {
|
||||||
|
const { t } = await useTranslation(['common', 'home'])
|
||||||
|
|
||||||
|
return <header className={styles.header} data-small={isSmall}>
|
||||||
|
{isFull ? <>
|
||||||
|
{!isSmall && <img className={styles.bigLogo} src={logo.src} alt="" />}
|
||||||
|
<span className={makeClass(styles.subtitle, samuraiBob.className, !/^[A-Za-z ]+$/.test(t('home:create')) && styles.hasAltChars)}>{t('home:create')}</span>
|
||||||
|
<h1 className={makeClass(styles.bigTitle, molot.className)}>CRAB FIT</h1>
|
||||||
|
</> : <Link href="/" className={styles.link}>
|
||||||
|
<div className={styles.top}>
|
||||||
|
<img className={styles.logo} src={logo.src} alt="" />
|
||||||
|
<span className={makeClass(styles.title, molot.className)}>CRAB FIT</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.tagline}>{t('common:tagline')}</span>
|
||||||
|
</Link>}
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { createPalette } from 'hue-map'
|
|
||||||
|
|
||||||
import { useSettingsStore } from '/src/stores'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Wrapper,
|
|
||||||
Label,
|
|
||||||
Bar,
|
|
||||||
Grade,
|
|
||||||
} from './Legend.styles'
|
|
||||||
|
|
||||||
const Legend = ({
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
total,
|
|
||||||
onSegmentFocus,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('event')
|
|
||||||
const highlight = useSettingsStore(state => state.highlight)
|
|
||||||
const colormap = useSettingsStore(state => state.colormap)
|
|
||||||
const setHighlight = useSettingsStore(state => state.setHighlight)
|
|
||||||
|
|
||||||
const [palette, setPalette] = useState([])
|
|
||||||
|
|
||||||
useEffect(() => setPalette(createPalette({
|
|
||||||
map: colormap === 'crabfit' ? [[0, [247,158,0,0]], [1, [247,158,0,255]]] : colormap,
|
|
||||||
steps: max+1-min,
|
|
||||||
}).format()), [min, max, colormap])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper>
|
|
||||||
<Label>{min}/{total} {t('event:available')}</Label>
|
|
||||||
|
|
||||||
<Bar
|
|
||||||
onMouseOut={() => onSegmentFocus(null)}
|
|
||||||
onClick={() => setHighlight(!highlight)}
|
|
||||||
title={t('event:group.legend_tooltip')}
|
|
||||||
>
|
|
||||||
{[...Array(max+1-min).keys()].map(i => i+min).map(i =>
|
|
||||||
<Grade
|
|
||||||
key={i}
|
|
||||||
$color={palette[i]}
|
|
||||||
$highlight={highlight && i === max && max > 0}
|
|
||||||
onMouseOver={() => onSegmentFocus(i)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Bar>
|
|
||||||
|
|
||||||
<Label>{max}/{total} {t('event:available')}</Label>
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Legend
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { styled } from 'goober'
|
.wrapper {
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -13,15 +11,15 @@ export const Wrapper = styled('div')`
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
`
|
}
|
||||||
|
|
||||||
export const Label = styled('label')`
|
.label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
`
|
}
|
||||||
|
|
||||||
export const Bar = styled('div')`
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 40%;
|
width: 40%;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|
@ -34,19 +32,14 @@ export const Bar = styled('div')`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
`
|
}
|
||||||
|
|
||||||
export const Grade = styled('div')`
|
.highlight {
|
||||||
flex: 1;
|
|
||||||
background-color: ${props => props.$color};
|
|
||||||
|
|
||||||
${props => props.$highlight && `
|
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
45deg,
|
45deg,
|
||||||
transparent,
|
transparent,
|
||||||
transparent 4.5px,
|
transparent 4.5px,
|
||||||
rgba(0,0,0,.5) 4.5px,
|
var(--highlight-color, rgba(0,0,0,.5)) 4.5px,
|
||||||
rgba(0,0,0,.5) 9px
|
var(--highlight-color, rgba(0,0,0,.5)) 9px
|
||||||
);
|
);
|
||||||
`}
|
}
|
||||||
`
|
|
||||||
43
frontend/src/components/Legend/Legend.tsx
Normal file
43
frontend/src/components/Legend/Legend.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
import { useStore } from '/src/stores'
|
||||||
|
import useSettingsStore from '/src/stores/settingsStore'
|
||||||
|
|
||||||
|
import styles from './Legend.module.scss'
|
||||||
|
|
||||||
|
interface LegendProps {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
total: number
|
||||||
|
palette: { string: string, highlight: string }[]
|
||||||
|
onSegmentFocus: (segment: number | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Legend = ({ min, max, total, palette, onSegmentFocus }: LegendProps) => {
|
||||||
|
const { t } = useTranslation('event')
|
||||||
|
const highlight = useStore(useSettingsStore, state => state.highlight)
|
||||||
|
const setHighlight = useSettingsStore(state => state.setHighlight)
|
||||||
|
|
||||||
|
return <div className={styles.wrapper}>
|
||||||
|
<label className={styles.label}>{min}/{total} {t('available')}</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.bar}
|
||||||
|
onMouseOut={() => onSegmentFocus(undefined)}
|
||||||
|
onClick={() => setHighlight?.(!highlight)}
|
||||||
|
title={t<string>('group.legend_tooltip')}
|
||||||
|
>
|
||||||
|
{[...Array(max + 1 - min).keys()].map(i => i + min).map((i, j) =>
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ flex: 1, backgroundColor: palette[j].string, '--highlight-color': palette[j].highlight } as React.CSSProperties}
|
||||||
|
className={highlight && i === max && max > 0 ? styles.highlight : undefined}
|
||||||
|
onMouseOver={() => onSegmentFocus(i)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className={styles.label}>{max}/{total} {t('available')}</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Legend
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { Wrapper, Loader } from './Loading.styles'
|
|
||||||
|
|
||||||
const Loading = () => <Wrapper><Loader /></Wrapper>
|
|
||||||
|
|
||||||
export default Loading
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
export const Wrapper = styled('main')`
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Loader = styled('div')`
|
|
||||||
@keyframes load {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
border: 3px solid var(--primary);
|
|
||||||
border-left-color: transparent;
|
|
||||||
border-radius: 100px;
|
|
||||||
animation: load .5s linear infinite;
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
animation: none;
|
|
||||||
border: 0;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: 'loading...';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
23
frontend/src/components/Login/Login.module.scss
Normal file
23
frontend/src/components/Login/Login.module.scss
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
.form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
align-items: flex-end;
|
||||||
|
grid-gap: 18px;
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
|
||||||
|
& div:last-child {
|
||||||
|
--btn-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin: 18px 0;
|
||||||
|
opacity: .75;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
100
frontend/src/components/Login/Login.tsx
Normal file
100
frontend/src/components/Login/Login.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||||
|
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import Error from '/src/components/Error/Error'
|
||||||
|
import TextField from '/src/components/TextField/TextField'
|
||||||
|
import { getPerson, PersonResponse } from '/src/config/api'
|
||||||
|
import { useTranslation } from '/src/i18n/client'
|
||||||
|
|
||||||
|
import styles from './Login.module.scss'
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
eventId: string
|
||||||
|
user: PersonResponse | undefined
|
||||||
|
onChange: (user: PersonResponse | undefined, password?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Login = ({ eventId, user, onChange }: LoginProps) => {
|
||||||
|
const { t } = useTranslation('event')
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setFocus,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
} = useForm({ defaultValues })
|
||||||
|
|
||||||
|
const [error, setError] = useState<React.ReactNode>()
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const focusName = useCallback(() => setFocus('username'), [setFocus])
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('focusName', focusName)
|
||||||
|
return () => document.removeEventListener('focusName', focusName)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<typeof defaultValues> = async ({ username, password }) => {
|
||||||
|
if (username.length === 0) {
|
||||||
|
focusName()
|
||||||
|
return setError(t('form.errors.name_required'))
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(undefined)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resUser = await getPerson(eventId, username, password || undefined)
|
||||||
|
onChange(resUser, password || undefined)
|
||||||
|
reset()
|
||||||
|
} catch (e) {
|
||||||
|
if (e && typeof e === 'object' && 'status' in e && e.status === 401) {
|
||||||
|
setError(t('form.errors.password_incorrect'))
|
||||||
|
setValue('password', '')
|
||||||
|
} else {
|
||||||
|
setError(t('form.errors.unknown'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user ? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '20px 0', flexWrap: 'wrap', gap: '10px' }}>
|
||||||
|
<h2 style={{ margin: 0 }}>{t('form.signed_in', { name: user.name })}</h2>
|
||||||
|
<Button isSmall onClick={() => onChange(undefined)}>{t('form.logout_button')}</Button>
|
||||||
|
</div> : <>
|
||||||
|
<h2>{t('form.signed_out')}</h2>
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<TextField
|
||||||
|
label={t('form.name')}
|
||||||
|
type="text"
|
||||||
|
isInline
|
||||||
|
required
|
||||||
|
{...register('username')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t('form.password')}
|
||||||
|
type="password"
|
||||||
|
isInline
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>{t('form.button')}</Button>
|
||||||
|
</form>
|
||||||
|
<Error onClose={() => setError(undefined)}>{error}</Error>
|
||||||
|
<p className={styles.info}>{t('form.info')}</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Wrapper,
|
|
||||||
A,
|
|
||||||
Top,
|
|
||||||
Image,
|
|
||||||
Title,
|
|
||||||
Tagline,
|
|
||||||
} from './Logo.styles'
|
|
||||||
|
|
||||||
import image from '/src/res/logo.svg'
|
|
||||||
|
|
||||||
const Logo = () => {
|
|
||||||
const { t } = useTranslation('common')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper>
|
|
||||||
<A as={Link} to="/">
|
|
||||||
<Top>
|
|
||||||
<Image src={image} alt="" />
|
|
||||||
<Title>CRAB FIT</Title>
|
|
||||||
</Top>
|
|
||||||
<Tagline>{t('common:tagline')}</Tagline>
|
|
||||||
</A>
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Logo
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const A = styled('a')`
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
@keyframes jelly {
|
|
||||||
from,to {
|
|
||||||
transform: scale(1,1)
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
transform: scale(.9,1.1)
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.1,.9)
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
transform: scale(.95,1.05)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover img {
|
|
||||||
animation: jelly .5s 1;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
&:hover img {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Top = styled('div')`
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Image = styled('img')`
|
|
||||||
width: 2.5rem;
|
|
||||||
margin-right: 16px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Title = styled('span')`
|
|
||||||
display: block;
|
|
||||||
font-size: 2rem;
|
|
||||||
color: var(--primary);
|
|
||||||
font-family: 'Molot', sans-serif;
|
|
||||||
font-weight: 400;
|
|
||||||
text-shadow: 0 2px 0 var(--shadow);
|
|
||||||
line-height: 1em;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Tagline = styled('span')`
|
|
||||||
text-decoration: underline;
|
|
||||||
font-size: 14px;
|
|
||||||
padding-top: 2px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { PublicClientApplication } from '@azure/msal-browser'
|
import { PublicClientApplication } from '@azure/msal-browser'
|
||||||
import { Client } from '@microsoft/microsoft-graph-client'
|
import { Client } from '@microsoft/microsoft-graph-client'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { Button, Center } from '/src/components'
|
import { Button, Center } from '/src/components'
|
||||||
import { Loader } from '../Loading/Loading.styles'
|
import outlookLogo from '/src/res/outlook.svg'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CalendarLabel,
|
||||||
CalendarList,
|
CalendarList,
|
||||||
CheckboxInput,
|
CheckboxInput,
|
||||||
CheckboxLabel,
|
CheckboxLabel,
|
||||||
CalendarLabel,
|
Icon,
|
||||||
Info,
|
Info,
|
||||||
|
LinkButton,
|
||||||
Options,
|
Options,
|
||||||
Title,
|
Title,
|
||||||
Icon,
|
|
||||||
LinkButton,
|
|
||||||
} from '../GoogleCalendar/GoogleCalendar.styles'
|
} from '../GoogleCalendar/GoogleCalendar.styles'
|
||||||
|
import { Loader } from '../Loading/Loading.styles'
|
||||||
import outlookLogo from '/src/res/outlook.svg'
|
|
||||||
|
|
||||||
const scopes = ['Calendars.Read', 'Calendars.Read.Shared']
|
const scopes = ['Calendars.Read', 'Calendars.Read.Shared']
|
||||||
|
|
||||||
|
|
|
||||||
4
frontend/src/components/Paragraph/Text.module.scss
Normal file
4
frontend/src/components/Paragraph/Text.module.scss
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.text {
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue