Compare commits
No commits in common. "main" and "add-decap" have entirely different histories.
72 changed files with 2707 additions and 19280 deletions
|
@ -1,7 +0,0 @@
|
|||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
|
@ -1,2 +0,0 @@
|
|||
# NEXT_PUBLIC_CONTENT_API_BASE_URL=http://localhost:8055
|
||||
NEXT_PUBLIC_CONTENT_API_BASE_URL=https://cms.aaronjy.me
|
|
@ -1,3 +1,6 @@
|
|||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@stylistic/jsx/jsx-pascal-case": "off"
|
||||
}
|
||||
}
|
||||
|
|
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -34,11 +34,3 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
deploy-vars.sh
|
||||
node_modules
|
||||
.env
|
||||
.env.production
|
||||
|
||||
tmp/*
|
||||
!.gitkeep
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
npx --no -- commitlint --edit $1
|
|
@ -1 +1,3 @@
|
|||
npx lint-staged
|
||||
echo Formatting...
|
||||
npm run format
|
||||
echo Successfully formatted.
|
1
.nvmrc
1
.nvmrc
|
@ -1 +0,0 @@
|
|||
v22.15.0
|
47
.vscode/settings.json
vendored
47
.vscode/settings.json
vendored
|
@ -1,47 +1,6 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"aaronjy",
|
||||
"apos",
|
||||
"Aurelia",
|
||||
"Datatrial",
|
||||
"Dirents",
|
||||
"doesn",
|
||||
"fiftyfiveandfive",
|
||||
"Firestore",
|
||||
"Goodreads",
|
||||
"helpdesk",
|
||||
"Hetzner",
|
||||
"Integra",
|
||||
"Levantine",
|
||||
"opengraph",
|
||||
"Orangebus",
|
||||
"pipdig",
|
||||
"pubdate",
|
||||
"quot",
|
||||
"Radr",
|
||||
"Recon",
|
||||
"Sitecore",
|
||||
"sportank",
|
||||
"Umbraco",
|
||||
"Yarborough",
|
||||
"Yarbz"
|
||||
],
|
||||
"files.autoSave": "off",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "vscode.css-language-features"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
||||
"Sitecore"
|
||||
]
|
||||
}
|
71
Dockerfile
71
Dockerfile
|
@ -1,71 +0,0 @@
|
|||
FROM node:22-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1s
|
||||
|
||||
ARG NEXT_PUBLIC_CONTENT_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_CONTENT_API_BASE_URL=$NEXT_PUBLIC_CONTENT_API_BASE_URL
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
|
@ -1,2 +0,0 @@
|
|||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default { extends: ["@commitlint/config-conventional"] };
|
14
content/home.md
Normal file
14
content/home.md
Normal file
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
title: Awesome kitties
|
||||
date: 2019-03-17T19:31:20.591Z
|
||||
cats:
|
||||
- description: 'Maru is a Scottish Fold from Japan, and he loves boxes.'
|
||||
name: Maru (まる)
|
||||
- description: Lil Bub is an American celebrity cat known for her unique appearance.
|
||||
name: Lil Bub
|
||||
- description: 'Grumpy cat is an American celebrity cat known for her grumpy appearance.'
|
||||
name: Grumpy cat (Tardar Sauce)
|
||||
---
|
||||
Welcome to my awesome page about cats of the internet.
|
||||
|
||||
This page is built with NextJS, and content is managed in Decap CMS
|
163
content/pages/cv.yml
Normal file
163
content/pages/cv.yml
Normal file
|
@ -0,0 +1,163 @@
|
|||
competencies:
|
||||
- Software Development
|
||||
- Software Architecture
|
||||
- UI/UX Design
|
||||
- Full-Stack Development
|
||||
- Team Leadership
|
||||
- Recruitment and Onboarding
|
||||
- Web Application Development
|
||||
- Cloud Hosting (AWS
|
||||
- GCP)
|
||||
- Database Management
|
||||
- User Interface Design
|
||||
- Project Management
|
||||
education:
|
||||
- 10 GCSEs @ Duchess Community High School
|
||||
certifications:
|
||||
- Sitecore Professional Developer Certification (Sitecore 8.2) - Aug. 2017
|
||||
languages:
|
||||
- name: English
|
||||
proficiency: Native
|
||||
- name: German
|
||||
proficiency: Professional Working Proficiency
|
||||
- name: Arabic (Levantine)
|
||||
proficiency: Elementary
|
||||
experience:
|
||||
- position: Software Development Tutor
|
||||
employer: Yarbz Digital Ltd
|
||||
start: Sep. 2023
|
||||
end: Mar. 2024
|
||||
desc: I teach students of all levels modern software development, including
|
||||
coding fundamentals, computer science theory and modern software
|
||||
technologies. I also help them prepare for tech interviews!
|
||||
- position: Freelance Software Consultant
|
||||
employer: Yarbz Digital Ltd
|
||||
start: Aug. 2021
|
||||
end: Mar. 2024
|
||||
desc: >-
|
||||
* Designed and developed the front-end and back-end of the innovative
|
||||
recruitment platform "Radr" using Angular 13, Node.js, TypeScript,
|
||||
MongoDB, and hosted on Google Cloud Platform.
|
||||
|
||||
* Developed Fifty Five and Five's flagship website (fiftyfiveandfive.com) using PHP, WordPress, and Tailwind, enhancing its online presence.
|
||||
|
||||
* Built front-end of sportank.com, a dynamic social network catering to American Football enthusiasts and athletes, using Angular 10, Tailwind, SCSS, and TypeScript.
|
||||
|
||||
* Designed and built a proof-of-concept using Angular, Node.js, and gRPC, enabling real-time voice input streaming from web browsers to medical speech recognition software, leading to its active use by doctors for efficient communication.
|
||||
|
||||
* Maintained and improved the Integra Planner event management platform used by thousands of people for multi-day events, specifically for GARA Choruses.
|
||||
|
||||
* Enabled efficient event planning and management by utilizing Angular, Ionic for mobile apps, AWS, SQL Server, and .NET Core.
|
||||
|
||||
|
||||
**Skills:** ASP.NET · Amazon Elastic Container Registry (ECR) · Angular · TypeScript · Amazon ECS · Python (Programming Language) · Node.js · ASP.NET MVC · Content Management Systems (CMS) · Amazon Web Services (AWS) · Next.js · Microsoft Azure · Git · React.js · C# · Full-Stack Development · Umbraco · WordPress · Web Development · JavaScript · Front-End Development · MySQL · Microsoft SQL Server · Cloud Development · PHP · NoSQL · AWS Lambda · HTML · Microservices · Technical Requirements · Firebase · ASP.NET Core · Agile Methodologies · Google Cloud Platform (GCP) · MongoDB · User Interface Programming
|
||||
- position: Contract Software Engineer
|
||||
employer: The Data Shed
|
||||
start: Jan. 2023
|
||||
end: Aug. 2023
|
||||
desc: >-
|
||||
* Facilitated the recovery of funds for more than 100,000 customers
|
||||
affected by loan mis-selling by developing a robust web application using
|
||||
Next.js, TypeScript, React, Node.js, AWS Cognito, and AWS ECS.
|
||||
|
||||
* Implemented a wide range of features, including voting, bank details collection, messaging functionality, claims processing, and document management.
|
||||
|
||||
|
||||
**Skills:** Amazon Elastic Container Registry (ECR) · TypeScript · Amazon ECS · Tailwind CSS · Node.js · Amazon Web Services (AWS) · Next.js · React.js · docker · Front-End Development · NoSQL · AWS Lambda · HTML · Agile Methodologies · User Interface Programming
|
||||
- position: Software Architecht
|
||||
employer: T101
|
||||
start: Sep. 2020
|
||||
end: Jul. 2021
|
||||
desc: >-
|
||||
* As well as fulfilling the engineering responsibilities required by my
|
||||
previous role, my responsibilities now additionally entail designing,
|
||||
documenting and leading on architectural changes.As well as fulfilling the
|
||||
engineering responsibilities required by my previous role, my
|
||||
responsibilities now additionally entail designing, documenting and
|
||||
leading on architectural changes.
|
||||
|
||||
|
||||
**Skills:** ASP.NET · Angular · TypeScript · Amazon ECS · ASP.NET MVC · Kubernetes · Amazon Web Services (AWS) · Git · C# · Full-Stack Development · Web Development · JavaScript · Front-End Development · MySQL · Microsoft SQL Server · Cloud Development · NoSQL · HTML · .NET Core · Microservices · Technical Requirements · ASP.NET Core · Agile Methodologies · Google Cloud Platform (GCP) · User Interface Programming
|
||||
- position: Senior Full-stack Developer
|
||||
employer: T101
|
||||
start: Feb. 2020
|
||||
end: Sep. 2020
|
||||
desc: >-
|
||||
* Drove the complete platform re-architecture and development for Recon, a
|
||||
UK-based dating app with 200,000 monthly active users.
|
||||
|
||||
* Modernized and scaled the app by using .NET Core, Firestore, SignalR, Angular 10/TypeScript, gRPC, SQL Server, MySQL, microservices and Kubernetes on GCP.
|
||||
|
||||
|
||||
**Skills:** ASP.NET · Angular · TypeScript · Amazon ECS · ASP.NET MVC · Kubernetes · Git · C# · Full-Stack Development · Web Development · JavaScript · Front-End Development · MySQL · Microsoft SQL Server · Cloud Development · NoSQL · HTML · .NET Core · Microservices · Technical Requirements · Agile Methodologies · User Interface Programming
|
||||
- position: Senior Software Developer
|
||||
employer: Datatrial
|
||||
start: Apr. 2019
|
||||
end: Feb. 2020
|
||||
desc: >-
|
||||
* Worked on developing new/improving existing functional modules for
|
||||
Datatrial's Nucleus offering, which aims to provide a web platform for
|
||||
facilitating clinical trials.
|
||||
|
||||
|
||||
**Skills:** ASP.NET · TypeScript · ASP.NET MVC · Git · C# · Full-Stack Development · Web Development · JavaScript · Front-End Development · MySQL · Microsoft SQL Server · Aurelia · HTML · Agile Methodologies · User Interface Programming
|
||||
- position: Software Engineer
|
||||
employer: pipdig
|
||||
start: Aug 2018
|
||||
end: Apr 2019
|
||||
desc: >-
|
||||
* Developing a mix of commercial sites and bespoke blogs, I was
|
||||
responsible for the entire product life-cycle. This included requirements
|
||||
gathering, development, management of the project and ultimately
|
||||
delivering and maintaining the product. Responsibilities also included
|
||||
improving internal software development practices and working to increase
|
||||
efficiency across a wide range of small, fast-paced projects.
|
||||
|
||||
|
||||
**Skills:** TypeScript · Content Management Systems (CMS) · Git · Full-Stack Development · WordPress · Web Development · JavaScript · Front-End Development · MySQL · Microsoft SQL Server · Cloud Development · PHP · HTML · Agile Methodologies · User Interface Programming
|
||||
- position: Senior Software Developer
|
||||
employer: The Works
|
||||
start: Apr. 2018
|
||||
end: Aug. 2018
|
||||
desc: >-
|
||||
* I was the sole developer for an event management platform at Newcastle
|
||||
University, enhancing it by developing key features.
|
||||
|
||||
|
||||
**Skills:** ASP.NET · ASP.NET MVC · Content Management Systems (CMS) · Git · C# · Full-Stack Development · Web Development · JavaScript · Front-End Development · Microsoft SQL Server · HTML · Agile Methodologies · User Interface Programming · Umbraco
|
||||
- position: Software Developer
|
||||
employer: Orangebus
|
||||
start: Jan. 2017
|
||||
end: Apr. 2018
|
||||
desc: >-
|
||||
* My responsibilities involved developing and managing a variety of
|
||||
different projects across different industries.
|
||||
|
||||
|
||||
**Skills:** ASP.NET MVC · Git · C# · Full-Stack Development · JavaScript · Front-End Development · Microsoft SQL Server · HTML · Agile Methodologies · User Interface Programming
|
||||
- position: Junior Software Developer
|
||||
employer: True Potential LLP
|
||||
start: Oct. 2015
|
||||
end: Dec. 2016
|
||||
desc: >-
|
||||
* Primarily a web developer, my responsibilities included developing
|
||||
interactivity on the front-end, back-end services and designing database
|
||||
structures for large-scale web applications that are in-use by over 2
|
||||
million clients as of November 2016.
|
||||
|
||||
|
||||
**Skills:** ASP.NET MVC · Git · C# · Full-Stack Development · Web Development · JavaScript · Visual Basic .NET (VB.NET) · Front-End Development · Microsoft SQL Server · HTML · Agile Methodologies · User Interface Programming
|
||||
- position: IT Support / Trainee Software Developer
|
||||
employer: Innovation Property (UK)
|
||||
start: Jan. 2013
|
||||
end: Sep. 2015
|
||||
desc: >-
|
||||
* I worked as an IT Support Technician and Developer Trainee. My
|
||||
responsibilities included dealing with IT issues via an IT helpdesk
|
||||
system. I also worked on improvements to internally-developed software
|
||||
that was used by our Arboricultural staff. I also provided updates to an
|
||||
internal MVC application used by office staff to log data, arrange
|
||||
appointments for external staff and contact clients.
|
||||
|
||||
|
||||
**Skills:** ASP.NET MVC · Git · C# · Full-Stack Development · JavaScript · Front-End Development · Microsoft SQL Server · HTML · Agile Methodologies · User Interface Programming
|
16
content/recipes/manakish-pan.md
Normal file
16
content/recipes/manakish-pan.md
Normal file
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
title: Manakish (pan)
|
||||
description: Yummy yummy!
|
||||
you-will-need: |-
|
||||
* flower
|
||||
* 1tbsp sugar
|
||||
* 1 tsp yeast
|
||||
* 1/4 cup vegetable oil
|
||||
* 1/2 tsp baking powder
|
||||
* cheese (mozarella, akkawi) or za'atar for filling
|
||||
---
|
||||
1. Mix the flower, sugar and yeast together in a bowl, and add a 1 cup of warm water. Keep mixing until it turns to a goo-like consistency
|
||||
2. Cover the bowl for 10 minutes
|
||||
3. Add the oil, baking powder and a further 1.5 cup of flower, and stir
|
||||
4. Once the dough comes together, knead it until it is combined fully, and separate it into small blobs of dough.
|
||||
5. Roll each blob out and fry it for 2 minutes on either side in the pan, adding your preferred filling on top while it's cooking (cheese or za'atar, or both)
|
10
deploy.sh
10
deploy.sh
|
@ -1,10 +0,0 @@
|
|||
source ./deploy-vars.sh
|
||||
|
||||
echo "Host: $SSH_HOST"
|
||||
echo "Path: $SSH_UPLOAD_PATH"
|
||||
|
||||
echo "Deploying..."
|
||||
|
||||
rsync -r --delete ./out/ root@$SSH_HOST:$SSH_UPLOAD_PATH
|
||||
|
||||
echo "Done!"
|
|
@ -1,17 +0,0 @@
|
|||
import nextJest from "next/jest.js";
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||
dir: "./",
|
||||
});
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const config = {
|
||||
coverageProvider: "v8",
|
||||
testEnvironment: "jsdom",
|
||||
// Add more setup options before each test is run
|
||||
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
};
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
export default createJestConfig(config);
|
|
@ -2,8 +2,6 @@
|
|||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"checkJs": true,
|
||||
"jsx": "preserve"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
const fs = require("fs");
|
||||
const fm = require("front-matter");
|
||||
|
||||
const siteUrl = process.env.SITE_URL || "https://www.aaronjy.me";
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl,
|
||||
changefreq: "weekly",
|
||||
generateRobotsTxt: true,
|
||||
autoLastmod: false,
|
||||
generateIndexSitemap: false,
|
||||
exclude: ["/server-sitemap-index.xml"], // <= exclude here
|
||||
robotsTxtOptions: {
|
||||
policies: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
],
|
||||
additionalSitemaps: [
|
||||
`${siteUrl}/server-sitemap-index.xml`, // <==== Add here
|
||||
],
|
||||
},
|
||||
transform: async (config, path) => {
|
||||
const metadata = {
|
||||
loc: path,
|
||||
};
|
||||
|
||||
if (isHomepage(path)) {
|
||||
metadata.priority = 1;
|
||||
} else if (isBasePage(path)) {
|
||||
metadata.priority = 0.8;
|
||||
} else if (isArticle(path)) {
|
||||
metadata.priority = 0.6;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
},
|
||||
};
|
||||
|
||||
function isHomepage(path) {
|
||||
return path === "/";
|
||||
}
|
||||
|
||||
function isBasePage(path) {
|
||||
return path.split("/").length === 2;
|
||||
}
|
||||
|
||||
function isArticle(path) {
|
||||
return path.startsWith("/writing/") || path.startsWith("/library/");
|
||||
}
|
|
@ -1,10 +1,6 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
reactStrictMode: true
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig
|
||||
|
|
19510
package-lock.json
generated
19510
package-lock.json
generated
File diff suppressed because it is too large
Load diff
61
package.json
61
package.json
|
@ -1,66 +1,31 @@
|
|||
{
|
||||
"name": "www-aaronjy-me",
|
||||
"version": "2.4.1",
|
||||
"name": "www-aaronjy-2024",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"lint-staged": {
|
||||
"**/*": "prettier --write --ignore-unknown"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"server": "npx decap-server",
|
||||
"build": "next build",
|
||||
"postbuild": "next-sitemap --config next-sitemap.config.cjs",
|
||||
"start": "next start",
|
||||
"link": "echo NOT CONFIGURED",
|
||||
"format": "prettier . --write",
|
||||
"prepare": "husky",
|
||||
"test": "jest --verbose --passWithNoTests",
|
||||
"lint": "next lint",
|
||||
"export:books": "node ./util/books-as-json.js > ./tmp/books.json",
|
||||
"export:writing": "node ./util/writing-as-json.js > ./tmp/writing.json"
|
||||
"link": "npx standard",
|
||||
"format": "npx standard --fix",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@highlightjs/cdn-assets": "^11.11.1",
|
||||
"@mdx-js/mdx": "^3.1.0",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.0",
|
||||
"i": "^0.3.7",
|
||||
"next": "^15.3.1",
|
||||
"next-mdx-remote-client": "^1.1.0",
|
||||
"next": "14.1.1",
|
||||
"next-seo": "^6.5.0",
|
||||
"node-html-parser": "^7.0.1",
|
||||
"npm": "^11.3.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"rehype-code-titles": "^1.2.0",
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"react-dom": "^18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-react": "^7.24.7",
|
||||
"@commitlint/cli": "^19.1.0",
|
||||
"@commitlint/config-conventional": "^19.1.0",
|
||||
"@next/eslint-plugin-next": "^15.3.1",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"babel-jest": "^29.7.0",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-next": "15.3.1",
|
||||
"front-matter": "^4.0.2",
|
||||
"@babel/preset-react": "^7.23.3",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.1.1",
|
||||
"frontmatter-markdown-loader": "^3.7.0",
|
||||
"husky": "^9.0.11",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lint-staged": "^15.5.1",
|
||||
"next-sitemap": "^4.0.9",
|
||||
"prettier": "3.5.3",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"showdown": "^2.1.0"
|
||||
"showdown": "^2.1.0",
|
||||
"standard": "^17.1.0"
|
||||
}
|
||||
}
|
||||
|
|
59
public/admin/config.yml
Normal file
59
public/admin/config.yml
Normal file
|
@ -0,0 +1,59 @@
|
|||
local_backend: true
|
||||
backend:
|
||||
name: git-gateway
|
||||
branch: main # Branch to update (optional; defaults to master)
|
||||
media_folder: public/img
|
||||
public_folder: img
|
||||
collections:
|
||||
- name: "recipes"
|
||||
label: "Recipes"
|
||||
folder: "content/recipes"
|
||||
create: true
|
||||
fields:
|
||||
- {label: "Title", name: "title", widget: "string"}
|
||||
- {label: "Description", name: "description", widget: "string"}
|
||||
- {label: Image, name: image, widget: image, required: false}
|
||||
- {label: "You Will Need", name: "you-will-need", widget: "markdown" }
|
||||
- {label: "Recipe", name: "body", widget: "markdown" }
|
||||
- name: "pages"
|
||||
label: "Pages"
|
||||
files:
|
||||
- label: "CV"
|
||||
name: "cv"
|
||||
file: "content/pages/cv.yml"
|
||||
fields:
|
||||
- label: Core competencies
|
||||
widget: list
|
||||
name: competencies
|
||||
allow_add: true
|
||||
- label: Education history
|
||||
widget: list
|
||||
name: education
|
||||
allow_add: true
|
||||
- label: Certifications
|
||||
widget: list
|
||||
name: certifications
|
||||
allow_add: true
|
||||
- label: Languages
|
||||
widget: list
|
||||
name: languages
|
||||
allow_add: true
|
||||
fields:
|
||||
- {label: "Name", name: name, widget: string }
|
||||
- {label: "Proficiency", name: proficiency, widget: string }
|
||||
- label: Technical skills
|
||||
widget: list
|
||||
name: tech-skills
|
||||
- label: Professional experience
|
||||
name: experience
|
||||
widget: list
|
||||
fields:
|
||||
- { label: Position, name: position, widget: string }
|
||||
- { label: Employer, name: employer, widget: string }
|
||||
- { label: Start date, name: start, widget: string }
|
||||
- { label: End date, name: end, widget: string }
|
||||
- { label: Description, name: desc, widget: markdown }
|
||||
|
||||
|
||||
|
||||
|
13
public/admin/index.html
Normal file
13
public/admin/index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Content Manager</title>
|
||||
<!-- <script src="https://identity.netlify.com/v1/netlify-identity-widget.js"></script> -->
|
||||
</head>
|
||||
<body>
|
||||
<!-- Include the script that builds the page and powers Decap CMS -->
|
||||
<script src="https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js"></script>
|
||||
</body>
|
||||
</html>
|
Binary file not shown.
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Before Width: | Height: | Size: 78 KiB |
Binary file not shown.
Before Width: | Height: | Size: 132 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -1,10 +0,0 @@
|
|||
# *
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Host
|
||||
Host: https://www.aaronjy.me
|
||||
|
||||
# Sitemaps
|
||||
Sitemap: https://www.aaronjy.me/sitemap.xml
|
||||
Sitemap: https://www.aaronjy.me/server-sitemap-index.xml
|
|
@ -1,44 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://www.aaronjy.me/cv</loc><priority>0.8</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library</loc><priority>0.8</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/tags</loc><priority>0.8</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing</loc><priority>0.8</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/the-alchemist</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/the-invisible-man</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/wintering-the-power-of-rest-and-retreat-in-difficult-times</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/the-time-machine</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/when-the-moon-hits-your-eye</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/the-song-of-achilles</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/to-be-taught-if-fortunate</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/on-tyranny</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/the-dangers-of-smoking-in-bed</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/the-midnight-library</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/a-night-to-remember</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/sex-punishment</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/a-monster-calls</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/diary-of-an-oxygen-thief</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/1984</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/alices-adventures-in-wonderland</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/the-nature-of-alexander</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/eleven-kinds-of-loneliness</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/star-maker</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/stray-reflections</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/stasiland-stories-from-behind-the-berlin-wall</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/cities-that-shaped-the-ancient-world</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/animal-farm</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/a-wizard-of-earthsea</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/one-thousand-and-one-nights-a-retelling</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/the-tombs-of-atuan</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/childhoods-end</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/library/the-farthest-shore</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/about</loc><priority>0.8</priority></url>
|
||||
<url><loc>https://www.aaronjy.me</loc><priority>1</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing/migrating-from-github-to-forgejo</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing/performance-considerations-when-writing-a-tcp-game-server-in-dotnet</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing/deploying-aaronjy-me-on-a-google-storage-bucket</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing/supporting-content-file-structure-changes-on-a-static-site</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing/attitudes-to-reading-and-how-mine-have-changed</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing/a-reflection-on-wintering-by-katherine-may</loc><priority>0.6</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing/how-my-adhd-makes-handling-relationships-difficult</loc><priority>0.6</priority></url>
|
||||
</urlset>
|
|
@ -1,41 +0,0 @@
|
|||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: BCPG v1.63
|
||||
|
||||
mQSuBGYs08wRDACGpe++RJHa0CmDnvo3001U19EUxv5xDo1cJk6crcX91KD2HrZu
|
||||
jO4/MGC3N93v/6NvvRgv9gxYqr4tvcc4euj1WbsyNJ5HEsDXkoi5ibUAQmDRQyIj
|
||||
l2AR5mDbrwXXRTxGgItoqK+mUqPCQL3OBRa1EMHcL28IhLS/bvhviA1My0u83B3d
|
||||
qFUm6lli4SIAOmubBytIF8kulx5Vn6SwXzlfmpJ7kyFr2dmd9tAF2l28zL/L2qbj
|
||||
6Q03EmCpvcWshiRuUjt8VnuBH+nuuY3YkEzCFZwa0kryKcoWpsQja7nACMZInWAr
|
||||
oTLAZVI9mJx8qnA8j8+gKE5rnkDibKkMKifiA9xX/392ufnxQ09sL0vq11d6iWFz
|
||||
v2hyq0h6Pj1MZ+QgQnVBmJiLnAQZq+B2OZcUPBD/pk+vX4FAqbfAATbLoZ1x/M3l
|
||||
kbmYvzgGNIowH+VaR8FEn3RGx/sqMsLihy7oanQVbBHWRvT+M7D7t8VCRkBkFb+X
|
||||
8OHAVsANbrg4O68BAP908xlKj9XOpQMr39FRPP3ZpbtMZA1FI9eh9POWvtgZC/9P
|
||||
wJDKnugYjV4rlMdiaAEFYkw9FEIWBKfjqvJbg0OedCz85qAqZZhLcEKz68J+PfIC
|
||||
QwnehW24CQMG3lFYlO/cXGz4HsYAQNkphc5RupksvnhLJxcf7ZOu79AMn2gxTW/u
|
||||
TnMGsJLD2S/ieToetG+xIXhAJ5+KSON+kMK1joxxJhwM3bYEQLRwtWv1EQjhFci0
|
||||
tEm19dp625MIWh6mometDRDobRwRdcsoD0xUY2mVQwDER0TjqUlJVvyS7QhvQHmg
|
||||
REFDdIaQbFpx6Ri3XTrHBX/2Z4Aaw/4m8ARZ8wXdN5f6ZjAgWmzBWMJyYst6mqeZ
|
||||
jZihmhz2CffknrveUSvBqv42As1feqScsmZZh5NjVMOpqBTToaWddKTSlmeg77kC
|
||||
x8k8lgZXQtGTpCKX2dWDfx63RbmvT3CeulK72a/UmkfgCvw0VRFE7d/nEr4yhg+1
|
||||
wxrI8l82HlEs5D1Gd9SiGlF7Naq54Hs7Njncmgx9//emYdftaommYDburHpu+E4L
|
||||
/12+HpIrPyAkUv1m58BcHpgRRoM1xD/3bIni21LXl/k2DLFhEQj7r4z405htdy43
|
||||
HYuneVGOYvM4ja3bOn8XSJyHFXLN5s6w2GrNgEo0cPtXLbAWiaC4gucxIkRv/LW4
|
||||
063qZ6sRIhzRgmSogGwhS5WJ09enxXj9N1EaIXmUH8AVStJbg72ema7PzZJkOWHK
|
||||
y12dTQJvJSFNnU2e4kny/XHZxk7rBOk7N6jEuCci1EbUj7+GSR5BqDm2TnrqQkAg
|
||||
BGCOnfQ/vOyjF8YWh5nBy66zQ/meTy5gzainDi8XOPRlmf1cqoQEoLdEh/MMY3pW
|
||||
W8Bd6wKt5NCnVSPWB1f48jXJrgx0iUR58EWqnCvDjojZlqI7uZUI7z5+Q3b4p8eF
|
||||
jqErsF1EKEaHBZcgb4ScwomKfZRIZj1ciPveLKQB4jd7bNN7GlICPJi25KKHemqy
|
||||
CIFWUYxJRWVbhGpMVkR/4IJmFuqj01/ApbZZVATDBRl2jdMBcIQ58TH0W6G/qULg
|
||||
k7QgQWFyb24gWWFyYm9yb3VnaCA8bWVAYWFyb25qeS5tZT6IXgQTEQoABgUCZizT
|
||||
zAAKCRB2asy1Uw4clqXGAP0YS2fW78sDrsUKP/9G67+PNYmf7x3hgz/wHPctdZF6
|
||||
MAD/X5q9506+vYoxPzIjg/WQs393eFhEPgv7FP+zI+8iJXK5AQ0EZizTzBAEAP//
|
||||
////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+V
|
||||
GbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44
|
||||
a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AAICA/0YD77y3YkEP8zVCDDi
|
||||
Tfjco7NOWoHSSbk0q8iHhrbfN9RMOQQVs1FXHCInyXGmmTc79g5PjqIN5n/+agXS
|
||||
0BGwbygN0/hMh0EnvmMLCfzRTAPufInrDN4/tKVrGxXMAT9Gnh1RDjSBzh7H/0vr
|
||||
63UNVLiQeRX1JIXAbuWB1dp6D4heBBgRCgAGBQJmLNPMAAoJEHZqzLVTDhyWxMEB
|
||||
AL0AMD/6SZsCgV6wV2NsCD/6TnErJjIiszJNSVOw4vpuAP4oriEQ2zTw1leT9DKA
|
||||
bRZce4ZhaHXiVBufyfiPIC3Jsw==
|
||||
=7Ll3
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
After Width: | Height: | Size: 629 B |
|
@ -1,40 +0,0 @@
|
|||
import { formatDate } from "@/lib/helpers";
|
||||
import { NextSeo } from "next-seo";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import "highlight.js/styles/atom-one-dark.css";
|
||||
import hljs from "highlight.js";
|
||||
|
||||
function Article({ title, excerpt, datePublished, tags, html }) {
|
||||
useEffect(() => {
|
||||
hljs.highlightAll();
|
||||
}, [html]);
|
||||
return (
|
||||
<>
|
||||
<h1>{title}</h1>
|
||||
<article>
|
||||
<NextSeo
|
||||
title={title}
|
||||
description={excerpt}
|
||||
openGraph={{
|
||||
title,
|
||||
description: excerpt,
|
||||
type: "article",
|
||||
article: {
|
||||
publishedTime: datePublished ?? null,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Link href="./">Back...</Link>
|
||||
{datePublished && <p>{formatDate(datePublished)}</p>}
|
||||
<div data-test="content" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{tags && <p>Tags: {tags.join(", ")}</p>}
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Article;
|
|
@ -1,80 +0,0 @@
|
|||
import { formatDate } from "@/lib/helpers";
|
||||
import { NextSeo } from "next-seo";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import style from "./BookReview.module.css";
|
||||
import ExternalLink from "../ExternalLink/ExternalLink";
|
||||
|
||||
function BookReview({ review, html }) {
|
||||
const { title, image, author, description, url, tags, rating, readDate } =
|
||||
review;
|
||||
|
||||
const imageUrl = image
|
||||
? `${process.env.NEXT_PUBLIC_CONTENT_API_BASE_URL}/assets/${image}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>
|
||||
{title} <small>- {author}</small>
|
||||
</h1>
|
||||
|
||||
<Link href="./">Back...</Link>
|
||||
|
||||
<article>
|
||||
<NextSeo
|
||||
title={title}
|
||||
description={description}
|
||||
openGraph={{
|
||||
title,
|
||||
description,
|
||||
type: "article",
|
||||
article: {
|
||||
publishedTime: readDate ?? null,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className={style.layout}>
|
||||
{imageUrl && (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
width={250}
|
||||
height={580}
|
||||
alt=""
|
||||
className={style.thumbnail}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div
|
||||
data-test="content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: html || "<p>(no review)</p>",
|
||||
}}
|
||||
/>
|
||||
<p>
|
||||
{tags?.length && (
|
||||
<>
|
||||
<span className="bold">Genres:</span> {tags.join(",")}
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
<span className="bold">Rating:</span> {rating}/5
|
||||
<br />
|
||||
<span className="bold">Read on:</span>
|
||||
{formatDate(readDate)}
|
||||
</p>
|
||||
<p>
|
||||
<ExternalLink href={url}>View on The StoryGraph</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BookReview;
|
|
@ -1,33 +0,0 @@
|
|||
.layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1.25rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.layout:first-child {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layout:last-child {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.layout p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.layout {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
import style from "./BookReviewItem.module.css";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function BookReviewItem({
|
||||
href,
|
||||
title,
|
||||
author,
|
||||
rating,
|
||||
image,
|
||||
tags,
|
||||
}) {
|
||||
const imageUrl = image
|
||||
? `${process.env.NEXT_PUBLIC_CONTENT_API_BASE_URL}/assets/${image}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={style.item}>
|
||||
<Link href={href}>
|
||||
<div
|
||||
className={style.thumb}
|
||||
style={{
|
||||
backgroundImage: `url(${imageUrl ?? "/img/book-placeholder.jpg"})`,
|
||||
}}
|
||||
>
|
||||
<div className={style.rating}>
|
||||
<Star />
|
||||
{rating}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div>
|
||||
<h2 className={style.heading}>
|
||||
<Link href={href}>{title}</Link>
|
||||
</h2>
|
||||
<p className={style.author}>{author}</p>
|
||||
{tags?.length && <p>{tags.join(", ")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Star() {
|
||||
return (
|
||||
<svg
|
||||
style={{
|
||||
fill: "currentColor",
|
||||
}}
|
||||
height="15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 47.94 47.94"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<path d="m26.285 2.486 5.407 10.956a2.58 2.58 0 0 0 1.944 1.412l12.091 1.757c2.118.308 2.963 2.91 1.431 4.403l-8.749 8.528a2.582 2.582 0 0 0-.742 2.285l2.065 12.042c.362 2.109-1.852 3.717-3.746 2.722l-10.814-5.685a2.585 2.585 0 0 0-2.403 0l-10.814 5.685c-1.894.996-4.108-.613-3.746-2.722l2.065-12.042a2.582 2.582 0 0 0-.742-2.285L.783 21.014c-1.532-1.494-.687-4.096 1.431-4.403l12.091-1.757a2.58 2.58 0 0 0 1.944-1.412l5.407-10.956c.946-1.919 3.682-1.919 4.629 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
.item {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.author {
|
||||
margin-top: 5px;
|
||||
/* font-weight: bold; */
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: auto;
|
||||
height: 290px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rating {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
background-color: var(--light);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
56
src/components/CV/CV.jsx
Normal file
56
src/components/CV/CV.jsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import React from 'react'
|
||||
|
||||
import style from './CV.module.css'
|
||||
|
||||
function CV ({ competencies, education, certifications, languages, experience }) {
|
||||
return (
|
||||
<div className={style.cv}>
|
||||
<div>
|
||||
<h2>Core competencies</h2>
|
||||
<ul>
|
||||
{competencies.sort().map(c => <li key={c}>{c}</li>)}
|
||||
</ul>
|
||||
|
||||
<h2>Certifications</h2>
|
||||
<ul>
|
||||
{certifications.sort().map(c => <li key={c}>{c}</li>)}
|
||||
</ul>
|
||||
|
||||
<h2>Languages</h2>
|
||||
<ul>
|
||||
{languages.sort().map(c => <li key={c}>{c.name} - {c.proficiency}</li>)}
|
||||
</ul>
|
||||
|
||||
<h2>Education history</h2>
|
||||
<p>{education}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Professional experience</h2>
|
||||
|
||||
{experience.map((exp, i) => (
|
||||
<CVWorkExperience key={i} employer={exp.employer} position={exp.position} start={exp.start} end={exp.end}>{exp.desc}</CVWorkExperience>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CV
|
||||
|
||||
function CVWorkExperience ({ position, employer, start, end, children }) {
|
||||
return (
|
||||
<div className={style['work-experience']}>
|
||||
<div>
|
||||
<h3>
|
||||
{position}
|
||||
<br />
|
||||
<small>{employer}</small>
|
||||
</h3>
|
||||
<div>
|
||||
<time>{start}</time>-<time>{end}</time>
|
||||
</div>
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: children }} />
|
||||
</div>
|
||||
)
|
||||
}
|
29
src/components/CV/CV.module.css
Normal file
29
src/components/CV/CV.module.css
Normal file
|
@ -0,0 +1,29 @@
|
|||
.cv {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.cv > div:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cv > div:last-child {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.cv .work-experience >div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cv ul {
|
||||
margin-left: 0;
|
||||
list-style: inside;
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
.cv ul li:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
}
|
31
src/components/ExternalLink.jsx
Normal file
31
src/components/ExternalLink.jsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Sets default values for external links on an anchor tag.
|
||||
* @returns
|
||||
*/
|
||||
function ExternalLink ({
|
||||
href,
|
||||
rel = 'nofollow noopener',
|
||||
children,
|
||||
target = '_blank'
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<a href={href} rel={rel} target={target}>
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
style={{ display: 'inline', width: '1rem', marginRight: '0.25rem' }}
|
||||
>
|
||||
<g>
|
||||
<path fill='currentColor' d='M10 6v2H5v11h11v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6zm11-3v8h-2V6.413l-7.793 7.794-1.414-1.414L17.585 5H13V3h8z' />
|
||||
</g>
|
||||
</svg>
|
||||
{children}
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExternalLink
|
|
@ -1,34 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
/**
|
||||
* Sets default values for external links on an anchor tag.
|
||||
* @returns
|
||||
*/
|
||||
function ExternalLink({
|
||||
href,
|
||||
rel = "nofollow noopener",
|
||||
children,
|
||||
target = "_blank",
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<a href={href} rel={rel} target={target}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ display: "inline", width: "1rem", marginRight: "0.25rem" }}
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10 6v2H5v11h11v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6zm11-3v8h-2V6.413l-7.793 7.794-1.414-1.414L17.585 5H13V3h8z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{children}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExternalLink;
|
|
@ -1,44 +1,47 @@
|
|||
import React from "react";
|
||||
import React from 'react'
|
||||
|
||||
import style from "./Footer.module.css";
|
||||
import style from './Footer.module.css'
|
||||
|
||||
// @ts-ignore
|
||||
import pck from "../../../package.json";
|
||||
|
||||
function Footer() {
|
||||
function Footer () {
|
||||
return (
|
||||
<footer className={`${style.footer} hide-print`} data-testid="footer">
|
||||
<hr />
|
||||
<footer className={style.footer}>
|
||||
<nav>
|
||||
<div>
|
||||
<span>
|
||||
<a href="#">Back to top</a>
|
||||
</span>
|
||||
{", "}
|
||||
<span>
|
||||
<a href="/static/pgp.txt">PGP key</a>
|
||||
</span>
|
||||
{", "}
|
||||
<span>
|
||||
<a
|
||||
href="https://git.aaronjy.me/aaron/aaronjy-me"
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
>
|
||||
View site src
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<a href='#'>Back to top</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='mailto:me@aaronjy.me'>Send me an email</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<small>
|
||||
2025 Aaron Yarborough,{" "}
|
||||
<span title="major.minior.patch.content">v{pck.version}</span>
|
||||
</small>
|
||||
</div>
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<small>
|
||||
2024 Aaron Yarborough, made with{' '}
|
||||
<a
|
||||
target='_blank'
|
||||
rel='nofollow noopener noreferrer'
|
||||
href='https://nextjs.org/'
|
||||
>
|
||||
Next.js
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
target='_blank'
|
||||
rel='nofollow noopener noreferrer'
|
||||
href='https://yegor256.github.io/tacit/'
|
||||
>
|
||||
Tacit
|
||||
</a>
|
||||
</small>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</footer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
export default Footer
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.footer nav:first-child a {
|
||||
text-transform: lowercase;
|
||||
}
|
|
@ -1,5 +1,11 @@
|
|||
import style from "./Grid.module.css";
|
||||
import React from 'react'
|
||||
|
||||
export default function Grid({ columns, children }) {
|
||||
return <div className={style.grid}>{children}</div>;
|
||||
import style from './Grid.module.css'
|
||||
|
||||
function Grid ({ children }) {
|
||||
return (
|
||||
<div className={style.grid}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Grid
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 32px;
|
||||
}
|
|
@ -1,26 +1,18 @@
|
|||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
import styles from "./Header.module.css";
|
||||
import styles from './Header.module.css'
|
||||
|
||||
function Header() {
|
||||
function Header () {
|
||||
return (
|
||||
<header className={`${styles.header} hide-print`} data-testid="header">
|
||||
<header className={styles.header}>
|
||||
<nav>
|
||||
<Link href="/">Home</Link>
|
||||
{", "}
|
||||
<Link href="/writing">Writing</Link>
|
||||
{", "}
|
||||
<Link href="/tags">Tags</Link>
|
||||
{", "}
|
||||
<Link href="/cv">CV</Link>
|
||||
{", "}
|
||||
<Link href="/library">Library</Link>
|
||||
{", "}
|
||||
<Link href="/about">About</Link>
|
||||
<Link href='/'>Home</Link>
|
||||
<Link href='/writing'>Writing</Link>
|
||||
<Link href='/cv'>CV</Link>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default Header;
|
||||
export default Header
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
.header {
|
||||
margin-top: 20px;
|
||||
.header nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header a {
|
||||
text-transform: lowercase;
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export default function Loading() {
|
||||
return <p>Loading...</p>;
|
||||
}
|
|
@ -1,144 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
import style from "./Resume.module.css";
|
||||
import { markdownToHtml } from "@/services/content-service";
|
||||
import { MDXClient } from "next-mdx-remote-client/csr";
|
||||
import Link from "next/link";
|
||||
|
||||
function Resume({
|
||||
introMdxSource,
|
||||
competencies,
|
||||
education,
|
||||
certifications,
|
||||
languages,
|
||||
experience,
|
||||
}) {
|
||||
return (
|
||||
<div className={style.cv}>
|
||||
<ol className="hide-print">
|
||||
<li>
|
||||
<a href="#experience">Professional experience</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#competencies">Competencies</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#competencies">Certifications</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#languages">Languages</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#education">Education</a>
|
||||
</li>
|
||||
</ol>
|
||||
<div>
|
||||
<div className={style.about}>
|
||||
<MDXClient {...introMdxSource} />
|
||||
<span className="print-only">
|
||||
See more at <a href="https://aaronjy.me/cv">aaronjy.me/cv</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.cvContent}>
|
||||
<div className={style.experience}>
|
||||
<h2 id="experience">Professional experience</h2>
|
||||
|
||||
{experience?.map((exp, i) => (
|
||||
<div key={i}>
|
||||
<WorkExperience
|
||||
employer={exp.employer}
|
||||
position={exp.position}
|
||||
start={exp.start}
|
||||
end={exp.end}
|
||||
skills={exp.skills}
|
||||
>
|
||||
{markdownToHtml(exp.description)}
|
||||
</WorkExperience>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={style.sidebar}>
|
||||
<h2 id="competencies">Competencies</h2>
|
||||
<ul>
|
||||
{competencies
|
||||
?.sort((a, b) => a.name - b.name)
|
||||
.map((c, i) => (
|
||||
<li key={i}>{c.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 id="certifications">Certifications</h2>
|
||||
<ul>
|
||||
{certifications
|
||||
?.sort((a, b) => a.name > b.name)
|
||||
.map((c, i) => (
|
||||
<li key={i}>{c.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="languages">Languages</h2>
|
||||
<ul>
|
||||
{languages?.sort().map((c, i) => (
|
||||
<li key={i}>
|
||||
<strong>{c.name}</strong> - {c.proficiency}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="education">Education</h2>
|
||||
<ul>
|
||||
{education
|
||||
?.sort((a, b) => a.name - b.name)
|
||||
.map((c, i) => (
|
||||
<li key={i}>{c.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Resume;
|
||||
|
||||
function WorkExperience({ position, employer, start, end, skills, children }) {
|
||||
return (
|
||||
<>
|
||||
<table className={style.experienceTable}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<span id={position} className={style.position}>
|
||||
{position}
|
||||
</span>
|
||||
<br />
|
||||
<span className={style.employer}>{employer}</span>
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<time>{start}</time> - <time>{end}</time>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<div
|
||||
data-test="children"
|
||||
dangerouslySetInnerHTML={{ __html: children }}
|
||||
className={style.experienceContent}
|
||||
/>
|
||||
|
||||
{!!skills?.length && (
|
||||
<div className={style.skillList}>
|
||||
{skills.sort().map((skill) => (
|
||||
<span key={skill}>{skill}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,74 +0,0 @@
|
|||
.experienceTable {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.position {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cvContent {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 0.5em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cvContent .experience {
|
||||
flex-basis: 66.66%;
|
||||
}
|
||||
|
||||
.cvContent .sidebar {
|
||||
flex-basis: 33.33%;
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
left: 0px;
|
||||
height: 1px;
|
||||
/* Needed to make sticky work */
|
||||
}
|
||||
|
||||
.about {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.cvContent ol,
|
||||
.cvContent ul {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.skillList {
|
||||
font-size: 0.8em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2em;
|
||||
}
|
||||
|
||||
.skillList span {
|
||||
background-color: var(--dark);
|
||||
color: var(--light);
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
|
||||
.experienceContent > p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.cvContent {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cvContent .sidebar {
|
||||
height: auto;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.cvContent .experience {
|
||||
flex-basis: 80%;
|
||||
}
|
||||
|
||||
.cvContent .sidebar {
|
||||
flex-basis: 20%;
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import { formatDate } from "@/lib/helpers";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function StaticContentList({ entries, urlPrefix, max = 0 }) {
|
||||
return (
|
||||
<div>
|
||||
{entries
|
||||
.map((e) => (
|
||||
<p key={e.slug}>
|
||||
<Link href={`${urlPrefix}${e.slug}`}>{e.title}</Link>{" "}
|
||||
{!!e.datePublished && <i>{`on ${formatDate(e.datePublished)}`}</i>}
|
||||
</p>
|
||||
))
|
||||
.slice(0, max > 0 ? max : entries.length)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
// Posts
|
||||
|
||||
export class FailedFetchPostsError extends Error {
|
||||
constructor(msg) {
|
||||
super(`Failed to fetch posts: ${msg}`);
|
||||
this.name = "FailedFetchPostsError";
|
||||
}
|
||||
}
|
||||
|
||||
export class FailedFetchPostError extends Error {
|
||||
constructor(slug, msg) {
|
||||
super(`Failed to fetch post '${slug}: ${msg}`);
|
||||
this.name = "FailedFetchPostError";
|
||||
}
|
||||
}
|
||||
|
||||
// Book reviews
|
||||
|
||||
export class FailedFetchBookReviewsError extends Error {
|
||||
constructor(msg) {
|
||||
super(`Failed to fetch book reviews: ${msg}`);
|
||||
this.name = "FailedFetchBookReviewsError";
|
||||
}
|
||||
}
|
||||
|
||||
export class FailedFetchBookReviewError extends Error {
|
||||
constructor(slug, msg) {
|
||||
super(`Failed to fetch book review '${slug}: ${msg}`);
|
||||
this.name = "FailedFetchBookReviewError";
|
||||
}
|
||||
}
|
||||
|
||||
// Basic pages
|
||||
|
||||
export class FailedFetchBasicPagesError extends Error {
|
||||
constructor(msg) {
|
||||
super(`Failed to fetch basic pages: ${msg}`);
|
||||
this.name = "FailedFetchBasicPagesError";
|
||||
}
|
||||
}
|
||||
|
||||
export class FailedFetchBasicPageError extends Error {
|
||||
constructor(slug, msg) {
|
||||
super(`Failed to fetch basic page '${slug}: ${msg}`);
|
||||
this.name = "FailedFetchBasicPageError";
|
||||
}
|
||||
}
|
||||
|
||||
// CV
|
||||
|
||||
export class FailedFetchCVError extends Error {
|
||||
constructor(msg) {
|
||||
super(`Failed to fetch basic pages: ${msg}`);
|
||||
this.name = "FailedFetchCVError";
|
||||
}
|
||||
}
|
|
@ -1,17 +1,21 @@
|
|||
import React from "react";
|
||||
import React from 'react'
|
||||
|
||||
import style from "./DefaultLayout.module.css";
|
||||
import Header from "@/components/Header/Header";
|
||||
import Footer from "@/components/Footer/Footer";
|
||||
import style from './DefaultLayout.module.css'
|
||||
import Header from '@/components/Header/Header'
|
||||
import Footer from '@/components/Footer/Footer'
|
||||
|
||||
function DefaultLayout({ children }) {
|
||||
import { Barlow } from 'next/font/google'
|
||||
|
||||
const fontMain = Barlow({ subsets: ['latin'], weight: ['400', '600'] })
|
||||
|
||||
function DefaultLayout ({ children }) {
|
||||
return (
|
||||
<main className={`${style.layout}`}>
|
||||
<div className={style.layout}>
|
||||
<Header />
|
||||
<>{children}</>
|
||||
<main className={`${fontMain.className}`}>{children}</main>
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DefaultLayout;
|
||||
export default DefaultLayout
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.layout main {
|
||||
flex-grow: 1;
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import * as dateFns from "date-fns";
|
||||
|
||||
export function filenameToSlug(input) {
|
||||
return stringToSlug(input.substring(0, input.indexOf(".")));
|
||||
}
|
||||
|
||||
export function stringToSlug(str) {
|
||||
return str
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\W_]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
return dateFns.format(Date.parse(date), "PPP");
|
||||
}
|
||||
|
||||
/**
|
||||
* Silliness to make sure dates don't get passed to the
|
||||
* page function below as [object Object]
|
||||
* @param {*} obj
|
||||
* @returns
|
||||
*/
|
||||
export function stringifyAndParse(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import StaticContentList from "@/components/StaticContentList/StaticContentList";
|
||||
import { fetchPosts } from "@/services/content-service";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
export const mdxComponents = {
|
||||
StaticContentList: ({ type, urlPrefix, max = 0 }) => {
|
||||
const [items, setItems] = useState([]);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
useEffect(
|
||||
function () {
|
||||
if (!urlPrefix || max <= 0) return;
|
||||
|
||||
switch (type) {
|
||||
case "posts":
|
||||
(async function () {
|
||||
setLoading(true);
|
||||
const res = await fetchPosts([]);
|
||||
const json = await res.json();
|
||||
setItems(
|
||||
json.data.sort((a, b) => a.datePublished < b.datePublished) ??
|
||||
[],
|
||||
);
|
||||
setLoading(false);
|
||||
})();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw `Could not render StaticContentList: content type ${type} not supported.`;
|
||||
}
|
||||
},
|
||||
[type, urlPrefix, max],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <p>Loading...</p>}
|
||||
{!isLoading && (
|
||||
<StaticContentList entries={items} urlPrefix={urlPrefix} max={max} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
|
@ -1,94 +0,0 @@
|
|||
import Loading from "@/components/Loading/Loading";
|
||||
import { FailedFetchBasicPagesError } from "@/errors";
|
||||
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
|
||||
import { mdxComponents } from "@/lib/mdx-components";
|
||||
import { fetchBasicPages } from "@/services/content-service";
|
||||
import { MDXClient } from "next-mdx-remote-client";
|
||||
import { serialize } from "next-mdx-remote-client/serialize";
|
||||
import { NextSeo } from "next-seo";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const res = await fetchBasicPages(["path"]);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchBasicPagesError(await res.text());
|
||||
}
|
||||
|
||||
const pages = (await res.json()).data;
|
||||
|
||||
return {
|
||||
paths: pages.map((page) => ({
|
||||
params: {
|
||||
path: [page.path ?? ""],
|
||||
},
|
||||
})),
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStaticProps({ params }) {
|
||||
const { path } = params;
|
||||
|
||||
const res = await fetchBasicPages([], {
|
||||
path: {
|
||||
_eq: path?.join("/") ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchBasicPageError(path, await res.text());
|
||||
}
|
||||
|
||||
const page = (await res.json()).data.at(0);
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
notFound: true,
|
||||
revalidate: 60,
|
||||
};
|
||||
}
|
||||
|
||||
const { content, title } = page;
|
||||
const mdxSource = await serialize({ source: content });
|
||||
|
||||
return {
|
||||
props: {
|
||||
title,
|
||||
mdxSource,
|
||||
},
|
||||
revalidate: 60,
|
||||
};
|
||||
}
|
||||
|
||||
export default function BasicPage({ title, mdxSource }) {
|
||||
const { isFallback } = useRouter();
|
||||
|
||||
if (isFallback) {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<Loading />
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mdxSource || "error" in mdxSource) {
|
||||
return <p>Something went wrong: {mdxSource?.error ?? "???"}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<NextSeo
|
||||
title={title}
|
||||
description={undefined}
|
||||
openGraph={{
|
||||
title,
|
||||
description: undefined,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<MDXClient {...mdxSource} components={mdxComponents} />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
|
@ -1,14 +1,12 @@
|
|||
import { DefaultSeo } from "next-seo";
|
||||
import "@/styles/globals.css";
|
||||
import '@/styles/globals.css'
|
||||
|
||||
export default function App({ Component, pageProps }) {
|
||||
import { DefaultSeo } from 'next-seo'
|
||||
|
||||
export default function App ({ Component, pageProps }) {
|
||||
return (
|
||||
<>
|
||||
<DefaultSeo
|
||||
defaultTitle="Aaron Yarborough"
|
||||
titleTemplate="%s | Aaron Yarborough"
|
||||
/>
|
||||
<DefaultSeo defaultTitle='Aaron Yarborough' titleTemplate='%s | Aaron Yarborough' />
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,20 +1,15 @@
|
|||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
export default function Document() {
|
||||
export default function Document () {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Html lang='en'>
|
||||
<Head>
|
||||
<link rel="stylesheet" href="https://neat.joeldare.com/neat.css" />
|
||||
<script
|
||||
defer
|
||||
data-domain="aaronjy.me"
|
||||
src="https://analytics.aaronjy.me/js/script.js"
|
||||
/>
|
||||
<link rel='stylesheet' href='https://cdn.jsdelivr.net/gh/yegor256/tacit@gh-pages/tacit-css-1.7.1.min.css' />
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,74 +1,25 @@
|
|||
import Resume from "@/components/Resume/Resume";
|
||||
import { FailedFetchCVError } from "@/errors";
|
||||
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
|
||||
import { fetchCV } from "@/services/content-service";
|
||||
import { NextSeo } from "next-seo";
|
||||
import CV from '@/components/CV/CV'
|
||||
import DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
|
||||
import React from 'react'
|
||||
import yaml from 'js-yaml'
|
||||
import fs from 'fs'
|
||||
import showdown from 'showdown'
|
||||
|
||||
import style from "./cv.module.css";
|
||||
import { serialize } from "next-mdx-remote-client/serialize";
|
||||
|
||||
export const Title = "CV";
|
||||
|
||||
export async function getStaticProps() {
|
||||
const res = await fetchCV([]);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchCVError(await res.text());
|
||||
}
|
||||
|
||||
const cv = (await res.json()).data;
|
||||
if (!cv) {
|
||||
return {
|
||||
notFound: true,
|
||||
revalidate: 60,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
intro,
|
||||
competencies,
|
||||
education,
|
||||
languages,
|
||||
certifications,
|
||||
experience,
|
||||
} = cv;
|
||||
|
||||
const introMdxSource = await serialize({ source: intro });
|
||||
|
||||
return {
|
||||
props: {
|
||||
introMdxSource,
|
||||
competencies,
|
||||
education,
|
||||
languages,
|
||||
certifications,
|
||||
experience,
|
||||
},
|
||||
revalidate: 60,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResumePage({
|
||||
introMdxSource,
|
||||
function CVPage ({
|
||||
competencies,
|
||||
education,
|
||||
certifications,
|
||||
languages,
|
||||
experience,
|
||||
experience
|
||||
}) {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<NextSeo
|
||||
title={Title}
|
||||
openGraph={{
|
||||
title: Title,
|
||||
}}
|
||||
/>
|
||||
<h1 className="hide-print">{Title}</h1>
|
||||
<h1 className={`${style.printHeading} print-only`}>Aaron Yarborough</h1>
|
||||
<section>
|
||||
<Resume
|
||||
introMdxSource={introMdxSource}
|
||||
<h1>CV</h1>
|
||||
</section>
|
||||
<section>
|
||||
{/* eslint-disable-next-line react/jsx-pascal-case */}
|
||||
<CV
|
||||
competencies={competencies}
|
||||
education={education}
|
||||
certifications={certifications}
|
||||
|
@ -77,5 +28,26 @@ export default function ResumePage({
|
|||
/>
|
||||
</section>
|
||||
</DefaultLayout>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function getStaticProps () {
|
||||
const content = fs.readFileSync('./content/pages/cv.yml', {
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
|
||||
const data = yaml.load(content)
|
||||
|
||||
const MDConverter = new showdown.Converter()
|
||||
|
||||
data.experience = data.experience.map((exp) => ({
|
||||
...exp,
|
||||
desc: MDConverter.makeHtml(exp.desc)
|
||||
}))
|
||||
|
||||
return {
|
||||
props: { ...data }
|
||||
}
|
||||
}
|
||||
|
||||
export default CVPage
|
||||
|
|
76
src/pages/index.js
Normal file
76
src/pages/index.js
Normal file
|
@ -0,0 +1,76 @@
|
|||
import Head from 'next/head'
|
||||
import ExternalLink from '@/components/ExternalLink'
|
||||
import DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
|
||||
|
||||
export default function Home () {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<Head>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
</Head>
|
||||
|
||||
<section>
|
||||
<h1>Hello!</h1>
|
||||
<p>
|
||||
I'm Aaron. I'm a Brit living in Newcastle-upon-tyne, UK. I
|
||||
work professionally as a Software Engineer and Tutor, and study
|
||||
languages in my spare time.
|
||||
</p>
|
||||
<p>
|
||||
This is my little corner of the web! I've always had a habit of
|
||||
'lurking' online; I barely interact with the content I
|
||||
consume, and you'll rarely if ever catch me posting or commenting
|
||||
on something. That said, this little site endeavours to pull me by my
|
||||
ankles out of the weeds in the great digital park we find ourselves,
|
||||
and encourage me to share a bit more about myself online.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Where to find me</h2>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>
|
||||
<ExternalLink href='https://letterboxd.com/aaronyarbz/'>
|
||||
Letterboxd
|
||||
</ExternalLink>
|
||||
</strong>{' '}
|
||||
is a social platform for film lovers to rate, review, and discover
|
||||
movies, akin to "Goodreads for film."
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
<ExternalLink href='https://github.com/AaronJY'>
|
||||
GitHub
|
||||
</ExternalLink>
|
||||
</strong>{' '}
|
||||
is a web-based platform for version control and collaboration on
|
||||
software development projects. Find out what I've been working
|
||||
on here!
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
<ExternalLink href='https://www.linkedin.com/in/aaronjyarborough/'>
|
||||
LinkedIn
|
||||
</ExternalLink>
|
||||
</strong>
|
||||
, unfortunately. A social network for professionals.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* <p>
|
||||
<strong>Yarbz Tutoring</strong> is my tutoring site. You can read a
|
||||
bit more about my tutoring and general software develpment experience,
|
||||
and book a class if it suits you.
|
||||
</p>
|
||||
<p>
|
||||
<ExternalLink href='https://tutoring.yarbz.digital'>
|
||||
Read more about my tutoring side-gig.
|
||||
</ExternalLink>
|
||||
</p> */}
|
||||
</section>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
|
@ -1,74 +0,0 @@
|
|||
import React from "react";
|
||||
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
|
||||
import BookReview from "@/components/Book/BookReview";
|
||||
import { fetchBookReviews, markdownToHtml } from "@/services/content-service";
|
||||
import {
|
||||
FailedFetchBookReviewError,
|
||||
FailedFetchBookReviewsError,
|
||||
} from "@/errors";
|
||||
import { useRouter } from "next/router";
|
||||
import Loading from "@/components/Loading/Loading";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const res = await fetchBookReviews(["slug"], {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchBookReviewsError(await res.text());
|
||||
}
|
||||
|
||||
const reviews = (await res.json()).data;
|
||||
|
||||
return {
|
||||
paths: reviews.map((post) => ({
|
||||
params: {
|
||||
slug: post.slug,
|
||||
},
|
||||
})),
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const getStaticProps = async ({ params }) => {
|
||||
const { slug } = params;
|
||||
|
||||
const res = await fetchBookReviews([], {
|
||||
slug,
|
||||
status: "published",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchBookReviewError(slug, await res.text());
|
||||
}
|
||||
|
||||
const review = (await res.json()).data.at(0);
|
||||
if (!review) {
|
||||
return {
|
||||
notFound: true,
|
||||
revalidate: 60,
|
||||
};
|
||||
}
|
||||
|
||||
const content = review.review;
|
||||
const html = markdownToHtml(content);
|
||||
|
||||
return {
|
||||
props: {
|
||||
review,
|
||||
html,
|
||||
},
|
||||
revalidate: 60,
|
||||
};
|
||||
};
|
||||
|
||||
export default function LibrarySingle({ review, html }) {
|
||||
const { isFallback } = useRouter();
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
{!isFallback && <BookReview review={review} html={html} />}
|
||||
{isFallback && <Loading />}
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
import BookReviewItem from "@/components/BookReviewItem/BookReviewItem";
|
||||
import Grid from "@/components/Grid/Grid";
|
||||
import { FailedFetchBookReviewsError } from "@/errors";
|
||||
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
|
||||
import { stringifyAndParse } from "@/lib/helpers";
|
||||
import { fetchBookReviews } from "@/services/content-service";
|
||||
import { NextSeo } from "next-seo";
|
||||
|
||||
export const Title = "Library";
|
||||
|
||||
export async function getStaticProps() {
|
||||
const res = await fetchBookReviews();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchBookReviewsError(await res.text());
|
||||
}
|
||||
|
||||
const reviews = (await res.json()).data.sort(
|
||||
(a, b) => new Date(b.readDate).getTime() - new Date(a.readDate).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
reviews: stringifyAndParse(reviews),
|
||||
},
|
||||
revalidate: 60,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Library({ reviews }) {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<NextSeo
|
||||
title={Title}
|
||||
openGraph={{
|
||||
title: Title,
|
||||
}}
|
||||
/>
|
||||
|
||||
<h1>{Title}</h1>
|
||||
|
||||
<section>
|
||||
<Grid columns={5}>
|
||||
{reviews.map((review, _) => (
|
||||
<BookReviewItem
|
||||
key={review.title}
|
||||
{...review}
|
||||
href={`/library/${review.slug}`}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</section>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
46
src/pages/recipes/index.js
Normal file
46
src/pages/recipes/index.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
// import Grid from '@/components/Grid/Grid'
|
||||
// import DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
|
||||
// import React from 'react'
|
||||
// import path from 'path'
|
||||
// import fs from 'fs'
|
||||
|
||||
// function Recipes ({ recipes }) {
|
||||
// return (
|
||||
// <DefaultLayout>
|
||||
// <section>
|
||||
// <h1>Recipes</h1>
|
||||
// <Grid>
|
||||
// {recipes.length &&
|
||||
// recipes.map((recipe) => (
|
||||
// <div key={recipe.name}>{recipe.name ?? 'unknown'}</div>
|
||||
// ))}
|
||||
// </Grid>
|
||||
// </section>
|
||||
// </DefaultLayout>
|
||||
// )
|
||||
// }
|
||||
|
||||
// export default Recipes
|
||||
|
||||
// export async function getStaticProps () {
|
||||
// const recipeDirents = await fs.promises
|
||||
// .readdir('./content/recipes', {
|
||||
// recursive: false,
|
||||
// withFileTypes: true
|
||||
// })
|
||||
|
||||
// const recipes = [{
|
||||
// name: 'lol'
|
||||
// }]
|
||||
|
||||
// for (const recipe of recipeDirents) {
|
||||
// // const recipePath = path.join('./', recipe.path, recipe.name)
|
||||
// // const recipeContent = fs.readFileSync(recipePath, { encoding: 'utf-8' })
|
||||
|
||||
// console.log(recipeFm)
|
||||
// }
|
||||
|
||||
// return {
|
||||
// props: { recipes }
|
||||
// }
|
||||
// }
|
|
@ -1,29 +0,0 @@
|
|||
import {
|
||||
fetchBasicPages,
|
||||
fetchBookReviews,
|
||||
fetchPosts,
|
||||
} from "@/services/content-service";
|
||||
import { getServerSideSitemapIndexLegacy } from "next-sitemap";
|
||||
|
||||
const siteUrl = process.env.SITE_URL || "https://www.aaronjy.me";
|
||||
|
||||
export const getServerSideProps = async (ctx) => {
|
||||
const [basicPagesResp, bookReviewsResp, postsResp] = await Promise.all([
|
||||
fetchBasicPages().then((res) => res.json()),
|
||||
fetchBookReviews().then((res) => res.json()),
|
||||
fetchPosts().then((res) => res.json()),
|
||||
]);
|
||||
|
||||
const urls = [
|
||||
...basicPagesResp.data.map((entry) => `${siteUrl}/${entry.path ?? ""}`),
|
||||
...bookReviewsResp.data.map(
|
||||
(entry) => `${siteUrl}/library/${entry.slug ?? ""}`,
|
||||
),
|
||||
...postsResp.data.map((entry) => `${siteUrl}/writing/${entry.slug ?? ""}`),
|
||||
];
|
||||
|
||||
return getServerSideSitemapIndexLegacy(ctx, [...urls]);
|
||||
};
|
||||
|
||||
// Default export to prevent next.js errors
|
||||
export default function SitemapIndex() {}
|
|
@ -1,73 +0,0 @@
|
|||
import { FailedFetchPostsError } from "@/errors";
|
||||
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
|
||||
import {
|
||||
fetchItems,
|
||||
fetchPosts,
|
||||
getTagsFromPosts,
|
||||
} from "@/services/content-service";
|
||||
import { NextSeo } from "next-seo";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
export async function getStaticProps() {
|
||||
const res = await fetchPosts(["title", "date_published", "tags", "slug"], {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchPostsError(await res.text());
|
||||
}
|
||||
|
||||
const posts = (await res.json()).data;
|
||||
const tags = getTagsFromPosts(posts);
|
||||
|
||||
return {
|
||||
props: {
|
||||
tags,
|
||||
posts,
|
||||
},
|
||||
revalidate: 60,
|
||||
};
|
||||
}
|
||||
|
||||
export const Title = "Tags";
|
||||
|
||||
export default function Tags({ tags, posts }) {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<NextSeo
|
||||
title={Title}
|
||||
openGraph={{
|
||||
title: Title,
|
||||
}}
|
||||
/>
|
||||
|
||||
<h1>{Title}</h1>
|
||||
|
||||
<section>
|
||||
{Object.keys(tags)
|
||||
.sort()
|
||||
.map((tag) => {
|
||||
const tagPosts = posts
|
||||
.filter((p) => p.tags.includes(tag))
|
||||
.sort((a, b) => b.title > -a.title);
|
||||
|
||||
return (
|
||||
<React.Fragment key={tag}>
|
||||
<h2>{tag}</h2>
|
||||
<ul>
|
||||
{tagPosts.map((post, _) => {
|
||||
return (
|
||||
<li key={post.slug}>
|
||||
<Link href={`/writing/${post.slug}`}>{post.title}</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
import React from "react";
|
||||
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
|
||||
import Article from "@/components/Article/Article";
|
||||
import { fetchPosts, markdownToHtml } from "@/services/content-service";
|
||||
import { FailedFetchPostError, FailedFetchPostsError } from "@/errors";
|
||||
import { useRouter } from "next/router";
|
||||
import Loading from "@/components/Loading/Loading";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const res = await fetchPosts(["slug"], {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchPostsError(await res.text());
|
||||
}
|
||||
|
||||
const posts = (await res.json()).data;
|
||||
|
||||
return {
|
||||
paths: posts.map((post) => ({
|
||||
params: {
|
||||
slug: post.slug,
|
||||
},
|
||||
})),
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const getStaticProps = async ({ params }) => {
|
||||
const { slug } = params;
|
||||
const res = await fetchPosts([], {
|
||||
slug,
|
||||
status: "published",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchPostError(slug, await res.text());
|
||||
}
|
||||
|
||||
const post = (await res.json()).data.at(0);
|
||||
if (!post) {
|
||||
return {
|
||||
notFound: true,
|
||||
revalidate: 60,
|
||||
};
|
||||
}
|
||||
|
||||
const { content } = post;
|
||||
const html = markdownToHtml(content);
|
||||
|
||||
return {
|
||||
props: {
|
||||
post,
|
||||
html,
|
||||
},
|
||||
revalidate: 60,
|
||||
};
|
||||
};
|
||||
|
||||
export default function WritingSingle({ post, html }) {
|
||||
const { isFallback } = useRouter();
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
{!isFallback && <Article {...post} html={html} />}
|
||||
{isFallback && <Loading />}
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
|
@ -1,50 +1,14 @@
|
|||
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
|
||||
import React from "react";
|
||||
import { NextSeo } from "next-seo";
|
||||
import StaticContentList from "@/components/StaticContentList/StaticContentList";
|
||||
import { fetchPosts } from "@/services/content-service";
|
||||
import { FailedFetchPostsError } from "@/errors";
|
||||
import DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const res = await fetchPosts(["title", "date_published", "slug"], {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchPostsError(await res.text());
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
const posts = json.data.sort(
|
||||
(a, b) =>
|
||||
new Date(b.datePublished).getTime() - new Date(a.datePublished).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
posts,
|
||||
},
|
||||
revalidate: 60,
|
||||
};
|
||||
};
|
||||
|
||||
export const Title = "Writing";
|
||||
|
||||
export default function Writing({ posts }) {
|
||||
export default function Writing () {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<NextSeo
|
||||
title={Title}
|
||||
openGraph={{
|
||||
title: Title,
|
||||
}}
|
||||
/>
|
||||
<h1>{Title}</h1>
|
||||
|
||||
<section>
|
||||
<StaticContentList entries={posts} urlPrefix="writing/" />
|
||||
<h1>Writing</h1>
|
||||
</section>
|
||||
<section>
|
||||
<i>Nothing to see here yet!</i>
|
||||
</section>
|
||||
</DefaultLayout>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,72 +0,0 @@
|
|||
import showdown from "showdown";
|
||||
import camelcaseKeys from "camelcase-keys";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_CONTENT_API_BASE_URL;
|
||||
|
||||
// @ts-ignore
|
||||
export const fetchPosts = async (...args) => fetchItems("post", ...args);
|
||||
export const fetchBookReviews = async (...args) =>
|
||||
fetchItems("book_review", ...args);
|
||||
export const fetchBasicPages = async (...args) =>
|
||||
fetchItems("basic_pages", ...args);
|
||||
export const fetchCV = async (...args) => fetchItems("cv", ...args);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (...args) => {
|
||||
const response = await originalFetch(...args);
|
||||
|
||||
const originalJson = response.json;
|
||||
|
||||
response.json = async function () {
|
||||
const data = await originalJson.call(this);
|
||||
return camelcaseKeys(data, { deep: true });
|
||||
};
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export async function fetchItems(type, fields = undefined, filter = undefined) {
|
||||
const url = new URL(`${baseUrl}/items/${type}`);
|
||||
|
||||
if (fields?.length) {
|
||||
url.searchParams.append("fields", fields.join(","));
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
url.searchParams.append("filter", JSON.stringify(filter));
|
||||
}
|
||||
|
||||
return await apiFetch(url.toString());
|
||||
}
|
||||
|
||||
export function getTagsFromPosts(posts) {
|
||||
const allTags = {};
|
||||
|
||||
for (const post of posts) {
|
||||
if (!post.tags) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const tag of post.tags) {
|
||||
allTags[tag] = !allTags[tag] ? 1 : allTags[tag] + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return allTags;
|
||||
}
|
||||
|
||||
export function markdownToHtml(content) {
|
||||
const converter = new showdown.Converter({
|
||||
tables: true,
|
||||
tablesHeaderId: true,
|
||||
});
|
||||
const html = converter.makeHtml(content);
|
||||
return html;
|
||||
}
|
||||
|
||||
async function apiFetch(...args) {
|
||||
// @ts-ignore
|
||||
const res = await fetch(...args);
|
||||
return res;
|
||||
}
|
|
@ -1,52 +1,8 @@
|
|||
pre {
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
tbody {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
html {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.3;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hide-print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: initial;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
import { readdirSync, readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import fm from "front-matter";
|
||||
|
||||
const dirPath = "./content/books";
|
||||
|
||||
const output = [];
|
||||
const files = readdirSync(dirPath);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const content = readFileSync(filePath, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
const { attributes, body } = fm(content, {
|
||||
allowUnsafe: true,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
title: attributes.title,
|
||||
author: attributes.author,
|
||||
read_date: attributes.readDate,
|
||||
rating: Math.round(attributes.stars * 2),
|
||||
// "image": attributes.thumbnailUrl,
|
||||
tags: attributes.tags.split(", "),
|
||||
review: body,
|
||||
url: attributes.url,
|
||||
};
|
||||
|
||||
output.push(entry);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(output));
|
|
@ -1,32 +0,0 @@
|
|||
import { readdirSync, readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import fm from "front-matter";
|
||||
import { stringToSlug } from "../src/lib/helpers.js";
|
||||
|
||||
const dirPath = "./content/writing";
|
||||
|
||||
const output = [];
|
||||
const files = readdirSync(dirPath);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const content = readFileSync(filePath, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
const { attributes, body } = fm(content, {
|
||||
allowUnsafe: true,
|
||||
});
|
||||
|
||||
const entry = {
|
||||
slug: stringToSlug(attributes.title),
|
||||
title: attributes.title,
|
||||
excerpt: attributes.desc,
|
||||
date_published: attributes.pubdate,
|
||||
tags: attributes.tags || [],
|
||||
content: body,
|
||||
};
|
||||
|
||||
output.push(entry);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(output));
|
Loading…
Add table
Reference in a new issue