Merge pull request #259 from GRA0007/refactor/nextjs-frontend

Next.js frontend refactor
This commit is contained in:
Benji Grant 2023-06-10 11:57:23 +10:00 committed by GitHub
commit d250ef67ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
245 changed files with 6609 additions and 7722 deletions

23
.github/workflows/check_api.yml vendored Normal file
View 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
View 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

View file

@ -2,36 +2,37 @@ name: Deploy Frontend
on:
push:
branches: ['main']
paths: ['frontend/**']
branches:
- main
paths:
- frontend/**
- .github/workflows/deploy_frontend.yml
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
jobs:
deploy:
name: Deploy to Vercel
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 17
node-version: 18
cache: yarn
cache-dependency-path: '**/yarn.lock'
- run: yarn install --immutable
- run: yarn build
- id: auth
uses: google-github-actions/auth@v0
with:
credentials_json: '${{ secrets.GCP_SA_KEY }}'
- id: deploy
uses: google-github-actions/deploy-appengine@v0
with:
working_directory: frontend
version: v1
- name: Install Vercel CLI
run: yarn global install vercel@latest
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}

View file

@ -1,5 +1,8 @@
# Crab Fit <img width="100" align="right" src="frontend/src/res/logo.svg" alt="avatar">
[![Frontend Checks](https://github.com/GRA0007/crab.fit/actions/workflows/check_frontend.yml/badge.svg)](https://github.com/GRA0007/crab.fit/actions/workflows/check_frontend.yml)
[![API Checks](https://github.com/GRA0007/crab.fit/actions/workflows/check_api.yml/badge.svg)](https://github.com/GRA0007/crab.fit/actions/workflows/check_api.yml)
Align your schedules to find the perfect time that works for everyone.
Licensed under the GNU GPLv3.
@ -29,9 +32,9 @@ The browser extension in `browser-extension` can be tested by first running the
## Deploy
Deployments are managed with GitHub Workflows.
Deployments are managed with GitHub Workflows. The frontend is deployed using Vercel, and the API uses Fly.io.
To deploy cron jobs (i.e. monthly cleanup of old events), run `gcloud app deploy cron.yaml`.
More detailed instructions on setting up your own deployment are coming soon.
### 🔌 Browser extension

View file

@ -3,7 +3,10 @@ use std::{env, net::SocketAddr, sync::Arc};
use axum::{
error_handling::HandleErrorLayer,
extract,
http::{HeaderValue, Method},
http::{
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
HeaderValue, Method,
},
routing::{get, patch, post},
BoxError, Router, Server,
};
@ -44,6 +47,8 @@ async fn main() {
// CORS configuration
let cors = CorsLayer::new()
.allow_credentials(true)
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE])
.allow_methods([Method::GET, Method::POST, Method::PATCH])
.allow_origin(
if cfg!(debug_assertions) {
@ -56,9 +61,14 @@ async fn main() {
);
// Rate limiting configuration (using tower_governor)
// From the docs: Allows bursts with up to eight requests and replenishes
// From the docs: Allows bursts with up to 20 requests and replenishes
// one element after 500ms, based on peer IP.
let governor_config = Box::new(GovernorConfigBuilder::default().finish().unwrap());
let governor_config = Box::new(
GovernorConfigBuilder::default()
.burst_size(20)
.finish()
.unwrap(),
);
let rate_limit = ServiceBuilder::new()
// Handle errors from governor and convert into HTTP responses
.layer(HandleErrorLayer::new(|e: BoxError| async move {

View file

@ -38,7 +38,18 @@ pub async fn get_people<A: Adaptor>(
.map_err(ApiError::AdaptorError)?;
match people {
Some(people) => Ok(Json(people.into_iter().map(|p| p.into()).collect())),
Some(people) => Ok(Json(
people
.into_iter()
.filter_map(|p| {
if !p.availability.is_empty() {
Some(p.into())
} else {
None
}
})
.collect(),
)),
None => Err(ApiError::NotFound),
}
}

View file

@ -1,5 +0,0 @@
dispatch:
- url: "api.crab.fit/*"
service: api
- url: "crab.fit/*"
service: default

5
frontend/.env.local Normal file
View 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=""

View file

@ -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
View 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/"],
["^./", "^.", "^../"]
]
}
]
}
}
]
}

View file

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

8
frontend/.gitignore vendored
View file

@ -1,8 +1,10 @@
node_modules
dist
build
dev-dist
.next
npm-debug.log*
yarn-debug.log*
yarn-error.log*
tsconfig.tsbuildinfo
.env
.vercel

3
frontend/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

View file

@ -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

View file

@ -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>

View file

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

5
frontend/next-env.d.ts vendored Normal file
View 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.

View file

@ -1,51 +1,51 @@
{
"name": "crabfit-frontend",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint --ext .js,.jsx ./src"
"dev": "next dev --port 1234",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@azure/msal-browser": "^2.28.1",
"@microsoft/microsoft-graph-client": "^3.0.2",
"dayjs": "^1.11.5",
"gapi-script": "^1.2.0",
"goober": "^2.1.10",
"@azure/msal-browser": "^2.37.1",
"@giraugh/tools": "^1.6.0",
"@js-temporal/polyfill": "^0.4.4",
"@microsoft/microsoft-graph-client": "^3.0.5",
"@vercel/analytics": "^1.0.1",
"accept-language": "^3.0.18",
"chroma.ts": "^1.0.10",
"hue-map": "^1.0.0",
"i18next": "^21.9.0",
"i18next-browser-languagedetector": "^6.1.5",
"i18next-http-backend": "^1.4.1",
"lucide-react": "^0.84.0",
"i18next": "^22.5.1",
"i18next-browser-languagedetector": "^7.0.2",
"i18next-http-backend": "^2.2.1",
"i18next-resources-to-backend": "^1.1.4",
"lucide-react": "^0.241.0",
"next": "^13.4.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.34.1",
"react-i18next": "^11.18.4",
"react-router-dom": "^6.3.0",
"workbox-background-sync": "^6.5.4",
"workbox-broadcast-update": "^6.5.4",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.5.4",
"workbox-expiration": "^6.5.4",
"workbox-google-analytics": "^6.5.4",
"workbox-navigation-preload": "^6.5.4",
"workbox-precaching": "^6.5.4",
"workbox-range-requests": "^6.5.4",
"workbox-routing": "^6.5.4",
"workbox-strategies": "^6.5.4",
"workbox-streams": "^6.5.4",
"workbox-window": "^6.5.4",
"zustand": "^4.0.0"
"react-hook-form": "^7.44.3",
"react-i18next": "^12.3.1",
"zod": "^3.21.4",
"zustand": "^4.3.8"
},
"devDependencies": {
"@vitejs/plugin-react": "^2.0.1",
"eslint": "^8.22.0",
"eslint-plugin-react": "^7.30.1",
"vite": "^3.0.7",
"vite-plugin-pwa": "^0.12.3",
"workbox-webpack-plugin": "^6.5.4"
"@types/gapi": "^0.0.44",
"@types/gapi.calendar": "^3.0.6",
"@types/google.accounts": "^0.0.7",
"@types/node": "^20.2.5",
"@types/react": "^18.2.9",
"@types/react-dom": "^18.2.4",
"@typescript-eslint/eslint-plugin": "^5.59.9",
"@typescript-eslint/parser": "^5.59.9",
"eslint": "^8.42.0",
"eslint-config-next": "^13.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"sass": "^1.63.2",
"typescript": "^5.1.3",
"typescript-plugin-css-modules": "^5.0.1"
},
"browserslist": {
"production": [

Binary file not shown.

View file

@ -1,67 +1,19 @@
/* eslint-disable no-restricted-globals */
// TODO: This is temporary, as I've made the decision to move away
// from a PWA, so must remove all existing service workers
import { clientsClaim, skipWaiting } from 'workbox-core'
import { ExpirationPlugin } from 'workbox-expiration'
import { precacheAndRoute, createHandlerBoundToURL, cleanupOutdatedCaches } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { StaleWhileRevalidate, NetworkFirst } from 'workbox-strategies'
self.addEventListener("install", () => {
self.skipWaiting()
})
skipWaiting()
clientsClaim()
// Injection point
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$')
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false
} // If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false
} // If this looks like a URL for a resource, because it contains // a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false
} // Return true to signal that we want to use the handler.
return true
},
createHandlerBoundToURL('index.html')
)
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) => url.origin === self.location.origin && (
url.pathname.endsWith('.png')
|| url.pathname.endsWith('.svg')
|| url.pathname.endsWith('.jpg')
|| url.pathname.endsWith('.jpeg')
|| url.pathname.endsWith('.ico')
|| url.pathname.endsWith('.ttf')
|| url.pathname.endsWith('.woff')
|| url.pathname.endsWith('.woff2')
), // Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'res',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
)
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) => url.origin === self.location.origin && url.pathname.includes('i18n'),
new NetworkFirst({
cacheName: 'i18n',
})
)
self.addEventListener("activate", () => {
self.registration
.unregister()
.then(() => self.clients.matchAll())
.then((clients) => {
clients.forEach((client) => {
if (client.url && "navigate" in client) {
client.navigate(client.url)
}
})
})
})

View file

@ -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

View 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

View 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

View 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

View 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;
}

View 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

View 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

View 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

View file

@ -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 {
color-scheme: light dark;
@ -85,10 +63,10 @@ html {
body {
margin: 0;
font-family: 'Karla', sans-serif;
background: var(--background);
color: var(--text);
font-weight: var(--font-weight);
--focus-ring: 2px solid var(--secondary);
}
.light {
@ -131,6 +109,11 @@ body {
a {
color: var(--primary);
border-radius: .2em;
}
a:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}
*::-webkit-scrollbar {
@ -152,23 +135,3 @@ a {
*::-webkit-scrollbar-thumb:active {
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.';
}
}

View 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);
}
}
}

View 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

View 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
View 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

View 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

View file

@ -1,6 +1,4 @@
import { styled } from 'goober'
export const Note = styled('p')`
.note {
background-color: var(--surface);
border: 1px solid var(--primary);
border-radius: 10px;
@ -13,10 +11,4 @@ export const Note = styled('p')`
& a {
color: var(--secondary);
}
`
export const ButtonArea = styled('div')`
@media print {
display: none;
}
`
}

View file

@ -1,46 +1,37 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Metadata } from 'next'
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 { Note, ButtonArea } from './Privacy.styles'
import styles from './page.module.scss'
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 = () => {
const navigate = useNavigate()
const { t, i18n } = useTranslation(['common', 'privacy'])
const contentRef = useRef()
const [content, setContent] = useState('')
return {
title: t('name'),
}
}
useEffect(() => {
document.title = `${t('privacy:name')} - Crab Fit`
}, [t])
useEffect(() => setContent(contentRef.current?.innerText || ''), [contentRef])
const Page = async () => {
const { t, i18n } = await useTranslation(['common', 'privacy'])
return <>
<StyledMain>
<Logo />
</StyledMain>
<Content>
<Header />
<StyledMain>
<h1>{t('privacy:name')}</h1>
{!i18n.language.startsWith('en') && (
<p>
<a
href={`https://translate.google.com/?sl=en&tl=${i18n.language.substring(0, 2)}&text=${encodeURIComponent(`${translationDisclaimer}\n\n${content}`)}&op=translate`}
target="_blank"
rel="noreferrer noopener"
>{t('privacy:translate')}</a>
</p>
)}
{!i18n.language.startsWith('en') && <GoogleTranslate language={i18n.language}>{t('privacy:translate')}</GoogleTranslate>}
<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 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>
@ -48,9 +39,10 @@ const Privacy = () => {
<h2>Information Collection and Use</h2>
<P>The Service uses third party services that may collect information used to identify you.</P>
<P>Links to privacy policies of the third party service providers used by the Service:</P>
<P as="ul">
<li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a></li>
</P>
<Ul>
<li><a href="https://www.google.com/policies/privacy/" target="blank">Google Play Services</a> (only used for Google Calendar sync)</li>
<li><a href="https://vercel.com/docs/concepts/analytics/privacy-policy" target="blank">Vercel Analytics</a></li>
</Ul>
<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>
@ -61,17 +53,17 @@ const Privacy = () => {
<h2>Service Providers</h2>
<P>Third-party companies may be employed for the following reasons:</P>
<P as="ul">
<Ul>
<li>To facilitate the Service</li>
<li>To provide the Service on our behalf</li>
<li>To perform Service-related services</li>
<li>To assist in analyzing how the Service is used</li>
</P>
</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>
<h2>Security</h2>
<P>Personal Information that is shared via the Service is protected, however remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, so take care when sharing Personal Information.</P>
<Note>Events that are created will be automatically permanently erased from storage after <strong>3 months</strong> of inactivity.</Note>
<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>
<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>
<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>
<P>If you have any questions or suggestions about the Privacy Policy, do not hesitate to contact us at <a href="mailto:contact@crab.fit">contact@crab.fit</a>.</P>
</div>
</StyledMain>
</Content>
<ButtonArea>
<AboutSection>
<StyledMain>
<Center><Button onClick={() => navigate('/')}>{t('common:cta')}</Button></Center>
</StyledMain>
</AboutSection>
</ButtonArea>
<Section>
<Content isCentered>
<Button href="/">{t('common:cta')}</Button>
</Content>
</Section>
<Footer />
</>
}
export default Privacy
export default Page

View file

@ -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

View file

@ -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);
`};
`

View file

@ -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

View file

@ -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

View file

@ -1,17 +1,4 @@
import { styled } from 'goober'
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')`
.heatmap {
display: inline-flex;
box-sizing: border-box;
min-width: 100%;
@ -22,152 +9,22 @@ export const Container = styled('div')`
@media (max-width: 660px) {
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')`
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')`
.timeLabels {
flex-shrink: 0;
display: flex;
flex-direction: column;
width: 40px;
padding-right: 6px;
`
}
export const TimeSpace = styled('div')`
.timeSpace {
height: 10px;
position: relative;
border-top: 2px solid transparent;
&.timespace {
&.grey {
background-origin: border-box;
background-image: repeating-linear-gradient(
45deg,
@ -177,9 +34,9 @@ export const TimeSpace = styled('div')`
var(--loading) 8.6px
);
}
`
}
export const TimeLabel = styled('label')`
.timeLabel {
display: block;
position: absolute;
top: -.7em;
@ -187,23 +44,107 @@ export const TimeLabel = styled('label')`
text-align: right;
user-select: none;
width: 100%;
`
}
export const StyledMain = styled('div')`
width: 600px;
margin: 20px auto;
max-width: calc(100% - 60px);
`
.dateColumn {
flex-shrink: 0;
display: flex;
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;
flex-wrap: wrap;
gap: 5px;
justify-content: center;
margin: 14px auto;
`
}
export const Person = styled('button')`
.person {
font: inherit;
font-size: 15px;
border-radius: 3px;
@ -215,18 +156,71 @@ export const Person = styled('button')`
padding: 2px 8px;
user-select: none;
${props => props.$filtered && `
background: var(--primary);
color: #FFFFFF;
border-color: var(--primary);
`}
`
export const Info = styled('span')`
display: block;
text-align: center;
@media print {
display: none;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}
`
}
.personSelected {
background: var(--primary);
color: #FFFFFF;
border-color: var(--primary);
}
.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);
}
}
}
}

View file

@ -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

View file

@ -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

View 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;
}
}

View file

@ -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;
}
}
`

View 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

View file

@ -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)
}
}}
>&lt;</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)
}
}}
>&gt;</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

View file

@ -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')};
`}
`

View 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

View file

@ -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);
}
}

View 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
}

View file

@ -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

View file

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

View 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);
}

View 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

View file

@ -0,0 +1,7 @@
.copyable {
cursor: pointer;
&:hover {
color: var(--secondary);
}
}

View 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

View file

@ -0,0 +1,4 @@
.buttonWrapper {
display: flex;
justify-content: center;
}

View 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

View file

@ -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;
}

View file

@ -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

View file

@ -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&currency_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&currency_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&currency_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&currency_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&currency_code=AUD" target="_blank" rel="noreferrer noopener payment">{t('donate.options.choose')}</a>
</Options>
</Wrapper>
)
}
export default Donate

View file

@ -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;
}
`

View file

@ -0,0 +1,8 @@
.buttonWrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
margin: 30px 0;
}

View 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

View file

@ -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

View 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...';
}
}
}

View file

@ -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;
`

View 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

View file

@ -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

View file

@ -1,6 +1,4 @@
import { styled } from 'goober'
export const Wrapper = styled('div')`
.error {
border-radius: 3px;
background-color: var(--error);
color: #FFFFFF;
@ -15,21 +13,21 @@ export const Wrapper = styled('div')`
visibility: hidden;
transition: margin .2s, padding .2s, max-height .2s;
${props => props.open && `
opacity: 1;
visibility: visible;
margin: 20px 0;
padding: 12px 16px;
max-height: 60px;
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')`
.open {
opacity: 1;
visibility: visible;
margin: 20px 0;
padding: 12px 16px;
max-height: 60px;
transition: opacity .15s .2s, max-height .2s, margin .2s, padding .2s, visibility .2s;
}
.closeButton {
border: 0;
background: none;
height: 30px;
@ -41,4 +39,4 @@ export const CloseButton = styled('button')`
justify-content: center;
margin-left: 16px;
padding: 0;
`
}

View 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

View 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;
}

View 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} />

View file

@ -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

View 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;
}
}

View file

@ -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;
}
`

View 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

View file

@ -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

View file

@ -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;
}

View file

@ -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;
`

View 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

View 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;
}
}
}

View 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

View file

@ -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

View file

@ -1,6 +1,4 @@
import { styled } from 'goober'
export const Wrapper = styled('div')`
.wrapper {
margin: 10px 0;
display: flex;
align-items: center;
@ -13,15 +11,15 @@ export const Wrapper = styled('div')`
@media (max-width: 400px) {
display: block;
}
`
}
export const Label = styled('label')`
.label {
display: block;
font-size: 14px;
text-align: left;
`
}
export const Bar = styled('div')`
.bar {
display: flex;
width: 40%;
height: 20px;
@ -34,19 +32,14 @@ export const Bar = styled('div')`
width: 100%;
margin: 8px 0;
}
`
}
export const Grade = styled('div')`
flex: 1;
background-color: ${props => props.$color};
${props => props.$highlight && `
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 4.5px,
rgba(0,0,0,.5) 4.5px,
rgba(0,0,0,.5) 9px
);
`}
`
.highlight {
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 4.5px,
var(--highlight-color, rgba(0,0,0,.5)) 4.5px,
var(--highlight-color, rgba(0,0,0,.5)) 9px
);
}

View 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

View file

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

View file

@ -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...';
}
}
`

View 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;
}

View 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

View file

@ -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

View file

@ -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;
}
`

View file

@ -1,23 +1,23 @@
import { useState, useEffect } from 'react'
import { PublicClientApplication } from '@azure/msal-browser'
import { Client } from '@microsoft/microsoft-graph-client'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Center } from '/src/components'
import { Loader } from '../Loading/Loading.styles'
import outlookLogo from '/src/res/outlook.svg'
import {
CalendarLabel,
CalendarList,
CheckboxInput,
CheckboxLabel,
CalendarLabel,
Icon,
Info,
LinkButton,
Options,
Title,
Icon,
LinkButton,
} from '../GoogleCalendar/GoogleCalendar.styles'
import outlookLogo from '/src/res/outlook.svg'
import { Loader } from '../Loading/Loading.styles'
const scopes = ['Calendars.Read', 'Calendars.Read.Shared']

View 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