Start setting up Next js with the new app router
This commit is contained in:
parent
49c6281b74
commit
2adecd13f7
|
|
@ -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
27
frontend/.eslintrc.json
Normal 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/"],
|
||||||
|
["^./", "^.", "^../"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
node_modules
|
|
||||||
.DS_Store
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.gcloudignore
|
|
||||||
src
|
|
||||||
public
|
|
||||||
.eslintrc.js
|
|
||||||
yarn.lock
|
|
||||||
package.json
|
|
||||||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
|
|
@ -1,8 +1,6 @@
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
build
|
.next
|
||||||
dev-dist
|
|
||||||
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
|
||||||
3
frontend/.vscode/settings.json
vendored
Normal file
3
frontend/.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
runtime: nodejs16
|
|
||||||
handlers:
|
|
||||||
# Serve all static files with url ending with a file extension
|
|
||||||
- url: /(.*\..+)$
|
|
||||||
static_files: dist/\1
|
|
||||||
upload: (.*\..+)$
|
|
||||||
secure: always
|
|
||||||
redirect_http_response_code: 301
|
|
||||||
|
|
||||||
# Catch all handler to index.html
|
|
||||||
- url: /.*
|
|
||||||
static_files: dist/index.html
|
|
||||||
upload: dist/index.html
|
|
||||||
secure: always
|
|
||||||
redirect_http_response_code: 301
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<link rel="icon" href="favicon.ico">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
|
||||||
<meta name="theme-color" content="#F79E00">
|
|
||||||
<meta
|
|
||||||
name="keywords"
|
|
||||||
content="crab, fit, crabfit, schedule, availability, availabilities, when2meet, doodle, meet, plan, time, timezone"
|
|
||||||
>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Enter your availability to find a time that works for everyone!"
|
|
||||||
>
|
|
||||||
<meta name="monetization" content="$ilp.uphold.com/HjDULeBk9JnH">
|
|
||||||
<!--V1.0--><meta http-equiv="origin-trial" content="ApibM5tjM3kUQQ2EQrkRcdTdWJRGAEKaUFzNhFmx+Of5H/cRyWuecMxs//Bikgo3WMSKs5kntElcM+U8kDy9cAEAAABOeyJvcmlnaW4iOiJodHRwczovL2NyYWIuZml0OjQ0MyIsImZlYXR1cmUiOiJEaWdpdGFsR29vZHMiLCJleHBpcnkiOjE2Mzk1MjYzOTl9">
|
|
||||||
<!--V2.0--><meta http-equiv="origin-trial" content="AiZrT13ogLT63ah6Abb/aG6KhscY5PTf1HNTI2rcqpiFeqiQ3s6+xd+qCe3c+bp3udvvzh5QMHF4GqPAlG110gcAAABQeyJvcmlnaW4iOiJodHRwczovL2NyYWIuZml0OjQ0MyIsImZlYXR1cmUiOiJEaWdpdGFsR29vZHNWMiIsImV4cGlyeSI6MTY0Nzk5MzU5OX0=">
|
|
||||||
<link rel="apple-touch-icon" href="logo192.png">
|
|
||||||
<link rel="manifest" href="manifest.json">
|
|
||||||
|
|
||||||
<meta property="og:title" content="Crab Fit">
|
|
||||||
<meta property="og:description" content="Enter your availability to find a time that works for everyone!">
|
|
||||||
<meta property="og:url" content="https://crab.fit">
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="index.css">
|
|
||||||
|
|
||||||
<title>Crab Fit</title>
|
|
||||||
|
|
||||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
|
||||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-E6S1CDFBCD"></script>
|
|
||||||
<script>
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag(){dataLayer.push(arguments);}
|
|
||||||
gtag('js', new Date());
|
|
||||||
|
|
||||||
gtag('config', 'G-E6S1CDFBCD');
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/index.jsx"></script>
|
|
||||||
|
|
||||||
<noscript>
|
|
||||||
<div style="font-family: Karla, sans-serif; text-align: center; margin: 20vh 0; display: block;">
|
|
||||||
<h1>🦀 Crab Fit doesn't work without Javascript 🏋️</h1>
|
|
||||||
<p>Enable Javascript or try a different browser.</p>
|
|
||||||
</div>
|
|
||||||
</noscript>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": "./",
|
|
||||||
"paths": {
|
|
||||||
"/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"**/node_modules/*",
|
|
||||||
"**/dist/*",
|
|
||||||
"**/.git/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
|
|
@ -1,51 +1,46 @@
|
||||||
{
|
{
|
||||||
"name": "crabfit-frontend",
|
"name": "crabfit-frontend",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "next dev --port 1234",
|
||||||
"build": "vite build",
|
"build": "next build",
|
||||||
"lint": "eslint --ext .js,.jsx ./src"
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^2.28.1",
|
"@azure/msal-browser": "^2.37.0",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.2",
|
"@microsoft/microsoft-graph-client": "^3.0.5",
|
||||||
"dayjs": "^1.11.5",
|
"accept-language": "^3.0.18",
|
||||||
|
"dayjs": "^1.11.7",
|
||||||
"gapi-script": "^1.2.0",
|
"gapi-script": "^1.2.0",
|
||||||
"goober": "^2.1.10",
|
"goober": "^2.1.13",
|
||||||
"hue-map": "^1.0.0",
|
"hue-map": "^1.0.0",
|
||||||
"i18next": "^21.9.0",
|
"i18next": "^22.5.0",
|
||||||
"i18next-browser-languagedetector": "^6.1.5",
|
"i18next-browser-languagedetector": "^7.0.1",
|
||||||
"i18next-http-backend": "^1.4.1",
|
"i18next-http-backend": "^2.2.1",
|
||||||
"lucide-react": "^0.84.0",
|
"i18next-resources-to-backend": "^1.1.4",
|
||||||
|
"lucide-react": "^0.220.0",
|
||||||
|
"next": "^13.4.3",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.34.1",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-i18next": "^11.18.4",
|
"react-i18next": "^12.3.1",
|
||||||
"react-router-dom": "^6.3.0",
|
"zustand": "^4.3.8"
|
||||||
"workbox-background-sync": "^6.5.4",
|
|
||||||
"workbox-broadcast-update": "^6.5.4",
|
|
||||||
"workbox-cacheable-response": "^6.5.4",
|
|
||||||
"workbox-core": "^6.5.4",
|
|
||||||
"workbox-expiration": "^6.5.4",
|
|
||||||
"workbox-google-analytics": "^6.5.4",
|
|
||||||
"workbox-navigation-preload": "^6.5.4",
|
|
||||||
"workbox-precaching": "^6.5.4",
|
|
||||||
"workbox-range-requests": "^6.5.4",
|
|
||||||
"workbox-routing": "^6.5.4",
|
|
||||||
"workbox-strategies": "^6.5.4",
|
|
||||||
"workbox-streams": "^6.5.4",
|
|
||||||
"workbox-window": "^6.5.4",
|
|
||||||
"zustand": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^2.0.1",
|
"@types/node": "^20.2.1",
|
||||||
"eslint": "^8.22.0",
|
"@types/react": "^18.2.6",
|
||||||
"eslint-plugin-react": "^7.30.1",
|
"@types/react-dom": "^18.2.4",
|
||||||
"vite": "^3.0.7",
|
"@typescript-eslint/eslint-plugin": "^5.59.6",
|
||||||
"vite-plugin-pwa": "^0.12.3",
|
"@typescript-eslint/parser": "^5.59.6",
|
||||||
"workbox-webpack-plugin": "^6.5.4"
|
"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": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Karla';
|
font-family: 'Karla';
|
||||||
src: url('fonts/karla-variable.ttf') format('truetype');
|
src: url('/fonts/karla-variable.ttf') format('truetype');
|
||||||
font-weight: 200 800;
|
font-weight: 200 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Samurai Bob';
|
font-family: 'Samurai Bob';
|
||||||
src: url('fonts/samuraibob.woff2') format('woff2'),
|
src: url('/fonts/samuraibob.woff2') format('woff2'),
|
||||||
url('fonts/samuraibob.woff') format('woff');
|
url('/fonts/samuraibob.woff') format('woff');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Molot';
|
font-family: 'Molot';
|
||||||
src: url('fonts/molot.woff2') format('woff2'),
|
src: url('/fonts/molot.woff2') format('woff2'),
|
||||||
url('fonts/molot.woff') format('woff');
|
url('/fonts/molot.woff') format('woff');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
@ -152,23 +152,3 @@ a {
|
||||||
*::-webkit-scrollbar-thumb:active {
|
*::-webkit-scrollbar-thumb:active {
|
||||||
background: var(--secondary);
|
background: var(--secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* IE 10+ */
|
|
||||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
|
||||||
#app {
|
|
||||||
text-align: center;
|
|
||||||
margin: 20vh auto;
|
|
||||||
font-size: 1.3em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
#app::before {
|
|
||||||
content: '🦀';
|
|
||||||
font-size: 1.5em;
|
|
||||||
display: block;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
#app::after {
|
|
||||||
display: block;
|
|
||||||
content: 'Crab Fit doesn\'t work in Internet Explorer. Please try using a modern browser.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4
frontend/src/app/home.module.scss
Normal file
4
frontend/src/app/home.module.scss
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.nav {
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
36
frontend/src/app/layout.tsx
Normal file
36
frontend/src/app/layout.tsx
Normal 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
24
frontend/src/app/page.tsx
Normal 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
|
||||||
|
|
@ -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
|
|
||||||
130
frontend/src/components/Button/Button.module.scss
Normal file
130
frontend/src/components/Button/Button.module.scss
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
59
frontend/src/components/Button/Button.tsx
Normal file
59
frontend/src/components/Button/Button.tsx
Normal 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
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
import { Button } from '/src/components'
|
|
||||||
import { useTWAStore } from '/src/stores'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Wrapper,
|
|
||||||
Options,
|
|
||||||
} from './Donate.styles'
|
|
||||||
|
|
||||||
import paypal_logo from '/src/res/paypal.svg'
|
|
||||||
|
|
||||||
const PAYMENT_METHOD = 'https://play.google.com/billing'
|
|
||||||
const SKU = 'crab_donation'
|
|
||||||
|
|
||||||
const Donate = () => {
|
|
||||||
const store = useTWAStore()
|
|
||||||
const { t } = useTranslation('common')
|
|
||||||
|
|
||||||
const firstLinkRef = useRef()
|
|
||||||
const modalRef = useRef()
|
|
||||||
const [isOpen, _setIsOpen] = useState(false)
|
|
||||||
const [closed, setClosed] = useState(false)
|
|
||||||
|
|
||||||
const setIsOpen = open => {
|
|
||||||
_setIsOpen(open)
|
|
||||||
|
|
||||||
if (open) {
|
|
||||||
window.setTimeout(() => firstLinkRef.current.focus(), 150)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkPressed = () => {
|
|
||||||
setIsOpen(false)
|
|
||||||
gtag('event', 'donate', { 'event_category': 'donate' })
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (store.TWA === undefined) {
|
|
||||||
store.setTWA(document.referrer.includes('android-app://fit.crab'))
|
|
||||||
}
|
|
||||||
}, [store])
|
|
||||||
|
|
||||||
const acknowledge = async (token, type='repeatable', onComplete = () => {}) => {
|
|
||||||
try {
|
|
||||||
const service = await window.getDigitalGoodsService(PAYMENT_METHOD)
|
|
||||||
await service.acknowledge(token, type)
|
|
||||||
if ('acknowledge' in service) {
|
|
||||||
// DGAPI 1.0
|
|
||||||
service.acknowledge(token, type)
|
|
||||||
} else {
|
|
||||||
// DGAPI 2.0
|
|
||||||
service.consume(token)
|
|
||||||
}
|
|
||||||
onComplete()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const purchase = () => {
|
|
||||||
if (!window.PaymentRequest) return false
|
|
||||||
if (!window.getDigitalGoodsService) return false
|
|
||||||
|
|
||||||
const supportedInstruments = [{
|
|
||||||
supportedMethods: PAYMENT_METHOD,
|
|
||||||
data: {
|
|
||||||
sku: SKU
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
|
|
||||||
const details = {
|
|
||||||
total: {
|
|
||||||
label: 'Total',
|
|
||||||
amount: { currency: 'AUD', value: '0' }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = new PaymentRequest(supportedInstruments, details)
|
|
||||||
|
|
||||||
request.show()
|
|
||||||
.then(response => {
|
|
||||||
response
|
|
||||||
.complete('success')
|
|
||||||
.then(() => {
|
|
||||||
console.log(`Payment done: ${JSON.stringify(response, undefined, 2)}`)
|
|
||||||
if (response.details && response.details.token) {
|
|
||||||
const token = response.details.token
|
|
||||||
console.log(`Read Token: ${token.substring(0, 6)}...`)
|
|
||||||
alert(t('donate.messages.success'))
|
|
||||||
acknowledge(token)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e.message)
|
|
||||||
alert(t('donate.messages.error'))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e)
|
|
||||||
alert(t('donate.messages.error'))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper>
|
|
||||||
<Button
|
|
||||||
small
|
|
||||||
title={t('donate.title')}
|
|
||||||
onClick={event => {
|
|
||||||
if (closed) {
|
|
||||||
event.preventDefault()
|
|
||||||
return setClosed(false)
|
|
||||||
}
|
|
||||||
if (store.TWA) {
|
|
||||||
gtag('event', 'donate', { 'event_category': 'donate' })
|
|
||||||
event.preventDefault()
|
|
||||||
if (window.confirm(t('donate.messages.about'))) {
|
|
||||||
if (purchase() === false) {
|
|
||||||
alert(t('donate.messages.error'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
event.preventDefault()
|
|
||||||
setIsOpen(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD&amount=5"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener payment"
|
|
||||||
id="donate_button"
|
|
||||||
role="button"
|
|
||||||
aria-expanded={isOpen ? 'true' : 'false'}
|
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
|
||||||
>{t('donate.button')}</Button>
|
|
||||||
|
|
||||||
<Options
|
|
||||||
$isOpen={isOpen}
|
|
||||||
ref={modalRef}
|
|
||||||
onBlur={e => {
|
|
||||||
if (modalRef.current?.contains(e.relatedTarget)) return
|
|
||||||
setIsOpen(false)
|
|
||||||
if (e.relatedTarget && e.relatedTarget.id === 'donate_button') {
|
|
||||||
setClosed(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img src={paypal_logo} alt="Donate with PayPal" />
|
|
||||||
<a onClick={linkPressed} ref={firstLinkRef} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD&amount=2" target="_blank" rel="noreferrer noopener payment">{t('donate.options.$2')}</a>
|
|
||||||
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD&amount=5" target="_blank" rel="noreferrer noopener payment"><strong>{t('donate.options.$5')}</strong></a>
|
|
||||||
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD&amount=10" target="_blank" rel="noreferrer noopener payment">{t('donate.options.$10')}</a>
|
|
||||||
<a onClick={linkPressed} href="https://www.paypal.com/donate?business=N89X6YXRT5HKW&item_name=Crab+Fit+Donation¤cy_code=AUD" target="_blank" rel="noreferrer noopener payment">{t('donate.options.choose')}</a>
|
|
||||||
</Options>
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Donate
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
import { forwardRef } from 'react'
|
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
margin-top: 6px;
|
|
||||||
margin-left: 12px;
|
|
||||||
position: relative;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Options = styled('div', forwardRef)`
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 20px);
|
|
||||||
right: 0;
|
|
||||||
background-color: var(--background);
|
|
||||||
border: 1px solid var(--surface);
|
|
||||||
z-index: 60;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 14px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-width: calc(100vw - 20px);
|
|
||||||
box-shadow: 0 3px 6px 0 rgba(0,0,0,.3);
|
|
||||||
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(5px);
|
|
||||||
transition: opacity .15s, transform .15s, visibility .15s;
|
|
||||||
|
|
||||||
${props => props.$isOpen && `
|
|
||||||
pointer-events: all;
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
visibility: visible;
|
|
||||||
`}
|
|
||||||
|
|
||||||
& img {
|
|
||||||
width: 80px;
|
|
||||||
margin: 10px auto 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
& a {
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-align: center;
|
|
||||||
padding: 4px 20px;
|
|
||||||
margin: 6px 0;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 100px;
|
|
||||||
background-color: var(--primary);
|
|
||||||
color: var(--background);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
& strong {
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { X } from 'lucide-react'
|
|
||||||
|
|
||||||
import { Wrapper, CloseButton } from './Error.styles'
|
|
||||||
|
|
||||||
const Error = ({
|
|
||||||
children,
|
|
||||||
onClose,
|
|
||||||
open = true,
|
|
||||||
...props
|
|
||||||
}) => (
|
|
||||||
<Wrapper role="alert" open={open} {...props}>
|
|
||||||
{children}
|
|
||||||
<CloseButton type="button" onClick={onClose} title="Close error"><X /></CloseButton>
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default Error
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { styled } from 'goober'
|
.error {
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: var(--error);
|
background-color: var(--error);
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
|
|
@ -15,21 +13,21 @@ export const Wrapper = styled('div')`
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: margin .2s, padding .2s, max-height .2s;
|
transition: margin .2s, padding .2s, max-height .2s;
|
||||||
|
|
||||||
${props => props.open && `
|
|
||||||
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
transition: none;
|
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;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
|
@ -41,4 +39,4 @@ export const CloseButton = styled('button')`
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
`
|
}
|
||||||
25
frontend/src/components/Error/Error.tsx
Normal file
25
frontend/src/components/Error/Error.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { makeClass } from '/src/utils'
|
||||||
|
|
||||||
|
import styles from './Error.module.scss'
|
||||||
|
|
||||||
|
interface ErrorProps {
|
||||||
|
children?: React.ReactNode
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Error = ({ children, onClose }: ErrorProps) =>
|
||||||
|
<div role="alert" className={makeClass(styles.error, children && styles.open)}>
|
||||||
|
{children}
|
||||||
|
<button
|
||||||
|
className={styles.closeButton}
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title="Dismiss error"
|
||||||
|
><X /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
export default Error
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
import { Donate } from '/src/components'
|
|
||||||
import { Wrapper } from './Footer.styles'
|
|
||||||
|
|
||||||
const Footer = props => {
|
|
||||||
const { t } = useTranslation('common')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper id="donate" {...props}>
|
|
||||||
<span>{t('donate.info')}</span>
|
|
||||||
<Donate />
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Footer
|
|
||||||
24
frontend/src/components/Footer/Footer.module.scss
Normal file
24
frontend/src/components/Footer/Footer.module.scss
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
.footer {
|
||||||
|
width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
max-width: calc(100% - 60px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
margin: 60px auto 0;
|
||||||
|
width: 250px;
|
||||||
|
max-width: initial;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
& span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
export const Wrapper = styled('footer')`
|
|
||||||
width: 600px;
|
|
||||||
margin: 20px auto;
|
|
||||||
max-width: calc(100% - 60px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
${props => props.small && `
|
|
||||||
margin: 60px auto 0;
|
|
||||||
width: 250px;
|
|
||||||
max-width: initial;
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
& span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
30
frontend/src/components/Footer/Footer.tsx
Normal file
30
frontend/src/components/Footer/Footer.tsx
Normal 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
|
||||||
124
frontend/src/components/Header/Header.module.scss
Normal file
124
frontend/src/components/Header/Header.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
frontend/src/components/Header/Header.tsx
Normal file
32
frontend/src/components/Header/Header.tsx
Normal 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
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
import {
|
|
||||||
Wrapper,
|
|
||||||
A,
|
|
||||||
Top,
|
|
||||||
Image,
|
|
||||||
Title,
|
|
||||||
Tagline,
|
|
||||||
} from './Logo.styles'
|
|
||||||
|
|
||||||
import image from '/src/res/logo.svg'
|
|
||||||
|
|
||||||
const Logo = () => {
|
|
||||||
const { t } = useTranslation('common')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Wrapper>
|
|
||||||
<A as={Link} to="/">
|
|
||||||
<Top>
|
|
||||||
<Image src={image} alt="" />
|
|
||||||
<Title>CRAB FIT</Title>
|
|
||||||
</Top>
|
|
||||||
<Tagline>{t('common:tagline')}</Tagline>
|
|
||||||
</A>
|
|
||||||
</Wrapper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Logo
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import { styled } from 'goober'
|
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const A = styled('a')`
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
@keyframes jelly {
|
|
||||||
from,to {
|
|
||||||
transform: scale(1,1)
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
transform: scale(.9,1.1)
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.1,.9)
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
transform: scale(.95,1.05)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover img {
|
|
||||||
animation: jelly .5s 1;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
&:hover img {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Top = styled('div')`
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Image = styled('img')`
|
|
||||||
width: 2.5rem;
|
|
||||||
margin-right: 16px;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Title = styled('span')`
|
|
||||||
display: block;
|
|
||||||
font-size: 2rem;
|
|
||||||
color: var(--primary);
|
|
||||||
font-family: 'Molot', sans-serif;
|
|
||||||
font-weight: 400;
|
|
||||||
text-shadow: 0 2px 0 var(--shadow);
|
|
||||||
line-height: 1em;
|
|
||||||
`
|
|
||||||
|
|
||||||
export const Tagline = styled('span')`
|
|
||||||
text-decoration: underline;
|
|
||||||
font-size: 14px;
|
|
||||||
padding-top: 2px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
@ -1,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
|
|
||||||
35
frontend/src/components/Recents/Recents.module.scss
Normal file
35
frontend/src/components/Recents/Recents.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
35
frontend/src/components/Recents/Recents.tsx
Normal file
35
frontend/src/components/Recents/Recents.tsx
Normal 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
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Settings as SettingsIcon } from 'lucide-react'
|
import { Settings as SettingsIcon } from 'lucide-react'
|
||||||
|
|
@ -18,6 +17,7 @@ import {
|
||||||
|
|
||||||
import locales from '/src/i18n/locales'
|
import locales from '/src/i18n/locales'
|
||||||
import { unhyphenate } from '/src/utils'
|
import { unhyphenate } from '/src/utils'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
// Language specific options
|
// Language specific options
|
||||||
const setDefaults = (lang, store) => {
|
const setDefaults = (lang, store) => {
|
||||||
|
|
@ -28,7 +28,7 @@ const setDefaults = (lang, store) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useRouter()
|
||||||
const store = useSettingsStore()
|
const store = useSettingsStore()
|
||||||
const [isOpen, _setIsOpen] = useState(false)
|
const [isOpen, _setIsOpen] = useState(false)
|
||||||
const { t, i18n } = useTranslation('common')
|
const { t, i18n } = useTranslation('common')
|
||||||
|
|
@ -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')
|
|
||||||
23
frontend/src/components/index.ts
Normal file
23
frontend/src/components/index.ts
Normal 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')
|
||||||
6
frontend/src/config/dayjs.ts
Normal file
6
frontend/src/config/dayjs.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
|
export default dayjs
|
||||||
25
frontend/src/i18n/client.ts
Normal file
25
frontend/src/i18n/client.ts
Normal 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)
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -6,18 +6,7 @@
|
||||||
"donate": {
|
"donate": {
|
||||||
"info": "Thank you for using Crab Fit. If you like it, consider donating.",
|
"info": "Thank you for using Crab Fit. If you like it, consider donating.",
|
||||||
"button": "Donate",
|
"button": "Donate",
|
||||||
"title": "Every amount counts :)",
|
"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 🦀"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"name": "Options",
|
"name": "Options",
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue