add location (#692)

* add current and next location

* add documentation

* api -> src/api symlink so local dev & Vercel work

* add to footer

* test changes

* styling changes

* spacing

* layout fixes
This commit is contained in:
Matthias Kretschmann 2021-10-19 21:47:19 +01:00 committed by GitHub
parent 3a75af8d35
commit 5a310017b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 834 additions and 640 deletions

View File

@ -1,2 +1,4 @@
GATSBY_GITHUB_TOKEN=xxx
GATSBY_TYPEKIT_ID=xxx
GATSBY_TYPEKIT_ID=xxx
NOMADLIST_PROFILE=xxx
NOMADLIST_KEY=xxx

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ plugins/gatsby-plugin-matomo
coverage
.env
static/matomo.js
.vercel

View File

@ -17,6 +17,7 @@
- [💍 One data file to rule all pages](#-one-data-file-to-rule-all-pages)
- [🗂 JSON Resume](#-json-resume)
- [🐱 GitHub repositories](#-github-repositories)
- [📍 Location](#-location)
- [💅 Theme switcher](#-theme-switcher)
- [🏆 SEO component](#-seo-component)
- [📇 Client-side vCard creation](#-client-side-vcard-creation)
@ -71,6 +72,18 @@ If you want to know how, have a look at the respective components:
- [`content/repos.yml`](content/repos.yml)
- [`src/components/molecules/Repository.jsx`](src/components/molecules/Repository.jsx)
### 📍 Location
On client-side, my current and, if known, my next physical location on a city level is fetched from my (private) [nomadlist.com](https://nomadlist.com) profile and displayed in the header.
Fetching is split up into a serverless function, a hook, and display component. Fetching is done with a serverless function as to not expose the whole profile response into the browser. Requires `NOMADLIST_PROFILE` & `NOMADLIST_KEY` environment variables.
If you want to know how, have a look at the respective components:
- [`api/location.js`](api/location.js)
- [`src/hooks/useLocation.js`](src/hooks/useLocation.js)
- [`src/components/molecules/Location.jsx`](src/components/molecules/Location.jsx)
### 💅 Theme switcher
Includes a theme switcher which allows user to toggle between a light and a dark theme. Switching between them also happens automatically based on user's system preferences.

15
api/location.js Normal file
View File

@ -0,0 +1,15 @@
import axios from 'axios'
export default async function getLocationHandler(req, res) {
if (!process.env.NOMADLIST_PROFILE) return
try {
const response = await axios(
`https://nomadlist.com/@${process.env.NOMADLIST_PROFILE}.json?key=${process.env.NOMADLIST_KEY}`
)
if (!response?.data) return
res.json(response.data.location)
} catch (error) {
res.status(500).send(error)
}
}

1241
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@
},
"dependencies": {
"@giphy/js-fetch-api": "^4.1.2",
"@yaireo/relative-time": "^1.0.1",
"axios": "^0.23.0",
"file-saver": "^2.0.5",
"framer-motion": "^4.1.17",
@ -70,7 +71,7 @@
"eslint-plugin-testing-library": "^4.12.4",
"gatsby-plugin-webpack-bundle-analyser-v2": "^1.1.25",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.2.5",
"jest": "^27.3.1",
"jest-canvas-mock": "^2.3.1",
"js-yaml": "^4.1.0",
"ora": "^6.0.1",

1
src/api Symbolic link
View File

@ -0,0 +1 @@
../api

View File

@ -1,5 +1,5 @@
import React from 'react'
import { render } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import HostnameCheck from './HostnameCheck'
describe('HostnameCheck', () => {
@ -8,16 +8,16 @@ describe('HostnameCheck', () => {
expect(window.location).not.toBe(undefined)
})
it('renders correctly', () => {
it('renders correctly', async () => {
const allowedHosts = ['hello.com']
const { container } = render(<HostnameCheck allowedHosts={allowedHosts} />)
expect(container.firstChild).toHaveTextContent('do a remix')
expect(container.firstChild).toBeInTheDocument()
render(<HostnameCheck allowedHosts={allowedHosts} />)
const element = await screen.findByText(/do a remix/i)
expect(element).toBeDefined()
})
it('does not render if on correct hostname', () => {
it('does not render if on correct hostname', async () => {
const allowedHosts = ['localhost']
const { container } = render(<HostnameCheck allowedHosts={allowedHosts} />)
expect(container.firstChild).not.toBeInTheDocument()
expect(container.firstChild).toBeNull()
})
})

View File

@ -1,10 +1,10 @@
.availability {
font-size: var(--font-size-small);
border-radius: var(--border-radius);
color: var(--text-color-light);
z-index: 2;
padding: calc(var(--spacer) / 2);
display: block;
margin-top: auto;
}
.availability p {

View File

@ -0,0 +1,63 @@
import React from 'react'
import PropTypes from 'prop-types'
import { motion, useReducedMotion } from 'framer-motion'
import { moveInTop, getAnimationProps } from '../atoms/Transitions'
import {
location as styleLocation,
emoji as styleEmoji,
next as styleNext
} from './Location.module.css'
import { useLocation } from '../../hooks/useLocation'
import RelativeTime from '@yaireo/relative-time'
function Flag({ countryCode }) {
if (!countryCode) return null
// offset between uppercase ascii and regional indicator symbols
const OFFSET = 127397
const emoji = countryCode.replace(/./g, (char) =>
String.fromCodePoint(char.charCodeAt(0) + OFFSET)
)
return (
<span role="img" className={styleEmoji}>
{emoji}
</span>
)
}
Flag.propTypes = {
countryCode: PropTypes.string
}
export default function Location({ hide }) {
const { now, next } = useLocation()
const shouldReduceMotion = useReducedMotion()
const isSSr = typeof window === 'undefined'
const isDifferentCountry = now?.country !== next?.country
const relativeTime = new RelativeTime({ locale: 'en' })
return !hide && now ? (
<motion.aside
variants={moveInTop}
className={styleLocation}
{...getAnimationProps(shouldReduceMotion, isSSr)}
>
<Flag countryCode={now?.country_code} />
{now?.city} <span>Now</span>
<div className={styleNext}>
{next?.city && (
<>
{isDifferentCountry && <Flag countryCode={next.country_code} />}
{next.city}{' '}
<span>{relativeTime.from(new Date(next.date_start))}</span>
</>
)}
</div>
</motion.aside>
) : null
}
Location.propTypes = {
hide: PropTypes.bool
}

View File

@ -0,0 +1,20 @@
.location {
font-size: var(--font-size-small);
}
.location span {
color: var(--text-color-light);
}
.emoji {
display: inline-block;
font-size: 1em;
line-height: 1em;
vertical-align: 'middle';
margin-right: calc(var(--spacer) / 6);
}
.next {
display: inline-block;
margin-left: calc(var(--spacer) / 3);
}

View File

@ -1,5 +1,5 @@
.networks {
margin-top: calc(var(--spacer) * var(--line-height));
margin-top: var(--spacer);
width: 100%;
text-align: center;
}

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react'
import React, { useEffect, forwardRef, createRef } from 'react'
import PropTypes from 'prop-types'
import { Link, graphql, useStaticQuery } from 'gatsby'
import ProjectImage from '../atoms/ProjectImage'
@ -22,18 +22,19 @@ const query = graphql`
}
`
const Project = ({ node, refCurrentItem }) => (
<Link className={item} to={node.slug} title={node.title} ref={refCurrentItem}>
const Project = forwardRef(({ node }, ref) => (
<Link className={item} to={node.slug} title={node.title} ref={ref}>
<ProjectImage
image={node.img.childImageSharp.gatsbyImageData}
alt={node.title}
/>
</Link>
)
))
Project.displayName = 'Project'
Project.propTypes = {
node: PropTypes.any.isRequired,
refCurrentItem: PropTypes.any
node: PropTypes.any.isRequired
}
export default function ProjectNav({ currentSlug }) {
@ -42,8 +43,8 @@ export default function ProjectNav({ currentSlug }) {
// Always keep the scroll position centered
// to currently viewed project on mount.
const scrollContainer = React.createRef()
const currentItem = React.createRef()
const scrollContainer = createRef()
const currentItem = createRef()
function scrollToCurrent() {
const activeItem = currentItem.current
@ -72,7 +73,7 @@ export default function ProjectNav({ currentSlug }) {
<Project
key={node.slug}
node={node}
refCurrentItem={isCurrent ? currentItem : null}
ref={isCurrent ? currentItem : null}
/>
)
})}

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
import loadable from '@loadable/component'
import LogoUnit from '../molecules/LogoUnit'
import Networks from '../molecules/Networks'
import Location from '../molecules/Location'
import { footer, actions, copyright } from './Footer.module.css'
import { useMeta } from '../../hooks/use-meta'
@ -12,6 +13,7 @@ const FooterMarkup = ({ meta, year }) => (
<footer className={`h-card ${footer}`}>
<LogoUnit minimal />
<Networks small />
<Location />
<p className={actions}>
<LazyVcard />

View File

@ -11,7 +11,7 @@
.footer > aside {
margin-top: 0;
margin-bottom: calc(var(--spacer) * 2);
margin-bottom: var(--spacer);
}
.actions a {

View File

@ -2,9 +2,10 @@ import React from 'react'
import PropTypes from 'prop-types'
import Networks from '../molecules/Networks'
import Availability from '../molecules/Availability'
import Location from '../molecules/Location'
import LogoUnit from '../molecules/LogoUnit'
import { header, minimal as styleMinimal } from './Header.module.css'
import { header, minimal as styleMinimal, meta } from './Header.module.css'
import { useMeta } from '../../hooks/use-meta'
Header.propTypes = {
@ -21,7 +22,10 @@ export default function Header({ minimal, hide }) {
<>
<LogoUnit minimal={minimal} />
<Networks hide={minimal} />
<Availability hide={minimal && !availability.status} />
<div className={meta}>
<Location hide={minimal} />
<Availability hide={minimal && !availability.status} />
</div>
</>
)}
</header>

View File

@ -10,6 +10,10 @@
justify-content: flex-start;
}
.meta {
margin-top: auto;
}
.minimal {
composes: header;
min-height: 0;

25
src/hooks/useLocation.js Normal file
View File

@ -0,0 +1,25 @@
import axios from 'axios'
import { useEffect, useState } from 'react'
export const useLocation = () => {
const [location, setLocation] = useState()
useEffect(() => {
async function fetchData() {
try {
const response = await axios(`/api/location`)
if (!response) return
setLocation(response.data)
} catch (error) {
console.error(error.message)
}
}
fetchData()
}, [])
return {
now: location?.now,
next: location?.next,
previous: location?.previous
}
}

View File

@ -11,7 +11,8 @@ module.exports = {
activeStyle,
getProps,
innerRef,
ref,
partiallyActive,
// ref,
replace,
to,
...rest

View File

@ -7,7 +7,13 @@ import meta from './__fixtures__/meta.json'
import resume from './__fixtures__/resume.json'
import projects from './__fixtures__/projects.json'
import axios from 'axios'
jest.mock('axios')
beforeAll(() => {
//
// Gatsby GraphQL data
//
const photoSrc = getSrc(resume.contentJson.basics.picture)
const dataMock = {
...meta,
@ -16,6 +22,27 @@ beforeAll(() => {
...projects
}
StaticQuery.mockImplementation(({ render }) => render({ ...dataMock }))
useStaticQuery.mockImplementation(() => ({ ...dataMock }))
StaticQuery.mockReturnValue({ ...dataMock })
useStaticQuery.mockReturnValue({ ...dataMock })
//
// Axios mocks
//
const responseMock = {
status: 'ok',
data: {
now: {
city: 'Lisbon',
country: 'Portugal',
country_code: 'PT',
date_start: '2021-10-01'
},
next: {
city: 'Barcelona',
country: 'Spain',
date_start: '2021-10-04'
}
}
}
axios.mockResolvedValue(responseMock)
})