diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index ff87843..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "env": { - "browser": true, - "es2020": true, - "node": true - }, - "extends": ["eslint:recommended", "plugin:react/recommended"], - "globals": { - "React": "writable" - }, - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 11, - "sourceType": "module" - }, - "plugins": ["react"], - "rules": { - "react/react-in-jsx-scope": "off", - "react/prop-types": "off" - }, - "settings": { - "react": { - "version": "detect" - } - } -} diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 7cf1274..ef11e75 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -1,12 +1,12 @@ -name: Bump version +name: Release on: push: branches: - - master + - main jobs: - bump-version: + release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 20fccdd..1437c53 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ # misc .DS_Store +*.pem # debug npm-debug.log* @@ -28,3 +29,6 @@ yarn-error.log* .env.development.local .env.test.local .env.production.local + +# vercel +.vercel diff --git a/.releaserc b/.releaserc index 8beff78..c8dd44b 100644 --- a/.releaserc +++ b/.releaserc @@ -1,4 +1,7 @@ { + "branches": [ + "main" + ], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", diff --git a/README.md b/README.md index 4b5c883..332b8ab 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,19 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# rafael.run -## Getting Started +Personal page where I log my runs and display them in a nice way. -First, run the development server: +This application consists in creating a new MongoDB entry for every new activity in my Strava account. This is powered by [Strava Webhooks](https://developers.strava.com/docs/webhooks/). -```bash -npm run dev -# or -yarn dev -``` +## Built with -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +- [Next.js](https://nextjs.org/) +- [React](https://reactjs.org/) +- [styled-components](https://styled-components.com/) +- [styled-system](https://styled-system.com/) +- [Mapbox](https://www.mapbox.com/) +- [Splitbee](https://splitbee.io/) +- [Meteostat](https://meteostat.net/) -You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. +## Design ~~ripoff~~ inspiration -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +- [Nike Run Club](https://www.nike.com/nrc-app) diff --git a/components/AllSelector.tsx b/components/AllSelector.tsx new file mode 100644 index 0000000..3b4040e --- /dev/null +++ b/components/AllSelector.tsx @@ -0,0 +1,38 @@ +import dayjs from 'dayjs' +import { ReactElement, useEffect } from 'react' + +import useGlobalState from '../hooks/useGlobalState' +import { DATE_FORMAT } from '../utils/constants' + +import SelectorLabel from './SelectorLabel' + +const AllSelector = (): ReactElement | null => { + const [state, setState] = useGlobalState() + + if (!state.years) { + return null + } + + const maxYear = Math.max(...state.years) + const minYear = Math.min(...state.years) + + useEffect((): void => { + setState({ + range: { + label: `${minYear} - ${maxYear}`, + value: { + start: dayjs().year(minYear).startOf('year').format(DATE_FORMAT), + end: dayjs().year(maxYear).endOf('year').format(DATE_FORMAT), + }, + }, + }) + }, []) + + return ( + + {minYear} - {maxYear} + + ) +} + +export default AllSelector diff --git a/components/Box.ts b/components/Box.ts new file mode 100644 index 0000000..5b4adf1 --- /dev/null +++ b/components/Box.ts @@ -0,0 +1,30 @@ +import styled from 'styled-components' +import { + border, + color, + flexbox, + layout, + space, + BorderProps, + ColorProps, + FlexboxProps, + LayoutProps, + SpaceProps, +} from 'styled-system' + +const Box = styled.div< + BorderProps & ColorProps & FlexboxProps & LayoutProps & SpaceProps +>( + { + boxSizing: 'border-box', + minWidth: 0, + width: '100%', + }, + border, + color, + flexbox, + layout, + space, +) + +export default Box diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..3c97846 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,16 @@ +import styled, { css } from 'styled-components' + +const Button = styled.button( + ({ theme: { colors } }) => css` + background-color: ${colors.black}; + border: none; + border-radius: 9999px; + color: ${colors.white}; + font-size: 1rem; + padding: 16px 24px; + outline: none; + width: 100%; + `, +) + +export default Button diff --git a/components/Container.tsx b/components/Container.tsx new file mode 100644 index 0000000..c58aaed --- /dev/null +++ b/components/Container.tsx @@ -0,0 +1,11 @@ +import { FunctionComponent } from 'react' + +import Box from './Box' + +const Container: FunctionComponent = ({ children }) => ( + + {children} + +) + +export default Container diff --git a/components/Error.js b/components/Error.js deleted file mode 100644 index 2fe8fd0..0000000 --- a/components/Error.js +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' -import styled from 'styled-components' - -import ExclamationTriangle from '../images/exclamation-triangle.svg' - -const StyledError = styled.div` - align-items: center; - color: currentColor; - display: flex; - height: 100%; - justify-content: center; - width: 100%; - - svg { - width: 100px; - } -` - -const Error = () => ( - - - -) - -export default Error diff --git a/components/Footer.js b/components/Footer.js deleted file mode 100644 index a4bd593..0000000 --- a/components/Footer.js +++ /dev/null @@ -1,40 +0,0 @@ -import styled from 'styled-components' - -import Text from './Text' - -const StyledFooter = styled.footer` - margin-top: 32px; - padding: 16px; - text-align: center; - - a { - color: currentColor; - text-decoration: none; - } -` - -const Footer = () => ( - - {new Date().getFullYear()} - {' | '} - - rfoel - - {' | '} - - source code - - -) - -export default Footer diff --git a/components/GlobalStyles.tsx b/components/GlobalStyles.tsx new file mode 100644 index 0000000..b0bf4eb --- /dev/null +++ b/components/GlobalStyles.tsx @@ -0,0 +1,24 @@ +import { createGlobalStyle } from 'styled-components' + +const GlobalStyles = createGlobalStyle` + html, + body, + #__next { + display: flex; + flex-grow: 1; + flex-direction: column; + font-size: 16px; + margin: 0; + min-height: 100%; + padding: 0; + width: 100%; + } + + body { + font-family: BlinkMacSystemFont, -apple-system, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Helvetica, + Arial, sans-serif; + } +` + +export default GlobalStyles diff --git a/components/Grid.js b/components/Grid.js deleted file mode 100644 index 96f6077..0000000 --- a/components/Grid.js +++ /dev/null @@ -1,108 +0,0 @@ -import styled, { css } from 'styled-components' -import { up, down } from 'styled-breakpoints' - -export const Grid = styled.div( - ({ - theme: { - grid: { breakpoints, width }, - }, - }) => css` - margin-right: auto; - margin-left: auto; - width: 100%; - - ${breakpoints.map( - breakpoint => css` - ${up(breakpoint)} { - max-width: ${width[breakpoint]}; - } - `, - )} - `, -) - -export const Row = styled.div( - ({ - align, - justify, - theme: { - grid: { breakpoints, gutter }, - }, - }) => css` - display: flex; - flex: 1 1 auto; - flex-wrap: wrap; - - ${breakpoints.map( - breakpoint => css` - ${up(breakpoint)} { - margin-left: -${gutter[breakpoint] / 2}px; - margin-right: -${gutter[breakpoint] / 2}px; - } - `, - )} - - ${ - align && - css` - align-items: ${align}; - ` - } - - ${ - justify && - css` - justify-content: ${justify}; - ` - } - `, -) - -export const Column = styled.div( - ({ - align, - justify, - theme: { - grid: { breakpoints, columns, gutter }, - }, - ...props - }) => css` - display: flex; - flex: 1 0 auto; - flex-direction: column; - - ${breakpoints.map( - breakpoint => - props[breakpoint] && - css` - ${down(breakpoint)} { - flex: 1 1 ${(props[breakpoint] / columns[breakpoint]) * 100}%; - max-width: ${(props[breakpoint] / columns[breakpoint]) * 100}%; - } - `, - )} - - ${breakpoints.map( - breakpoint => css` - ${up(breakpoint)} { - padding-left: ${gutter[breakpoint] / 2}px; - padding-right: ${gutter[breakpoint] / 2}px; - } - `, - )} - - ${ - align && - css` - align-items: ${align}; - ` - } - - ${ - justify && - css` - justify-content: ${justify}; - ` - } - `, -) diff --git a/components/Header.js b/components/Header.js deleted file mode 100644 index 2f7a34a..0000000 --- a/components/Header.js +++ /dev/null @@ -1,40 +0,0 @@ -import useSWR from 'swr' -import styled, { css } from 'styled-components' -import ContentLoader from 'styled-content-loader' - -import MutedText from './MutedText' -import Text from './Text' - -const StyledHeader = styled(ContentLoader)( - () => css` - align-items: center; - display: flex; - flex-direction: column; - height: 156px; - justify-content: center; - - ${Text} { - font-size: 56px; - line-height: 56px; - margin-bottom: 8px; - } - - ${MutedText} { - font-size: 16px; - line-height: 16px; - } - `, -) - -const Header = () => { - const { data: totalDistance, error } = useSWR('/api/total-distance') - - return ( - - {(totalDistance / 1000).toFixed(2)} km - Total kilometers - - ) -} - -export default Header diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..b21b38e --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,29 @@ +import styled, { css } from 'styled-components' + +import Container from './Container' +import Run from '../images/run.svg' +import Social from './Social' + +const StyledHeader = styled.header( + () => css` + align-items: center; + display: flex; + justify-content: space-between; + padding: 24px 0; + + svg { + height: 36px; + } + `, +) + +const Header = () => ( + + + + + + +) + +export default Header diff --git a/components/HiddenInput.tsx b/components/HiddenInput.tsx new file mode 100644 index 0000000..0f71873 --- /dev/null +++ b/components/HiddenInput.tsx @@ -0,0 +1,13 @@ +import styled from 'styled-components' + +const HiddenInput = styled.input` + border: 0; + height: 0; + margin: 0; + opacity: 0; + overflow: hidden; + padding: 0; + position: absolute; + width: 0; +` +export default HiddenInput diff --git a/components/Info.js b/components/Info.js deleted file mode 100644 index d557838..0000000 --- a/components/Info.js +++ /dev/null @@ -1,71 +0,0 @@ -import useSWR from 'swr' -import styled from 'styled-components' -import ContentLoader from 'styled-content-loader' - -import formatPace from '../utils/formatPace' -import MutedText from './MutedText' -import Text from './Text' -import { Row, Column } from './Grid' -import { WORLD_RECORD } from '../utils/constants' - -const StyledInfo = styled.div` - width: 100%; - - ${Column}, ${Row} { - align-items: center; - justify-content: center; - } - - ${Column} { - height: 156px; - } - - ${Text} { - font-size: 40px; - margin-bottom: 8px; - } - - ${MutedText} { - font-size: 16px; - } -` - -const Info = () => { - const { data: totalRuns, error: totalRunsError } = useSWR('/api/total-runs') - const { data: averageDistance, error: averageDistanceError } = useSWR( - '/api/average-distance', - ) - const { data: averagePace, error: averagePaceError } = useSWR( - '/api/average-pace', - ) - - const error = totalRunsError || averageDistanceError || averagePaceError - const isLoading = Boolean( - !totalRuns || !averageDistance || !averagePace || error, - ) - - return ( - - - - {totalRuns || 999} - Total runs - - - {((totalRuns * 100) / WORLD_RECORD).toFixed(2)}% - WR progress - - - {(averageDistance / 1000).toFixed(2)} km - Average distance - - - {formatPace(averagePace)} - Average pace - - - - ) -} - -export default Info diff --git a/components/Info.tsx b/components/Info.tsx new file mode 100644 index 0000000..ba594b3 --- /dev/null +++ b/components/Info.tsx @@ -0,0 +1,66 @@ +import { ReactElement } from 'react' +import useSWR from 'swr' + +import useGlobalState from '../hooks/useGlobalState' +import formatNumber from '../utils/formatNumber' +import formatPace from '../utils/formatPace' +import formatTime from '../utils/formatTime' + +import Box from './Box' +import Container from './Container' +import InfoItem from './InfoItem' + +const Info = (): ReactElement | null => { + const [state] = useGlobalState() + + if (!state.range) { + return null + } + + const query = new URLSearchParams(state.range.value) + + const { data: averagePace } = useSWR(`/api/average-pace?${query}`) + const { data: totalDistance } = useSWR(`/api/total-distance?${query}`) + const { data: totalRuns } = useSWR(`/api/total-runs?${query}`) + const { data: totalTime } = useSWR(`/api/total-time?${query}`) + + return ( + + + + + + + + + + + + + + + + + ) +} + +export default Info diff --git a/components/InfoItem.tsx b/components/InfoItem.tsx new file mode 100644 index 0000000..eab7e23 --- /dev/null +++ b/components/InfoItem.tsx @@ -0,0 +1,55 @@ +import styled, { css, FlattenSimpleInterpolation } from 'styled-components' +import ContentLoader from 'styled-content-loader' + +const Wrapper = styled.div<{ size: string }>( + ({ size, theme: { colors } }): FlattenSimpleInterpolation => css` + display: flex; + + h1 { + color: ${colors.black}; + margin: 0; + margin-bottom: 4px; + } + + span { + color: ${colors.gray}; + font-size: 0.9rem; + } + + h1 { + font-size: 0.9rem; + font-weight: normal; + + ${size === 'md' && + css` + font-size: 1rem; + `}; + + ${size === 'lg' && + css` + font-family: 'Futura'; + font-size: 3rem; + `}; + } + `, +) + +type Props = { + title: string + label: string + size?: 'sm' | 'md' | 'lg' + isLoading?: boolean +} + +const Info = ({ title, label, size = 'md', isLoading = false }: Props) => { + return ( + + +

{title}

+ {label} +
+
+ ) +} + +export default Info diff --git a/components/Layout.js b/components/Layout.js deleted file mode 100644 index 62410f4..0000000 --- a/components/Layout.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react' -import { node } from 'prop-types' -import styled, { css } from 'styled-components' - -import { Grid } from './Grid' - -const Container = styled.div( - ({ theme: { colors } }) => - css` - background-color: ${colors.base}; - color: ${colors.contrast}; - display: flex; - flex: 1 0 auto; - flex-direction: column; - transition-property: all; - transition-duration: 200ms; - transition-timing-function: ease; - width: 100%; - - > ${Grid} { - display: flex; - flex: 1 0 auto; - flex-direction: column; - } - `, -) - -const Layout = ({ children }) => ( - - {children} - -) - -Layout.propTypes = { - children: node.isRequired, -} - -export default Layout diff --git a/components/Layout.tsx b/components/Layout.tsx new file mode 100644 index 0000000..47c7c74 --- /dev/null +++ b/components/Layout.tsx @@ -0,0 +1,26 @@ +import React, { FunctionComponent } from 'react' +import styled, { css, ThemeProvider } from 'styled-components' + +import GlobalStyles from './GlobalStyles' +import theme from '../utils/theme' + +const Container = styled.div( + ({ theme: { colors } }) => css` + color: ${colors.black}; + display: flex; + flex: 1 0 100%; + flex-direction: column; + width: 100%; + `, +) + +const Layout: FunctionComponent = ({ children }) => { + return ( + + + {children} + + ) +} + +export default Layout diff --git a/components/Loader.js b/components/Loader.js deleted file mode 100644 index 1dcd976..0000000 --- a/components/Loader.js +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' -import styled from 'styled-components' - -import Run from '../images/run.svg' - -const StyledLoader = styled.div` - align-items: center; - color: currentColor; - display: flex; - height: 100%; - justify-content: center; - width: 100%; - - svg { - width: 100px; - } -` - -const Loader = () => ( - - - -) - -export default Loader diff --git a/components/Map.js b/components/Map.js deleted file mode 100644 index 4ec477f..0000000 --- a/components/Map.js +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react' -import styled from 'styled-components' -import polyline from '@mapbox/polyline' - -const Svg = styled.svg` - overflow: visible; - height: auto; -` - -const convertLatLngToPoint = ([lat, lng]) => { - return { - x: (lng + 180) * (256 / 360), - y: - 256 / 2 - - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 180 / 2))) / - (2 * Math.PI), - } -} - -const getPolylineProps = latLng => { - const points = [] - let minX = 256 - let minY = 256 - let maxX = 0 - let maxY = 0 - - for (var pp = 0; pp < latLng.length; ++pp) { - const currentLatLng = latLng[pp] - for (var p = 0; p < currentLatLng.length; ++p) { - const point = convertLatLngToPoint(currentLatLng) - minX = Math.min(minX, point.x) - minY = Math.min(minY, point.y) - maxX = Math.max(maxX, point.x) - maxY = Math.max(maxY, point.y) - points.push([point.x, point.y].join(',')) - } - } - - return { - points: points.join(' '), - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - } -} - -const Map = ({ summaryPolyline }) => { - const latLng = polyline.decode(summaryPolyline) - const { height, points, x, y, width } = getPolylineProps(latLng) - - return ( - - - - ) -} - -export default Map diff --git a/components/Modal.tsx b/components/Modal.tsx new file mode 100644 index 0000000..02d507e --- /dev/null +++ b/components/Modal.tsx @@ -0,0 +1,40 @@ +import { ReactElement } from 'react' +import styled, { css } from 'styled-components' + +const Overlay = styled.div` + align-items: flex-end; + background-color: rgba(0, 0, 0, 0.5); + bottom: 0; + cursor: default; + display: flex; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 1; +` + +const Content = styled.div( + ({ theme: { colors } }): any => css` + background-color: ${colors.white}; + padding: 16px 24px; + width: 100%; + `, +) + +type Props = { + children: JSX.Element | JSX.Element[] + isOpen: boolean + handleClose: (event?: Event) => void +} + +const Modal = ({ children, isOpen, handleClose }: Props): ReactElement | null => + isOpen ? ( + handleClose()}> + event.stopPropagation()}> + {children} + + + ) : null + +export default Modal diff --git a/components/MonthSelector.tsx b/components/MonthSelector.tsx new file mode 100644 index 0000000..a23d8c5 --- /dev/null +++ b/components/MonthSelector.tsx @@ -0,0 +1,140 @@ +import dayjs from 'dayjs' +import { ReactElement, useEffect, useState } from 'react' +import styled, { css } from 'styled-components' + +import useGlobalState from '../hooks/useGlobalState' +import { DATE_FORMAT } from '../utils/constants' +import { getMonthNames } from '../utils/period' + +import Button from './Button' +import HiddenInput from './HiddenInput' +import Modal from './Modal' +import SelectorLabel from './SelectorLabel' + +const Wrapper = styled.div` + cursor: pointer; + + ${Button} { + margin-top: 24px; + } +` + +const Selector = styled.div` + align-items: center; + display: flex; + justify-content: center; + overflow: hidden; + padding: 16px 24px; +` + +const Options = styled.div` + display: flex; + flex-direction: column; + height: 150px; + padding: 16px 32px; + overflow-y: scroll; +` + +const Option = styled.label<{ selected: boolean }>( + ({ selected, theme: { colors } }): any => css` + border-radius: 8px; + cursor: pointer; + margin: 4px; + padding: 8px; + text-align: center; + user-select: none; + width: 100%; + + ${selected && + css` + background-color: ${colors.whisper}; + `} + `, +) + +const MonthSelector = (): ReactElement | null => { + const [state, setState] = useGlobalState() + const [isOpen, setIsOpen] = useState(false) + + const monthNames = getMonthNames() + + const [month, setMonth] = useState(dayjs().month()) + const [year, setYear] = useState(dayjs().year()) + + if (!state.years) { + return null + } + + useEffect(() => { + handleClick() + }, []) + + const handleClick = () => { + setState({ + range: { + value: { + start: dayjs() + .month(month) + .year(year) + .startOf('month') + .format(DATE_FORMAT), + end: dayjs() + .month(month) + .year(year) + .endOf('month') + .format(DATE_FORMAT), + }, + label: dayjs().month(month).year(year).format('MMMM YYYY'), + }, + }) + setIsOpen(false) + } + + const handleClose = (event?: Event): void => { + event?.stopPropagation() + setIsOpen(false) + } + + return ( + setIsOpen(true)}> + {state.range?.label} + + + + {monthNames.map( + (value, index): ReactElement => ( + + ), + )} + + + {state.years.map( + (value): ReactElement => ( + + ), + )} + + + + + + ) +} + +export default MonthSelector diff --git a/components/MutedText.js b/components/MutedText.js deleted file mode 100644 index 87875f0..0000000 --- a/components/MutedText.js +++ /dev/null @@ -1,11 +0,0 @@ -import styled, { css } from 'styled-components' - -import Text from './Text' - -const MutedText = styled(Text)( - ({ theme: { colors } }) => css` - color: ${colors.muted}; - `, -) - -export default MutedText diff --git a/components/Navbar.js b/components/Navbar.js deleted file mode 100644 index 8a07d45..0000000 --- a/components/Navbar.js +++ /dev/null @@ -1,32 +0,0 @@ -import { useRecoilState } from 'recoil' -import styled from 'styled-components' - -import Run from '../images/run.svg' -import Switch from './Switch' -import { darkModeAtom } from '../utils/theme' - -const StyledNavbar = styled.nav` - align-items: center; - color: currentColor; - display: flex; - justify-content: space-between; - padding: 16px; - width: 100%; - - svg { - width: 46px; - } -` - -const Navbar = () => { - const [darkMode, setDarkMode] = useRecoilState(darkModeAtom) - - return ( - - - setDarkMode(!darkMode)} /> - - ) -} - -export default Navbar diff --git a/components/Page.js b/components/Page.js deleted file mode 100644 index 5067360..0000000 --- a/components/Page.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react' -import { node } from 'prop-types' -import styled from 'styled-components' - -import Footer from './Footer' -import Navbar from './Navbar' - -const Children = styled.div` - align-items: center; - display: flex; - flex: 1; - flex-direction: column; - justify-content: center; - width: 100%; -` - -const Container = styled.div` - align-items: center; - display: flex; - flex: 1 0 auto; - flex-direction: column; - justify-content: center; - width: 100%; -` - -const Page = ({ children }) => ( - - - {children} -