mirror of
https://github.com/kremalicious/portfolio.git
synced 2024-12-22 09:13:19 +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_TYPEKIT_ID=xxx
|
||||
NOMADLIST_PROFILE=xxx
|
||||
NOMADLIST_KEY=xxx
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -12,3 +12,5 @@ plugins/gatsby-plugin-matomo
|
||||
coverage
|
||||
.env
|
||||
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)
|
||||
- [🗂 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
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": {
|
||||
"@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,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()
|
||||
})
|
||||
})
|
||||
|
@ -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 {
|
||||
|
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 {
|
||||
margin-top: calc(var(--spacer) * var(--line-height));
|
||||
margin-top: var(--spacer);
|
||||
width: 100%;
|
||||
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 { 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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
@ -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 />
|
||||
|
@ -11,7 +11,7 @@
|
||||
|
||||
.footer > aside {
|
||||
margin-top: 0;
|
||||
margin-bottom: calc(var(--spacer) * 2);
|
||||
margin-bottom: var(--spacer);
|
||||
}
|
||||
|
||||
.actions a {
|
||||
|
@ -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>
|
||||
|
@ -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
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,
|
||||
getProps,
|
||||
innerRef,
|
||||
ref,
|
||||
partiallyActive,
|
||||
// ref,
|
||||
replace,
|
||||
to,
|
||||
...rest
|
||||
|
@ -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)
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user