mirror of
https://github.com/kremalicious/portfolio.git
synced 2024-12-22 17:23:22 +01:00
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:
parent
3a75af8d35
commit
5a310017b9
@ -1,2 +1,4 @@
|
|||||||
GATSBY_GITHUB_TOKEN=xxx
|
GATSBY_GITHUB_TOKEN=xxx
|
||||||
GATSBY_TYPEKIT_ID=xxx
|
GATSBY_TYPEKIT_ID=xxx
|
||||||
|
NOMADLIST_PROFILE=xxx
|
||||||
|
NOMADLIST_KEY=xxx
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -12,3 +12,5 @@ plugins/gatsby-plugin-matomo
|
|||||||
coverage
|
coverage
|
||||||
.env
|
.env
|
||||||
static/matomo.js
|
static/matomo.js
|
||||||
|
|
||||||
|
.vercel
|
||||||
|
13
README.md
13
README.md
@ -17,6 +17,7 @@
|
|||||||
- [💍 One data file to rule all pages](#-one-data-file-to-rule-all-pages)
|
- [💍 One data file to rule all pages](#-one-data-file-to-rule-all-pages)
|
||||||
- [🗂 JSON Resume](#-json-resume)
|
- [🗂 JSON Resume](#-json-resume)
|
||||||
- [🐱 GitHub repositories](#-github-repositories)
|
- [🐱 GitHub repositories](#-github-repositories)
|
||||||
|
- [📍 Location](#-location)
|
||||||
- [💅 Theme switcher](#-theme-switcher)
|
- [💅 Theme switcher](#-theme-switcher)
|
||||||
- [🏆 SEO component](#-seo-component)
|
- [🏆 SEO component](#-seo-component)
|
||||||
- [📇 Client-side vCard creation](#-client-side-vcard-creation)
|
- [📇 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)
|
- [`content/repos.yml`](content/repos.yml)
|
||||||
- [`src/components/molecules/Repository.jsx`](src/components/molecules/Repository.jsx)
|
- [`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
|
### 💅 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.
|
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
15
api/location.js
Normal 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
1241
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@giphy/js-fetch-api": "^4.1.2",
|
"@giphy/js-fetch-api": "^4.1.2",
|
||||||
|
"@yaireo/relative-time": "^1.0.1",
|
||||||
"axios": "^0.23.0",
|
"axios": "^0.23.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"framer-motion": "^4.1.17",
|
"framer-motion": "^4.1.17",
|
||||||
@ -70,7 +71,7 @@
|
|||||||
"eslint-plugin-testing-library": "^4.12.4",
|
"eslint-plugin-testing-library": "^4.12.4",
|
||||||
"gatsby-plugin-webpack-bundle-analyser-v2": "^1.1.25",
|
"gatsby-plugin-webpack-bundle-analyser-v2": "^1.1.25",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^27.2.5",
|
"jest": "^27.3.1",
|
||||||
"jest-canvas-mock": "^2.3.1",
|
"jest-canvas-mock": "^2.3.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"ora": "^6.0.1",
|
"ora": "^6.0.1",
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { render } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import HostnameCheck from './HostnameCheck'
|
import HostnameCheck from './HostnameCheck'
|
||||||
|
|
||||||
describe('HostnameCheck', () => {
|
describe('HostnameCheck', () => {
|
||||||
@ -8,16 +8,16 @@ describe('HostnameCheck', () => {
|
|||||||
expect(window.location).not.toBe(undefined)
|
expect(window.location).not.toBe(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders correctly', () => {
|
it('renders correctly', async () => {
|
||||||
const allowedHosts = ['hello.com']
|
const allowedHosts = ['hello.com']
|
||||||
const { container } = render(<HostnameCheck allowedHosts={allowedHosts} />)
|
render(<HostnameCheck allowedHosts={allowedHosts} />)
|
||||||
expect(container.firstChild).toHaveTextContent('do a remix')
|
const element = await screen.findByText(/do a remix/i)
|
||||||
expect(container.firstChild).toBeInTheDocument()
|
expect(element).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not render if on correct hostname', () => {
|
it('does not render if on correct hostname', async () => {
|
||||||
const allowedHosts = ['localhost']
|
const allowedHosts = ['localhost']
|
||||||
const { container } = render(<HostnameCheck allowedHosts={allowedHosts} />)
|
const { container } = render(<HostnameCheck allowedHosts={allowedHosts} />)
|
||||||
expect(container.firstChild).not.toBeInTheDocument()
|
expect(container.firstChild).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
.availability {
|
.availability {
|
||||||
|
font-size: var(--font-size-small);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
color: var(--text-color-light);
|
color: var(--text-color-light);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
padding: calc(var(--spacer) / 2);
|
padding: calc(var(--spacer) / 2);
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.availability p {
|
.availability p {
|
||||||
|
63
src/components/molecules/Location.jsx
Normal file
63
src/components/molecules/Location.jsx
Normal 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
|
||||||
|
}
|
20
src/components/molecules/Location.module.css
Normal file
20
src/components/molecules/Location.module.css
Normal 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);
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
.networks {
|
.networks {
|
||||||
margin-top: calc(var(--spacer) * var(--line-height));
|
margin-top: var(--spacer);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react'
|
import React, { useEffect, forwardRef, createRef } from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import { Link, graphql, useStaticQuery } from 'gatsby'
|
import { Link, graphql, useStaticQuery } from 'gatsby'
|
||||||
import ProjectImage from '../atoms/ProjectImage'
|
import ProjectImage from '../atoms/ProjectImage'
|
||||||
@ -22,18 +22,19 @@ const query = graphql`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const Project = ({ node, refCurrentItem }) => (
|
const Project = forwardRef(({ node }, ref) => (
|
||||||
<Link className={item} to={node.slug} title={node.title} ref={refCurrentItem}>
|
<Link className={item} to={node.slug} title={node.title} ref={ref}>
|
||||||
<ProjectImage
|
<ProjectImage
|
||||||
image={node.img.childImageSharp.gatsbyImageData}
|
image={node.img.childImageSharp.gatsbyImageData}
|
||||||
alt={node.title}
|
alt={node.title}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
))
|
||||||
|
|
||||||
|
Project.displayName = 'Project'
|
||||||
|
|
||||||
Project.propTypes = {
|
Project.propTypes = {
|
||||||
node: PropTypes.any.isRequired,
|
node: PropTypes.any.isRequired
|
||||||
refCurrentItem: PropTypes.any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectNav({ currentSlug }) {
|
export default function ProjectNav({ currentSlug }) {
|
||||||
@ -42,8 +43,8 @@ export default function ProjectNav({ currentSlug }) {
|
|||||||
|
|
||||||
// Always keep the scroll position centered
|
// Always keep the scroll position centered
|
||||||
// to currently viewed project on mount.
|
// to currently viewed project on mount.
|
||||||
const scrollContainer = React.createRef()
|
const scrollContainer = createRef()
|
||||||
const currentItem = React.createRef()
|
const currentItem = createRef()
|
||||||
|
|
||||||
function scrollToCurrent() {
|
function scrollToCurrent() {
|
||||||
const activeItem = currentItem.current
|
const activeItem = currentItem.current
|
||||||
@ -72,7 +73,7 @@ export default function ProjectNav({ currentSlug }) {
|
|||||||
<Project
|
<Project
|
||||||
key={node.slug}
|
key={node.slug}
|
||||||
node={node}
|
node={node}
|
||||||
refCurrentItem={isCurrent ? currentItem : null}
|
ref={isCurrent ? currentItem : null}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
|
|||||||
import loadable from '@loadable/component'
|
import loadable from '@loadable/component'
|
||||||
import LogoUnit from '../molecules/LogoUnit'
|
import LogoUnit from '../molecules/LogoUnit'
|
||||||
import Networks from '../molecules/Networks'
|
import Networks from '../molecules/Networks'
|
||||||
|
import Location from '../molecules/Location'
|
||||||
import { footer, actions, copyright } from './Footer.module.css'
|
import { footer, actions, copyright } from './Footer.module.css'
|
||||||
import { useMeta } from '../../hooks/use-meta'
|
import { useMeta } from '../../hooks/use-meta'
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ const FooterMarkup = ({ meta, year }) => (
|
|||||||
<footer className={`h-card ${footer}`}>
|
<footer className={`h-card ${footer}`}>
|
||||||
<LogoUnit minimal />
|
<LogoUnit minimal />
|
||||||
<Networks small />
|
<Networks small />
|
||||||
|
<Location />
|
||||||
|
|
||||||
<p className={actions}>
|
<p className={actions}>
|
||||||
<LazyVcard />
|
<LazyVcard />
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
.footer > aside {
|
.footer > aside {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: calc(var(--spacer) * 2);
|
margin-bottom: var(--spacer);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions a {
|
.actions a {
|
||||||
|
@ -2,9 +2,10 @@ import React from 'react'
|
|||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import Networks from '../molecules/Networks'
|
import Networks from '../molecules/Networks'
|
||||||
import Availability from '../molecules/Availability'
|
import Availability from '../molecules/Availability'
|
||||||
|
import Location from '../molecules/Location'
|
||||||
|
|
||||||
import LogoUnit from '../molecules/LogoUnit'
|
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'
|
import { useMeta } from '../../hooks/use-meta'
|
||||||
|
|
||||||
Header.propTypes = {
|
Header.propTypes = {
|
||||||
@ -21,7 +22,10 @@ export default function Header({ minimal, hide }) {
|
|||||||
<>
|
<>
|
||||||
<LogoUnit minimal={minimal} />
|
<LogoUnit minimal={minimal} />
|
||||||
<Networks hide={minimal} />
|
<Networks hide={minimal} />
|
||||||
<Availability hide={minimal && !availability.status} />
|
<div className={meta}>
|
||||||
|
<Location hide={minimal} />
|
||||||
|
<Availability hide={minimal && !availability.status} />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
@ -10,6 +10,10 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.minimal {
|
.minimal {
|
||||||
composes: header;
|
composes: header;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
25
src/hooks/useLocation.js
Normal file
25
src/hooks/useLocation.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -11,7 +11,8 @@ module.exports = {
|
|||||||
activeStyle,
|
activeStyle,
|
||||||
getProps,
|
getProps,
|
||||||
innerRef,
|
innerRef,
|
||||||
ref,
|
partiallyActive,
|
||||||
|
// ref,
|
||||||
replace,
|
replace,
|
||||||
to,
|
to,
|
||||||
...rest
|
...rest
|
||||||
|
@ -7,7 +7,13 @@ import meta from './__fixtures__/meta.json'
|
|||||||
import resume from './__fixtures__/resume.json'
|
import resume from './__fixtures__/resume.json'
|
||||||
import projects from './__fixtures__/projects.json'
|
import projects from './__fixtures__/projects.json'
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
|
jest.mock('axios')
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
//
|
||||||
|
// Gatsby GraphQL data
|
||||||
|
//
|
||||||
const photoSrc = getSrc(resume.contentJson.basics.picture)
|
const photoSrc = getSrc(resume.contentJson.basics.picture)
|
||||||
const dataMock = {
|
const dataMock = {
|
||||||
...meta,
|
...meta,
|
||||||
@ -16,6 +22,27 @@ beforeAll(() => {
|
|||||||
...projects
|
...projects
|
||||||
}
|
}
|
||||||
|
|
||||||
StaticQuery.mockImplementation(({ render }) => render({ ...dataMock }))
|
StaticQuery.mockReturnValue({ ...dataMock })
|
||||||
useStaticQuery.mockImplementation(() => ({ ...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)
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user