mirror of
https://github.com/kremalicious/portfolio.git
synced 2024-12-22 09:13:19 +01:00
move to app router (#1284)
* app router migration * fixes * move to Next.js metadata handling * theme switch fixes * use some server actions * update tests * more unit tests * restore prettier-plugin-sort-imports functionality * cleanup * package updates * basic layout test * test tweak * readme updates
This commit is contained in:
parent
f065d05248
commit
1d74f420be
@ -28,7 +28,7 @@ exclude_patterns:
|
|||||||
- '**/*_test.go'
|
- '**/*_test.go'
|
||||||
- '**/*.d.ts'
|
- '**/*.d.ts'
|
||||||
- '**/@types/'
|
- '**/@types/'
|
||||||
- '**/interfaces/'
|
- '**/types/'
|
||||||
- '**/_types.*'
|
- '**/_types.*'
|
||||||
- '**/*.stories.*'
|
- '**/*.stories.*'
|
||||||
- '**/*.test.*'
|
- '**/*.test.*'
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
GITHUB_TOKEN=xxx
|
GITHUB_TOKEN=xxx
|
||||||
NEXT_PUBLIC_TYPEKIT_ID=xxx
|
NEXT_PUBLIC_TYPEKIT_ID=xxx
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://umami.example.com/umami.js
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://umami.example.com/script.js
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=1
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=1
|
12
.github/dependabot.yml
vendored
12
.github/dependabot.yml
vendored
@ -1,8 +1,8 @@
|
|||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: npm
|
- package-ecosystem: npm
|
||||||
directory: "/"
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: monthly
|
||||||
time: '04:00'
|
time: '04:00'
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
|
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
@ -70,26 +70,3 @@ jobs:
|
|||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
# - uses: actions/upload-artifact@v1
|
|
||||||
# if: github.ref == 'refs/heads/main'
|
|
||||||
# with:
|
|
||||||
# name: public
|
|
||||||
# path: public
|
|
||||||
|
|
||||||
# deploy:
|
|
||||||
# needs: build
|
|
||||||
# if: success() && github.ref == 'refs/heads/main'
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
# steps:
|
|
||||||
# - uses: actions/checkout@v3
|
|
||||||
# - uses: actions/download-artifact@v1
|
|
||||||
# with:
|
|
||||||
# name: public
|
|
||||||
# - name: Deploy to S3
|
|
||||||
# run: npm run deploy:s3
|
|
||||||
# env:
|
|
||||||
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
# AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
|
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"endOfLine": "lf",
|
"endOfLine": "lf",
|
||||||
|
"plugins": ["@trivago/prettier-plugin-sort-imports"],
|
||||||
"importOrder": [
|
"importOrder": [
|
||||||
"^(react/(.*)$)|^(react$)",
|
"^(react/(.*)$)|^(react$)",
|
||||||
"^(next/(.*)$)|^(next$)",
|
"^(next/(.*)$)|^(next$)",
|
||||||
@ -11,8 +12,5 @@
|
|||||||
"^[./]"
|
"^[./]"
|
||||||
],
|
],
|
||||||
"importOrderSeparation": false,
|
"importOrderSeparation": false,
|
||||||
"importOrderSortSpecifiers": true,
|
"importOrderSortSpecifiers": true
|
||||||
"importOrderBuiltinModulesToTop": true,
|
|
||||||
"importOrderMergeDuplicateImports": true,
|
|
||||||
"importOrderCombineTypeAndValueImports": true
|
|
||||||
}
|
}
|
||||||
|
50
README.md
50
README.md
@ -21,10 +21,8 @@
|
|||||||
- [🐱 GitHub repositories](#-github-repositories)
|
- [🐱 GitHub repositories](#-github-repositories)
|
||||||
- [📍 Location](#-location)
|
- [📍 Location](#-location)
|
||||||
- [💅 Theme switcher](#-theme-switcher)
|
- [💅 Theme switcher](#-theme-switcher)
|
||||||
- [🏆 SEO component](#-seo-component)
|
|
||||||
- [📇 Client-side vCard creation](#-client-side-vcard-creation)
|
- [📇 Client-side vCard creation](#-client-side-vcard-creation)
|
||||||
- [💎 Importing SVG assets](#-importing-svg-assets)
|
- [💎 Importing SVG assets](#-importing-svg-assets)
|
||||||
- [🍬 Typekit component](#-typekit-component)
|
|
||||||
- [🤓 Scripts](#-scripts)
|
- [🤓 Scripts](#-scripts)
|
||||||
- [🎈 Add a new project](#-add-a-new-project)
|
- [🎈 Add a new project](#-add-a-new-project)
|
||||||
- [🌄 Favicon generation](#-favicon-generation)
|
- [🌄 Favicon generation](#-favicon-generation)
|
||||||
@ -38,7 +36,7 @@
|
|||||||
|
|
||||||
## 🎉 Features
|
## 🎉 Features
|
||||||
|
|
||||||
The whole [portfolio](https://matthiaskretschmann.com) is a React-based single page app built with [Next.js](https://nextjs.org) in Typescript, using only statically generated pages.
|
The whole [portfolio](https://matthiaskretschmann.com) is a React-based app built with [Next.js](https://nextjs.org) in Typescript, using statically generated pages with a pinch of server-side rendering and server actions.
|
||||||
|
|
||||||
If you are looking for the former Gatsby-based app, it is archived in the [`gatsby-deprecated`](https://github.com/kremalicious/portfolio/tree/gatsby-deprecated) branch.
|
If you are looking for the former Gatsby-based app, it is archived in the [`gatsby-deprecated`](https://github.com/kremalicious/portfolio/tree/gatsby-deprecated) branch.
|
||||||
|
|
||||||
@ -46,10 +44,10 @@ If you are looking for the former Gatsby-based app, it is archived in the [`gats
|
|||||||
|
|
||||||
All displayed project content is powered by one YAML file where all the portfolio's projects are defined. The project description itself is transformed from Markdown written inside the YAML file into HTML on build time.
|
All displayed project content is powered by one YAML file where all the portfolio's projects are defined. The project description itself is transformed from Markdown written inside the YAML file into HTML on build time.
|
||||||
|
|
||||||
Next.js automatically creates pages from each item in that file utilizing the [`[slug].tsx`](src/pages/[slug].tsx) template.
|
Next.js automatically creates pages from each item in that file utilizing the [`[slug]/page.tsx`](src/app/[slug]/page.tsx) template.
|
||||||
|
|
||||||
- [`_content/projects.yml`](_content/projects.yml)
|
- [`_content/projects.yml`](_content/projects.yml)
|
||||||
- [`src/pages/[slug].tsx`](src/pages/[slug].tsx)
|
- [`src/app/[slug]/page.tsx`](src/app/[slug]/page.tsx)
|
||||||
|
|
||||||
### 🖼 Project images
|
### 🖼 Project images
|
||||||
|
|
||||||
@ -64,7 +62,7 @@ Next.js with `next/image` generates all required image sizes for delivering resp
|
|||||||
|
|
||||||
The open source section at the bottom of the front page shows selected GitHub repositories, sourced from GitHub.
|
The open source section at the bottom of the front page shows selected GitHub repositories, sourced from GitHub.
|
||||||
|
|
||||||
On build time, all my public repositories are fetched from GitHub, then filtered against the ones defined in `content/repos.yml`, sorted by the last push date, and provided via the `pageContext` of the front page.
|
On build time, all my public repositories are fetched from GitHub, then filtered against the ones defined in `_content/repos.json`, sorted by the last push date, and provided via the `pageContext` of the front page.
|
||||||
|
|
||||||
If you want to know how, have a look at the respective components:
|
If you want to know how, have a look at the respective components:
|
||||||
|
|
||||||
@ -76,33 +74,25 @@ If you want to know how, have a look at the respective components:
|
|||||||
|
|
||||||
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.
|
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 an external 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.
|
Fetching is split up into an external serverless function, a server action, and display component. Fetching is done with a serverless function as to not expose the whole profile response into the browser.
|
||||||
|
|
||||||
If you want to know how, have a look at the respective components:
|
If you want to know how, have a look at the respective components:
|
||||||
|
|
||||||
- [`src/hooks/useLocation.ts`](src/hooks/useLocation.ts)
|
- [`src/app/actions.ts`](src/app/actions.ts)
|
||||||
- [`src/components/Location/index.tsx`](src/components/Location/index.tsx)
|
- [`src/components/Location/index.tsx`](src/components/Location/index.tsx)
|
||||||
- [kremalicious/location](https://github.com/kremalicious/location)
|
- [kremalicious/location](https://github.com/kremalicious/location)
|
||||||
|
|
||||||
### 💅 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. Uses [next-themes](https://github.com/pacocoursey/next-themes) under the hood.
|
Includes a theme switcher which allows user to toggle between a light and a dark theme, where by default the user's system theme is used automatically. Uses [next-themes](https://github.com/pacocoursey/next-themes) under the hood.
|
||||||
|
|
||||||
If you want to know how, have a look at the respective component:
|
If you want to know how, have a look at the respective component:
|
||||||
|
|
||||||
- [`src/components/ThemeSwitch/index.tsx`](src/components/ThemeSwitch/index.tsx)
|
- [`src/components/ThemeSwitch/index.tsx`](src/components/ThemeSwitch/index.tsx)
|
||||||
|
|
||||||
### 🏆 SEO component
|
|
||||||
|
|
||||||
Includes a SEO component which automatically switches all required `meta` tags for search engines, Twitter Cards, and Facebook OpenGraph tags based on the browsed route/page.
|
|
||||||
|
|
||||||
If you want to know how, have a look at the respective component:
|
|
||||||
|
|
||||||
- [`src/components/Meta/index.tsx`](src/components/Meta/index.tsx)
|
|
||||||
|
|
||||||
### 📇 Client-side vCard creation
|
### 📇 Client-side vCard creation
|
||||||
|
|
||||||
The _Add to addressbook_ link in the footer automatically creates a downloadable vCard file on the client-side, based on data defined in `content/meta.yml`.
|
The _Add to addressbook_ link in the footer automatically creates a downloadable vCard file on the client-side, based on data defined in `_content/meta.json`.
|
||||||
|
|
||||||
If you want to know how, have a look at the respective component:
|
If you want to know how, have a look at the respective component:
|
||||||
|
|
||||||
@ -113,18 +103,10 @@ If you want to know how, have a look at the respective component:
|
|||||||
All SVG assets will be converted to React components with the help of [@svgr/webpack](https://react-svgr.com). Makes use of [SVGR](https://github.com/smooth-code/svgr) so SVG assets can be imported like so:
|
All SVG assets will be converted to React components with the help of [@svgr/webpack](https://react-svgr.com). Makes use of [SVGR](https://github.com/smooth-code/svgr) so SVG assets can be imported like so:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import Logo from './components/svg/Logo'
|
import Logo from './images/logo.svg'
|
||||||
return <Logo />
|
return <Logo />
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🍬 Typekit component
|
|
||||||
|
|
||||||
Includes a component for adding the Typekit snippet.
|
|
||||||
|
|
||||||
If you want to know how, have a look at the respective component:
|
|
||||||
|
|
||||||
- [`src/components/Typekit/index.tsx`](src/components/Typekit/index.tsx)
|
|
||||||
|
|
||||||
## 🤓 Scripts
|
## 🤓 Scripts
|
||||||
|
|
||||||
### 🎈 Add a new project
|
### 🎈 Add a new project
|
||||||
@ -204,21 +186,9 @@ Most test files live beside the respective component. Testing setup, fixtures, a
|
|||||||
|
|
||||||
Every branch or Pull Request is automatically deployed by [Vercel](https://vercel.com) with their GitHub integration, where the `main` branch is automatically aliased to `matthiaskretschmann.com`. A link to a preview deployment will appear under each Pull Request.
|
Every branch or Pull Request is automatically deployed by [Vercel](https://vercel.com) with their GitHub integration, where the `main` branch is automatically aliased to `matthiaskretschmann.com`. A link to a preview deployment will appear under each Pull Request.
|
||||||
|
|
||||||
A backup deployment is also happening to a S3 bucket, triggered by pushes to `main` and executed via GitHub Actions. The deploy command simply calls the [`scripts/deploy-s3.sh`](scripts/deploy-s3.sh) script, syncing the contents of the `public/` folder to S3:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run deploy:s3
|
|
||||||
```
|
|
||||||
|
|
||||||
Upon live deployment, deploy script also pings search engines. GitHub requires the following environment variables to be setup for successful deployments in the repository secrets:
|
|
||||||
|
|
||||||
- `AWS_ACCESS_KEY_ID`
|
|
||||||
- `AWS_SECRET_ACCESS_KEY`
|
|
||||||
- `AWS_DEFAULT_REGION`
|
|
||||||
|
|
||||||
## 🏛 Licenses
|
## 🏛 Licenses
|
||||||
|
|
||||||
**© Copyright 2023 Matthias Kretschmann**
|
**© Copyright 2024 Matthias Kretschmann**
|
||||||
|
|
||||||
All images and projects are plain ol' copyright, most displayed projects are subject to the copyright of their respective owners.
|
All images and projects are plain ol' copyright, most displayed projects are subject to the copyright of their respective owners.
|
||||||
|
|
||||||
|
@ -2,11 +2,33 @@
|
|||||||
"description": "Portfolio of web & ui designer/developer Matthias Kretschmann.",
|
"description": "Portfolio of web & ui designer/developer Matthias Kretschmann.",
|
||||||
"img": "twitter-card.png",
|
"img": "twitter-card.png",
|
||||||
"url": "https://matthiaskretschmann.com",
|
"url": "https://matthiaskretschmann.com",
|
||||||
|
"author": {
|
||||||
|
"name": "Matthias Kretschmann",
|
||||||
|
"label": "Designer & Developer",
|
||||||
|
"email": "m@kretschmann.io",
|
||||||
|
"picture": "../src/images/avatar.jpg"
|
||||||
|
},
|
||||||
"availability": {
|
"availability": {
|
||||||
"status": false,
|
"status": false,
|
||||||
"available": "👔 Available for new projects. <a href=\"mailto:m@kretschmann.io\">Let’s talk</a>!",
|
"available": "👔 Available for new projects. <a href=\"mailto:m@kretschmann.io\">Let’s talk</a>!",
|
||||||
"unavailable": "Not available for new projects."
|
"unavailable": "Not available for new projects."
|
||||||
},
|
},
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"network": "Blog",
|
||||||
|
"url": "https://kremalicious.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"network": "Mastodon",
|
||||||
|
"username": "@krema@mas.to",
|
||||||
|
"url": "https://mas.to/@krema"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"network": "GitHub",
|
||||||
|
"username": "kremalicious",
|
||||||
|
"url": "https://github.com/kremalicious"
|
||||||
|
}
|
||||||
|
],
|
||||||
"gpg": "/gpg.txt",
|
"gpg": "/gpg.txt",
|
||||||
"addressbook": "/matthias-kretschmann.vcf",
|
"addressbook": "/matthias-kretschmann.vcf",
|
||||||
"bugs": "https://github.com/kremalicious/portfolio/issues/new",
|
"bugs": "https://github.com/kremalicious/portfolio/issues/new",
|
||||||
|
@ -1,226 +0,0 @@
|
|||||||
{
|
|
||||||
"basics": {
|
|
||||||
"name": "Matthias Kretschmann",
|
|
||||||
"label": "Designer & Developer",
|
|
||||||
"picture": "../src/images/avatar.jpg",
|
|
||||||
"email": "m@kretschmann.io",
|
|
||||||
"website": "https://matthiaskretschmann.com",
|
|
||||||
"summary": "",
|
|
||||||
"profiles": [
|
|
||||||
{
|
|
||||||
"network": "Blog",
|
|
||||||
"url": "https://kremalicious.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"network": "Mastodon",
|
|
||||||
"username": "@krema@mas.to",
|
|
||||||
"url": "https://mas.to/@krema"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"network": "GitHub",
|
|
||||||
"username": "kremalicious",
|
|
||||||
"url": "https://github.com/kremalicious"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"location": {
|
|
||||||
"city": "Lisboa",
|
|
||||||
"countryCode": "PT"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"work": [
|
|
||||||
{
|
|
||||||
"company": "Ocean Protocol Foundation",
|
|
||||||
"position": "Lead UI Designer & Developer",
|
|
||||||
"website": "https://oceanprotocol.com",
|
|
||||||
"startDate": "2017-01-01",
|
|
||||||
"summary": "Co-Founded the Ocean Protocol project and as a core developer leading the execution of [multiple user interfaces](/oceanprotocol) and core components.\n\nIn general, leading the UI design & development of Ocean Protocol's user interfaces, iterating on a components-based UI design system spanning all of Ocean Protocol's web properties. This also includes the conceptualization, execution and iteration of the creative and visual direction of the Ocean Protocol visual brand."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "BigchainDB GmbH",
|
|
||||||
"position": "Lead UI Designer & Developer",
|
|
||||||
"website": "https://bigchaindb.com",
|
|
||||||
"startDate": "2016-12-01",
|
|
||||||
"endDate": "2018-12-31",
|
|
||||||
"summary": "Leading the UI design & development of all BigchainDB web properties. I created the initial BigchainDB brand and further conceptualized, executed and iterated on the creative and visual direction of BigchainDB. This included creating and iterating on a components-based UI design system for all of [BigchainDB's user interfaces](/bigchaindb)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "ascribe GmbH",
|
|
||||||
"position": "UI Designer & Developer",
|
|
||||||
"website": "https://ascribe.io",
|
|
||||||
"startDate": "2016-01-01",
|
|
||||||
"endDate": "2017-12-31",
|
|
||||||
"summary": "Leading the technical architecture of ascribe's web presence, and maintaining the front-end of the product."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "ChartMogul Ltd.",
|
|
||||||
"position": "Lead UI Engineer",
|
|
||||||
"website": "https://chartmogul.com",
|
|
||||||
"startDate": "2015-07-15",
|
|
||||||
"endDate": "2017-02-01",
|
|
||||||
"summary": "Co-designing and leading the UI design & development of various [ChartMogul web properties](/chartmogul), helping the company to position itself as a market leader. This included the creation of a components-based UI design system and implementing it across all web touch points.\n\nBesides designing and implementing new features, I maintained the front-end of the ChartMogul application and implemented the UI design system by refactoring most of its front-end codebase."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "UN World Food Programme/ShareTheMeal",
|
|
||||||
"position": "UI Engineer",
|
|
||||||
"website": "https://sharethemeal.org",
|
|
||||||
"startDate": "2014-10-01",
|
|
||||||
"endDate": "2015-06-01",
|
|
||||||
"summary": "Leading the creation of the [website for ShareTheMeal](/sharethemeal) and assisting in building and consulting for the iOS and Android app."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "ezeep GmbH",
|
|
||||||
"position": "Lead Designer & Front End Developer",
|
|
||||||
"website": "https://ezeep.com",
|
|
||||||
"startDate": "2012-01-01",
|
|
||||||
"endDate": "2014-09-01",
|
|
||||||
"summary": "Creating an unprecedented, market-leading & award-winning user experience around printing based on the principles of emotional design way ahead of all competitors.\n\nThis included defining the product based on user & market research in an iterative process and designing & building [ezeep’s numerous touch points](/ezeep), like the web app, web site, desktop apps for Windows & Mac OS X and apps for iOS & Android.\n\nOn top of that I created the corporate identity and a consistent visual branding, including the logo."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "Martin Luther University Halle-Wittenberg",
|
|
||||||
"position": "UI/UX Designer & Front End Developer",
|
|
||||||
"startDate": "2009-02-01",
|
|
||||||
"endDate": "2012-01-01",
|
|
||||||
"summary": "Conceptualizing & implementing [numerous in-house and public facing interfaces](/unihalle) for thousands of students and staff. Additionally, conceptualizing, creating and maintaining the blog network & community for all students & staff."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "Harz University of Applied Sciences",
|
|
||||||
"position": "Consultant & Teacher",
|
|
||||||
"startDate": "2011-02-01",
|
|
||||||
"endDate": "2011-05-01",
|
|
||||||
"summary": "Conceptualizing a web design & development university seminar and building a [responsive & fluid grid framework](https://github.com/kremalicious/hsresponsive) with a basic HTML/CSS template for students of Media Informatics at the Harz University of Applied Sciences to learn and use."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "Martin Luther University Halle-Wittenberg",
|
|
||||||
"position": "Consultant & Teacher",
|
|
||||||
"startDate": "2011-02-01",
|
|
||||||
"endDate": "2011-05-01",
|
|
||||||
"summary": "Conceptualizing a WordPress-based web design university seminar and building a minimal starting theme for students of media & communication science at the MLU Halle-Wittenberg to learn and use."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "Shortmoves",
|
|
||||||
"position": "Web Designer & Developer",
|
|
||||||
"startDate": "2009-01-01",
|
|
||||||
"endDate": "2010-01-01",
|
|
||||||
"summary": "Creating & managing the web presence and marketing material of the International Shortfilm Festival Shortmoves in Halle (Saale), Germany."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "Agentur Ahron",
|
|
||||||
"position": "Co-Founder & Photojournalist & Photographer",
|
|
||||||
"startDate": "2005-01-01",
|
|
||||||
"endDate": "2008-12-31",
|
|
||||||
"summary": "Co-founded and built up a photo agency from the ground up and worked as a photographer ranging from journalistic works for news agencies & newspapers to photographic work for private clients."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"company": "Freelance",
|
|
||||||
"position": "Designer & Developer",
|
|
||||||
"startDate": "2004-01-01",
|
|
||||||
"summary": "Numerous projects and clients as a UI/UX Designer, Front End Developer, Icon Designer & Photographer."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"education": [
|
|
||||||
{
|
|
||||||
"institution": "Self-taught",
|
|
||||||
"area": "UI Design & Web Development",
|
|
||||||
"studyType": "Autodidactic",
|
|
||||||
"startDate": "1999-01-01",
|
|
||||||
"endDate": "2004-01-01"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"institution": "Martin Luther University Halle-Wittenberg",
|
|
||||||
"area": "Media/Communication Science & Art History",
|
|
||||||
"studyType": "Bachelor of Arts",
|
|
||||||
"startDate": "2008-01-01",
|
|
||||||
"endDate": "2012-01-01"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"institution": "Martin Luther University Halle-Wittenberg",
|
|
||||||
"area": "Political Science & Sociology",
|
|
||||||
"studyType": "Magister Artium",
|
|
||||||
"startDate": "2006-01-01",
|
|
||||||
"endDate": "2008-01-01"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"awards": [
|
|
||||||
{
|
|
||||||
"title": "German Design Award",
|
|
||||||
"date": "2015-11-01",
|
|
||||||
"awarder": "ezeep GmbH",
|
|
||||||
"summary": "Nominated in the category _Interactive User Experience (Excellent Communications Design)_"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "CeBIT Preview Award",
|
|
||||||
"date": "2013-11-01",
|
|
||||||
"awarder": "ezeep GmbH"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"skills": [
|
|
||||||
{
|
|
||||||
"name": "Design",
|
|
||||||
"level": "Master",
|
|
||||||
"keywords": [
|
|
||||||
"Product Design",
|
|
||||||
"Service Design",
|
|
||||||
"Interface Design",
|
|
||||||
"User Experience Design",
|
|
||||||
"Communication Design",
|
|
||||||
"Interaction Design",
|
|
||||||
"Information Architecture",
|
|
||||||
"Icon Design",
|
|
||||||
"Web Design",
|
|
||||||
"Typography",
|
|
||||||
"Design management"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Web Development",
|
|
||||||
"level": "Master",
|
|
||||||
"keywords": [
|
|
||||||
"HTML",
|
|
||||||
"CSS",
|
|
||||||
"Javascript",
|
|
||||||
"Node.js",
|
|
||||||
"npm ecosystem",
|
|
||||||
"SASS/SCSS",
|
|
||||||
"Less",
|
|
||||||
"Stylus",
|
|
||||||
"Gulp",
|
|
||||||
"Gatsby",
|
|
||||||
"React",
|
|
||||||
"Styled Components",
|
|
||||||
"JAMstack"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "General Software Development",
|
|
||||||
"level": "Master",
|
|
||||||
"keywords": [
|
|
||||||
"Git",
|
|
||||||
"GitHub",
|
|
||||||
"Bash",
|
|
||||||
"UNIX",
|
|
||||||
"Agile: Kanban & Scrum",
|
|
||||||
"Prototyping",
|
|
||||||
"Incremental"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DevOps",
|
|
||||||
"level": "Intermediate",
|
|
||||||
"keywords": ["AWS", "Now", "Serverless", "Cloudflare", "NGINX", "Apache"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"languages": [
|
|
||||||
{
|
|
||||||
"language": "English",
|
|
||||||
"fluency": "Advanced speaker"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "German",
|
|
||||||
"fluency": "Native speaker"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"meta": {
|
|
||||||
"canonical": "https://matthiaskretschmann.com/resume",
|
|
||||||
"lastModified": ""
|
|
||||||
}
|
|
||||||
}
|
|
@ -21,8 +21,8 @@ const next = (phase, { defaultConfig }) => {
|
|||||||
// Convert all other *.svg imports to React components
|
// Convert all other *.svg imports to React components
|
||||||
{
|
{
|
||||||
test: /\.svg$/i,
|
test: /\.svg$/i,
|
||||||
issuer: /\.[jt]sx?$/,
|
issuer: fileLoaderRule.issuer,
|
||||||
resourceQuery: { not: /url/ }, // exclude if *.svg?url
|
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
|
||||||
use: [{ loader: '@svgr/webpack', options: { icon: true } }]
|
use: [{ loader: '@svgr/webpack', options: { icon: true } }]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -40,9 +40,7 @@ const next = (phase, { defaultConfig }) => {
|
|||||||
return typeof defaultConfig.webpack === 'function'
|
return typeof defaultConfig.webpack === 'function'
|
||||||
? defaultConfig.webpack(config, options)
|
? defaultConfig.webpack(config, options)
|
||||||
: config
|
: config
|
||||||
},
|
}
|
||||||
// https://nextjs.org/docs/api-reference/next.config.js/react-strict-mode
|
|
||||||
reactStrictMode: true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextConfig
|
return nextConfig
|
||||||
|
2414
package-lock.json
generated
2414
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@ -19,7 +19,6 @@
|
|||||||
"format": "prettier --write 'src/**/*.{ts,tsx,css}'",
|
"format": "prettier --write 'src/**/*.{ts,tsx,css}'",
|
||||||
"jest": "jest --coverage -c tests/jest.config.ts",
|
"jest": "jest --coverage -c tests/jest.config.ts",
|
||||||
"test": "NODE_ENV=test npm run lint && npm run typecheck && npm run jest",
|
"test": "NODE_ENV=test npm run lint && npm run typecheck && npm run jest",
|
||||||
"deploy:s3": "./scripts/deploy-s3.sh",
|
|
||||||
"new": "ts-node-esm ./scripts/new.ts",
|
"new": "ts-node-esm ./scripts/new.ts",
|
||||||
"favicon": "ts-node-esm ./scripts/favicon.ts"
|
"favicon": "ts-node-esm ./scripts/favicon.ts"
|
||||||
},
|
},
|
||||||
@ -29,25 +28,25 @@
|
|||||||
"@yaireo/relative-time": "^1.0.4",
|
"@yaireo/relative-time": "^1.0.4",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"framer-motion": "^11.0.3",
|
"framer-motion": "^11.0.3",
|
||||||
"lucide-react": "^0.314.0",
|
"lucide-react": "^0.321.0",
|
||||||
"next": "14.1.0",
|
"next": "14.1.0",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"remark": "^14.0.3",
|
"remark": "^15.0.1",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"remark-html": "^15.0.2",
|
"remark-html": "^16.0.1",
|
||||||
"vcf": "github:jhermsmeier/node-vcf"
|
"vcf": "github:jhermsmeier/node-vcf"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@testing-library/jest-dom": "^6.2.0",
|
"@testing-library/jest-dom": "^6.4.1",
|
||||||
"@testing-library/react": "^14.1.2",
|
"@testing-library/react": "^14.2.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"eslint": "^8.54.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-next": "^14.1.0",
|
"eslint-config-next": "^14.1.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-canvas-mock": "^2.5.2",
|
"jest-canvas-mock": "^2.5.2",
|
||||||
@ -55,13 +54,13 @@
|
|||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"ora": "^8.0.1",
|
"ora": "^8.0.1",
|
||||||
"prepend": "^1.0.2",
|
"prepend": "^1.0.2",
|
||||||
"prettier": "^3.2.2",
|
"prettier": "^3.2.4",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"sharp-ico": "^0.1.5",
|
"sharp-ico": "^0.1.5",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"stylelint": "^16.2.0",
|
"stylelint": "^16.2.1",
|
||||||
"stylelint-prettier": "^4.1.0",
|
"stylelint-prettier": "^5.0.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
@ -1,6 +1,3 @@
|
|||||||
# https://platform.openai.com/docs/gptbot
|
# https://platform.openai.com/docs/gptbot
|
||||||
User-agent: GPTBot
|
User-agent: GPTBot
|
||||||
Disallow: /
|
Disallow: /
|
||||||
|
|
||||||
User-agent: *
|
|
||||||
Disallow: /resume
|
|
||||||
|
@ -1,55 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# required environment variables:
|
|
||||||
# AWS_ACCESS_KEY_ID
|
|
||||||
# AWS_SECRET_ACCESS_KEY
|
|
||||||
# AWS_DEFAULT_REGION
|
|
||||||
AWS_S3_BUCKET="matthiaskretschmann.com"
|
|
||||||
SITEMAP_URL="https%3A%2F%2Fmatthiaskretschmann.com%2Fsitemap.xml"
|
|
||||||
|
|
||||||
set -e;
|
|
||||||
|
|
||||||
function s3sync {
|
|
||||||
aws s3 sync ./public s3://"$1" \
|
|
||||||
--include "*" \
|
|
||||||
--exclude "*.html" \
|
|
||||||
--exclude "sw.js" \
|
|
||||||
--exclude "*page-data.json" \
|
|
||||||
--exclude "*app-data.json" \
|
|
||||||
--exclude "chunk-map.json" \
|
|
||||||
--exclude "sitemap.xml" \
|
|
||||||
--exclude ".iconstats.json" \
|
|
||||||
--exclude "humans.txt" \
|
|
||||||
--exclude "robots.txt" \
|
|
||||||
--cache-control public,max-age=31536000,immutable \
|
|
||||||
--delete \
|
|
||||||
--acl public-read
|
|
||||||
|
|
||||||
aws s3 sync ./public s3://"$1" \
|
|
||||||
--exclude "*" \
|
|
||||||
--include "*.html" \
|
|
||||||
--include "sw.js" \
|
|
||||||
--include "*page-data.json" \
|
|
||||||
--include "*app-data.json" \
|
|
||||||
--include "chunk-map.json" \
|
|
||||||
--include "sitemap.xml" \
|
|
||||||
--include ".iconstats.json" \
|
|
||||||
--include "humans.txt" \
|
|
||||||
--include "robots.txt" \
|
|
||||||
--cache-control public,max-age=0,must-revalidate \
|
|
||||||
--delete \
|
|
||||||
--acl public-read
|
|
||||||
}
|
|
||||||
|
|
||||||
# ping search engines
|
|
||||||
# returns: HTTP_STATUSCODE URL
|
|
||||||
function ping {
|
|
||||||
curl -sL -w "%{http_code} %{url_effective}\\n" \
|
|
||||||
"http://www.google.com/webmasters/tools/ping?sitemap=$SITEMAP_URL" -o /dev/null \
|
|
||||||
"http://www.bing.com/webmaster/ping.aspx?siteMap=$SITEMAP_URL" -o /dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# start deployment
|
|
||||||
s3sync $AWS_S3_BUCKET
|
|
||||||
|
|
||||||
ping
|
|
57
src/app/[slug]/page.tsx
Normal file
57
src/app/[slug]/page.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Metadata, ResolvingMetadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import meta from '../../../_content/meta.json'
|
||||||
|
import Project from '../../components/Project'
|
||||||
|
import ProjectNav from '../../components/ProjectNav'
|
||||||
|
import {
|
||||||
|
getAllProjects,
|
||||||
|
getProjectBySlug,
|
||||||
|
getProjectSlugs
|
||||||
|
} from '../../lib/content'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: { slug: string }
|
||||||
|
// searchParams: { [key: string]: string | string[] | undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(
|
||||||
|
{ params }: Props
|
||||||
|
// parent: ResolvingMetadata
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const project = await getProjectBySlug(params.slug)
|
||||||
|
if (!project) return
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: project.title,
|
||||||
|
description: `${project.description.slice(0, 157)}...`,
|
||||||
|
metadataBase: new URL(meta.url),
|
||||||
|
alternates: {
|
||||||
|
canonical: '/' + project.slug
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
url: '/' + project.slug,
|
||||||
|
images: [{ url: project.images[0].src }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProjectPage({ params }: Props) {
|
||||||
|
const project = await getProjectBySlug(params.slug)
|
||||||
|
|
||||||
|
if (!project) notFound()
|
||||||
|
|
||||||
|
const projects = await getAllProjects(['slug', 'title', 'images'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Project project={project} />
|
||||||
|
<ProjectNav projects={projects} currentSlug={params.slug} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const slugs = getProjectSlugs()
|
||||||
|
|
||||||
|
return slugs.map((slug) => ({ slug }))
|
||||||
|
}
|
41
src/app/__tests__/[slug].test.tsx
Normal file
41
src/app/__tests__/[slug].test.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import meta from '../../../_content/meta.json'
|
||||||
|
import projectMock from '../../../tests/__fixtures__/project.json'
|
||||||
|
import projectsMock from '../../../tests/__fixtures__/projects.json'
|
||||||
|
import Page, { generateMetadata, generateStaticParams } from '../[slug]/page'
|
||||||
|
|
||||||
|
jest.mock('../../lib/content', () => ({
|
||||||
|
getAllProjects: jest.fn().mockImplementation(() => projectsMock),
|
||||||
|
getProjectBySlug: jest.fn().mockImplementation(() => projectMock),
|
||||||
|
getProjectSlugs: jest.fn().mockImplementation(() => ['slug1', 'slug2'])
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('app: [slug]/page', () => {
|
||||||
|
it('renders correctly', async () => {
|
||||||
|
render(await Page({ params: { slug: 'slug' } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generateStaticParams()', async () => {
|
||||||
|
const slugs = await generateStaticParams()
|
||||||
|
expect(slugs).toEqual([{ slug: 'slug1' }, { slug: 'slug2' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generateMetadata()', async () => {
|
||||||
|
const metadata = await generateMetadata({
|
||||||
|
params: { slug: projectMock.slug }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(metadata).toEqual({
|
||||||
|
title: projectMock.title,
|
||||||
|
description: `${projectMock.description.slice(0, 157)}...`,
|
||||||
|
metadataBase: new URL(meta.url),
|
||||||
|
alternates: {
|
||||||
|
canonical: '/' + projectMock.slug
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
url: '/' + projectMock.slug,
|
||||||
|
images: [{ url: projectMock.images[0].src }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
24
src/app/__tests__/layout.test.tsx
Normal file
24
src/app/__tests__/layout.test.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { dataLocation } from '../../../tests/__fixtures__/location'
|
||||||
|
import Layout from '../layout'
|
||||||
|
|
||||||
|
describe('app: /layout', () => {
|
||||||
|
// suppress error "Warning: validateDOMNesting(...): <html> cannot appear as a child of <div>"
|
||||||
|
// https://github.com/testing-library/react-testing-library/issues/1250
|
||||||
|
let originalError
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
originalError = console.error
|
||||||
|
console.error = jest.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
console.error = originalError
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders correctly', async () => {
|
||||||
|
render(<Layout>Hello</Layout>)
|
||||||
|
|
||||||
|
await screen.findByText(dataLocation.now.city)
|
||||||
|
})
|
||||||
|
})
|
11
src/app/__tests__/not-found.tsx
Normal file
11
src/app/__tests__/not-found.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import mockData from '../../../tests/__fixtures__/giphy.json'
|
||||||
|
import Page from '../not-found'
|
||||||
|
|
||||||
|
describe('app: /not-found', () => {
|
||||||
|
it('renders correctly', async () => {
|
||||||
|
render(<Page />)
|
||||||
|
|
||||||
|
await screen.findByTestId(mockData.data.images.original.mp4)
|
||||||
|
})
|
||||||
|
})
|
18
src/app/__tests__/page.test.tsx
Normal file
18
src/app/__tests__/page.test.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import projectsMock from '../../../tests/__fixtures__/projects.json'
|
||||||
|
import reposMock from '../../../tests/__fixtures__/repos.json'
|
||||||
|
import Page from '../page'
|
||||||
|
|
||||||
|
jest.mock('../../lib/content', () => ({
|
||||||
|
getAllProjects: jest.fn().mockImplementationOnce(() => projectsMock)
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('../../lib/github', () => ({
|
||||||
|
getGithubRepos: jest.fn().mockImplementationOnce(() => reposMock)
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('app: /page', () => {
|
||||||
|
it('renders correctly', async () => {
|
||||||
|
render(await Page())
|
||||||
|
})
|
||||||
|
})
|
32
src/app/actions.ts
Normal file
32
src/app/actions.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { GiphyFetch } from '@giphy/js-fetch-api'
|
||||||
|
|
||||||
|
export async function getLocation() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://location.kretschmann.io')
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error('Network response for location was not ok.')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRandomGif(tag: string, pathname?: string) {
|
||||||
|
try {
|
||||||
|
// Famous last words:
|
||||||
|
// "It's just the 404 page so why not expose the dev API key"
|
||||||
|
const giphyClient = new GiphyFetch('LfXRwufRyt6PK414G2kKJBv3L8NdnxyR')
|
||||||
|
const { data } = await giphyClient.random({ tag })
|
||||||
|
const gif = data.images.original.mp4
|
||||||
|
return gif
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname) revalidatePath(pathname)
|
||||||
|
}
|
76
src/app/layout.tsx
Normal file
76
src/app/layout.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Metadata, Viewport } from 'next'
|
||||||
|
import Script from 'next/script'
|
||||||
|
import meta from '../../_content/meta.json'
|
||||||
|
import Footer from '../components/Footer'
|
||||||
|
import Header from '../components/Header'
|
||||||
|
import HostnameCheck from '../components/HostnameCheck'
|
||||||
|
import ThemeSwitch from '../components/ThemeSwitch'
|
||||||
|
import { UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID } from '../lib/umami'
|
||||||
|
import '../styles/global.css'
|
||||||
|
import styles from '../styles/layout.module.css'
|
||||||
|
import { Providers } from './providers'
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
const { name, label } = meta.author
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
template: `%s // ${name.toLowerCase()} { ${label.toLowerCase()} }`,
|
||||||
|
default: `${name.toLowerCase()} { ${label.toLowerCase()} }`
|
||||||
|
},
|
||||||
|
description: meta.description,
|
||||||
|
metadataBase: new URL(meta.url),
|
||||||
|
alternates: { canonical: '/' },
|
||||||
|
openGraph: {
|
||||||
|
images: [{ url: meta.img }],
|
||||||
|
url: meta.url,
|
||||||
|
locale: 'en_US',
|
||||||
|
type: 'website'
|
||||||
|
},
|
||||||
|
twitter: { card: 'summary_large_image' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: 'var(--theme-color)'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href={`https://use.typekit.net/${process.env.NEXT_PUBLIC_TYPEKIT_ID}.css`}
|
||||||
|
/>
|
||||||
|
{/*
|
||||||
|
Stop the favicon madness
|
||||||
|
https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs
|
||||||
|
*/}
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest/manifest.webmanifest"></link>
|
||||||
|
|
||||||
|
{isProduction && (
|
||||||
|
<Script
|
||||||
|
src={UMAMI_SCRIPT_URL}
|
||||||
|
data-website-id={UMAMI_WEBSITE_ID}
|
||||||
|
async
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Providers>
|
||||||
|
<HostnameCheck allowedHosts={meta.allowedHosts} />
|
||||||
|
<ThemeSwitch />
|
||||||
|
|
||||||
|
<Header />
|
||||||
|
<main className={styles.screen}>{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
@ -1,15 +1,11 @@
|
|||||||
|
import { Metadata } from 'next'
|
||||||
import NotFound from '../components/404'
|
import NotFound from '../components/404'
|
||||||
import Page from '../layouts/Page'
|
|
||||||
|
|
||||||
const pageMeta = {
|
export const metadata: Metadata = {
|
||||||
title: `Shenanigans`,
|
title: `Shenanigans`,
|
||||||
description: 'Page not found.'
|
description: 'Page not found.'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
return (
|
return <NotFound />
|
||||||
<Page {...pageMeta}>
|
|
||||||
<NotFound />
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
}
|
16
src/app/page.tsx
Normal file
16
src/app/page.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import Projects from '../components/Projects'
|
||||||
|
import Repositories from '../components/Repositories'
|
||||||
|
import { getAllProjects } from '../lib/content'
|
||||||
|
import { getGithubRepos } from '../lib/github'
|
||||||
|
|
||||||
|
export default async function IndexPage() {
|
||||||
|
const projects = await getAllProjects(['title', 'images', 'slug'])
|
||||||
|
const repos = await getGithubRepos()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Projects projects={projects} />
|
||||||
|
<Repositories repos={repos} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
7
src/app/providers.tsx
Normal file
7
src/app/providers.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ThemeProvider } from 'next-themes'
|
||||||
|
|
||||||
|
export function Providers({ children }) {
|
||||||
|
return <ThemeProvider attribute="class">{children}</ThemeProvider>
|
||||||
|
}
|
@ -10,7 +10,8 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
box-shadow: 0 3px 5px rgba(var(--brand-main), 0.15),
|
box-shadow:
|
||||||
|
0 3px 5px rgba(var(--brand-main), 0.15),
|
||||||
0 5px 16px rgba(var(--brand-main), 0.15);
|
0 5px 16px rgba(var(--brand-main), 0.15);
|
||||||
margin: calc(var(--spacer) / 4) auto calc(var(--spacer) / 2) auto;
|
margin: calc(var(--spacer) / 4) auto calc(var(--spacer) / 2) auto;
|
||||||
}
|
}
|
||||||
|
17
src/components/404/index.test.tsx
Normal file
17
src/components/404/index.test.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import NotFoundPage from '../../../src/components/404'
|
||||||
|
import mockData from '../../../tests/__fixtures__/giphy.json'
|
||||||
|
|
||||||
|
jest.setTimeout(30000)
|
||||||
|
|
||||||
|
describe('NotFoundPage', () => {
|
||||||
|
it('renders correctly', async () => {
|
||||||
|
render(<NotFoundPage />)
|
||||||
|
await screen.findByText(/Shenanigans, page not found./)
|
||||||
|
await screen.findByTestId(mockData.data.images.original.mp4)
|
||||||
|
|
||||||
|
const button = await screen.findByText(`Get another 'cat' gif`)
|
||||||
|
fireEvent.click(button)
|
||||||
|
await screen.findByTestId(mockData.data.images.original.mp4)
|
||||||
|
})
|
||||||
|
})
|
@ -1,37 +1,27 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { MouseEvent, useEffect, useState } from 'react'
|
import { MouseEvent, useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { getRandomGif } from '../../app/actions'
|
||||||
import Button from '../Button'
|
import Button from '../Button'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
const tag = 'cat'
|
const tag = 'cat'
|
||||||
|
|
||||||
async function getRandomGif() {
|
|
||||||
const Giphy = await import('@giphy/js-fetch-api')
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Famous last words:
|
|
||||||
// "It's just the 404 page so why not expose the dev API key"
|
|
||||||
const giphyClient = new Giphy.GiphyFetch('LfXRwufRyt6PK414G2kKJBv3L8NdnxyR')
|
|
||||||
let response = await giphyClient.random({ tag })
|
|
||||||
const gif = response.data.images.original.mp4
|
|
||||||
return gif
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
|
const pathname = usePathname()
|
||||||
const [gif, setGif] = useState<string>()
|
const [gif, setGif] = useState<string>()
|
||||||
|
|
||||||
async function handleClick(e: MouseEvent) {
|
async function handleClick(e: MouseEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const gif = await getRandomGif()
|
const gif = await getRandomGif(tag, pathname)
|
||||||
setGif(gif)
|
setGif(gif)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
const gif = await getRandomGif()
|
const gif = await getRandomGif(tag)
|
||||||
setGif(gif)
|
setGif(gif)
|
||||||
}
|
}
|
||||||
init()
|
init()
|
||||||
|
@ -3,43 +3,6 @@ import Availability from '.'
|
|||||||
|
|
||||||
describe('Availability', () => {
|
describe('Availability', () => {
|
||||||
it('renders correctly from data file values', () => {
|
it('renders correctly from data file values', () => {
|
||||||
const { container } = render(<Availability />)
|
render(<Availability />)
|
||||||
expect(container.firstChild).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// it('renders correctly when status: true', () => {
|
|
||||||
// useStaticQuery.mockImplementationOnce(() => {
|
|
||||||
// return {
|
|
||||||
// metaYaml: {
|
|
||||||
// availability: {
|
|
||||||
// status: true,
|
|
||||||
// available: 'I am available.',
|
|
||||||
// unavailable: 'Not available.'
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
|
|
||||||
// const { container } = render(<Availability />)
|
|
||||||
// expect(container.firstChild).toBeInTheDocument()
|
|
||||||
// expect(container.firstChild).toHaveTextContent('I am available.')
|
|
||||||
// })
|
|
||||||
|
|
||||||
// it('renders correctly when status: false', () => {
|
|
||||||
// useStaticQuery.mockImplementationOnce(() => {
|
|
||||||
// return {
|
|
||||||
// metaYaml: {
|
|
||||||
// availability: {
|
|
||||||
// status: false,
|
|
||||||
// available: 'I am available.',
|
|
||||||
// unavailable: 'Not available.'
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
|
|
||||||
// const { container } = render(<Availability />)
|
|
||||||
// expect(container.firstChild).toBeInTheDocument()
|
|
||||||
// expect(container.firstChild).toHaveTextContent('Not available.')
|
|
||||||
// })
|
|
||||||
})
|
})
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
|
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
|
||||||
import meta from '../../../_content/meta.json'
|
import meta from '../../../_content/meta.json'
|
||||||
import { getAnimationProps, moveInBottom } from '../Transitions'
|
import { getAnimationProps, moveInBottom } from '../Transitions'
|
||||||
|
8
src/components/Footer/index.test.tsx
Normal file
8
src/components/Footer/index.test.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import Footer from '.'
|
||||||
|
|
||||||
|
describe('Footer', () => {
|
||||||
|
it('renders correctly', async () => {
|
||||||
|
render(<Footer />)
|
||||||
|
})
|
||||||
|
})
|
@ -1,5 +1,4 @@
|
|||||||
import meta from '../../../_content/meta.json'
|
import meta from '../../../_content/meta.json'
|
||||||
import resume from '../../../_content/resume.json'
|
|
||||||
import LogoUnit from '../LogoUnit'
|
import LogoUnit from '../LogoUnit'
|
||||||
import Networks from '../Networks'
|
import Networks from '../Networks'
|
||||||
import Vcard from '../Vcard'
|
import Vcard from '../Vcard'
|
||||||
@ -25,7 +24,7 @@ export default function Footer() {
|
|||||||
<small>
|
<small>
|
||||||
© {year}{' '}
|
© {year}{' '}
|
||||||
<a className="u-url" href={meta.url}>
|
<a className="u-url" href={meta.url}>
|
||||||
{resume.basics.name.toLowerCase()}
|
{meta.author.name.toLowerCase()}
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
— All Rights Reserved
|
— All Rights Reserved
|
||||||
</small>
|
</small>
|
||||||
|
@ -10,6 +10,12 @@ describe('Header', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('renders small', async () => {
|
it('renders small', async () => {
|
||||||
render(<Header small />)
|
jest.mock('next/navigation', () => ({
|
||||||
|
usePathname: jest.fn().mockImplementation(() => '/something')
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(<Header />)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('header')).toHaveClass('small')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,30 +1,26 @@
|
|||||||
import { Suspense } from 'react'
|
'use client'
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
import Availability from '../Availability'
|
import Availability from '../Availability'
|
||||||
|
import Location from '../Location'
|
||||||
import LogoUnit from '../LogoUnit'
|
import LogoUnit from '../LogoUnit'
|
||||||
import Networks from '../Networks'
|
import Networks from '../Networks'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
const DynamicLocation = dynamic(() => import('../Location'), {
|
export default function Header() {
|
||||||
suspense: true
|
const pathname = usePathname()
|
||||||
})
|
const isSmall = pathname !== '/'
|
||||||
|
|
||||||
type Props = {
|
|
||||||
small?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Header({ small }: Props) {
|
|
||||||
return (
|
return (
|
||||||
<header className={`${styles.header} ${small ? styles.small : ''}`}>
|
<header
|
||||||
<LogoUnit small={small} />
|
className={`${styles.header} ${isSmall ? styles.small : ''}`}
|
||||||
{!small ? <Networks label="Networks" /> : null}
|
data-testid="header"
|
||||||
|
>
|
||||||
|
<LogoUnit small={isSmall} />
|
||||||
|
{!isSmall ? <Networks label="Networks" /> : null}
|
||||||
<div className={styles.meta}>
|
<div className={styles.meta}>
|
||||||
{!small ? (
|
{!isSmall ? <Location /> : null}
|
||||||
<Suspense>
|
{!isSmall ? <Availability /> : null}
|
||||||
<DynamicLocation />
|
|
||||||
</Suspense>
|
|
||||||
) : null}
|
|
||||||
{!small ? <Availability /> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import HostnameCheck from '.'
|
import HostnameCheck, { generateMetadata } from '.'
|
||||||
|
|
||||||
describe('HostnameCheck', () => {
|
describe('HostnameCheck', () => {
|
||||||
it('can access window.location', () => {
|
it('can access window.location', () => {
|
||||||
@ -19,4 +19,24 @@ describe('HostnameCheck', () => {
|
|||||||
const { container } = render(<HostnameCheck allowedHosts={allowedHosts} />)
|
const { container } = render(<HostnameCheck allowedHosts={allowedHosts} />)
|
||||||
expect(container.firstChild).toBeNull()
|
expect(container.firstChild).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('generateMetadata: should return robots metadata when host is not allowed', async () => {
|
||||||
|
// Mock window object
|
||||||
|
global.window = Object.create(window)
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: {
|
||||||
|
hostname: 'disallowed.com'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const params = { allowedHosts: ['allowed.com'] }
|
||||||
|
const result = await generateMetadata({ params })
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,11 +1,25 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Head from 'next/head'
|
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
allowedHosts: string[]
|
allowedHosts: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
const isAllowedHost = params.allowedHosts.includes(window.location.hostname)
|
||||||
|
|
||||||
|
if (!isAllowedHost) {
|
||||||
|
return {
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function HostnameCheck({ allowedHosts }: Props) {
|
export default function HostnameCheck({ allowedHosts }: Props) {
|
||||||
// default to true so SSR builds never show the banner
|
// default to true so SSR builds never show the banner
|
||||||
const [isAllowedHost, setIsAllowedHost] = useState(true)
|
const [isAllowedHost, setIsAllowedHost] = useState(true)
|
||||||
@ -18,16 +32,11 @@ export default function HostnameCheck({ allowedHosts }: Props) {
|
|||||||
}, [allowedHosts])
|
}, [allowedHosts])
|
||||||
|
|
||||||
return isAllowedHost ? null : (
|
return isAllowedHost ? null : (
|
||||||
<>
|
<aside className={styles.hostnameInfo}>
|
||||||
<Head>
|
<p>{`Hi there 👋. Please note that only the code and documentation of this
|
||||||
<meta name="robots" content="noindex,nofollow" />
|
|
||||||
</Head>
|
|
||||||
<aside className={styles.hostnameInfo}>
|
|
||||||
<p>{`Hi there 👋. Please note that only the code and documentation of this
|
|
||||||
site are open source. But my logo and the combination of typography,
|
site are open source. But my logo and the combination of typography,
|
||||||
colors, and layout making up my brand identity are not. Don't just
|
colors, and layout making up my brand identity are not. Don't just
|
||||||
clone, do a remix.`}</p>
|
clone, do a remix.`}</p>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
.location,
|
||||||
|
.wrapper {
|
||||||
|
min-height: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
.location {
|
.location {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
}
|
}
|
||||||
|
@ -1,40 +1,68 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import RelativeTime from '@yaireo/relative-time'
|
import RelativeTime from '@yaireo/relative-time'
|
||||||
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
|
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
|
||||||
import { useLocation } from '../../hooks/useLocation'
|
import { getLocation } from '../../app/actions'
|
||||||
import { getAnimationProps, moveInTop } from '../Transitions'
|
import { getAnimationProps, moveInTop } from '../Transitions'
|
||||||
import { Flag } from './Flag'
|
import { Flag } from './Flag'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
import { UseLocation } from './types'
|
||||||
|
|
||||||
export default function Location() {
|
export default function Location() {
|
||||||
const { now, next } = useLocation()
|
|
||||||
const shouldReduceMotion = useReducedMotion()
|
const shouldReduceMotion = useReducedMotion()
|
||||||
const isDifferentCountry = now?.country !== next?.country
|
const [location, setLocation] = useState<UseLocation>()
|
||||||
|
|
||||||
|
const isDifferentCountry = location?.now?.country !== location?.next?.country
|
||||||
const relativeTime = new RelativeTime({ locale: 'en' })
|
const relativeTime = new RelativeTime({ locale: 'en' })
|
||||||
|
|
||||||
return now?.city ? (
|
useEffect(() => {
|
||||||
<LazyMotion features={domAnimation}>
|
async function fetchData() {
|
||||||
<m.section
|
const location = await getLocation()
|
||||||
aria-label="Location"
|
if (!location) return
|
||||||
variants={moveInTop}
|
setLocation(location)
|
||||||
className={styles.location}
|
}
|
||||||
{...getAnimationProps(shouldReduceMotion)}
|
fetchData()
|
||||||
>
|
}, [])
|
||||||
<Flag country={{ code: now.country_code, name: now.country }} />
|
|
||||||
{now?.city} <span>Now</span>
|
return (
|
||||||
<div className={styles.next}>
|
<div className={styles.wrapper}>
|
||||||
{next?.city && (
|
{location?.now?.city ? (
|
||||||
<>
|
<LazyMotion features={domAnimation}>
|
||||||
{isDifferentCountry && (
|
<m.section
|
||||||
<Flag
|
aria-label="Location"
|
||||||
country={{ code: next.country_code, name: next.country }}
|
variants={moveInTop}
|
||||||
/>
|
className={styles.location}
|
||||||
|
{...getAnimationProps(shouldReduceMotion)}
|
||||||
|
>
|
||||||
|
<Flag
|
||||||
|
country={{
|
||||||
|
code: location.now.country_code,
|
||||||
|
name: location.now.country
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{location.now.city} <span>Now</span>
|
||||||
|
<div className={styles.next}>
|
||||||
|
{location?.next?.city && (
|
||||||
|
<>
|
||||||
|
{isDifferentCountry && (
|
||||||
|
<Flag
|
||||||
|
country={{
|
||||||
|
code: location.next.country_code,
|
||||||
|
name: location.next.country
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{location.next.city}{' '}
|
||||||
|
<span>
|
||||||
|
{relativeTime.from(new Date(location.next.date_start))}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{next.city}{' '}
|
</div>
|
||||||
<span>{relativeTime.from(new Date(next.date_start))}</span>
|
</m.section>
|
||||||
</>
|
</LazyMotion>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</m.section>
|
)
|
||||||
</LazyMotion>
|
|
||||||
) : null
|
|
||||||
}
|
}
|
||||||
|
13
src/components/Location/types.ts
Normal file
13
src/components/Location/types.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export type Location = {
|
||||||
|
country: string
|
||||||
|
city: string
|
||||||
|
country_code: string
|
||||||
|
date_start: string
|
||||||
|
date_end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseLocation = {
|
||||||
|
now: Location
|
||||||
|
next: Location
|
||||||
|
previous: Location
|
||||||
|
}
|
@ -1,18 +1,17 @@
|
|||||||
import { render } from '@testing-library/react'
|
import { render } from '@testing-library/react'
|
||||||
import LogoUnit from '.'
|
import LogoUnit from '.'
|
||||||
import data from '../../../_content/resume.json'
|
import meta from '../../../_content/meta.json'
|
||||||
|
|
||||||
describe('LogoUnit', () => {
|
describe('LogoUnit', () => {
|
||||||
it('renders correctly from data file values', () => {
|
it('renders correctly from data file values', () => {
|
||||||
const { basics } = data
|
|
||||||
const { container } = render(<LogoUnit />)
|
const { container } = render(<LogoUnit />)
|
||||||
|
|
||||||
expect(container.firstChild).toBeInTheDocument()
|
expect(container.firstChild).toBeInTheDocument()
|
||||||
expect(container.querySelector('.title')).toHaveTextContent(
|
expect(container.querySelector('.title')).toHaveTextContent(
|
||||||
basics.name.toLowerCase()
|
meta.author.name.toLowerCase()
|
||||||
)
|
)
|
||||||
expect(container.querySelector('.description')).toHaveTextContent(
|
expect(container.querySelector('.description')).toHaveTextContent(
|
||||||
basics.label.toLowerCase()
|
meta.author.label.toLowerCase()
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
import meta from '../../../_content/meta.json'
|
||||||
import resume from '../../../_content/resume.json'
|
|
||||||
import Logo from '../../images/logo.svg'
|
import Logo from '../../images/logo.svg'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
@ -9,24 +8,20 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LogoUnit({ small }: Props) {
|
export default function LogoUnit({ small }: Props) {
|
||||||
const router = useRouter()
|
|
||||||
const { pathname } = router
|
|
||||||
const isHome = pathname === '/'
|
|
||||||
|
|
||||||
const H = small ? 'h2' : 'h1'
|
const H = small ? 'h2' : 'h1'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className={`${styles.logounit} ${small ? styles.small : null}`}
|
className={`${styles.logounit} ${small ? styles.small : null}`}
|
||||||
href="/"
|
href="/"
|
||||||
aria-current={isHome ? 'page' : null}
|
aria-current={!small ? 'page' : null}
|
||||||
>
|
>
|
||||||
<Logo className={styles.logo} />
|
<Logo className={styles.logo} />
|
||||||
<H className={`p-name ${styles.title}`}>
|
<H className={`p-name ${styles.title}`}>
|
||||||
{resume.basics.name.toLowerCase()}
|
{meta.author.name.toLowerCase()}
|
||||||
</H>
|
</H>
|
||||||
<p className={`p-job-title ${styles.description}`}>
|
<p className={`p-job-title ${styles.description}`}>
|
||||||
{resume.basics.label.toLowerCase()}
|
{meta.author.label.toLowerCase()}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
import Head from 'next/head'
|
|
||||||
|
|
||||||
const MetaFavicons = () => {
|
|
||||||
return (
|
|
||||||
<Head>
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1"></meta>
|
|
||||||
{/*
|
|
||||||
Stop the favicon madness
|
|
||||||
https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs
|
|
||||||
*/}
|
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
||||||
<link rel="manifest" href="/manifest/manifest.webmanifest"></link>
|
|
||||||
</Head>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MetaFavicons
|
|
@ -1,20 +0,0 @@
|
|||||||
import { render } from '@testing-library/react'
|
|
||||||
import Meta from '.'
|
|
||||||
|
|
||||||
describe('Meta', () => {
|
|
||||||
it('renders without crashing', async () => {
|
|
||||||
render(<Meta title="Hello World" description="Hello" />, {
|
|
||||||
container: document.head
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(document.title).toBe('Hello World')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders without crashing with slug', async () => {
|
|
||||||
render(<Meta title="Hello World" description="Hello" slug="hello" />, {
|
|
||||||
container: document.head
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(document.title).toBe('Hello World')
|
|
||||||
})
|
|
||||||
})
|
|
@ -1,33 +0,0 @@
|
|||||||
import Head from 'next/head'
|
|
||||||
import meta from '../../../_content/meta.json'
|
|
||||||
import resume from '../../../_content/resume.json'
|
|
||||||
|
|
||||||
const Meta = ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
image,
|
|
||||||
slug
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
image?: string
|
|
||||||
slug?: string
|
|
||||||
}) => {
|
|
||||||
const url = slug ? `${meta.url}/${slug}` : meta.url
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Head>
|
|
||||||
<link rel="canonical" href={url} />
|
|
||||||
|
|
||||||
{/* <!-- Essential META Tags --> */}
|
|
||||||
<title>{title}</title>
|
|
||||||
<meta name="description" content={`${description.slice(0, 200)}...`} />
|
|
||||||
<meta property="og:title" content={title} />
|
|
||||||
<meta property="og:image" content={`${meta.url}/${image}`} />
|
|
||||||
<meta property="og:url" content={url} />
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
</Head>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Meta
|
|
@ -1,3 +1,5 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { LazyMotion, domAnimation, m } from 'framer-motion'
|
import { LazyMotion, domAnimation, m } from 'framer-motion'
|
||||||
import Icon from '../Icon'
|
import Icon from '../Icon'
|
||||||
import { moveInTop } from '../Transitions'
|
import { moveInTop } from '../Transitions'
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
|
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
|
||||||
import resume from '../../../_content/resume.json'
|
import meta from '../../../_content/meta.json'
|
||||||
import { getAnimationProps } from '../Transitions'
|
import { getAnimationProps } from '../Transitions'
|
||||||
import { NetworkLink } from './NetworkLink'
|
import { NetworkLink } from './NetworkLink'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
@ -33,10 +35,10 @@ export default function Networks({ label, small }: Props) {
|
|||||||
<NetworkLink
|
<NetworkLink
|
||||||
name="Mail"
|
name="Mail"
|
||||||
key="Mail"
|
key="Mail"
|
||||||
url={`mailto:${resume.basics.email}`}
|
url={`mailto:${meta.author.email}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{resume.basics.profiles.map((profile) => (
|
{meta.profiles.map((profile) => (
|
||||||
<NetworkLink
|
<NetworkLink
|
||||||
key={profile.network}
|
key={profile.network}
|
||||||
name={profile.network}
|
name={profile.network}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
|
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
|
||||||
import type ImageType from '../../interfaces/image'
|
import type ImageType from '../../types/image'
|
||||||
import type ProjectType from '../../interfaces/project'
|
import type ProjectType from '../../types/project'
|
||||||
import ProjectImage from '../ProjectImage'
|
import ProjectImage from '../ProjectImage'
|
||||||
import { getAnimationProps, moveInBottom, moveInTop } from '../Transitions'
|
import { getAnimationProps, moveInBottom } from '../Transitions'
|
||||||
import ProjectLinks from './Links'
|
import ProjectLinks from './Links'
|
||||||
import ProjectTechstack from './Techstack'
|
import ProjectTechstack from './Techstack'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
@ -16,7 +18,11 @@ const containerVariants = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Project({ project }: { project: ProjectType }) {
|
export default function Project({
|
||||||
|
project
|
||||||
|
}: {
|
||||||
|
project: Partial<ProjectType>
|
||||||
|
}) {
|
||||||
const { title, descriptionHtml, images, links, techstack } = project
|
const { title, descriptionHtml, images, links, techstack } = project
|
||||||
const shouldReduceMotion = useReducedMotion()
|
const shouldReduceMotion = useReducedMotion()
|
||||||
const animationProps = getAnimationProps(shouldReduceMotion)
|
const animationProps = getAnimationProps(shouldReduceMotion)
|
||||||
@ -41,7 +47,7 @@ export default function Project({ project }: { project: ProjectType }) {
|
|||||||
</m.header>
|
</m.header>
|
||||||
</LazyMotion>
|
</LazyMotion>
|
||||||
|
|
||||||
{images.map((image: ImageType, i: number) => (
|
{images?.map((image: ImageType, i: number) => (
|
||||||
<ProjectImage
|
<ProjectImage
|
||||||
className={styles.fullContainer}
|
className={styles.fullContainer}
|
||||||
image={image}
|
image={image}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import {
|
import {
|
||||||
@ -7,7 +9,7 @@ import {
|
|||||||
useAnimation,
|
useAnimation,
|
||||||
useReducedMotion
|
useReducedMotion
|
||||||
} from 'framer-motion'
|
} from 'framer-motion'
|
||||||
import ImageType from '../../interfaces/image'
|
import ImageType from '../../types/image'
|
||||||
import { getAnimationProps } from '../Transitions'
|
import { getAnimationProps } from '../Transitions'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ForwardedRef, forwardRef } from 'react'
|
import { ForwardedRef, forwardRef } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import ProjectType from '../../interfaces/project'
|
import ProjectType from '../../types/project'
|
||||||
import ProjectImage from '../ProjectImage'
|
import ProjectImage from '../ProjectImage'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ export const Project = forwardRef(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<ProjectImage
|
<ProjectImage
|
||||||
image={project.images[0]}
|
image={project.images?.[0]}
|
||||||
alt={project.title}
|
alt={project.title}
|
||||||
sizes="(max-width: 30rem) 66vw, 33vw"
|
sizes="(max-width: 30rem) 66vw, 33vw"
|
||||||
/>
|
/>
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { createRef, useEffect } from 'react'
|
import { createRef, useEffect } from 'react'
|
||||||
import ProjectType from '../../interfaces/project'
|
import ProjectType from '../../types/project'
|
||||||
import { Project } from './Project'
|
import { Project } from './Project'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projects: { slug: string }[]
|
projects: Partial<ProjectType>[]
|
||||||
currentSlug: string
|
currentSlug: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import ImageType from '../../interfaces/image'
|
import ImageType from '../../types/image'
|
||||||
import ProjectImage from '../ProjectImage'
|
import ProjectImage from '../ProjectImage'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import ProjectType from '../../interfaces/project'
|
import ProjectType from '../../types/project'
|
||||||
import ProjectPreview from '../ProjectPreview'
|
import ProjectPreview from '../ProjectPreview'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projects: ProjectType[]
|
projects: Partial<ProjectType>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Projects({ projects }: Props) {
|
export default function Projects({ projects }: Props) {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { render } from '@testing-library/react'
|
import { render } from '@testing-library/react'
|
||||||
import Repositories from '.'
|
import Repositories from '.'
|
||||||
import repos from '../../../tests/__fixtures__/repos.json'
|
import repos from '../../../tests/__fixtures__/repos.json'
|
||||||
import Repo from '../../interfaces/repo'
|
import Repo from '../../types/repo'
|
||||||
|
|
||||||
describe('Repositories', () => {
|
describe('Repositories', () => {
|
||||||
it('renders correctly', () => {
|
it('renders correctly', () => {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import Repo from '../../interfaces/repo'
|
import Repo from '../../types/repo'
|
||||||
import Repository from '../Repository'
|
import Repository from '../Repository'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { render } from '@testing-library/react'
|
import { render } from '@testing-library/react'
|
||||||
import repos from '../../../tests/__fixtures__/repos.json'
|
import repos from '../../../tests/__fixtures__/repos.json'
|
||||||
import Repo from '../../interfaces/repo'
|
import Repo from '../../types/repo'
|
||||||
import Repository from '../Repository'
|
import Repository from '../Repository'
|
||||||
|
|
||||||
describe('Repository', () => {
|
describe('Repository', () => {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import Repo from '../../interfaces/repo'
|
import Repo from '../../types/repo'
|
||||||
import Icon from '../Icon'
|
import Icon from '../Icon'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import * as Select from '@radix-ui/react-select'
|
import * as Select from '@radix-ui/react-select'
|
||||||
import Icon from '../Icon'
|
import Icon from '../Icon'
|
||||||
import styles from './Item.module.css'
|
import styles from './Item.module.css'
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Head from 'next/head'
|
|
||||||
import * as Select from '@radix-ui/react-select'
|
import * as Select from '@radix-ui/react-select'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import Icon from '../Icon'
|
import Icon from '../Icon'
|
||||||
@ -12,56 +13,45 @@ export function getIconName(theme: string) {
|
|||||||
|
|
||||||
export default function ThemeSwitch() {
|
export default function ThemeSwitch() {
|
||||||
const { theme, themes, resolvedTheme, setTheme } = useTheme()
|
const { theme, themes, resolvedTheme, setTheme } = useTheme()
|
||||||
const [mounted, setMounted] = useState(false)
|
const iconName = getIconName(resolvedTheme)
|
||||||
|
|
||||||
|
// hydration errors workaround
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<aside className={styles.themeSwitch}>
|
||||||
<Head>
|
{mounted ? (
|
||||||
<meta name="theme-color" content="var(--theme-color)" />
|
<Select.Root
|
||||||
<meta
|
defaultValue={theme}
|
||||||
name="apple-mobile-web-app-status-bar-style"
|
value={theme}
|
||||||
content="black-translucent"
|
onValueChange={(value) => setTheme(value)}
|
||||||
/>
|
>
|
||||||
</Head>
|
<Select.Trigger className={styles.trigger} aria-label="Theme Switch">
|
||||||
|
<Select.Value>
|
||||||
|
<Icon name={iconName} />
|
||||||
|
</Select.Value>
|
||||||
|
<Select.Icon className={styles.chevron}>
|
||||||
|
<Icon name="ChevronDown" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
<aside className={styles.themeSwitch}>
|
<Select.Portal>
|
||||||
{mounted ? (
|
<Select.Content
|
||||||
<Select.Root
|
className={styles.content}
|
||||||
defaultValue={theme}
|
position="popper"
|
||||||
value={theme}
|
align="end"
|
||||||
onValueChange={(value) => setTheme(value)}
|
|
||||||
>
|
|
||||||
<Select.Trigger
|
|
||||||
className={styles.trigger}
|
|
||||||
aria-label="Theme Switch"
|
|
||||||
>
|
>
|
||||||
<Select.Value>
|
<Select.Arrow className={styles.arrow} width={14} height={7} />
|
||||||
<Icon name={getIconName(resolvedTheme)} />
|
<Select.Viewport className={styles.viewport}>
|
||||||
</Select.Value>
|
{themes
|
||||||
<Select.Icon className={styles.chevron}>
|
.map((theme) => <Item key={theme} theme={theme}></Item>)
|
||||||
<Icon name="ChevronDown" />
|
.reverse()}
|
||||||
</Select.Icon>
|
</Select.Viewport>
|
||||||
</Select.Trigger>
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
<Select.Portal>
|
</Select.Root>
|
||||||
<Select.Content
|
) : null}
|
||||||
className={styles.content}
|
</aside>
|
||||||
position="popper"
|
|
||||||
align="end"
|
|
||||||
>
|
|
||||||
<Select.Arrow className={styles.arrow} width={14} height={7} />
|
|
||||||
<Select.Viewport className={styles.viewport}>
|
|
||||||
{themes
|
|
||||||
.map((theme) => <Item key={theme} theme={theme}></Item>)
|
|
||||||
.reverse()}
|
|
||||||
</Select.Viewport>
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Portal>
|
|
||||||
</Select.Root>
|
|
||||||
) : null}
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
import { render } from '@testing-library/react'
|
|
||||||
import Typekit from '.'
|
|
||||||
|
|
||||||
describe('Typekit', () => {
|
|
||||||
it('renders without crashing', async () => {
|
|
||||||
render(<Typekit />)
|
|
||||||
})
|
|
||||||
})
|
|
@ -1,37 +0,0 @@
|
|||||||
const script = `(function (d) {
|
|
||||||
var config = {
|
|
||||||
kitId: '${process.env.NEXT_PUBLIC_TYPEKIT_ID}',
|
|
||||||
scriptTimeout: 3000,
|
|
||||||
async: true
|
|
||||||
},
|
|
||||||
h = d.documentElement,
|
|
||||||
t = setTimeout(function () {
|
|
||||||
h.className = h.className.replace(/\bwf-loading\b/g, '') + ' wf-inactive'
|
|
||||||
}, config.scriptTimeout),
|
|
||||||
tk = d.createElement('script'),
|
|
||||||
f = false,
|
|
||||||
s = d.getElementsByTagName('script')[0],
|
|
||||||
a
|
|
||||||
h.className += ' wf-loading'
|
|
||||||
tk.src = 'https://use.typekit.net/' + config.kitId + '.js'
|
|
||||||
tk.async = true
|
|
||||||
tk.onload = tk.onreadystatechange = function () {
|
|
||||||
a = this.readyState
|
|
||||||
if (f || (a && a != 'complete' && a != 'loaded')) return
|
|
||||||
f = true
|
|
||||||
clearTimeout(t)
|
|
||||||
try {
|
|
||||||
Typekit.load(config)
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
s.parentNode.insertBefore(tk, s)
|
|
||||||
})(document)`
|
|
||||||
|
|
||||||
export default function Typekit() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<link rel="preconnect" href="https://use.typekit.net" />
|
|
||||||
<script id="typekit" async dangerouslySetInnerHTML={{ __html: script }} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,13 +1,12 @@
|
|||||||
import meta from '../../../_content/meta.json'
|
import meta from '../../../_content/meta.json'
|
||||||
import resume from '../../../_content/resume.json'
|
|
||||||
import { constructVcard, init, toDataURL } from './_utils'
|
import { constructVcard, init, toDataURL } from './_utils'
|
||||||
|
|
||||||
const metaMock = {
|
const metaMock = {
|
||||||
...meta,
|
...meta,
|
||||||
name: resume.basics.name,
|
name: meta.author.name,
|
||||||
label: resume.basics.label,
|
label: meta.author.label,
|
||||||
email: resume.basics.email,
|
email: meta.author.email,
|
||||||
profiles: [...resume.basics.profiles]
|
profiles: [...meta.profiles]
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Vcard/_utils', () => {
|
describe('Vcard/_utils', () => {
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import meta from '../../../_content/meta.json'
|
import meta from '../../../_content/meta.json'
|
||||||
import resume from '../../../_content/resume.json'
|
|
||||||
|
|
||||||
export default function Vcard() {
|
export default function Vcard() {
|
||||||
const { name, label, email, profiles } = resume.basics
|
const { name, label, email } = meta.author
|
||||||
|
|
||||||
const vCardMeta = {
|
const vCardMeta = {
|
||||||
...meta,
|
...meta,
|
||||||
@ -10,7 +11,7 @@ export default function Vcard() {
|
|||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
email,
|
email,
|
||||||
profiles
|
profiles: meta.profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddressbookClick = (e) => {
|
const handleAddressbookClick = (e) => {
|
||||||
|
@ -1,39 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
export type Location = {
|
|
||||||
country: string
|
|
||||||
city: string
|
|
||||||
country_code: string
|
|
||||||
date_start: string
|
|
||||||
date_end: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UseLocation = {
|
|
||||||
now: Location
|
|
||||||
next: Location
|
|
||||||
previous: Location
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useLocation = () => {
|
|
||||||
const [location, setLocation] = useState<UseLocation>()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('https://location.kretschmann.io')
|
|
||||||
const data = await response.json()
|
|
||||||
if (!data) return
|
|
||||||
setLocation(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
now: location?.now,
|
|
||||||
next: location?.next,
|
|
||||||
previous: location?.previous
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +1,8 @@
|
|||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="330"
|
width="330"
|
||||||
height="330"
|
height="330"
|
||||||
viewBox="0 0 330 330"
|
viewBox="0 0 330 330"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
|
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 327 B |
@ -1,24 +0,0 @@
|
|||||||
import Meta from '../../components/Meta'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: React.ReactNode
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
image?: string
|
|
||||||
slug?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page({
|
|
||||||
children,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
image,
|
|
||||||
slug
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Meta title={title} description={description} image={image} slug={slug} />
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import Site from '.'
|
|
||||||
|
|
||||||
describe('Site', () => {
|
|
||||||
it('renders without crashing', async () => {
|
|
||||||
render(<Site>Hello Site</Site>)
|
|
||||||
|
|
||||||
await screen.findByText('Hello Site')
|
|
||||||
await screen.findAllByText('Lisbon')
|
|
||||||
})
|
|
||||||
})
|
|
@ -1,31 +0,0 @@
|
|||||||
import { useRouter } from 'next/router'
|
|
||||||
import Script from 'next/script'
|
|
||||||
import meta from '../../../_content/meta.json'
|
|
||||||
import Footer from '../../components/Footer'
|
|
||||||
import Header from '../../components/Header'
|
|
||||||
import HostnameCheck from '../../components/HostnameCheck'
|
|
||||||
import MetaFavicon from '../../components/Meta/Favicon'
|
|
||||||
import ThemeSwitch from '../../components/ThemeSwitch'
|
|
||||||
import { UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID } from '../../lib/umami'
|
|
||||||
import styles from './index.module.css'
|
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production'
|
|
||||||
|
|
||||||
export default function Site({ children }: { children: React.ReactNode }) {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isProduction && (
|
|
||||||
<Script src={UMAMI_SCRIPT_URL} data-website-id={UMAMI_WEBSITE_ID} />
|
|
||||||
)}
|
|
||||||
<HostnameCheck allowedHosts={meta.allowedHosts} />
|
|
||||||
<MetaFavicon />
|
|
||||||
<ThemeSwitch />
|
|
||||||
|
|
||||||
<Header small={router.pathname !== '/'} />
|
|
||||||
<main className={styles.screen}>{children}</main>
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
@ -14,22 +14,14 @@ describe('lib/content', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('getProjectBySlug', async () => {
|
test('getProjectBySlug', async () => {
|
||||||
const project = await getProjectBySlug('ipixelpad', [
|
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
'slug',
|
|
||||||
'images',
|
|
||||||
'techstack',
|
|
||||||
'links'
|
|
||||||
])
|
|
||||||
expect(project).toBeDefined()
|
|
||||||
expect(project.images[0].src).toContain('ipixelpad')
|
|
||||||
// expect(project.images[0].blurDataURL).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('getProjectBySlug without fields', async () => {
|
|
||||||
const project = await getProjectBySlug('ipixelpad')
|
const project = await getProjectBySlug('ipixelpad')
|
||||||
expect(project).toBeDefined()
|
expect(project).toBeDefined()
|
||||||
|
expect(project.images[0].src).toContain('ipixelpad')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getProjectBySlug returns early', async () => {
|
||||||
|
const project = await getProjectBySlug('gibberish')
|
||||||
|
expect(project).not.toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getProjectImages', async () => {
|
test('getProjectImages', async () => {
|
||||||
|
@ -2,12 +2,12 @@ import fs from 'fs'
|
|||||||
import yaml from 'js-yaml'
|
import yaml from 'js-yaml'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import type ImageType from '../interfaces/image'
|
import type ImageType from '../types/image'
|
||||||
import type ProjectType from '../interfaces/project'
|
import type ProjectType from '../types/project'
|
||||||
import { markdownToHtml } from './markdown'
|
import { markdownToHtml } from './markdown'
|
||||||
|
|
||||||
const imagesDirectory = join(process.cwd(), 'public', 'images')
|
|
||||||
const contentDirectory = join(process.cwd(), '_content')
|
const contentDirectory = join(process.cwd(), '_content')
|
||||||
|
const imagesDirectory = join(process.cwd(), 'public', 'images')
|
||||||
const projects = yaml.load(
|
const projects = yaml.load(
|
||||||
fs.readFileSync(`${contentDirectory}/projects.yml`, 'utf8')
|
fs.readFileSync(`${contentDirectory}/projects.yml`, 'utf8')
|
||||||
) as Partial<ProjectType>[]
|
) as Partial<ProjectType>[]
|
||||||
@ -62,32 +62,16 @@ export async function getProjectImages(slug: string) {
|
|||||||
|
|
||||||
export async function getProjectBySlug(slug: string, fields: string[] = []) {
|
export async function getProjectBySlug(slug: string, fields: string[] = []) {
|
||||||
const project = projects.find((item) => item.slug === slug)
|
const project = projects.find((item) => item.slug === slug)
|
||||||
|
if (!project) return
|
||||||
|
|
||||||
type Items = {
|
// enhance data with additional fields
|
||||||
[key: string]: string
|
const descriptionHtml = await markdownToHtml(project.description)
|
||||||
}
|
project.descriptionHtml = descriptionHtml
|
||||||
|
|
||||||
const items: Items = {}
|
const images = await getProjectImages(slug)
|
||||||
|
project.images = images
|
||||||
|
|
||||||
// Ensure only the minimal needed data is exposed
|
return project
|
||||||
await Promise.all(
|
|
||||||
fields.map(async (field) => {
|
|
||||||
if (field === 'description') {
|
|
||||||
const descriptionHtml = await markdownToHtml(project.description)
|
|
||||||
items[field] = project.description
|
|
||||||
items['descriptionHtml'] = descriptionHtml
|
|
||||||
}
|
|
||||||
if (field === 'images') {
|
|
||||||
const images = await getProjectImages(slug)
|
|
||||||
;(items[field] as unknown as ImageType[]) = images
|
|
||||||
}
|
|
||||||
if (typeof project[field] !== 'undefined') {
|
|
||||||
items[field] = project[field]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return items as Partial<ProjectType>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllProjects(
|
export async function getAllProjects(
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import data from '../../_content/repos.json'
|
import data from '../../_content/repos.json'
|
||||||
import Repo from '../interfaces/repo'
|
import Repo from '../types/repo'
|
||||||
|
|
||||||
//
|
//
|
||||||
// Get GitHub repos
|
// Get GitHub repos
|
||||||
|
@ -1,61 +0,0 @@
|
|||||||
import { GetStaticPaths, GetStaticProps } from 'next/types'
|
|
||||||
import resume from '../../_content/resume.json'
|
|
||||||
import Project from '../components/Project'
|
|
||||||
import ProjectNav from '../components/ProjectNav'
|
|
||||||
import type ProjectType from '../interfaces/project'
|
|
||||||
import Page from '../layouts/Page'
|
|
||||||
import {
|
|
||||||
getAllProjects,
|
|
||||||
getProjectBySlug,
|
|
||||||
getProjectSlugs
|
|
||||||
} from '../lib/content'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
project: ProjectType
|
|
||||||
projects: { slug: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectPage({ project, projects }: Props) {
|
|
||||||
const pageMeta = {
|
|
||||||
title: `${
|
|
||||||
project.title
|
|
||||||
} // ${resume.basics.name.toLowerCase()} { ${resume.basics.label.toLowerCase()} }`,
|
|
||||||
description: project.description,
|
|
||||||
image: project.images[0].src,
|
|
||||||
slug: project.slug
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page {...pageMeta}>
|
|
||||||
<Project project={project} />
|
|
||||||
<ProjectNav projects={projects} currentSlug={project.slug} />
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Params = {
|
|
||||||
params: {
|
|
||||||
slug: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async ({ params }: Params) => {
|
|
||||||
const project = await getProjectBySlug(params.slug, [
|
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
'slug',
|
|
||||||
'images',
|
|
||||||
'techstack',
|
|
||||||
'links'
|
|
||||||
])
|
|
||||||
const projects = await getAllProjects(['slug', 'title', 'images'])
|
|
||||||
return { props: { project, projects } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths = () => {
|
|
||||||
const slugs = getProjectSlugs()
|
|
||||||
return {
|
|
||||||
paths: slugs.map((slug) => ({ params: { slug } })),
|
|
||||||
fallback: false
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
import type { AppProps } from 'next/app'
|
|
||||||
import { ThemeProvider } from 'next-themes'
|
|
||||||
import Site from '../layouts/Site'
|
|
||||||
import '../styles/global.css'
|
|
||||||
|
|
||||||
export default function MyApp({ Component, pageProps, router }: AppProps) {
|
|
||||||
return (
|
|
||||||
<ThemeProvider attribute="class">
|
|
||||||
<Site>
|
|
||||||
<Component {...pageProps} />
|
|
||||||
</Site>
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
import { Head, Html, Main, NextScript } from 'next/document'
|
|
||||||
import Typekit from '../components/Typekit'
|
|
||||||
|
|
||||||
export default function Document() {
|
|
||||||
return (
|
|
||||||
<Html lang="en">
|
|
||||||
<Head>
|
|
||||||
<Typekit />
|
|
||||||
</Head>
|
|
||||||
<body>
|
|
||||||
<Main />
|
|
||||||
<NextScript />
|
|
||||||
</body>
|
|
||||||
</Html>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
import { GetStaticProps } from 'next/types'
|
|
||||||
import meta from '../../_content/meta.json'
|
|
||||||
import resume from '../../_content/resume.json'
|
|
||||||
import Projects from '../components/Projects'
|
|
||||||
import Repositories from '../components/Repositories'
|
|
||||||
import Project from '../interfaces/project'
|
|
||||||
import Repo from '../interfaces/repo'
|
|
||||||
import Page from '../layouts/Page'
|
|
||||||
import { getAllProjects } from '../lib/content'
|
|
||||||
import { getGithubRepos } from '../lib/github'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
projects: Project[]
|
|
||||||
repos: Repo[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function IndexPage({ projects, repos }: Props) {
|
|
||||||
const pageMeta = {
|
|
||||||
title: `${resume.basics.name.toLowerCase()} { ${resume.basics.label.toLowerCase()} }`,
|
|
||||||
description: meta.description,
|
|
||||||
image: meta.img
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page {...pageMeta}>
|
|
||||||
<Projects projects={projects} />
|
|
||||||
<Repositories repos={repos} />
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async () => {
|
|
||||||
const projects = await getAllProjects(['title', 'images', 'slug'])
|
|
||||||
const repos = await getGithubRepos()
|
|
||||||
return { props: { projects, repos } }
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
type ImageType = {
|
declare type ImageType = {
|
||||||
src: string
|
src: string
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
@ -1,6 +1,6 @@
|
|||||||
import ImageType from './image'
|
import ImageType from './image'
|
||||||
|
|
||||||
type ProjectType = {
|
declare type ProjectType = {
|
||||||
images: ImageType[]
|
images: ImageType[]
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
@ -1,4 +1,4 @@
|
|||||||
type Repo = {
|
declare type Repo = {
|
||||||
name: string
|
name: string
|
||||||
full_name: string
|
full_name: string
|
||||||
description: string
|
description: string
|
@ -5,31 +5,22 @@ import giphy from './__fixtures__/giphy.json'
|
|||||||
import { dataLocation } from './__fixtures__/location'
|
import { dataLocation } from './__fixtures__/location'
|
||||||
import './__mocks__/matchMedia'
|
import './__mocks__/matchMedia'
|
||||||
|
|
||||||
jest.mock('next/router', () => ({
|
jest.mock('next/navigation', () => ({
|
||||||
useRouter: jest.fn().mockImplementation(() => ({
|
usePathname: jest.fn().mockImplementationOnce(() => '/')
|
||||||
route: '/',
|
|
||||||
pathname: '/'
|
|
||||||
}))
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
jest.mock('next/head', () => {
|
jest.mock('../src/app/actions', () => ({
|
||||||
return {
|
getLocation: jest.fn().mockImplementation(() => dataLocation),
|
||||||
__esModule: true,
|
getRandomGif: jest
|
||||||
default: ({ children }: { children: Array<React.ReactElement> }) => {
|
.fn()
|
||||||
return <>{children}</>
|
.mockImplementation(() => giphy.data.images.original.mp4)
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
jest.mock('../src/hooks/useLocation', () => ({
|
|
||||||
useLocation: jest.fn().mockImplementation(() => dataLocation)
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
jest.mock('@giphy/js-fetch-api', () => ({
|
// jest.mock('@giphy/js-fetch-api', () => ({
|
||||||
GiphyFetch: jest.fn().mockImplementation(() => ({
|
// GiphyFetch: jest.fn().mockImplementation(() => ({
|
||||||
random: jest.fn().mockImplementation(() => Promise.resolve(giphy))
|
// random: jest.fn().mockImplementation(() => Promise.resolve(giphy))
|
||||||
}))
|
// }))
|
||||||
}))
|
// }))
|
||||||
|
|
||||||
const unmockedFetch = global.fetch
|
const unmockedFetch = global.fetch
|
||||||
const unmockedEnv = process.env
|
const unmockedEnv = process.env
|
||||||
@ -43,5 +34,5 @@ beforeEach(() => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = unmockedFetch
|
global.fetch = unmockedFetch
|
||||||
process.env = unmockedEnv
|
process.env = unmockedEnv
|
||||||
// jest.restoreAllMocks()
|
jest.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
@ -1,55 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
|
||||||
import IndexPage, { getStaticProps } from '../src/pages'
|
|
||||||
import NotFoundPage from '../src/pages/404'
|
|
||||||
import ProjectPage, {
|
|
||||||
getStaticPaths,
|
|
||||||
getStaticProps as getStaticPropsProject
|
|
||||||
} from '../src/pages/[slug]'
|
|
||||||
import mockData from './__fixtures__/giphy.json'
|
|
||||||
import project from './__fixtures__/project.json'
|
|
||||||
import projects from './__fixtures__/projects.json'
|
|
||||||
import repos from './__fixtures__/repos.json'
|
|
||||||
|
|
||||||
jest.setTimeout(30000)
|
|
||||||
|
|
||||||
describe('pages', () => {
|
|
||||||
it('IndexPage', async () => {
|
|
||||||
render(<IndexPage projects={projects} repos={repos} />)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('IndexPage/getStaticProps', async () => {
|
|
||||||
;(global.fetch as jest.Mock) = jest.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(repos)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const props = await getStaticProps({} as any)
|
|
||||||
expect(props).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ProjectPage', () => {
|
|
||||||
render(<ProjectPage project={project} projects={projects} />)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ProjectPage/getStaticPaths', async () => {
|
|
||||||
const props = await getStaticPaths({} as any)
|
|
||||||
expect(props).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ProjectPage/getStaticProps', async () => {
|
|
||||||
const props = await getStaticPropsProject({ params: { slug: 'ipixelpad' } })
|
|
||||||
expect(props).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('NotFoundPage', async () => {
|
|
||||||
render(<NotFoundPage />)
|
|
||||||
await screen.findByText(/Shenanigans, page not found./)
|
|
||||||
await screen.findByTestId(mockData.data.images.original.mp4)
|
|
||||||
|
|
||||||
const button = await screen.findByText(`Get another 'cat' gif`)
|
|
||||||
fireEvent.click(button)
|
|
||||||
await screen.findByTestId(mockData.data.images.original.mp4)
|
|
||||||
})
|
|
||||||
})
|
|
@ -13,8 +13,9 @@
|
|||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"incremental": true
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }]
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules"],
|
"exclude": ["node_modules"],
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user