Start setting up Next js with the new app router
This commit is contained in:
parent
49c6281b74
commit
2adecd13f7
147 changed files with 2637 additions and 3667 deletions
|
|
@ -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'
|
||||
|
||||
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;
|
||||
`
|
||||
}
|
||||
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 { 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')
|
||||
|
|
@ -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')
|
||||
Loading…
Add table
Add a link
Reference in a new issue