Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
d6e8b857b8 |
|
@ -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,7 @@
|
|||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@stylistic/jsx/jsx-pascal-case": "off",
|
||||
"@next/next/no-html-link-for-pages": "off"
|
||||
}
|
||||
}
|
||||
|
|
74
.github/workflows/docker-image.yml
vendored
Normal file
|
@ -0,0 +1,74 @@
|
|||
|
||||
name: Create and publish a Docker image
|
||||
|
||||
# Configures this workflow to run every time a change is pushed to the branch called `release`.
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
|
||||
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install node dependencies
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Test
|
||||
run: npm run test
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
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 +1,3 @@
|
|||
npx lint-staged
|
||||
echo Formatting...
|
||||
npm run format
|
||||
echo Successfully formatted.
|
2
.nvmrc
|
@ -1 +1 @@
|
|||
v22.15.0
|
||||
v18.20.5
|
||||
|
|
19
.vscode/settings.json
vendored
|
@ -23,25 +23,16 @@
|
|||
"Sitecore",
|
||||
"sportank",
|
||||
"Umbraco",
|
||||
"Yarborough",
|
||||
"Yarbz"
|
||||
],
|
||||
"files.autoSave": "off",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"standard.autoFixOnSave": true,
|
||||
"prettier.enable": false,
|
||||
"editor.defaultFormatter": "standard.vscode-standard",
|
||||
"[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"
|
||||
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:22-alpine AS base
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
|
@ -42,9 +42,6 @@ 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
|
||||
|
||||
|
|
46
__test__/components/Article.test.jsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
/* eslint-env jest */
|
||||
import { render } from '@testing-library/react'
|
||||
import Article from '../../src/components/Article/Article'
|
||||
import '@testing-library/jest-dom'
|
||||
import { formatDate } from '@/lib/helpers'
|
||||
|
||||
describe('Article', () => {
|
||||
it('renders title', () => {
|
||||
const props = generateArticleProps()
|
||||
const { getByText } = render(<Article {...props} />)
|
||||
const titleElement = getByText(props.attributes.title)
|
||||
expect(titleElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders description', () => {
|
||||
const props = generateArticleProps()
|
||||
const { getByText } = render(<Article {...props} />)
|
||||
const descriptionElement = getByText(props.attributes.desc)
|
||||
expect(descriptionElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders pubdate if available', () => {
|
||||
const props = generateArticleProps()
|
||||
const { getByText } = render(<Article {...props} />)
|
||||
const pubdateElement = getByText(formatDate(props.attributes.pubdate))
|
||||
expect(pubdateElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders content', () => {
|
||||
const props = generateArticleProps()
|
||||
const { container } = render(<Article {...props} />)
|
||||
const contentElement = container.querySelector('[data-test=content]')
|
||||
expect(contentElement.innerHTML).toBe(props.html)
|
||||
})
|
||||
})
|
||||
|
||||
function generateArticleProps () {
|
||||
return {
|
||||
attributes: {
|
||||
title: 'My title',
|
||||
desc: 'My description',
|
||||
pubdate: new Date().toUTCString()
|
||||
},
|
||||
html: '<p>This is my content!</p>'
|
||||
}
|
||||
}
|
28
__test__/components/ExsternalLink.test.jsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
/* eslint-env jest */
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ExternalLink from '../../src/components/ExternalLink/ExternalLink'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
describe('ExternalLink', () => {
|
||||
const props = {
|
||||
href: 'https://example.com',
|
||||
children: 'Test Link'
|
||||
}
|
||||
|
||||
it('renders without crashing', () => {
|
||||
render(<ExternalLink {...props} />)
|
||||
})
|
||||
|
||||
it('renders correct href and rel attributes', () => {
|
||||
render(<ExternalLink {...props} />)
|
||||
const link = screen.getByText(props.children)
|
||||
expect(link).toHaveAttribute('href', props.href)
|
||||
expect(link).toHaveAttribute('rel', 'nofollow noopener')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('renders children correctly', () => {
|
||||
render(<ExternalLink {...props} />)
|
||||
expect(screen.getByText(props.children)).toBeInTheDocument()
|
||||
})
|
||||
})
|
10
__test__/components/Footer.test.jsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
/* eslint-env jest */
|
||||
import { render } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import Footer from '@/components/Footer/Footer'
|
||||
|
||||
describe('Footer', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<Footer />)
|
||||
})
|
||||
})
|
16
__test__/components/Grid.test.jsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
/* eslint-env jest */
|
||||
import { render } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import Grid from '@/components/Grid/Grid'
|
||||
|
||||
describe('Grid', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<Grid />)
|
||||
expect(container.firstChild).toHaveClass('grid')
|
||||
})
|
||||
|
||||
it('renders its children', () => {
|
||||
const { getByText } = render(<Grid><div>Child</div></Grid>)
|
||||
expect(getByText('Child')).toBeInTheDocument()
|
||||
})
|
||||
})
|
19
__test__/components/Header.test.jsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
/* eslint-env jest */
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Header from '../../src/components/Header/Header'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
describe('Header', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(
|
||||
<Header />)
|
||||
})
|
||||
|
||||
it('renders correct navigation links', () => {
|
||||
render(<Header />)
|
||||
const links = ['Home', 'Writing', 'CV']
|
||||
links.forEach(link => {
|
||||
expect(screen.getByText(link)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
63
__test__/components/Resume.test.jsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
/* eslint-env jest */
|
||||
import { render } from '@testing-library/react'
|
||||
import Resume from '../../src/components/Resume/Resume'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
describe('Resume', () => {
|
||||
const props = {
|
||||
competencies: ['Competency 1', 'Competency 2'],
|
||||
education: 'My education history',
|
||||
certifications: ['Certification 1', 'Certification 2'],
|
||||
languages: [{ name: 'English', proficiency: 'Fluent' }],
|
||||
experience: [
|
||||
{
|
||||
employer: 'Employer 1',
|
||||
position: 'Position 1',
|
||||
start: 'Start date 1',
|
||||
end: 'End date 1',
|
||||
desc: 'Description 1'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
it('renders without crashing', () => {
|
||||
render(<Resume {...props} />)
|
||||
})
|
||||
|
||||
it('renders competencies', () => {
|
||||
const { getByText } = render(<Resume {...props} />)
|
||||
props.competencies.forEach(competency => {
|
||||
expect(getByText(competency)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders education', () => {
|
||||
const { getByText } = render(<Resume {...props} />)
|
||||
expect(getByText(props.education)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders certifications', () => {
|
||||
const { getByText } = render(<Resume {...props} />)
|
||||
props.certifications.forEach(certification => {
|
||||
expect(getByText(certification)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders languages', () => {
|
||||
const { getByText } = render(<Resume {...props} />)
|
||||
props.languages.forEach(language => {
|
||||
expect(getByText(`${language.name} - ${language.proficiency}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders experience', () => {
|
||||
const { getByText } = render(<Resume {...props} />)
|
||||
props.experience.forEach(exp => {
|
||||
expect(getByText(exp.employer)).toBeInTheDocument()
|
||||
expect(getByText(exp.position)).toBeInTheDocument()
|
||||
expect(getByText(exp.start)).toBeInTheDocument()
|
||||
expect(getByText(exp.end)).toBeInTheDocument()
|
||||
expect(getByText(exp.desc)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
29
__test__/layouts/DefaultLayout.test.jsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
/* eslint-env jest */
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import DefaultLayout from '../../src/layouts/DefaultLayout/DefaultLayout'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
describe('DefaultLayout', () => {
|
||||
const props = {
|
||||
children: <div>Test Content</div>
|
||||
}
|
||||
|
||||
it('renders without crashing', () => {
|
||||
render(<DefaultLayout {...props} />)
|
||||
})
|
||||
|
||||
it('renders Header', () => {
|
||||
render(<DefaultLayout {...props} />)
|
||||
expect(screen.getByTestId('header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Footer', () => {
|
||||
render(<DefaultLayout {...props} />)
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders children correctly', () => {
|
||||
render(<DefaultLayout {...props} />)
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
69
__test__/pages/Writing.test.jsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
/* eslint-env jest */
|
||||
import { render } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import Writing, { Title, Description } from '../../src/pages/writing'
|
||||
|
||||
jest.mock('next/head', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ children }) => {
|
||||
return <>{children}</>
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('Writing', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(
|
||||
getStubWritingComponent())
|
||||
})
|
||||
|
||||
it('renders meta title', async () => {
|
||||
const { container } = render(getStubWritingComponent())
|
||||
expect(container.querySelector('title')).toHaveTextContent(Title)
|
||||
})
|
||||
|
||||
it('renders opengraph title', () => {
|
||||
const { container } = render(getStubWritingComponent())
|
||||
expect(container.querySelector('meta[property="og:title"]'))
|
||||
.toHaveAttribute('content', Title)
|
||||
})
|
||||
|
||||
it('renders meta description', () => {
|
||||
const { container } = render(getStubWritingComponent())
|
||||
expect(container.querySelector('meta[name="description"]'))
|
||||
.toHaveAttribute('content', Description)
|
||||
})
|
||||
|
||||
it('renders opengraph description', () => {
|
||||
const { container } = render(getStubWritingComponent())
|
||||
|
||||
expect(container.querySelector('meta[property="og:description"]'))
|
||||
.toHaveAttribute('content', Description)
|
||||
})
|
||||
})
|
||||
|
||||
function getStubWritingComponent () {
|
||||
const entries = [
|
||||
{
|
||||
attributes: {
|
||||
title: 'My title one',
|
||||
pubdate: 'Mon, 18 Mar 2024 16:47:32 GMT',
|
||||
desc: 'This is my description.'
|
||||
},
|
||||
html: '<p>This is some content</p>',
|
||||
slug: 'my-title-one'
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
title: 'My title Two',
|
||||
pubdate: 'Mon, 19 Mar 2024 16:47:32 GMT',
|
||||
desc: 'This is my description.'
|
||||
},
|
||||
html: '<p>This is some content</p>',
|
||||
slug: 'my-title-two'
|
||||
}
|
||||
]
|
||||
|
||||
return <Writing entries={entries} urlPrefix='/' />
|
||||
}
|
|
@ -1,2 +1,2 @@
|
|||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default { extends: ["@commitlint/config-conventional"] };
|
||||
export default { extends: ['@commitlint/config-conventional'] }
|
||||
|
|
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
|
177
content/pages/cv.yml
Normal file
|
@ -0,0 +1,177 @@
|
|||
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: Lead Consultant
|
||||
employer: Hippo
|
||||
start: Feb. 2024
|
||||
end: Present
|
||||
desc: >-
|
||||
* Leading a technical delivery on a UK public sector project for the Department for Education (DfE)
|
||||
|
||||
* Designing and documenting architectural changes in line with GDS architectural and technology standards
|
||||
|
||||
* Planning and managing releases to production
|
||||
|
||||
* Working with key stakeholders at both the project and policy level to explain, advise on and plan key decisions
|
||||
|
||||
|
||||
**Skills:** ASP.NET Core · Microsoft Azure · Azure Data Factory · Git · C# · Full-Stack Development · Umbraco · Web Development · Microsoft SQL Server · Cloud Development · Microservices · Technical Requirements · Agile Methodologies
|
||||
- position: Software Development Tutor
|
||||
employer: Yarbz Digital Ltd
|
||||
start: Sep. 2023
|
||||
end: Mar. 2024
|
||||
desc: I taught students of all levels modern software development, including
|
||||
coding fundamentals, computer science theory and modern software
|
||||
technologies.
|
||||
- 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 GALA 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 Architect
|
||||
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 help desk
|
||||
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
|
133
content/writing/static-site-on-google-cloud.md
Normal file
|
@ -0,0 +1,133 @@
|
|||
---
|
||||
title: Deploying aaronjy.me on a Google Storage bucket
|
||||
pubdate: 2024-05-01T00:00:00.000Z
|
||||
desc: "Google Cloud Storage is an effective solution for hosting static sites, offering a simple and scalable way to manage web assets. A manual deployment strategy involves four key steps: backing up existing files to a backup bucket, removing sensitive files for security, uploading the latest site files from the build directory, and invalidating Google’s global cache to ensure users access updated content."
|
||||
---
|
||||
Google actually has [documentation](https://cloud.google.com/storage/docs/hosting-static-website) on how to deploy a static site to a storage bucket, but I wanted to talk about how I handle deployments, as Google doesn't covert that!
|
||||
|
||||
## Networking
|
||||
|
||||
This site is just a collection of static assets (HTML, JS, CSS and images) that live inside a Google Cloud Storage bucket. When you load the site, the below route is taken once your request reaches GCP.
|
||||
|
||||

|
||||
|
||||
1. As you can see, you:
|
||||
2. Hit a load balancer, which then
|
||||
3. Directs you to a backend service, which then
|
||||
4. Decides either to either a) serve content directly from the storage bucket, or
|
||||
b) service it from the cache (if available)
|
||||
|
||||
The setup is pretty simple, and doesn't really deviate from Google's suggested setup configuration for static sites hosted from a bucket.
|
||||
|
||||
## Deploying
|
||||
|
||||
Setting up a seamless deployment strategy gets a little tricker, however. I opted to set up a manual deployment strategy, which involves calling `npm run deploy` to kick off the deployment. This in turn calls a bash script that handles the deployment.
|
||||
|
||||
The script consists of 4 deployment steps:
|
||||
|
||||
1. Backup existing bucket files to a backup bucket
|
||||
2. Remove sensitive files before deploying (e.g. `admin/index.html` for Decap CMS)
|
||||
3. Upload the latest files to the hosting bucket
|
||||
4. Invalidate Google's cache, so users receive the latest version of the site
|
||||
|
||||
### Step 1 - Backing up existing files
|
||||
|
||||
Before we do anything, we need to back up what we have already. I created a storage bucket specifically for holding backup files for this purpose, and use the gcloud CLI to copy the live files across to the backup bucket.
|
||||
|
||||
```
|
||||
BUCKET_URL="gs://aaronjy-www"
|
||||
BACKUP_BUCKET_URL="gs://aaronjy-www-backup"
|
||||
|
||||
echo "------------------------------"
|
||||
echo "BACKUP CURRENT SITE FILES"
|
||||
echo "------------------------------"
|
||||
|
||||
TIMESTAMP=$(date +%Y-%m-%d_%H:%M:%S)
|
||||
gcloud transfer jobs create $BUCKET_URL $BACKUP_BUCKET_URL/$(date +%Y-%m-%d_%H:%M:%S)/ --no-async --delete-from=source-after-transfer;
|
||||
```
|
||||
|
||||
The backed-up files are copied into a dated folder, and the `--delete-from` flag ensures the live websites files are deleted from the hosting bucket once they've been backed up.
|
||||
|
||||
### Step 2 - Removing sensitive files
|
||||
|
||||
Because I'm using Decap CMS for content management locally, I need to manually remove the `admin/` folder where Decap lives, as I don't want that to be available on the live site.
|
||||
|
||||
```
|
||||
echo "------------------------------"
|
||||
echo "REMOVE SENSITIVE FILES"
|
||||
echo "------------------------------"
|
||||
|
||||
rm -rfv ./out/admin/
|
||||
```
|
||||
|
||||
### Step 3 - Upload files to hosting bucket
|
||||
|
||||
Now we come to actually uploading the new files to the live site. I take everything from the `/out` directory (where Next.js throws its build output) and upload them directly to the hosting bucket.
|
||||
|
||||
```
|
||||
echo "------------------------------"
|
||||
echo "UPLOADING NEW SITE FILES"
|
||||
echo "------------------------------"
|
||||
|
||||
gcloud storage cp --recursive ./out/* $BUCKET_URL --gzip-in-flight-all
|
||||
```
|
||||
|
||||
The `--gzip-in-flight-all` is a handy edition, as the cli will apply gzip compression locally, and Google will uncompress them before dumping them in the bucket on the other end, resulting in a lower upload size/quicker deployment time.
|
||||
|
||||
### Step 3 - Invalidate the global cache
|
||||
|
||||
As Google uses a global cache for bucket files, we must invalidate it to ensure users get the latest website version.
|
||||
|
||||
```
|
||||
echo "------------------------------"
|
||||
echo "INVALIDATING GLOBAL CACHE"
|
||||
echo "------------------------------"
|
||||
|
||||
echo "WARNING: This is an async operation that can take upwards of 10 minutes depending on how fast Google Cloud CDN invalidates its cache. It does take around 10 minutes on average."
|
||||
|
||||
gcloud compute url-maps invalidate-cdn-cache lb-aaronjy-www --path "/*" --async
|
||||
```
|
||||
|
||||
This can take anywhere between 7-10 minutes, so the `--async` flag has been applied because we don't need to sit and wait for it.
|
||||
|
||||
### Full deployment script
|
||||
|
||||
Here's the deployment script in full:
|
||||
|
||||
```
|
||||
BUCKET_URL="gs://aaronjy-www"
|
||||
BACKUP_BUCKET_URL="gs://aaronjy-www-backup"
|
||||
|
||||
echo "------------------------------"
|
||||
echo "BACKUP CURRENT SITE FILES"
|
||||
echo "------------------------------"
|
||||
|
||||
TIMESTAMP=$(date +%Y-%m-%d_%H:%M:%S)
|
||||
gcloud transfer jobs create $BUCKET_URL $BACKUP_BUCKET_URL/$(date +%Y-%m-%d_%H:%M:%S)/ --no-async --delete-from=source-after-transfer;
|
||||
|
||||
echo "------------------------------"
|
||||
echo "REMOVE SENSITIVE FILES"
|
||||
echo "------------------------------"
|
||||
|
||||
rm -rfv ./out/admin/
|
||||
|
||||
echo "Removed all sensitive files."
|
||||
|
||||
echo "------------------------------"
|
||||
echo "UPLOADING NEW SITE FILES"
|
||||
echo "------------------------------"
|
||||
|
||||
gcloud storage cp --recursive ./out/* $BUCKET_URL --gzip-in-flight-all
|
||||
|
||||
echo "------------------------------"
|
||||
echo "INVALIDATING GLOBAL CACHE"
|
||||
echo "------------------------------"
|
||||
|
||||
echo "WARNING: This is an async operation that can take upwards of 10 minutes depending on how fast Google Cloud CDN invalidates its cache. It does take around 10 minutes on average."
|
||||
|
||||
gcloud compute url-maps invalidate-cdn-cache lb-aaronjy-www --path "/*" --async
|
||||
|
||||
echo "------------------------------"
|
||||
echo "DONE!"
|
||||
echo "------------------------------"
|
||||
```
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
title: Supporting content file structure changes on a static site
|
||||
pubdate: 2024-03-18T16:47:32.150Z
|
||||
desc: Static site generators (SSGs) convert complex site sources into HTML, CSS, and JS, allowing flexible hosting options. While they offer benefits like speed and low costs, updating content can be challenging for non-technical users. A solution involves assigning unique identifiers to articles and creating a URL mapping file to simplify restructuring and managing content links.
|
||||
---
|
||||
Static site generators (SSGs) are great. They take your complex site source and distil it down to the web's native language: HTML, CSS and JS. You can host your files anywhere: in cloud-native storage buckets; on low-cost CPanel hosting; on global CDNs; your old Lenovo ThinkPad in your cupboard running an Apache server that hasn't been patched since 2008; the list goes on. Wanna go further and throw away your CMS? Cool, you can use markdown files and a text editor as your CMS.
|
||||
|
||||
Simplicity is great, and SSGs bring loads of benefits (speed, a great developer experience, low cost overheads), but they're not a silver bullet, and can present a bit of a learning curve to the non-technical tasked with updating your site's content.
|
||||
|
||||
## The problem
|
||||
|
||||
Say you have an SSG that uses MD files for the site's content. In order to edit the site's content, you simply edit the MD files, build the site, and upload your new HTML/JS/CSS files.
|
||||
|
||||
Your folder structure could look like this:
|
||||
|
||||
```
|
||||
site/
|
||||
├─ content/
|
||||
│ ├─ index.md <-- homepage
|
||||
│ ├─ recipes/
|
||||
│ │ ├─ pizza.md
|
||||
│ │ ├─ chilli.md
|
||||
├─ src/ <-- site source code
|
||||
├─ public/ <-- static assets (images, videos, PDFs, etc.)
|
||||
```
|
||||
|
||||
And your `content/index.md` looks like this:
|
||||
|
||||
```md
|
||||
# Welcome!
|
||||
|
||||
This is my site! Want some fire recipes? Check [/recipes](these bad boys out)
|
||||
```
|
||||
|
||||
Your site homepage would contain a link to the recipe listing page at `/recipes` using Markdown to generate the anchor tag.
|
||||
|
||||
Assuming your `content/` folder informs your site's URL structure, there must be a file at `content/recipes/index.md`, or the link would send you to a 404.
|
||||
|
||||
Now, imagine you want to restructure your site's link hierarchy. Maybe you want to house all of your recipes under a new URL: `www.site.com/yummy/recipes` With a small site like ours, the time required to do this would be trivial. We'd lift and shift everything under `content/recipes/` to `content/yummy/recipes`, and we'd also have to manually update the link in `content/index.md` to `/yummy/recipes`; no big deal.
|
||||
|
||||
But what if we had hundreds of files, and we wanted to restructure our file system in the same way? We'd potentially have hundreds of MD files that all need to be manually updated to have their links point to a new URL. For a developer, we could probably write some funky regex or do a mass find & replace to find and update the links en masse, but what if a non-technical editor wants to make the same change? They would potentially have to manually work through each MD file and update the links by hand.
|
||||
|
||||
## Map those URLs!
|
||||
|
||||
Essentially, the problem is that there's no way to uniquely identify a particlar piece of content/article *other than* its link, and the link changes based on the content's position in the file system.
|
||||
|
||||
The solution is to give each article a uniqiue identifier, and keep track of any previous links. Then all we need to do is tell our SSG to generate static files for both the current link and *all previous links*.
|
||||
|
||||
Here's the gist of it:
|
||||
|
||||
* **Generate unique IDs:** Instead of relying on file paths, assign a unique identifier (an "id" property) to the [frontmatter](https://dpericich.medium.com/what-is-front-matter-and-how-is-it-used-to-create-dynamic-webpages-9d8dc053b457) of each markdown file.
|
||||
* **URL mapping for flexibility:** Create a central mapping file that acts like a translator, mapping each content ID to all the different URLs (slugs/paths) that can access it.
|
||||
* **Say goodbye to path dependence:** When a visitor requests a URL, the system checks the url-map.json file. If it finds a matching ID, it grabs the "canonical path" (the preferred URL) and uses that to locate the actual file. This lets you access the same content through multiple URLs!
|
||||
|
||||
Carrying on from our previous example, this is what our mapping file might look like:
|
||||
|
||||
```json
|
||||
{
|
||||
"892c5a5c-1f77-43ce-a13a-b9d8bd02971c": [
|
||||
"yummy/recipes", <-- The canonical/latest path
|
||||
"recipes" <-- The previous path
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
There's some work in keeping the mapping file up-to-date, but depending on your workflow, you could automate this using Git hooks or a CI pipeline like GitHub actions or Drone.
|
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 +1,17 @@
|
|||
import nextJest from "next/jest.js";
|
||||
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: "./",
|
||||
});
|
||||
dir: './'
|
||||
})
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const config = {
|
||||
coverageProvider: "v8",
|
||||
testEnvironment: "jsdom",
|
||||
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);
|
||||
export default createJestConfig(config)
|
||||
|
|
|
@ -2,8 +2,6 @@
|
|||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"checkJs": true,
|
||||
"jsx": "preserve"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,51 +1,6 @@
|
|||
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/");
|
||||
siteUrl: process.env.SITE_URL || 'https://www.aaronjy.me',
|
||||
generateRobotsTxt: true // (optional)
|
||||
// ...other options
|
||||
}
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
output: 'standalone',
|
||||
trailingSlash: true // ensure pages get their own directory in output
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig
|
||||
|
|
14641
package-lock.json
generated
46
package.json
|
@ -1,42 +1,29 @@
|
|||
{
|
||||
"name": "www-aaronjy-me",
|
||||
"version": "2.4.1",
|
||||
"name": "www-aaronjy-2024",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"lint-staged": {
|
||||
"**/*": "prettier --write --ignore-unknown"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"server": "npx decap-server",
|
||||
"build": "next build",
|
||||
"docker:build": "docker build -t www-aaronjy-2024 .",
|
||||
"postbuild": "next-sitemap --config next-sitemap.config.cjs",
|
||||
"start": "next start",
|
||||
"link": "echo NOT CONFIGURED",
|
||||
"format": "prettier . --write",
|
||||
"link": "npx standard",
|
||||
"format": "npx standard --fix",
|
||||
"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"
|
||||
"deploy": "./util/pre-deploy.sh && ./util/deploy-gcloud.sh",
|
||||
"test": "jest --verbose",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"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",
|
||||
"feather-icons": "^4.29.2",
|
||||
"next": "^14.2.6",
|
||||
"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",
|
||||
|
@ -44,23 +31,20 @@
|
|||
"@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",
|
||||
"eslint": "^9.9.0",
|
||||
"front-matter": "^4.0.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
79
public/admin/config.yml
Normal file
|
@ -0,0 +1,79 @@
|
|||
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: "Publish Date", name: pubdate, widget: datetime, required: false, default: "" }
|
||||
- {label: Description, name: desc, widget: text}
|
||||
- {label: Body, name: body, widget: markdown }
|
||||
|
||||
- name: writing
|
||||
label: Writing
|
||||
folder: content/writing
|
||||
create: true
|
||||
fields:
|
||||
- {label: Title, name: title, widget: string}
|
||||
- {label: "Publish Date", name: pubdate, widget: datetime, required: false, default: "" }
|
||||
- {label: Description, name: desc, widget: text}
|
||||
- {label: Body, name: body, widget: markdown }
|
||||
|
||||
- name: fun
|
||||
label: Fun
|
||||
folder: content/fun
|
||||
create: true
|
||||
fields:
|
||||
- {label: Title, name: title, widget: string}
|
||||
- {label: "Publish Date", name: pubdate, widget: datetime, required: false, default: "" }
|
||||
- {label: Description, name: desc, widget: text}
|
||||
- {label: Body, 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
|
@ -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>
|
3
public/img/bg.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg class="topography-shape js-shape" width="1200px" height="580px" viewBox="0 0 1200 580" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero"></path>
|
||||
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 0; fill: rgb(214, 242, 255);" transform="translate(-1800, 60) scale(2.8, 2.8) skewX(30) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 1; fill: rgb(199, 225, 243);" transform="translate(-1650, 55) scale(2.65, 2.65) skewX(27.5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 2; fill: rgb(184, 207, 230);" transform="translate(-1500, 50) scale(2.5, 2.5) skewX(25) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 3; fill: rgb(169, 190, 218);" transform="translate(-1350, 45) scale(2.3499999999999996, 2.3499999999999996) skewX(22.5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 4; fill: rgb(154, 173, 206);" transform="translate(-1200, 40) scale(2.2, 2.2) skewX(20) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 5; fill: rgb(139, 155, 193);" transform="translate(-1050, 35) scale(2.05, 2.05) skewX(17.5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 6; fill: rgb(125, 138, 181);" transform="translate(-900, 30) scale(1.9, 1.9) skewX(15) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 7; fill: rgb(110, 121, 169);" transform="translate(-750, 25) scale(1.75, 1.75) skewX(12.5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 8; fill: rgb(95, 103, 156);" transform="translate(-600, 20) scale(1.6, 1.6) skewX(10) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 9; fill: rgb(80, 86, 144);" transform="translate(-450, 15) scale(1.45, 1.45) skewX(7.5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 10; fill: rgb(65, 69, 132);" transform="translate(-300, 10) scale(1.3, 1.3) skewX(5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 11; fill: rgb(50, 51, 119);" transform="translate(-150, 5) scale(1.15, 1.15) skewX(2.5) "></path></svg>
|
After Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 78 KiB |
BIN
public/img/image-27-10-2024-at-12.10.jpg
Normal file
After Width: | Height: | Size: 181 KiB |
BIN
public/img/image-27-10-2024-at-12.13.jpg
Normal file
After Width: | Height: | Size: 316 KiB |
BIN
public/img/image-27-10-2024-at-12.18.jpg
Normal file
After Width: | Height: | Size: 330 KiB |
BIN
public/img/tech-debt-steps.jpeg
Normal file
After Width: | Height: | Size: 78 KiB |
BIN
public/img/techdebt-board.jpg
Normal file
After Width: | Height: | Size: 573 KiB |
|
@ -7,4 +7,3 @@ Host: https://www.aaronjy.me
|
|||
|
||||
# Sitemaps
|
||||
Sitemap: https://www.aaronjy.me/sitemap.xml
|
||||
Sitemap: https://www.aaronjy.me/server-sitemap-index.xml
|
||||
|
|
8
public/sitemap-0.xml
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?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/</loc><lastmod>2025-01-27T13:04:24.469Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/cv/</loc><lastmod>2025-01-27T13:04:24.469Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing/</loc><lastmod>2025-01-27T13:04:24.469Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing/static-site-on-google-cloud/</loc><lastmod>2025-01-27T13:04:24.469Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://www.aaronjy.me/writing/support-content-filte-structure-changes-on-a-static-site/</loc><lastmod>2025-01-27T13:04:24.469Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
</urlset>
|
|
@ -1,44 +1,4 @@
|
|||
<?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>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap><loc>https://www.aaronjy.me/sitemap-0.xml</loc></sitemap>
|
||||
</sitemapindex>
|
|
@ -1,40 +1,39 @@
|
|||
import { formatDate } from "@/lib/helpers";
|
||||
import { NextSeo } from "next-seo";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect } from "react";
|
||||
import { formatDate } from '@/lib/helpers'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import * as feather from 'feather-icons'
|
||||
|
||||
import "highlight.js/styles/atom-one-dark.css";
|
||||
import hljs from "highlight.js";
|
||||
|
||||
function Article({ title, excerpt, datePublished, tags, html }) {
|
||||
useEffect(() => {
|
||||
hljs.highlightAll();
|
||||
}, [html]);
|
||||
function Article ({ attributes, 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>
|
||||
</>
|
||||
);
|
||||
<section>
|
||||
<NextSeo
|
||||
title={attributes.title} description={attributes.desc} openGraph={
|
||||
{
|
||||
title: attributes.title,
|
||||
description: attributes.desc,
|
||||
type: 'article',
|
||||
article: {
|
||||
publishedTime: attributes.pubdate ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Link href='./'>
|
||||
<p className='row'>
|
||||
<span className='icon icon-left' dangerouslySetInnerHTML={{ __html: feather.icons['arrow-left'].toSvg() }} />
|
||||
<span>Go back</span>
|
||||
</p>
|
||||
</Link>
|
||||
<h1>{attributes.title}</h1>
|
||||
<p>{attributes.desc}</p>
|
||||
{attributes.pubdate && <p>{formatDate(attributes.pubdate)}</p>}
|
||||
<hr />
|
||||
<div data-test='content' dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default 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;
|
||||
}
|
|
@ -1,34 +1,31 @@
|
|||
import React from "react";
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Sets default values for external links on an anchor tag.
|
||||
* @returns
|
||||
*/
|
||||
function ExternalLink({
|
||||
function ExternalLink ({
|
||||
href,
|
||||
rel = "nofollow noopener",
|
||||
rel = 'nofollow noopener',
|
||||
children,
|
||||
target = "_blank",
|
||||
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" }}
|
||||
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"
|
||||
/>
|
||||
<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;
|
||||
export default ExternalLink
|
||||
|
|
|
@ -1,44 +1,50 @@
|
|||
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} data-testid='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='/static/pgp.txt'>pgp key</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,7 @@
|
|||
.footer nav:first-child a {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.footer nav li {
|
||||
margin-bottom: 0;
|
||||
}
|
|
@ -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} data-testid='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 +1,81 @@
|
|||
import React from "react";
|
||||
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";
|
||||
import style from './Resume.module.css'
|
||||
|
||||
function Resume({
|
||||
introMdxSource,
|
||||
function Resume ({
|
||||
competencies,
|
||||
education,
|
||||
certifications,
|
||||
languages,
|
||||
experience,
|
||||
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>
|
||||
<h2>Core competencies</h2>
|
||||
<ul>
|
||||
{competencies.sort().map((c, i) => (
|
||||
<li key={i}>{c}</li>
|
||||
))}
|
||||
</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>
|
||||
</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>Certifications</h2>
|
||||
<ul>
|
||||
{certifications.sort().map((c, i) => (
|
||||
<li key={i}>{c}</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>Languages</h2>
|
||||
<ul>
|
||||
{languages.sort().map((c, i) => (
|
||||
<li key={i}>
|
||||
{c.name} - {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>
|
||||
<h2>Education history</h2>
|
||||
<p>{education}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Professional experience</h2>
|
||||
|
||||
{experience.map((exp, i) => (
|
||||
<WorkExperience
|
||||
key={i}
|
||||
employer={exp.employer}
|
||||
position={exp.position}
|
||||
start={exp.start}
|
||||
end={exp.end}
|
||||
>
|
||||
{exp.desc}
|
||||
</WorkExperience>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default Resume;
|
||||
export default Resume
|
||||
|
||||
function WorkExperience({ position, employer, start, end, skills, children }) {
|
||||
function WorkExperience ({ position, employer, start, end, 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 />
|
||||
</>
|
||||
);
|
||||
<div className={style['work-experience']}>
|
||||
<div>
|
||||
<h3>
|
||||
{position}
|
||||
<br />
|
||||
<small>{employer}</small>
|
||||
</h3>
|
||||
<small>
|
||||
<time>{start}</time> - <time>{end}</time>
|
||||
</small>
|
||||
</div>
|
||||
<div
|
||||
data-test='children'
|
||||
dangerouslySetInnerHTML={{ __html: children }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,74 +1,25 @@
|
|||
.experienceTable {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.position {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cvContent {
|
||||
.cv {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 0.5em;
|
||||
position: relative;
|
||||
flex-direction: row;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.cvContent .experience {
|
||||
flex-basis: 66.66%;
|
||||
.cv > div:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cvContent .sidebar {
|
||||
flex-basis: 33.33%;
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
left: 0px;
|
||||
height: 1px;
|
||||
/* Needed to make sticky work */
|
||||
.cv > div:last-child {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.about {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.cvContent ol,
|
||||
.cvContent ul {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.skillList {
|
||||
font-size: 0.8em;
|
||||
.cv .work-experience >div:first-child {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2em;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.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;
|
||||
@media (max-width: 768px) {
|
||||
.cv {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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,17 @@
|
|||
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 }) {
|
||||
function DefaultLayout ({ children }) {
|
||||
return (
|
||||
<main className={`${style.layout}`}>
|
||||
<Header />
|
||||
<>{children}</>
|
||||
<article>{children}</article>
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default DefaultLayout;
|
||||
export default DefaultLayout
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.layout main {
|
||||
flex-grow: 1;
|
||||
}
|
62
src/lib/content.js
Normal file
|
@ -0,0 +1,62 @@
|
|||
import fs from 'fs'
|
||||
import fm from 'front-matter'
|
||||
import showdown from 'showdown'
|
||||
import { toSlug } from './helpers'
|
||||
|
||||
export function getMarkdownEntry (path) {
|
||||
const fileContents = fs.readFileSync(path, {
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
|
||||
const { attributes, body } = fm(fileContents)
|
||||
|
||||
const converter = new showdown.Converter({
|
||||
tables: true,
|
||||
tablesHeaderId: true
|
||||
})
|
||||
const html = converter.makeHtml(body)
|
||||
|
||||
const slug = toSlug(path.substring(path.lastIndexOf('/') + 1))
|
||||
|
||||
return {
|
||||
attributes: {
|
||||
...attributes,
|
||||
pubdate: attributes.pubdate?.toUTCString() ?? null
|
||||
|
||||
},
|
||||
html,
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
export function getStaticEntryPaths (contentPath) {
|
||||
const entries = fs.readdirSync(contentPath, { withFileTypes: true })
|
||||
|
||||
const paths = entries.map((dirent) => ({
|
||||
params: {
|
||||
slug: toSlug(dirent.name)
|
||||
}
|
||||
}))
|
||||
|
||||
return {
|
||||
fallback: false,
|
||||
paths
|
||||
}
|
||||
}
|
||||
|
||||
export function getStaticEntryProps (contentPath, { params }) {
|
||||
const path = `${contentPath}/${params.slug}.md`
|
||||
const entry = getMarkdownEntry(path)
|
||||
const { attributes } = entry
|
||||
|
||||
return { props: { ...entry, attributes } }
|
||||
}
|
||||
|
||||
export function getStaticEntryListProps (contentPath, urlPrefix) {
|
||||
const fun = fs.readdirSync(contentPath, { withFileTypes: true })
|
||||
const entries = fun.map((dirent) =>
|
||||
getMarkdownEntry(`${dirent.path}/${dirent.name}`)
|
||||
).sort((a, b) => new Date(b.attributes.pubdate) - new Date(a.attributes.pubdate))
|
||||
|
||||
return { props: { entries, urlPrefix } }
|
||||
}
|
|
@ -1,27 +1,9 @@
|
|||
import * as dateFns from "date-fns";
|
||||
import * as dateFns from 'date-fns'
|
||||
|
||||
export function filenameToSlug(input) {
|
||||
return stringToSlug(input.substring(0, input.indexOf(".")));
|
||||
export function toSlug (input) {
|
||||
return input.substring(0, input.indexOf('.')).trim()
|
||||
}
|
||||
|
||||
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));
|
||||
export function formatDate (date) {
|
||||
return dateFns.format(Date.parse(date), 'PPP')
|
||||
}
|
||||
|
|
|
@ -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,37 @@
|
|||
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 DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
|
||||
import React from 'react'
|
||||
import yaml from 'js-yaml'
|
||||
import fs from 'fs'
|
||||
import showdown from 'showdown'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import Resume from '@/components/Resume/Resume'
|
||||
|
||||
import style from "./cv.module.css";
|
||||
import { serialize } from "next-mdx-remote-client/serialize";
|
||||
export const Title = 'CV'
|
||||
export const Description = 'Read about my professional experience as a software engineer, core competencies, and certifications.'
|
||||
|
||||
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 ResumePage ({
|
||||
competencies,
|
||||
education,
|
||||
certifications,
|
||||
languages,
|
||||
experience,
|
||||
experience
|
||||
}) {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<NextSeo
|
||||
title={Title}
|
||||
openGraph={{
|
||||
title: Title,
|
||||
}}
|
||||
title={Title} description={Description} openGraph={
|
||||
{
|
||||
Title,
|
||||
Description
|
||||
}
|
||||
}
|
||||
/>
|
||||
<h1 className="hide-print">{Title}</h1>
|
||||
<h1 className={`${style.printHeading} print-only`}>Aaron Yarborough</h1>
|
||||
<section>
|
||||
<h1>{Title} 💼</h1>
|
||||
<p>{Description}</p>
|
||||
</section>
|
||||
<section>
|
||||
<Resume
|
||||
introMdxSource={introMdxSource}
|
||||
competencies={competencies}
|
||||
education={education}
|
||||
certifications={certifications}
|
||||
|
@ -77,5 +40,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 ResumePage
|
||||
|
|
115
src/pages/index.js
Normal file
|
@ -0,0 +1,115 @@
|
|||
import Head from 'next/head'
|
||||
import DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
|
||||
import ExternalLink from '@/components/ExternalLink/ExternalLink'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Title as WritingTitle, Description as WritingDescription } from './writing'
|
||||
import { Title as CvTitle, Description as CvDescription } from './cv'
|
||||
|
||||
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, a Brit living in Newcastle-upon-tyne, UK. I
|
||||
work professionally as a Software Engineer, and study
|
||||
languages, history and philosophy in my spare time.
|
||||
</p>
|
||||
<p>
|
||||
I current work as a Lead Consultant at Hippo Digital, working on public sector project for the Department of Education. You can find out more about my work history <Link href='/cv'>on my CV</Link>.
|
||||
</p>
|
||||
|
||||
<div className='row'>
|
||||
<div className='box'>
|
||||
<Link href='/writing' className='box-title'>{WritingTitle}</Link>
|
||||
<p className='box-text'>{WritingDescription}</p>
|
||||
<Link href='/writing' className='box-link'>Read more...</Link>
|
||||
</div>
|
||||
|
||||
<div className='box'>
|
||||
<Link href='/cv' className='box-title'>{CvTitle}</Link>
|
||||
<p className='box-text'>{CvDescription}</p>
|
||||
<Link href='/cv' className='box-link'>Read more...</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Tech I Like</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Web Development:</strong> I primarily use Node.js with TypeScript
|
||||
(or JavaScript for smaller projects) alongside Next.js to build websites
|
||||
and applications.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Scripting:</strong> My preferred scripting languages are Python
|
||||
and JavaScript, as I'm well-versed in them and they offer extensive
|
||||
libraries that typically cover my needs.
|
||||
</li>
|
||||
<li>
|
||||
<strong>API and Backend Development:</strong> For more robust API or backend
|
||||
architecture, I often choose .NET Core with C# and ASP.NET. The strongly-typed
|
||||
nature of C# and the structured framework of ASP.NET help maintain clean and
|
||||
organised code.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cloud Hosting:</strong> When possible, I opt for hosting on a
|
||||
DigitalOcean droplet. If more extensive cloud services are required, I usually
|
||||
opt for Google Cloud Platform (GCP), which I find more user-friendly than Azure
|
||||
or AWS. I also self-host services on shared server hosting running Ubuntu Server, typically with Hetzner.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>About this site</h2>
|
||||
<p>www.aaronjy.me is a static site (i.e. a bunch of HTML, JS, CSS and image files) written in JavaScript using Next.js. Tacit is being used as a micro CSS framework, and various smaller bits of custom CSS have been applied on top.</p>
|
||||
<p>The site is hosted inside a Google Cloud Storage bucket with a load balancer sat in front of it. The load balancer is required as Cloud Storage doesn't support a) custom domains, b) HTTPS out of the box or c) a global CDN solution.</p>
|
||||
<p>One of the biggest benefits of a website made of simple static files and assets is that I can deploy it easily, almost anywhere, and for very little money.</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>
|
||||
);
|
||||
}
|
|
@ -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 +1,15 @@
|
|||
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";
|
||||
import React from 'react'
|
||||
import DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
|
||||
import { getStaticEntryPaths, getStaticEntryProps } from '@/lib/content'
|
||||
import Article from '@/components/Article/Article'
|
||||
|
||||
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();
|
||||
export const getStaticPaths = () => getStaticEntryPaths('./content/writing')
|
||||
export const getStaticProps = (ctx) => getStaticEntryProps('./content/writing', ctx)
|
||||
|
||||
export default function WritingSingle ({ attributes, html }) {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
{!isFallback && <Article {...post} html={html} />}
|
||||
{isFallback && <Loading />}
|
||||
<Article attributes={attributes} html={html} />
|
||||
</DefaultLayout>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,50 +1,46 @@
|
|||
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'
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getStaticEntryListProps } from '@/lib/content'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import { formatDate } from '@/lib/helpers'
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const res = await fetchPosts(["title", "date_published", "slug"], {
|
||||
status: "published",
|
||||
});
|
||||
export const getStaticProps = () => getStaticEntryListProps('./content/writing', '/writing/')
|
||||
|
||||
if (!res.ok) {
|
||||
throw new FailedFetchPostsError(await res.text());
|
||||
}
|
||||
export const Title = 'Writing'
|
||||
export const Description = 'A collection of writing and musings on various topics that interest me, as well as technical writing.'
|
||||
|
||||
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 ({ entries, urlPrefix }) {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<NextSeo
|
||||
title={Title}
|
||||
openGraph={{
|
||||
title: Title,
|
||||
}}
|
||||
description={Description}
|
||||
openGraph={
|
||||
{
|
||||
Title,
|
||||
Description
|
||||
}
|
||||
}
|
||||
/>
|
||||
<h1>{Title}</h1>
|
||||
<section>
|
||||
<h1>{Title} ✍🏻</h1>
|
||||
<p>{Description}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<StaticContentList entries={posts} urlPrefix="writing/" />
|
||||
{entries.map((e) => (
|
||||
<div key={e.attributes.title}>
|
||||
<h2>
|
||||
<Link href={`${urlPrefix}${e.slug}`}>{e.attributes.title}</Link>
|
||||
</h2>
|
||||
{!!e.attributes.pubdate && <p>{formatDate(e.attributes.pubdate)}</p>}
|
||||
|
||||
<p>{e.attributes.desc}</p>
|
||||
<Link href={`${urlPrefix}${e.slug}`}>Read more</Link>
|
||||
</div>
|
||||
))}
|
||||
</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,118 @@
|
|||
:root {
|
||||
--color-default: #1f3470;
|
||||
--color-primary: #5172cf;
|
||||
--color-tertiary: grey;
|
||||
--color-bg: #fbfbfb;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-default);
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2vh;
|
||||
right: 0;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
opacity: 0.2;
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' version='1.1' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:svgjs='http://svgjs.dev/svgjs' width='1440' height='360' preserveAspectRatio='none' viewBox='0 0 1440 360'%3e%3cg mask='url(%26quot%3b%23SvgjsMask1038%26quot%3b)' fill='none'%3e%3cpath d='M -47.398109710771735%2c191 C 48.6%2c178 240.6%2c108.8 432.60189028922827%2c126 C 624.6%2c143.2 720.6%2c282.4 912.6018902892283%2c277 C 1104.6%2c271.6 1200.6%2c93.6 1392.6018902892283%2c99 C 1584.6%2c104.4 1863.12%2c292.6 1872.6018902892283%2c304 C 1882.08%2c315.4 1526.52%2c185.6 1440%2c156' stroke='rgba(122%2c 122%2c 122%2c 0.58)' stroke-width='2'%3e%3c/path%3e%3cpath d='M -191.9102853036337%2c280 C -95.91%2c256.8 96.09%2c173.2 288.0897146963663%2c164 C 480.09%2c154.8 576.09%2c246.8 768.0897146963663%2c234 C 960.09%2c221.2 1056.09%2c108.2 1248.0897146963662%2c100 C 1440.09%2c91.8 1689.71%2c189.6 1728.0897146963662%2c193 C 1766.47%2c196.4 1497.62%2c132.2 1440%2c117' stroke='rgba(122%2c 122%2c 122%2c 0.58)' stroke-width='2'%3e%3c/path%3e%3cpath d='M -851.6383122526047%2c117 C -755.64%2c157.8 -563.64%2c331.2 -371.63831225260463%2c321 C -179.64%2c310.8 -83.64%2c67.8 108.36168774739537%2c66 C 300.36%2c64.2 396.36%2c296.4 588.3616877473953%2c312 C 780.36%2c327.6 898.03%2c143.4 1068.3616877473953%2c144 C 1238.69%2c144.6 1365.67%2c280.8 1440%2c315' stroke='rgba(122%2c 122%2c 122%2c 0.58)' stroke-width='2'%3e%3c/path%3e%3c/g%3e%3cdefs%3e%3cmask id='SvgjsMask1038'%3e%3crect width='1440' height='360' fill='white'%3e%3c/rect%3e%3c/mask%3e%3c/defs%3e%3c/svg%3e");
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: 600;
|
||||
color: var(--color-default)
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary)
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-left: 0;
|
||||
list-style: inside;
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
ul li:not(:last-child) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
article {
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
article img {
|
||||
border: 1px solid var(--color-default);
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0;
|
||||
background-color: var(--color-default);
|
||||
border-radius: 5px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
tbody {
|
||||
vertical-align: top;
|
||||
pre code {
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
tr {
|
||||
text-align: left;
|
||||
code:not(pre > code) {
|
||||
background-color: var(--color-default);
|
||||
color: var(--color-bg)
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
padding-right: 20px;
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin: 20px 0;
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: 700;
|
||||
.icon-left {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: none;
|
||||
.box {
|
||||
flex-grow: 1;
|
||||
/* border: 1px solid var(--color-default); */
|
||||
/* padding: 1.2rem; */
|
||||
display: flex;
|
||||
align-items: left;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media print {
|
||||
html {
|
||||
padding-top: 1rem;
|
||||
.box-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.box-text {
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 640px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.3;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hide-print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|