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:
Matthias Kretschmann 2024-02-01 18:59:51 +00:00 committed by GitHub
parent f065d05248
commit 1d74f420be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
79 changed files with 865 additions and 3246 deletions

View File

@ -28,7 +28,7 @@ exclude_patterns:
- '**/*_test.go'
- '**/*.d.ts'
- '**/@types/'
- '**/interfaces/'
- '**/types/'
- '**/_types.*'
- '**/*.stories.*'
- '**/*.test.*'

View File

@ -1,4 +1,4 @@
GITHUB_TOKEN=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

View File

@ -1,8 +1,8 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
time: '04:00'
open-pull-requests-limit: 10
- package-ecosystem: npm
directory: '/'
schedule:
interval: monthly
time: '04:00'
open-pull-requests-limit: 10

View File

@ -70,26 +70,3 @@ jobs:
- run: npm ci
- 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 }}

View File

@ -4,6 +4,7 @@
"trailingComma": "none",
"tabWidth": 2,
"endOfLine": "lf",
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrder": [
"^(react/(.*)$)|^(react$)",
"^(next/(.*)$)|^(next$)",
@ -11,8 +12,5 @@
"^[./]"
],
"importOrderSeparation": false,
"importOrderSortSpecifiers": true,
"importOrderBuiltinModulesToTop": true,
"importOrderMergeDuplicateImports": true,
"importOrderCombineTypeAndValueImports": true
"importOrderSortSpecifiers": true
}

View File

@ -21,10 +21,8 @@
- [🐱 GitHub repositories](#-github-repositories)
- [📍 Location](#-location)
- [💅 Theme switcher](#-theme-switcher)
- [🏆 SEO component](#-seo-component)
- [📇 Client-side vCard creation](#-client-side-vcard-creation)
- [💎 Importing SVG assets](#-importing-svg-assets)
- [🍬 Typekit component](#-typekit-component)
- [🤓 Scripts](#-scripts)
- [🎈 Add a new project](#-add-a-new-project)
- [🌄 Favicon generation](#-favicon-generation)
@ -38,7 +36,7 @@
## 🎉 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.
@ -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.
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)
- [`src/pages/[slug].tsx`](src/pages/[slug].tsx)
- [`src/app/[slug]/page.tsx`](src/app/[slug]/page.tsx)
### 🖼 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.
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:
@ -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.
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:
- [`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)
- [kremalicious/location](https://github.com/kremalicious/location)
### 💅 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:
- [`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
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:
@ -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:
```js
import Logo from './components/svg/Logo'
import Logo from './images/logo.svg'
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
### 🎈 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.
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
**© 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.

View File

@ -2,11 +2,33 @@
"description": "Portfolio of web & ui designer/developer Matthias Kretschmann.",
"img": "twitter-card.png",
"url": "https://matthiaskretschmann.com",
"author": {
"name": "Matthias Kretschmann",
"label": "Designer & Developer",
"email": "m@kretschmann.io",
"picture": "../src/images/avatar.jpg"
},
"availability": {
"status": false,
"available": "👔 Available for new projects. <a href=\"mailto:m@kretschmann.io\">Lets talk</a>!",
"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",
"addressbook": "/matthias-kretschmann.vcf",
"bugs": "https://github.com/kremalicious/portfolio/issues/new",

View File

@ -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 [ezeeps 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": ""
}
}

View File

@ -21,8 +21,8 @@ const next = (phase, { defaultConfig }) => {
// Convert all other *.svg imports to React components
{
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
resourceQuery: { not: /url/ }, // exclude if *.svg?url
issuer: fileLoaderRule.issuer,
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
use: [{ loader: '@svgr/webpack', options: { icon: true } }]
}
)
@ -40,9 +40,7 @@ const next = (phase, { defaultConfig }) => {
return typeof defaultConfig.webpack === 'function'
? defaultConfig.webpack(config, options)
: config
},
// https://nextjs.org/docs/api-reference/next.config.js/react-strict-mode
reactStrictMode: true
}
}
return nextConfig

2414
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,6 @@
"format": "prettier --write 'src/**/*.{ts,tsx,css}'",
"jest": "jest --coverage -c tests/jest.config.ts",
"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",
"favicon": "ts-node-esm ./scripts/favicon.ts"
},
@ -29,25 +28,25 @@
"@yaireo/relative-time": "^1.0.4",
"file-saver": "^2.0.5",
"framer-motion": "^11.0.3",
"lucide-react": "^0.314.0",
"lucide-react": "^0.321.0",
"next": "14.1.0",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remark": "^14.0.3",
"remark": "^15.0.1",
"remark-gfm": "^4.0.0",
"remark-html": "^15.0.2",
"remark-html": "^16.0.1",
"vcf": "github:jhermsmeier/node-vcf"
},
"devDependencies": {
"@svgr/webpack": "^8.1.0",
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.4.1",
"@testing-library/react": "^14.2.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",
"chalk": "^5.3.0",
"eslint": "^8.54.0",
"eslint": "^8.56.0",
"eslint-config-next": "^14.1.0",
"jest": "^29.7.0",
"jest-canvas-mock": "^2.5.2",
@ -55,13 +54,13 @@
"js-yaml": "^4.1.0",
"ora": "^8.0.1",
"prepend": "^1.0.2",
"prettier": "^3.2.2",
"prettier": "^3.2.4",
"sharp": "^0.33.2",
"sharp-ico": "^0.1.5",
"slugify": "^1.6.6",
"stylelint": "^16.2.0",
"stylelint-prettier": "^4.1.0",
"ts-node": "^10.9.1",
"stylelint": "^16.2.1",
"stylelint-prettier": "^5.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
},
"engines": {

View File

@ -1,6 +1,3 @@
# https://platform.openai.com/docs/gptbot
User-agent: GPTBot
Disallow: /
User-agent: *
Disallow: /resume

View File

@ -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
View 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 }))
}

View 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 }]
}
})
})
})

View 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)
})
})

View 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)
})
})

View 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
View 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
View 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>
)
}

View File

@ -1,15 +1,11 @@
import { Metadata } from 'next'
import NotFound from '../components/404'
import Page from '../layouts/Page'
const pageMeta = {
export const metadata: Metadata = {
title: `Shenanigans`,
description: 'Page not found.'
}
export default function NotFoundPage() {
return (
<Page {...pageMeta}>
<NotFound />
</Page>
)
return <NotFound />
}

16
src/app/page.tsx Normal file
View 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
View File

@ -0,0 +1,7 @@
'use client'
import { ThemeProvider } from 'next-themes'
export function Providers({ children }) {
return <ThemeProvider attribute="class">{children}</ThemeProvider>
}

View File

@ -10,7 +10,8 @@
display: block;
width: auto;
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);
margin: calc(var(--spacer) / 4) auto calc(var(--spacer) / 2) auto;
}

View 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)
})
})

View File

@ -1,37 +1,27 @@
'use client'
import { MouseEvent, useEffect, useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { getRandomGif } from '../../app/actions'
import Button from '../Button'
import styles from './index.module.css'
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() {
const pathname = usePathname()
const [gif, setGif] = useState<string>()
async function handleClick(e: MouseEvent) {
e.preventDefault()
const gif = await getRandomGif()
const gif = await getRandomGif(tag, pathname)
setGif(gif)
}
useEffect(() => {
async function init() {
const gif = await getRandomGif()
const gif = await getRandomGif(tag)
setGif(gif)
}
init()

View File

@ -3,43 +3,6 @@ import Availability from '.'
describe('Availability', () => {
it('renders correctly from data file values', () => {
const { container } = render(<Availability />)
expect(container.firstChild).toBeInTheDocument()
render(<Availability />)
})
// 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.')
// })
})

View File

@ -1,3 +1,5 @@
'use client'
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
import meta from '../../../_content/meta.json'
import { getAnimationProps, moveInBottom } from '../Transitions'

View File

@ -0,0 +1,8 @@
import { render } from '@testing-library/react'
import Footer from '.'
describe('Footer', () => {
it('renders correctly', async () => {
render(<Footer />)
})
})

View File

@ -1,5 +1,4 @@
import meta from '../../../_content/meta.json'
import resume from '../../../_content/resume.json'
import LogoUnit from '../LogoUnit'
import Networks from '../Networks'
import Vcard from '../Vcard'
@ -25,7 +24,7 @@ export default function Footer() {
<small>
&copy; {year}{' '}
<a className="u-url" href={meta.url}>
{resume.basics.name.toLowerCase()}
{meta.author.name.toLowerCase()}
</a>{' '}
&mdash; All Rights Reserved
</small>

View File

@ -10,6 +10,12 @@ describe('Header', () => {
})
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')
})
})

View File

@ -1,30 +1,26 @@
import { Suspense } from 'react'
import dynamic from 'next/dynamic'
'use client'
import { usePathname } from 'next/navigation'
import Availability from '../Availability'
import Location from '../Location'
import LogoUnit from '../LogoUnit'
import Networks from '../Networks'
import styles from './index.module.css'
const DynamicLocation = dynamic(() => import('../Location'), {
suspense: true
})
export default function Header() {
const pathname = usePathname()
const isSmall = pathname !== '/'
type Props = {
small?: boolean
}
export default function Header({ small }: Props) {
return (
<header className={`${styles.header} ${small ? styles.small : ''}`}>
<LogoUnit small={small} />
{!small ? <Networks label="Networks" /> : null}
<header
className={`${styles.header} ${isSmall ? styles.small : ''}`}
data-testid="header"
>
<LogoUnit small={isSmall} />
{!isSmall ? <Networks label="Networks" /> : null}
<div className={styles.meta}>
{!small ? (
<Suspense>
<DynamicLocation />
</Suspense>
) : null}
{!small ? <Availability /> : null}
{!isSmall ? <Location /> : null}
{!isSmall ? <Availability /> : null}
</div>
</header>
)

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import HostnameCheck from '.'
import HostnameCheck, { generateMetadata } from '.'
describe('HostnameCheck', () => {
it('can access window.location', () => {
@ -19,4 +19,24 @@ describe('HostnameCheck', () => {
const { container } = render(<HostnameCheck allowedHosts={allowedHosts} />)
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
}
})
})
})

View File

@ -1,11 +1,25 @@
'use client'
import { useEffect, useState } from 'react'
import Head from 'next/head'
import styles from './index.module.css'
type Props = {
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) {
// default to true so SSR builds never show the banner
const [isAllowedHost, setIsAllowedHost] = useState(true)
@ -18,16 +32,11 @@ export default function HostnameCheck({ allowedHosts }: Props) {
}, [allowedHosts])
return isAllowedHost ? null : (
<>
<Head>
<meta name="robots" content="noindex,nofollow" />
</Head>
<aside className={styles.hostnameInfo}>
<p>{`Hi there 👋. Please note that only the code and documentation of this
<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,
colors, and layout making up my brand identity are not. Don't just
clone, do a remix.`}</p>
</aside>
</>
</aside>
)
}

View File

@ -1,3 +1,8 @@
.location,
.wrapper {
min-height: 23px;
}
.location {
font-size: var(--font-size-small);
}

View File

@ -1,40 +1,68 @@
'use client'
import { useEffect, useState } from 'react'
import RelativeTime from '@yaireo/relative-time'
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
import { useLocation } from '../../hooks/useLocation'
import { getLocation } from '../../app/actions'
import { getAnimationProps, moveInTop } from '../Transitions'
import { Flag } from './Flag'
import styles from './index.module.css'
import { UseLocation } from './types'
export default function Location() {
const { now, next } = useLocation()
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' })
return now?.city ? (
<LazyMotion features={domAnimation}>
<m.section
aria-label="Location"
variants={moveInTop}
className={styles.location}
{...getAnimationProps(shouldReduceMotion)}
>
<Flag country={{ code: now.country_code, name: now.country }} />
{now?.city} <span>Now</span>
<div className={styles.next}>
{next?.city && (
<>
{isDifferentCountry && (
<Flag
country={{ code: next.country_code, name: next.country }}
/>
useEffect(() => {
async function fetchData() {
const location = await getLocation()
if (!location) return
setLocation(location)
}
fetchData()
}, [])
return (
<div className={styles.wrapper}>
{location?.now?.city ? (
<LazyMotion features={domAnimation}>
<m.section
aria-label="Location"
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}{' '}
<span>{relativeTime.from(new Date(next.date_start))}</span>
</>
)}
</div>
</m.section>
</LazyMotion>
) : null
</div>
</m.section>
</LazyMotion>
) : null}
</div>
)
}

View 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
}

View File

@ -1,18 +1,17 @@
import { render } from '@testing-library/react'
import LogoUnit from '.'
import data from '../../../_content/resume.json'
import meta from '../../../_content/meta.json'
describe('LogoUnit', () => {
it('renders correctly from data file values', () => {
const { basics } = data
const { container } = render(<LogoUnit />)
expect(container.firstChild).toBeInTheDocument()
expect(container.querySelector('.title')).toHaveTextContent(
basics.name.toLowerCase()
meta.author.name.toLowerCase()
)
expect(container.querySelector('.description')).toHaveTextContent(
basics.label.toLowerCase()
meta.author.label.toLowerCase()
)
})

View File

@ -1,6 +1,5 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import resume from '../../../_content/resume.json'
import meta from '../../../_content/meta.json'
import Logo from '../../images/logo.svg'
import styles from './index.module.css'
@ -9,24 +8,20 @@ type Props = {
}
export default function LogoUnit({ small }: Props) {
const router = useRouter()
const { pathname } = router
const isHome = pathname === '/'
const H = small ? 'h2' : 'h1'
return (
<Link
className={`${styles.logounit} ${small ? styles.small : null}`}
href="/"
aria-current={isHome ? 'page' : null}
aria-current={!small ? 'page' : null}
>
<Logo className={styles.logo} />
<H className={`p-name ${styles.title}`}>
{resume.basics.name.toLowerCase()}
{meta.author.name.toLowerCase()}
</H>
<p className={`p-job-title ${styles.description}`}>
{resume.basics.label.toLowerCase()}
{meta.author.label.toLowerCase()}
</p>
</Link>
)

View File

@ -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

View File

@ -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')
})
})

View File

@ -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

View File

@ -1,3 +1,5 @@
'use client'
import { LazyMotion, domAnimation, m } from 'framer-motion'
import Icon from '../Icon'
import { moveInTop } from '../Transitions'

View File

@ -1,5 +1,7 @@
'use client'
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 { NetworkLink } from './NetworkLink'
import styles from './index.module.css'
@ -33,10 +35,10 @@ export default function Networks({ label, small }: Props) {
<NetworkLink
name="Mail"
key="Mail"
url={`mailto:${resume.basics.email}`}
url={`mailto:${meta.author.email}`}
/>
{resume.basics.profiles.map((profile) => (
{meta.profiles.map((profile) => (
<NetworkLink
key={profile.network}
name={profile.network}

View File

@ -1,8 +1,10 @@
'use client'
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
import type ImageType from '../../interfaces/image'
import type ProjectType from '../../interfaces/project'
import type ImageType from '../../types/image'
import type ProjectType from '../../types/project'
import ProjectImage from '../ProjectImage'
import { getAnimationProps, moveInBottom, moveInTop } from '../Transitions'
import { getAnimationProps, moveInBottom } from '../Transitions'
import ProjectLinks from './Links'
import ProjectTechstack from './Techstack'
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 shouldReduceMotion = useReducedMotion()
const animationProps = getAnimationProps(shouldReduceMotion)
@ -41,7 +47,7 @@ export default function Project({ project }: { project: ProjectType }) {
</m.header>
</LazyMotion>
{images.map((image: ImageType, i: number) => (
{images?.map((image: ImageType, i: number) => (
<ProjectImage
className={styles.fullContainer}
image={image}

View File

@ -1,3 +1,5 @@
'use client'
import { useEffect, useState } from 'react'
import Image from 'next/image'
import {
@ -7,7 +9,7 @@ import {
useAnimation,
useReducedMotion
} from 'framer-motion'
import ImageType from '../../interfaces/image'
import ImageType from '../../types/image'
import { getAnimationProps } from '../Transitions'
import styles from './index.module.css'

View File

@ -1,6 +1,6 @@
import { ForwardedRef, forwardRef } from 'react'
import Link from 'next/link'
import ProjectType from '../../interfaces/project'
import ProjectType from '../../types/project'
import ProjectImage from '../ProjectImage'
import styles from './index.module.css'
@ -16,7 +16,7 @@ export const Project = forwardRef(
ref={ref}
>
<ProjectImage
image={project.images[0]}
image={project.images?.[0]}
alt={project.title}
sizes="(max-width: 30rem) 66vw, 33vw"
/>

View File

@ -1,10 +1,12 @@
'use client'
import { createRef, useEffect } from 'react'
import ProjectType from '../../interfaces/project'
import ProjectType from '../../types/project'
import { Project } from './Project'
import styles from './index.module.css'
type Props = {
projects: { slug: string }[]
projects: Partial<ProjectType>[]
currentSlug: string
}

View File

@ -1,5 +1,5 @@
import Link from 'next/link'
import ImageType from '../../interfaces/image'
import ImageType from '../../types/image'
import ProjectImage from '../ProjectImage'
import styles from './index.module.css'

View File

@ -1,9 +1,9 @@
import ProjectType from '../../interfaces/project'
import ProjectType from '../../types/project'
import ProjectPreview from '../ProjectPreview'
import styles from './index.module.css'
type Props = {
projects: ProjectType[]
projects: Partial<ProjectType>[]
}
export default function Projects({ projects }: Props) {

View File

@ -1,7 +1,7 @@
import { render } from '@testing-library/react'
import Repositories from '.'
import repos from '../../../tests/__fixtures__/repos.json'
import Repo from '../../interfaces/repo'
import Repo from '../../types/repo'
describe('Repositories', () => {
it('renders correctly', () => {

View File

@ -1,4 +1,4 @@
import Repo from '../../interfaces/repo'
import Repo from '../../types/repo'
import Repository from '../Repository'
import styles from './index.module.css'

View File

@ -1,6 +1,6 @@
import { render } from '@testing-library/react'
import repos from '../../../tests/__fixtures__/repos.json'
import Repo from '../../interfaces/repo'
import Repo from '../../types/repo'
import Repository from '../Repository'
describe('Repository', () => {

View File

@ -1,4 +1,4 @@
import Repo from '../../interfaces/repo'
import Repo from '../../types/repo'
import Icon from '../Icon'
import styles from './index.module.css'

View File

@ -1,3 +1,5 @@
'use client'
import * as Select from '@radix-ui/react-select'
import Icon from '../Icon'
import styles from './Item.module.css'

View File

@ -1,5 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import Head from 'next/head'
import * as Select from '@radix-ui/react-select'
import { useTheme } from 'next-themes'
import Icon from '../Icon'
@ -12,56 +13,45 @@ export function getIconName(theme: string) {
export default function ThemeSwitch() {
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), [])
return (
<>
<Head>
<meta name="theme-color" content="var(--theme-color)" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
</Head>
<aside className={styles.themeSwitch}>
{mounted ? (
<Select.Root
defaultValue={theme}
value={theme}
onValueChange={(value) => setTheme(value)}
>
<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}>
{mounted ? (
<Select.Root
defaultValue={theme}
value={theme}
onValueChange={(value) => setTheme(value)}
>
<Select.Trigger
className={styles.trigger}
aria-label="Theme Switch"
<Select.Portal>
<Select.Content
className={styles.content}
position="popper"
align="end"
>
<Select.Value>
<Icon name={getIconName(resolvedTheme)} />
</Select.Value>
<Select.Icon className={styles.chevron}>
<Icon name="ChevronDown" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content
className={styles.content}
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>
</>
<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>
)
}

View File

@ -1,8 +0,0 @@
import { render } from '@testing-library/react'
import Typekit from '.'
describe('Typekit', () => {
it('renders without crashing', async () => {
render(<Typekit />)
})
})

View File

@ -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 }} />
</>
)
}

View File

@ -1,13 +1,12 @@
import meta from '../../../_content/meta.json'
import resume from '../../../_content/resume.json'
import { constructVcard, init, toDataURL } from './_utils'
const metaMock = {
...meta,
name: resume.basics.name,
label: resume.basics.label,
email: resume.basics.email,
profiles: [...resume.basics.profiles]
name: meta.author.name,
label: meta.author.label,
email: meta.author.email,
profiles: [...meta.profiles]
}
describe('Vcard/_utils', () => {

View File

@ -1,8 +1,9 @@
'use client'
import meta from '../../../_content/meta.json'
import resume from '../../../_content/resume.json'
export default function Vcard() {
const { name, label, email, profiles } = resume.basics
const { name, label, email } = meta.author
const vCardMeta = {
...meta,
@ -10,7 +11,7 @@ export default function Vcard() {
name,
label,
email,
profiles
profiles: meta.profiles
}
const handleAddressbookClick = (e) => {

View File

@ -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
}
}

View File

@ -1,8 +1,8 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="330"
height="330"
viewBox="0 0 330 330"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"

Before

Width:  |  Height:  |  Size: 327 B

After

Width:  |  Height:  |  Size: 327 B

View File

@ -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}
</>
)
}

View File

@ -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')
})
})

View File

@ -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 />
</>
)
}

View File

@ -14,22 +14,14 @@ describe('lib/content', () => {
})
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')
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 () => {

View File

@ -2,12 +2,12 @@ import fs from 'fs'
import yaml from 'js-yaml'
import { join } from 'path'
import sharp from 'sharp'
import type ImageType from '../interfaces/image'
import type ProjectType from '../interfaces/project'
import type ImageType from '../types/image'
import type ProjectType from '../types/project'
import { markdownToHtml } from './markdown'
const imagesDirectory = join(process.cwd(), 'public', 'images')
const contentDirectory = join(process.cwd(), '_content')
const imagesDirectory = join(process.cwd(), 'public', 'images')
const projects = yaml.load(
fs.readFileSync(`${contentDirectory}/projects.yml`, 'utf8')
) as Partial<ProjectType>[]
@ -62,32 +62,16 @@ export async function getProjectImages(slug: string) {
export async function getProjectBySlug(slug: string, fields: string[] = []) {
const project = projects.find((item) => item.slug === slug)
if (!project) return
type Items = {
[key: string]: string
}
// enhance data with additional fields
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
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>
return project
}
export async function getAllProjects(

View File

@ -1,5 +1,5 @@
import data from '../../_content/repos.json'
import Repo from '../interfaces/repo'
import Repo from '../types/repo'
//
// Get GitHub repos

View File

@ -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
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 } }
}

View File

@ -1,4 +1,4 @@
type ImageType = {
declare type ImageType = {
src: string
width: number
height: number

View File

@ -1,6 +1,6 @@
import ImageType from './image'
type ProjectType = {
declare type ProjectType = {
images: ImageType[]
slug: string
title: string

View File

@ -1,4 +1,4 @@
type Repo = {
declare type Repo = {
name: string
full_name: string
description: string

View File

@ -5,31 +5,22 @@ import giphy from './__fixtures__/giphy.json'
import { dataLocation } from './__fixtures__/location'
import './__mocks__/matchMedia'
jest.mock('next/router', () => ({
useRouter: jest.fn().mockImplementation(() => ({
route: '/',
pathname: '/'
}))
jest.mock('next/navigation', () => ({
usePathname: jest.fn().mockImplementationOnce(() => '/')
}))
jest.mock('next/head', () => {
return {
__esModule: true,
default: ({ children }: { children: Array<React.ReactElement> }) => {
return <>{children}</>
}
}
})
jest.mock('../src/hooks/useLocation', () => ({
useLocation: jest.fn().mockImplementation(() => dataLocation)
jest.mock('../src/app/actions', () => ({
getLocation: jest.fn().mockImplementation(() => dataLocation),
getRandomGif: jest
.fn()
.mockImplementation(() => giphy.data.images.original.mp4)
}))
jest.mock('@giphy/js-fetch-api', () => ({
GiphyFetch: jest.fn().mockImplementation(() => ({
random: jest.fn().mockImplementation(() => Promise.resolve(giphy))
}))
}))
// jest.mock('@giphy/js-fetch-api', () => ({
// GiphyFetch: jest.fn().mockImplementation(() => ({
// random: jest.fn().mockImplementation(() => Promise.resolve(giphy))
// }))
// }))
const unmockedFetch = global.fetch
const unmockedEnv = process.env
@ -43,5 +34,5 @@ beforeEach(() => {
afterEach(() => {
global.fetch = unmockedFetch
process.env = unmockedEnv
// jest.restoreAllMocks()
jest.restoreAllMocks()
})

View File

@ -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)
})
})

View File

@ -13,8 +13,9 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true
"incremental": true,
"plugins": [{ "name": "next" }]
},
"exclude": ["node_modules"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
}