Start setting up Next js with the new app router

This commit is contained in:
Ben Grant 2023-05-19 23:59:44 +10:00
parent 49c6281b74
commit 2adecd13f7
147 changed files with 2637 additions and 3667 deletions

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'],
}
}

27
frontend/.eslintrc.json Normal file
View file

@ -0,0 +1,27 @@
{
"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"
},
"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

4
frontend/.gitignore vendored
View file

@ -1,8 +1,6 @@
node_modules
dist
build
dev-dist
.next
npm-debug.log*
yarn-debug.log*
yarn-error.log*

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,46 @@
{
"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",
"@azure/msal-browser": "^2.37.0",
"@microsoft/microsoft-graph-client": "^3.0.5",
"accept-language": "^3.0.18",
"dayjs": "^1.11.7",
"gapi-script": "^1.2.0",
"goober": "^2.1.10",
"goober": "^2.1.13",
"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.0",
"i18next-browser-languagedetector": "^7.0.1",
"i18next-http-backend": "^2.2.1",
"i18next-resources-to-backend": "^1.1.4",
"lucide-react": "^0.220.0",
"next": "^13.4.3",
"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.43.9",
"react-i18next": "^12.3.1",
"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/node": "^20.2.1",
"@types/react": "^18.2.6",
"@types/react-dom": "^18.2.4",
"@typescript-eslint/eslint-plugin": "^5.59.6",
"@typescript-eslint/parser": "^5.59.6",
"eslint": "^8.40.0",
"eslint-config-next": "^13.4.3",
"eslint-plugin-simple-import-sort": "^10.0.0",
"sass": "^1.62.1",
"typescript": "^5.0.4",
"typescript-plugin-css-modules": "^5.0.1"
},
"browserslist": {
"production": [

View file

@ -1,21 +1,21 @@
@font-face {
font-family: 'Karla';
src: url('fonts/karla-variable.ttf') format('truetype');
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');
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');
src: url('/fonts/molot.woff2') format('woff2'),
url('/fonts/molot.woff') format('woff');
font-weight: 400;
font-style: normal;
}
@ -152,23 +152,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,4 @@
.nav {
text-align: center;
margin: 20px 0;
}

View file

@ -0,0 +1,36 @@
import { Metadata } from 'next'
import { fallbackLng } from '/src/i18n/options'
import { useTranslation } from '/src/i18n/server'
import './global.css'
export const metadata: Metadata = {
metadataBase: new URL('https://crab.fit'),
title: '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 { i18n } = await useTranslation([])
return <html lang={i18n.resolvedLanguage ?? fallbackLng}>
<body>
{children}
</body>
</html>
}
export default RootLayout

24
frontend/src/app/page.tsx Normal file
View file

@ -0,0 +1,24 @@
import { Button, Footer, Header, Recents } from '/src/components'
import { useTranslation } from '/src/i18n/server'
import styles from './home.module.scss'
const Page = async () => {
const { t } = await useTranslation('home')
return <div>
<Header isFull />
<nav className={styles.nav}>
<a href="#about">{t('home:nav.about')}</a>
{' / '}
<a href="#donate">{t('home:nav.donate')}</a>
</nav>
<Recents />
<Button>Hey there!</Button>
<Footer />
</div>
}
export default Page

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,130 @@
.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: 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: 3px;
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, &:focus {
transform: translate(0, 1px);
&::before {
transform: translate3d(0, 4px, -1em);
}
}
&:active {
transform: translate(0, 5px);
&::before {
transform: translate3d(0, 0, -1em);
}
}
@media print {
&::before {
display: none;
}
}
}
.small {
padding: .4em 1.3em;
}
.loading {
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 var(--override-text-color, var(--background));
border-left-color: transparent;
border-radius: 100px;
animation: load .5s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
&::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 {
background: transparent;
border: 1px solid var(--override-surface-color, var(--secondary));
color: var(--override-surface-color, var(--secondary));
margin-bottom: 0;
&::before {
content: none;
}
&:hover, &:active, &:focus {
transform: none;
}
@media print {
box-shadow: 0 4px 0 0 var(--override-shadow-color, var(--secondary));
}
}

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,59 @@
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
// TODO: evaluate
size?: string
} & Omit<React.ComponentProps<'button'> & React.ComponentProps<'a'>, 'ref'>
const Button: React.FC<ButtonProps> = ({
href,
type = 'button',
icon,
children,
isSecondary,
isSmall,
isLoading,
surfaceColor,
shadowColor,
size,
style,
...props
}) => {
const sharedProps = {
className: makeClass(
styles.button,
isSecondary && styles.secondary,
isSmall && styles.small,
isLoading && styles.loading,
),
style: {
...surfaceColor && { '--override-surface-color': surfaceColor, '--override-text-color': '#FFFFFF' },
...shadowColor && { '--override-shadow-color': shadowColor },
...size && { padding: 0, height: size, width: size },
...style,
},
children: [icon, children],
...props,
}
return href
? <Link href={href} {...sharedProps} />
: <button type={type} {...sharedProps} />
}
export default Button

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

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

@ -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,30 @@
import { Button } from '/src/components'
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')
return <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

@ -0,0 +1,124 @@
.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-family: 'Molot', sans-serif;
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-family: 'Samurai Bob', sans-serif;
font-weight: 400;
color: var(--secondary);
line-height: 1em;
text-transform: uppercase;
}
.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-family: 'Molot', sans-serif;
font-weight: 400;
text-shadow: 0 4px 0 var(--shadow);
line-height: 1em;
text-transform: uppercase;
@media (max-width: 350px) {
font-size: 3.5rem;
}
}
.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,32 @@
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'
interface HeaderProps {
/** Show the full header */
isFull?: boolean
}
const Header = async ({ isFull }: HeaderProps) => {
const { t } = await useTranslation(['common', 'home'])
return <header className={styles.header}>
{isFull ? <>
<img className={styles.bigLogo} src={logo.src} alt="" />
<span className={makeClass(styles.subtitle, !/^[A-Za-z ]+$/.test(t('home:create')) && styles.hasAltChars)}>{t('home:create')}</span>
<h1 className={styles.bigTitle}>CRAB FIT</h1>
</> : <Link href="/" className={styles.link}>
<div className={styles.top}>
<img className={styles.logo} src={logo.src} alt="" />
<span className={styles.title}>CRAB FIT</span>
</div>
<span className={styles.tagline}>{t('common:tagline')}</span>
</Link>}
</header>
}
export default Header

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,34 +0,0 @@
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useRecentsStore, useLocaleUpdateStore } from '/src/stores'
import { AboutSection, StyledMain } from '../../pages/Home/Home.styles'
import { Wrapper, Recent } from './Recents.styles'
dayjs.extend(relativeTime)
const Recents = ({ target }) => {
const recents = useRecentsStore(state => state.recents)
const locale = useLocaleUpdateStore(state => state.locale)
const { t } = useTranslation(['home', 'common'])
return !!recents.length && (
<Wrapper>
<AboutSection id="recents">
<StyledMain>
<h2>{t('home:recently_visited')}</h2>
{recents.map(event => (
<Recent href={`/${event.id}`} target={target} key={event.id}>
<span className="name">{event.name}</span>
<span locale={locale} className="date" title={dayjs.unix(event.created).format('D MMMM, YYYY')}>{t('common:created', { date: dayjs.unix(event.created).fromNow() })}</span>
</Recent>
))}
</StyledMain>
</AboutSection>
</Wrapper>
)
}
export default Recents

View file

@ -0,0 +1,35 @@
.recent {
text-decoration: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 0;
flex-wrap: wrap;
&:hover .name {
text-decoration: underline;
}
@media (max-width: 500px) {
display: block;
}
}
.name {
font-weight: 700;
font-size: 1.1em;
color: var(--secondary);
flex: 1;
display: block;
}
.date {
font-weight: 400;
opacity: .8;
white-space: nowrap;
color: var(--text);
@media (max-width: 500px) {
white-space: normal;
}
}

View file

@ -1,42 +0,0 @@
import { styled } from 'goober'
export const Wrapper = styled('div')`
@media print {
display: none;
}
`
export const Recent = styled('a')`
text-decoration: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 0;
flex-wrap: wrap;
& .name {
font-weight: 700;
font-size: 1.1em;
color: var(--secondary);
flex: 1;
display: block;
}
& .date {
font-weight: 400;
opacity: .8;
white-space: nowrap;
color: var(--text);
}
&:hover .name {
text-decoration: underline;
}
@media (max-width: 500px) {
display: block;
& .date {
white-space: normal;
}
}
`

View file

@ -0,0 +1,35 @@
'use client'
import Link from 'next/link'
import dayjs from '/src/config/dayjs'
import { useTranslation } from '/src/i18n/client'
import { useRecentsStore, useStore } from '/src/stores'
import styles from './Recents.module.scss'
interface RecentsProps {
target?: React.ComponentProps<'a'>['target']
}
const Recents = ({ target }: RecentsProps) => {
const recents = useStore(useRecentsStore, state => state.recents)
const { t } = useTranslation(['home', 'common'])
return recents?.length ? <section id="recents">
<div>
<h2>{t('home:recently_visited')}</h2>
{recents.map(event => (
<Link className={styles.recent} href={`/${event.id}`} target={target} key={event.id}>
<span className={styles.name}>{event.name}</span>
<span
className={styles.date}
title={dayjs.unix(event.created_at).format('D MMMM, YYYY')}
>{t('common:created', { date: dayjs.unix(event.created_at).fromNow() })}</span>
</Link>
))}
</div>
</section> : null
}
export default Recents

View file

@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import { Settings as SettingsIcon } from 'lucide-react'
@ -18,6 +17,7 @@ import {
import locales from '/src/i18n/locales'
import { unhyphenate } from '/src/utils'
import { useRouter } from 'next/router'
// Language specific options
const setDefaults = (lang, store) => {
@ -28,7 +28,7 @@ const setDefaults = (lang, store) => {
}
const Settings = () => {
const { pathname } = useLocation()
const { pathname } = useRouter()
const store = useSettingsStore()
const [isOpen, _setIsOpen] = useState(false)
const { t, i18n } = useTranslation('common')

View file

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

View file

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

View file

@ -0,0 +1,6 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
export default dayjs

View file

@ -0,0 +1,25 @@
'use client'
import { initReactI18next, useTranslation as useTranslationHook } from 'react-i18next'
import i18next from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import resourcesToBackend from 'i18next-resources-to-backend'
import { getOptions } from './options'
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(resourcesToBackend((language: string, namespace: string) =>
import(`./locales/${language}/${namespace}.json`)
))
.init({
...getOptions(),
lng: undefined,
detection: {
order: ['htmlTag', 'cookie', 'navigator'],
},
})
export const useTranslation: typeof useTranslationHook = (ns, options) => useTranslationHook(ns, options)

View file

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

View file

@ -6,18 +6,7 @@
"donate": {
"info": "Thank you for using Crab Fit. If you like it, consider donating.",
"button": "Donate",
"title": "Every amount counts :)",
"options": {
"$2": "Donate $2",
"$5": "Donate $5",
"$10": "Donate $10",
"choose": "Choose an amount"
},
"messages": {
"about": "If it's helped you out at all, consider donating to help keep it running. 🦀",
"success": "Thank you for your donation! Without you, Crab Fit wouldn't be free, so thank you and keep being super awesome!",
"error": "Cannot make donation through Google. Please try donating through the website crab.fit 🦀"
}
"title": "Every amount counts :)"
},
"options": {
"name": "Options",

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