mirror of
https://github.com/kremalicious/portfolio.git
synced 2024-12-12 04:27:11 +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'
|
||||
- '**/*.d.ts'
|
||||
- '**/@types/'
|
||||
- '**/interfaces/'
|
||||
- '**/types/'
|
||||
- '**/_types.*'
|
||||
- '**/*.stories.*'
|
||||
- '**/*.test.*'
|
||||
|
@ -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
|
12
.github/dependabot.yml
vendored
12
.github/dependabot.yml
vendored
@ -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
|
||||
|
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
@ -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 }}
|
||||
|
@ -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
|
||||
}
|
||||
|
50
README.md
50
README.md
@ -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.
|
||||
|
||||
|
@ -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\">Let’s 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",
|
||||
|
@ -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
|
||||
{
|
||||
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
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}'",
|
||||
"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": {
|
||||
|
@ -1,6 +1,3 @@
|
||||
# https://platform.openai.com/docs/gptbot
|
||||
User-agent: GPTBot
|
||||
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 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
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;
|
||||
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;
|
||||
}
|
||||
|
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 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()
|
||||
|
@ -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.')
|
||||
// })
|
||||
})
|
||||
|
@ -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'
|
||||
|
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 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>
|
||||
© {year}{' '}
|
||||
<a className="u-url" href={meta.url}>
|
||||
{resume.basics.name.toLowerCase()}
|
||||
{meta.author.name.toLowerCase()}
|
||||
</a>{' '}
|
||||
— All Rights Reserved
|
||||
</small>
|
||||
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -1,3 +1,8 @@
|
||||
.location,
|
||||
.wrapper {
|
||||
min-height: 23px;
|
||||
}
|
||||
|
||||
.location {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
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 = {
|
||||
< |