mirror of
https://github.com/kremalicious/portfolio.git
synced 2024-12-22 01:03:20 +01:00
refactor: handle content generation as prebuild step
This commit is contained in:
parent
5a2f2478c9
commit
a5cd4aebda
3
.gitignore
vendored
3
.gitignore
vendored
@ -39,4 +39,5 @@ coverage
|
|||||||
public/matomo.js
|
public/matomo.js
|
||||||
# public/favicon*
|
# public/favicon*
|
||||||
# public/apple-touch-icon*
|
# public/apple-touch-icon*
|
||||||
# public/manifest*
|
# public/manifest*
|
||||||
|
generated
|
7
package-lock.json
generated
7
package-lock.json
generated
@ -39,7 +39,6 @@
|
|||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"ora": "^8.0.1",
|
"ora": "^8.0.1",
|
||||||
"prepend": "^1.0.2",
|
|
||||||
"prettier": "^3.2.4",
|
"prettier": "^3.2.4",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"sharp-ico": "^0.1.5",
|
"sharp-ico": "^0.1.5",
|
||||||
@ -12159,12 +12158,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prepend": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/prepend/-/prepend-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-ImzjnkCUZRATBOHwlpmxz0IY21r+3/bcK3OTcrrU0njZlTWh58tgD7L5dH3TiZcc8ShDU7JEa5wk/UufWF6p7w==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz",
|
||||||
|
12
package.json
12
package.json
@ -8,19 +8,20 @@
|
|||||||
"author": "Matthias Kretschmann <m@kretschmann.io>",
|
"author": "Matthias Kretschmann <m@kretschmann.io>",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "next",
|
"start": "npm run prebuild && next",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"preview": "npm run build && next start",
|
"preview": "next start",
|
||||||
"export": "next export",
|
"export": "npm run prebuild && next export",
|
||||||
"typecheck": "tsc",
|
"typecheck": "tsc",
|
||||||
"lint:js": "next lint",
|
"lint:js": "next lint",
|
||||||
"lint:css": "stylelint ./src/**/*.css",
|
"lint:css": "stylelint ./src/**/*.css",
|
||||||
"lint": "npm run lint:js && npm run lint:css",
|
"lint": "npm run lint:js && npm run lint:css",
|
||||||
"format": "prettier --write 'src/**/*.{ts,tsx,css}'",
|
"format": "prettier --write 'src/**/*.{ts,tsx,css}'",
|
||||||
"jest": "jest --coverage -c tests/jest.config.ts",
|
"jest": "jest --coverage -c tests/jest.config.ts",
|
||||||
"test": "NODE_ENV=test npm run lint && npm run typecheck && npm run jest",
|
"test": "NODE_ENV=test npm run prebuild && npm run lint && npm run typecheck && npm run jest",
|
||||||
"new": "ts-node-esm ./scripts/new.ts",
|
"new": "ts-node-esm ./scripts/new.ts",
|
||||||
"favicon": "ts-node-esm ./scripts/favicon.ts"
|
"favicon": "ts-node-esm ./scripts/favicon.ts",
|
||||||
|
"prebuild": "ts-node-esm ./scripts/prebuild.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@giphy/js-fetch-api": "^5.3.0",
|
"@giphy/js-fetch-api": "^5.3.0",
|
||||||
@ -53,7 +54,6 @@
|
|||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"ora": "^8.0.1",
|
"ora": "^8.0.1",
|
||||||
"prepend": "^1.0.2",
|
|
||||||
"prettier": "^3.2.4",
|
"prettier": "^3.2.4",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"sharp-ico": "^0.1.5",
|
"sharp-ico": "^0.1.5",
|
||||||
|
48
scripts/content/content.test-disabled.ts
Normal file
48
scripts/content/content.test-disabled.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// import { getProjectBySlug, getProjectImages } from '.'
|
||||||
|
|
||||||
|
// jest.setTimeout(20000)
|
||||||
|
|
||||||
|
// describe('lib/content', () => {
|
||||||
|
// test('getProjectSlugs', async () => {
|
||||||
|
// const slugs: string[] = getProjectSlugs()
|
||||||
|
// expect(slugs).toContain('ipixelpad')
|
||||||
|
// })
|
||||||
|
|
||||||
|
// test('getProjectBySlug', async () => {
|
||||||
|
// const project = await getProjectBySlug('ipixelpad')
|
||||||
|
// expect(project).toBeDefined()
|
||||||
|
// expect(project.images[0].src).toContain('ipixelpad')
|
||||||
|
// })
|
||||||
|
|
||||||
|
// test('getProjectBySlug returns early', async () => {
|
||||||
|
// const project = await getProjectBySlug('gibberish')
|
||||||
|
// expect(project).not.toBeDefined()
|
||||||
|
// })
|
||||||
|
|
||||||
|
// test('getProjectImages', async () => {
|
||||||
|
// const images = await getProjectImages('ipixelpad')
|
||||||
|
// expect(images).toBeDefined()
|
||||||
|
// expect(images[0].src).toContain('ipixelpad')
|
||||||
|
// // expect(images[0].blurDataURL).toBeDefined()
|
||||||
|
// expect(images[0].width).toBeDefined()
|
||||||
|
// expect(images[0].height).toBeDefined()
|
||||||
|
// expect(images[0].format).toBeDefined()
|
||||||
|
// })
|
||||||
|
|
||||||
|
// test('getAllProjects', async () => {
|
||||||
|
// const projects = await getAllProjects([
|
||||||
|
// 'title',
|
||||||
|
// 'description',
|
||||||
|
// 'slug',
|
||||||
|
// 'images',
|
||||||
|
// 'techstack',
|
||||||
|
// 'links'
|
||||||
|
// ])
|
||||||
|
// expect(projects).toBeDefined()
|
||||||
|
// })
|
||||||
|
|
||||||
|
// test('getAllProjects without fields', async () => {
|
||||||
|
// const projects = await getAllProjects()
|
||||||
|
// expect(projects).toBeDefined()
|
||||||
|
// })
|
||||||
|
// })
|
32
scripts/content/generateProjects.ts
Normal file
32
scripts/content/generateProjects.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
import ora from 'ora'
|
||||||
|
import { join } from 'path'
|
||||||
|
import type ProjectType from '../../src/types/project.js'
|
||||||
|
import { transformProject } from './transformProject.js'
|
||||||
|
|
||||||
|
const contentDirectory = join(process.cwd(), '_content')
|
||||||
|
const projectsOriginal = yaml.load(
|
||||||
|
fs.readFileSync(`${contentDirectory}/projects.yml`, 'utf8')
|
||||||
|
) as ProjectType[]
|
||||||
|
const projectsOutput = 'generated/projects.json'
|
||||||
|
|
||||||
|
export async function generateProjects(): Promise<void> {
|
||||||
|
const spinner = ora('Generating projects content...\n').start()
|
||||||
|
const slugs = projectsOriginal.map(({ slug }: { slug: string }) => slug)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projects: ProjectType[] = []
|
||||||
|
|
||||||
|
for (const slug of slugs) {
|
||||||
|
spinner.text = `Generating content for ${slug}...\n`
|
||||||
|
const project = await transformProject(projectsOriginal, slug)
|
||||||
|
if (project) projects.push(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(projectsOutput, JSON.stringify(projects, null, 2))
|
||||||
|
spinner.succeed(`Projects content written to ${projectsOutput}\n`)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
spinner.fail((error as Error).message)
|
||||||
|
}
|
||||||
|
}
|
36
scripts/content/images.ts
Normal file
36
scripts/content/images.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import type ImageType from '@/src/types/image'
|
||||||
|
import fs from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { rgbDataURL } from './rgbDataURL.js'
|
||||||
|
|
||||||
|
const imagesDirectory = join(process.cwd(), 'public', 'images')
|
||||||
|
|
||||||
|
export async function getProjectImages(slug: string) {
|
||||||
|
const allImages = fs.readdirSync(imagesDirectory, 'utf8')
|
||||||
|
const projectImages = allImages.filter((image) => image.includes(slug))
|
||||||
|
|
||||||
|
let images: ImageType[] = []
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
projectImages.map(async (image) => {
|
||||||
|
const file = `${imagesDirectory}/${image}`
|
||||||
|
const transformer = sharp(file)
|
||||||
|
const { width, height, format } = await transformer.metadata()
|
||||||
|
const { dominant } = await transformer.stats()
|
||||||
|
const blurDataURL = rgbDataURL(dominant.r, dominant.g, dominant.b)
|
||||||
|
|
||||||
|
const imageType: ImageType = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format,
|
||||||
|
blurDataURL,
|
||||||
|
src: `/images/${image}`
|
||||||
|
}
|
||||||
|
images.push(imageType)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
// Sort images by sequentially numbered name to be sure
|
||||||
|
images = images.sort((a, b) => a.src.localeCompare(b.src))
|
||||||
|
return images
|
||||||
|
}
|
11
scripts/content/rgbDataURL.ts
Normal file
11
scripts/content/rgbDataURL.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Pixel GIF code adapted from https://stackoverflow.com/a/33919020/266535
|
||||||
|
const keyStr =
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
|
||||||
|
const triplet = (e1: number, e2: number, e3: number) =>
|
||||||
|
keyStr.charAt(e1 >> 2) +
|
||||||
|
keyStr.charAt(((e1 & 3) << 4) | (e2 >> 4)) +
|
||||||
|
keyStr.charAt(((e2 & 15) << 2) | (e3 >> 6)) +
|
||||||
|
keyStr.charAt(e3 & 63)
|
||||||
|
|
||||||
|
export const rgbDataURL = (r: number, g: number, b: number) =>
|
||||||
|
`data:image/gif;base64,R0lGODlhAQABAPAA${triplet(0, r, g) + triplet(b, 255, 255)}/yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==`
|
20
scripts/content/transformProject.ts
Normal file
20
scripts/content/transformProject.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import ProjectType from '../../src/types/project'
|
||||||
|
import { getProjectImages } from './images.js'
|
||||||
|
import { markdownToHtml } from './markdown.js'
|
||||||
|
|
||||||
|
export async function transformProject(
|
||||||
|
projectsOriginal: ProjectType[],
|
||||||
|
slug: string
|
||||||
|
) {
|
||||||
|
const project = projectsOriginal.find((item) => item.slug === slug)
|
||||||
|
if (!project) return
|
||||||
|
|
||||||
|
// enhance data with additional fields
|
||||||
|
const descriptionHtml = await markdownToHtml(project.description)
|
||||||
|
project.descriptionHtml = descriptionHtml
|
||||||
|
|
||||||
|
const images = await getProjectImages(slug)
|
||||||
|
project.images = images
|
||||||
|
|
||||||
|
return project
|
||||||
|
}
|
@ -86,8 +86,8 @@ async function buildFavicons() {
|
|||||||
Add this to src/components/Meta/Favicon.tsx:
|
Add this to src/components/Meta/Favicon.tsx:
|
||||||
${outputMeta}
|
${outputMeta}
|
||||||
`)
|
`)
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error(error.message)
|
console.error((error as Error).message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
#!/usr/bin/env ts-node
|
#!/usr/bin/env ts-node
|
||||||
|
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
|
||||||
import prepend from 'prepend'
|
|
||||||
import slugify from 'slugify'
|
|
||||||
import ora from 'ora'
|
import ora from 'ora'
|
||||||
|
import path from 'path'
|
||||||
|
import slugify from 'slugify'
|
||||||
|
|
||||||
const templatePath = path.join(process.cwd(), 'scripts', 'new.yml')
|
const templatePath = path.join(process.cwd(), 'scripts', 'new.yml')
|
||||||
const template = fs.readFileSync(templatePath).toString()
|
const template = fs.readFileSync(templatePath).toString()
|
||||||
@ -26,7 +24,13 @@ const newContents = template
|
|||||||
.split('SLUG')
|
.split('SLUG')
|
||||||
.join(titleSlug)
|
.join(titleSlug)
|
||||||
|
|
||||||
prepend(projects, newContents, (error) => {
|
// prepend newContents to projects.yml file
|
||||||
if (error) spinner.fail(error)
|
fs.readFile(projects, 'utf8', (error, data) => {
|
||||||
spinner.succeed(`Added '${title}' to top of projects.yml file.`)
|
if (error) spinner.fail(error.message)
|
||||||
|
|
||||||
|
fs.writeFile(projects, newContents + data, (error) => {
|
||||||
|
if (error) spinner.fail(error.message)
|
||||||
|
|
||||||
|
spinner.succeed(`Added '${title}' to top of projects.yml file.`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
3
scripts/prebuild.ts
Normal file
3
scripts/prebuild.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { generateProjects } from './content/generateProjects.js'
|
||||||
|
|
||||||
|
generateProjects()
|
6
src/app/[slug]/getAllSlugs.ts
Normal file
6
src/app/[slug]/getAllSlugs.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import projects from '@/generated/projects.json'
|
||||||
|
|
||||||
|
export function getAllSlugs() {
|
||||||
|
const slugs = projects.map(({ slug }: { slug: string }) => slug)
|
||||||
|
return slugs
|
||||||
|
}
|
5
src/app/[slug]/getProjectBySlug.ts
Normal file
5
src/app/[slug]/getProjectBySlug.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import projects from '@/generated/projects.json'
|
||||||
|
|
||||||
|
export function getProjectBySlug(slug: string) {
|
||||||
|
return projects.find((item) => item.slug === slug)
|
||||||
|
}
|
@ -1,26 +1,20 @@
|
|||||||
import { Metadata, ResolvingMetadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import meta from '../../../_content/meta.json'
|
import meta from '@/_content/meta.json'
|
||||||
import Header from '../../components/Header/Header'
|
import projects from '@/generated/projects.json'
|
||||||
import Project from '../../components/Project'
|
import Header from '@/src/components/Header/Header'
|
||||||
import ProjectNav from '../../components/ProjectNav'
|
import Project from '@/src/components/Project'
|
||||||
import {
|
import ProjectNav from '@/src/components/ProjectNav'
|
||||||
getAllProjects,
|
import { getAllSlugs } from './getAllSlugs'
|
||||||
getProjectBySlug,
|
import { getProjectBySlug } from './getProjectBySlug'
|
||||||
getProjectSlugs
|
|
||||||
} from '../../lib/content'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: { slug: string }
|
params: { slug: string }
|
||||||
// searchParams: { [key: string]: string | string[] | undefined }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
{ params }: Props
|
const project = getProjectBySlug(params.slug)
|
||||||
// parent: ResolvingMetadata
|
if (!project) return {}
|
||||||
): Promise<Metadata> {
|
|
||||||
const project = await getProjectBySlug(params.slug)
|
|
||||||
if (!project) return
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: project.title,
|
title: project.title,
|
||||||
@ -37,12 +31,10 @@ export async function generateMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProjectPage({ params }: Props) {
|
export default async function ProjectPage({ params }: Props) {
|
||||||
const project = await getProjectBySlug(params.slug)
|
const project = getProjectBySlug(params.slug)
|
||||||
|
|
||||||
if (!project) notFound()
|
if (!project) notFound()
|
||||||
|
|
||||||
const projects = await getAllProjects(['slug', 'title', 'images'])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
@ -53,7 +45,6 @@ export default async function ProjectPage({ params }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const slugs = getProjectSlugs()
|
const slugs = getAllSlugs()
|
||||||
|
|
||||||
return slugs.map((slug) => ({ slug }))
|
return slugs.map((slug) => ({ slug }))
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import { render } from '@testing-library/react'
|
import { render } from '@testing-library/react'
|
||||||
import meta from '../../../_content/meta.json'
|
import meta from '../../../_content/meta.json'
|
||||||
import projectMock from '../../../tests/__fixtures__/project.json'
|
import projectMock from '../../../tests/__fixtures__/project.json'
|
||||||
import projectsMock from '../../../tests/__fixtures__/projects.json'
|
|
||||||
import Page, { generateMetadata, generateStaticParams } from '../[slug]/page'
|
import Page, { generateMetadata, generateStaticParams } from '../[slug]/page'
|
||||||
|
|
||||||
jest.mock('../../lib/content', () => ({
|
jest.mock('../[slug]/getProjectBySlug', () => ({
|
||||||
getAllProjects: jest.fn().mockImplementation(() => projectsMock),
|
getProjectBySlug: jest.fn().mockImplementation(() => projectMock)
|
||||||
getProjectBySlug: jest.fn().mockImplementation(() => projectMock),
|
}))
|
||||||
getProjectSlugs: jest.fn().mockImplementation(() => ['slug1', 'slug2'])
|
|
||||||
|
jest.mock('../[slug]/getAllSlugs', () => ({
|
||||||
|
getAllSlugs: jest.fn().mockImplementationOnce(() => ['slug1', 'slug2'])
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('app: [slug]/page', () => {
|
describe('app: [slug]/page', () => {
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { dataLocation } from '../../../tests/__fixtures__/location'
|
|
||||||
import Layout from '../layout'
|
import Layout from '../layout'
|
||||||
|
|
||||||
describe('app: /layout', () => {
|
describe('app: /layout', () => {
|
||||||
// suppress error "Warning: validateDOMNesting(...): <html> cannot appear as a child of <div>"
|
// suppress error "Warning: validateDOMNesting(...): <html> cannot appear as a child of <div>"
|
||||||
// https://github.com/testing-library/react-testing-library/issues/1250
|
// https://github.com/testing-library/react-testing-library/issues/1250
|
||||||
let originalError
|
let originalError: {
|
||||||
|
(...data: any[]): void
|
||||||
|
(message?: any, ...optionalParams: any[]): void
|
||||||
|
(...data: any[]): void
|
||||||
|
(message?: any, ...optionalParams: any[]): void
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
originalError = console.error
|
originalError = console.error
|
||||||
|
@ -1,16 +1,6 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import projectsMock from '../../../tests/__fixtures__/projects.json'
|
|
||||||
import reposMock from '../../../tests/__fixtures__/repos.json'
|
|
||||||
import Page from '../page'
|
import Page from '../page'
|
||||||
|
|
||||||
jest.mock('../../lib/content', () => ({
|
|
||||||
getAllProjects: jest.fn().mockImplementationOnce(() => projectsMock)
|
|
||||||
}))
|
|
||||||
|
|
||||||
jest.mock('../../lib/github', () => ({
|
|
||||||
getGithubRepos: jest.fn().mockImplementationOnce(() => reposMock)
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('app: /page', () => {
|
describe('app: /page', () => {
|
||||||
it('renders correctly', async () => {
|
it('renders correctly', async () => {
|
||||||
render(await Page())
|
render(await Page())
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import { cache } from 'react'
|
import { cache } from 'react'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { GiphyFetch } from '@giphy/js-fetch-api'
|
import { GiphyFetch } from '@giphy/js-fetch-api'
|
||||||
|
import { getGithubRepos } from '../lib/github'
|
||||||
|
|
||||||
export const preloadLocation = () => {
|
export const preloadLocation = () => {
|
||||||
void getLocation()
|
void getLocation()
|
||||||
@ -16,8 +17,17 @@ export const getLocation = cache(async () => {
|
|||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error(error.message)
|
console.error((error as Error).message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getRepos = cache(async () => {
|
||||||
|
try {
|
||||||
|
const repos = await getGithubRepos()
|
||||||
|
return repos
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error((error as Error).message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -29,8 +39,8 @@ export async function getRandomGif(tag: string, pathname?: string) {
|
|||||||
const { data } = await giphyClient.random({ tag })
|
const { data } = await giphyClient.random({ tag })
|
||||||
const gif = data.images.original.mp4
|
const gif = data.images.original.mp4
|
||||||
return gif
|
return gif
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error(error.message)
|
console.error((error as Error).message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname) revalidatePath(pathname)
|
if (pathname) revalidatePath(pathname)
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
|
import { Suspense } from 'react'
|
||||||
|
import projects from '../../generated/projects.json'
|
||||||
import Hero from '../components/Hero'
|
import Hero from '../components/Hero'
|
||||||
import Projects from '../components/Projects'
|
import Projects from '../components/Projects'
|
||||||
import Repositories from '../components/Repositories'
|
import Repositories from '../components/Repositories'
|
||||||
import { getAllProjects } from '../lib/content'
|
import { getRepos, preloadLocation } from './actions'
|
||||||
import { getGithubRepos } from '../lib/github'
|
|
||||||
import { preloadLocation } from './actions'
|
|
||||||
|
|
||||||
export default async function IndexPage() {
|
export default async function IndexPage() {
|
||||||
const projects = await getAllProjects(['title', 'images', 'slug'])
|
const repos = await getRepos()
|
||||||
const repos = await getGithubRepos()
|
|
||||||
|
|
||||||
preloadLocation()
|
preloadLocation()
|
||||||
|
|
||||||
@ -15,7 +14,9 @@ export default async function IndexPage() {
|
|||||||
<>
|
<>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Projects projects={projects} />
|
<Projects projects={projects} />
|
||||||
<Repositories repos={repos} />
|
<Suspense fallback={<p>Loading open source projects...</p>}>
|
||||||
|
<Repositories repos={repos} />
|
||||||
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from 'next-themes'
|
||||||
|
|
||||||
export function Providers({ children }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return <ThemeProvider attribute="class">{children}</ThemeProvider>
|
return <ThemeProvider attribute="class">{children}</ThemeProvider>
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
const Button = ({ children, ...props }) => (
|
declare type ButtonProps = React.DetailedHTMLProps<
|
||||||
<a className={styles.button} {...props}>
|
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||||
{children}
|
HTMLAnchorElement
|
||||||
</a>
|
>
|
||||||
)
|
|
||||||
|
|
||||||
export default Button
|
export default function Button({ children, ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<a {...props} className={styles.button}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -7,7 +7,7 @@ type Props = {
|
|||||||
allowedHosts: string[]
|
allowedHosts: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }) {
|
export async function generateMetadata({ params }: { params: Props }) {
|
||||||
const isAllowedHost = params.allowedHosts.includes(window.location.hostname)
|
const isAllowedHost = params.allowedHosts.includes(window.location.hostname)
|
||||||
|
|
||||||
if (!isAllowedHost) {
|
if (!isAllowedHost) {
|
||||||
|
@ -4,16 +4,16 @@ import Icon from '.'
|
|||||||
describe('Icon', () => {
|
describe('Icon', () => {
|
||||||
it('renders correctly', () => {
|
it('renders correctly', () => {
|
||||||
const { container, rerender } = render(<Icon name={'Compass'} />)
|
const { container, rerender } = render(<Icon name={'Compass'} />)
|
||||||
expect(container.firstChild.nodeName).toBe('svg')
|
expect(container.firstChild?.nodeName).toBe('svg')
|
||||||
|
|
||||||
rerender(<Icon name={'Download'} />)
|
rerender(<Icon name={'Download'} />)
|
||||||
expect(container.firstChild.nodeName).toBe('svg')
|
expect(container.firstChild?.nodeName).toBe('svg')
|
||||||
|
|
||||||
rerender(<Icon name={'Styleguide'} />)
|
rerender(<Icon name={'Styleguide'} />)
|
||||||
expect(container.firstChild.nodeName).toBe('svg')
|
expect(container.firstChild?.nodeName).toBe('svg')
|
||||||
|
|
||||||
rerender(<Icon name={'Blog'} />)
|
rerender(<Icon name={'Blog'} />)
|
||||||
expect(container.firstChild.nodeName).toBe('svg')
|
expect(container.firstChild?.nodeName).toBe('svg')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not render with unknown name', () => {
|
it('does not render with unknown name', () => {
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getLocation } from '@/src/app/actions'
|
||||||
import RelativeTime from '@yaireo/relative-time'
|
import RelativeTime from '@yaireo/relative-time'
|
||||||
import { getLocation } from '../../app/actions'
|
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
|
||||||
|
import { fadeIn, getAnimationProps } from '../Transitions'
|
||||||
import { Flag } from './Flag'
|
import { Flag } from './Flag'
|
||||||
import styles from './Location.module.css'
|
import styles from './Location.module.css'
|
||||||
import { UseLocation } from './types'
|
import { UseLocation } from './types'
|
||||||
|
|
||||||
export default function Location() {
|
export default function Location() {
|
||||||
|
const shouldReduceMotion = useReducedMotion()
|
||||||
const [location, setLocation] = useState<UseLocation | null>(null)
|
const [location, setLocation] = useState<UseLocation | null>(null)
|
||||||
|
|
||||||
const isDifferentCountry = location?.now?.country !== location?.next?.country
|
const isDifferentCountry = location?.now?.country !== location?.next?.country
|
||||||
@ -24,37 +27,40 @@ export default function Location() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
{location?.now?.city ? (
|
{location?.now?.city ? (
|
||||||
<section
|
<LazyMotion features={domAnimation}>
|
||||||
aria-label="Location"
|
<m.section
|
||||||
className={styles.location}
|
aria-label="Location"
|
||||||
style={{ opacity: 1 }}
|
variants={fadeIn}
|
||||||
>
|
className={styles.location}
|
||||||
<Flag
|
{...getAnimationProps(shouldReduceMotion)}
|
||||||
country={{
|
>
|
||||||
code: location.now.country_code,
|
<Flag
|
||||||
name: location.now.country
|
country={{
|
||||||
}}
|
code: location.now.country_code,
|
||||||
/>
|
name: location.now.country
|
||||||
{location.now.city} <span>Now</span>
|
}}
|
||||||
<div className={styles.next}>
|
/>
|
||||||
{location?.next?.city && (
|
{location.now.city} <span>Now</span>
|
||||||
<>
|
<div className={styles.next}>
|
||||||
{isDifferentCountry && (
|
{location?.next?.city && (
|
||||||
<Flag
|
<>
|
||||||
country={{
|
{isDifferentCountry && (
|
||||||
code: location.next.country_code,
|
<Flag
|
||||||
name: location.next.country
|
country={{
|
||||||
}}
|
code: location.next.country_code,
|
||||||
/>
|
name: location.next.country
|
||||||
)}
|
}}
|
||||||
{location.next.city}{' '}
|
/>
|
||||||
<span>
|
)}
|
||||||
{relativeTime.from(new Date(location.next.date_start))}
|
{location.next.city}{' '}
|
||||||
</span>
|
<span>
|
||||||
</>
|
{relativeTime.from(new Date(location.next.date_start))}
|
||||||
)}
|
</span>
|
||||||
</div>
|
</>
|
||||||
</section>
|
)}
|
||||||
|
</div>
|
||||||
|
</m.section>
|
||||||
|
</LazyMotion>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -14,7 +14,6 @@ export default function LogoUnit({ small }: Props) {
|
|||||||
<Link
|
<Link
|
||||||
className={`${styles.logounit} ${small ? styles.small : null}`}
|
className={`${styles.logounit} ${small ? styles.small : null}`}
|
||||||
href="/"
|
href="/"
|
||||||
aria-current={!small ? 'page' : null}
|
|
||||||
>
|
>
|
||||||
<Logo className={styles.logo} />
|
<Logo className={styles.logo} />
|
||||||
<H className={`p-name ${styles.title}`}>
|
<H className={`p-name ${styles.title}`}>
|
||||||
|
@ -5,7 +5,7 @@ describe('Networks', () => {
|
|||||||
it('renders correctly from data file values', () => {
|
it('renders correctly from data file values', () => {
|
||||||
const { container } = render(<Networks label="Networks" />)
|
const { container } = render(<Networks label="Networks" />)
|
||||||
expect(container.firstChild).toBeInTheDocument()
|
expect(container.firstChild).toBeInTheDocument()
|
||||||
expect(container.firstChild.nodeName).toBe('SECTION')
|
expect(container.firstChild?.nodeName).toBe('SECTION')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders correctly in small variant', () => {
|
it('renders correctly in small variant', () => {
|
||||||
|
@ -22,7 +22,7 @@ const containerVariants = {
|
|||||||
|
|
||||||
export default function Networks({ label, small }: Props) {
|
export default function Networks({ label, small }: Props) {
|
||||||
const shouldReduceMotion = useReducedMotion()
|
const shouldReduceMotion = useReducedMotion()
|
||||||
const animationProps = getAnimationProps(shouldReduceMotion)
|
const animationProps = getAnimationProps(shouldReduceMotion || false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LazyMotion features={domAnimation}>
|
<LazyMotion features={domAnimation}>
|
||||||
|
@ -12,7 +12,6 @@ import styles from './index.module.css'
|
|||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
enter: {
|
enter: {
|
||||||
transition: {
|
transition: {
|
||||||
delay: 0.3,
|
|
||||||
staggerChildren: 0.2
|
staggerChildren: 0.2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -25,7 +24,7 @@ export default function Project({
|
|||||||
}) {
|
}) {
|
||||||
const { title, descriptionHtml, images, links, techstack } = project
|
const { title, descriptionHtml, images, links, techstack } = project
|
||||||
const shouldReduceMotion = useReducedMotion()
|
const shouldReduceMotion = useReducedMotion()
|
||||||
const animationProps = getAnimationProps(shouldReduceMotion)
|
const animationProps = getAnimationProps(shouldReduceMotion || false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={styles.project}>
|
<article className={styles.project}>
|
||||||
@ -42,7 +41,7 @@ export default function Project({
|
|||||||
<m.div
|
<m.div
|
||||||
variants={moveInBottom}
|
variants={moveInBottom}
|
||||||
className={styles.description}
|
className={styles.description}
|
||||||
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
|
dangerouslySetInnerHTML={{ __html: descriptionHtml ?? '' }}
|
||||||
/>
|
/>
|
||||||
</m.header>
|
</m.header>
|
||||||
</LazyMotion>
|
</LazyMotion>
|
||||||
@ -54,8 +53,6 @@ export default function Project({
|
|||||||
alt={`Showcase image no. ${i + 1} for ${title}`}
|
alt={`Showcase image no. ${i + 1} for ${title}`}
|
||||||
key={i}
|
key={i}
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
// give priority to the first image
|
|
||||||
priority={i === 0}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
@ -16,6 +16,8 @@ describe('ProjectImage', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns without errors without image', async () => {
|
it('returns without errors without image', async () => {
|
||||||
render(<ProjectImage image={null} alt={project.title} sizes="100vw" />)
|
render(
|
||||||
|
<ProjectImage image={null as any} alt={project.title} sizes="100vw" />
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,24 +1,7 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import {
|
|
||||||
LazyMotion,
|
|
||||||
domAnimation,
|
|
||||||
m,
|
|
||||||
useAnimation,
|
|
||||||
useReducedMotion
|
|
||||||
} from 'framer-motion'
|
|
||||||
import ImageType from '../../types/image'
|
import ImageType from '../../types/image'
|
||||||
import { getAnimationProps } from '../Transitions'
|
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
const animationVariants = {
|
|
||||||
initial: { opacity: 0 },
|
|
||||||
enter: { opacity: 1 },
|
|
||||||
exit: { opacity: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectImage({
|
export default function ProjectImage({
|
||||||
image,
|
image,
|
||||||
alt,
|
alt,
|
||||||
@ -32,39 +15,20 @@ export default function ProjectImage({
|
|||||||
className?: string
|
className?: string
|
||||||
priority?: boolean
|
priority?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [loaded, setLoaded] = useState(false)
|
|
||||||
const animationControls = useAnimation()
|
|
||||||
const shouldReduceMotion = useReducedMotion()
|
|
||||||
const animationProps = getAnimationProps(shouldReduceMotion)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loaded && animationControls) {
|
|
||||||
animationControls.start('enter')
|
|
||||||
}
|
|
||||||
}, [loaded, animationControls])
|
|
||||||
|
|
||||||
return image ? (
|
return image ? (
|
||||||
<LazyMotion features={domAnimation}>
|
<figure className={`${styles.imageWrap} ${className || null}`}>
|
||||||
<m.figure
|
<Image
|
||||||
variants={animationVariants}
|
className={styles.image}
|
||||||
{...animationProps}
|
src={image.src}
|
||||||
transition={{ ease: 'easeOut', duration: 1 }}
|
alt={alt}
|
||||||
className={`${styles.imageWrap} ${className || null}`}
|
width={image.width}
|
||||||
>
|
height={image.height}
|
||||||
<Image
|
sizes={sizes}
|
||||||
className={styles.image}
|
quality={85}
|
||||||
src={image.src}
|
priority={priority}
|
||||||
alt={alt}
|
placeholder="blur"
|
||||||
width={image.width}
|
blurDataURL={image.blurDataURL}
|
||||||
height={image.height}
|
/>
|
||||||
sizes={sizes}
|
</figure>
|
||||||
quality={85}
|
|
||||||
priority={priority}
|
|
||||||
placeholder="empty"
|
|
||||||
// blurDataURL={image.blurDataURL}
|
|
||||||
onLoad={() => setLoaded(true)}
|
|
||||||
/>
|
|
||||||
</m.figure>
|
|
||||||
</LazyMotion>
|
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import { Project } from './Project'
|
|||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projects: Partial<ProjectType>[]
|
projects: ProjectType[]
|
||||||
currentSlug: string
|
currentSlug: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ export default function ProjectNav({ projects, currentSlug }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function scrollToCurrent() {
|
function scrollToCurrent() {
|
||||||
const activeItem = currentItem.current
|
const activeItem = currentItem.current
|
||||||
const scrollRect = scrollContainer.current.getBoundingClientRect()
|
const scrollRect = scrollContainer.current?.getBoundingClientRect()
|
||||||
const activeRect = activeItem && activeItem.getBoundingClientRect()
|
const activeRect = activeItem && activeItem.getBoundingClientRect()
|
||||||
const newScrollLeftPosition =
|
const newScrollLeftPosition =
|
||||||
activeRect &&
|
activeRect &&
|
||||||
|
@ -7,22 +7,15 @@ type Props = {
|
|||||||
title: string
|
title: string
|
||||||
slug: string
|
slug: string
|
||||||
image: ImageType
|
image: ImageType
|
||||||
imagePriority: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectPreview({
|
export default function ProjectPreview({ title, slug, image }: Props) {
|
||||||
title,
|
|
||||||
slug,
|
|
||||||
image,
|
|
||||||
imagePriority
|
|
||||||
}: Props) {
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/${slug}`} className={styles.project} key={slug}>
|
<Link href={`/${slug}`} className={styles.project} key={slug}>
|
||||||
<ProjectImage
|
<ProjectImage
|
||||||
image={image}
|
image={image}
|
||||||
alt={`Showcase image for ${title}`}
|
alt={`Showcase image for ${title}`}
|
||||||
sizes="(max-width: 1090px) 100vw, 40vw"
|
sizes="(max-width: 1090px) 100vw, 40vw"
|
||||||
priority={imagePriority}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer className={styles.meta}>
|
<footer className={styles.meta}>
|
||||||
|
@ -3,7 +3,7 @@ import ProjectPreview from '../ProjectPreview'
|
|||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
projects: Partial<ProjectType>[]
|
projects: ProjectType[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Projects({ projects }: Props) {
|
export default function Projects({ projects }: Props) {
|
||||||
@ -15,8 +15,6 @@ export default function Projects({ projects }: Props) {
|
|||||||
key={project.slug}
|
key={project.slug}
|
||||||
title={project.title}
|
title={project.title}
|
||||||
image={project.images[0]}
|
image={project.images[0]}
|
||||||
// give priority for the first 2 images
|
|
||||||
imagePriority={i == 0 || i === 1}
|
|
||||||
slug={project.slug}
|
slug={project.slug}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -8,9 +8,4 @@ describe('Repositories', () => {
|
|||||||
const { container } = render(<Repositories repos={repos as Repo[]} />)
|
const { container } = render(<Repositories repos={repos as Repo[]} />)
|
||||||
expect(container.firstChild).toBeInTheDocument()
|
expect(container.firstChild).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('return nothing when no repos are passed', () => {
|
|
||||||
const { container } = render(<Repositories repos={null} />)
|
|
||||||
expect(container.firstChild).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
@ -2,16 +2,12 @@ import Repo from '../../types/repo'
|
|||||||
import Repository from '../Repository'
|
import Repository from '../Repository'
|
||||||
import styles from './index.module.css'
|
import styles from './index.module.css'
|
||||||
|
|
||||||
export default function Repositories({ repos }: { repos: Repo[] }) {
|
export default function Repositories({ repos }: { repos: Repo[] | undefined }) {
|
||||||
if (!repos) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className={styles.sectionTitle}>Open Source Projects</h2>
|
<h2 className={styles.sectionTitle}>Open Source Projects</h2>
|
||||||
<div className={styles.repos}>
|
<div className={styles.repos}>
|
||||||
{repos.map((repo) => (
|
{repos?.map((repo) => <Repository key={repo.name} repo={repo} />)}
|
||||||
<Repository key={repo.name} repo={repo} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -17,7 +17,7 @@ describe('Repository', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { container } = render(<Repository repo={repo1 as Repo} />)
|
const { container } = render(<Repository repo={repo1 as Repo} />)
|
||||||
expect(container.querySelector('h3 > a').getAttribute('href')).toBe(
|
expect(container.querySelector('h3 > a')?.getAttribute('href')).toBe(
|
||||||
repo1.html_url
|
repo1.html_url
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -13,7 +13,7 @@ export function getIconName(theme: string) {
|
|||||||
|
|
||||||
export default function ThemeSwitch() {
|
export default function ThemeSwitch() {
|
||||||
const { theme, themes, resolvedTheme, setTheme } = useTheme()
|
const { theme, themes, resolvedTheme, setTheme } = useTheme()
|
||||||
const iconName = getIconName(resolvedTheme)
|
const iconName = getIconName(resolvedTheme || '')
|
||||||
|
|
||||||
// hydration errors workaround
|
// hydration errors workaround
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
@ -47,6 +47,12 @@ export const moveInBottom = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const fadeIn = {
|
||||||
|
initial: { opacity: 0 },
|
||||||
|
enter: { opacity: 1 },
|
||||||
|
exit: { opacity: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
export function getAnimationProps(shouldReduceMotion: boolean) {
|
export function getAnimationProps(shouldReduceMotion: boolean) {
|
||||||
return {
|
return {
|
||||||
initial: `${shouldReduceMotion ? 'enter' : 'initial'}`,
|
initial: `${shouldReduceMotion ? 'enter' : 'initial'}`,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { MouseEvent } from 'react'
|
||||||
import meta from '../../../_content/meta.json'
|
import meta from '../../../_content/meta.json'
|
||||||
|
|
||||||
export default function Vcard() {
|
export default function Vcard() {
|
||||||
@ -14,7 +15,7 @@ export default function Vcard() {
|
|||||||
profiles: meta.profiles
|
profiles: meta.profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddressbookClick = (e) => {
|
const handleAddressbookClick = (e: MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
import('./_utils').then(({ init }) => {
|
import('./_utils').then(({ init }) => {
|
||||||
|
19
src/components/Vcard/types.ts
Normal file
19
src/components/Vcard/types.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export declare type Meta = {
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
email?: string
|
||||||
|
profiles?: (
|
||||||
|
| { network: string; url: string; username?: undefined }
|
||||||
|
| { network: string; username: string; url: string }
|
||||||
|
)[]
|
||||||
|
description?: string
|
||||||
|
img?: string
|
||||||
|
url?: string
|
||||||
|
author?: { name: string; label: string; email: string; picture: string }
|
||||||
|
availability?: { status: boolean; available: string; unavailable: string }
|
||||||
|
gpg?: string
|
||||||
|
addressbook?: any
|
||||||
|
bugs?: string
|
||||||
|
allowedHosts?: string[]
|
||||||
|
photoSrc?: any
|
||||||
|
}
|
@ -1,53 +0,0 @@
|
|||||||
import {
|
|
||||||
getAllProjects,
|
|
||||||
getProjectBySlug,
|
|
||||||
getProjectImages,
|
|
||||||
getProjectSlugs
|
|
||||||
} from './content'
|
|
||||||
|
|
||||||
jest.setTimeout(20000)
|
|
||||||
|
|
||||||
describe('lib/content', () => {
|
|
||||||
test('getProjectSlugs', async () => {
|
|
||||||
const slugs: string[] = getProjectSlugs()
|
|
||||||
expect(slugs).toContain('ipixelpad')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('getProjectBySlug', async () => {
|
|
||||||
const project = await getProjectBySlug('ipixelpad')
|
|
||||||
expect(project).toBeDefined()
|
|
||||||
expect(project.images[0].src).toContain('ipixelpad')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('getProjectBySlug returns early', async () => {
|
|
||||||
const project = await getProjectBySlug('gibberish')
|
|
||||||
expect(project).not.toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('getProjectImages', async () => {
|
|
||||||
const images = await getProjectImages('ipixelpad')
|
|
||||||
expect(images).toBeDefined()
|
|
||||||
expect(images[0].src).toContain('ipixelpad')
|
|
||||||
// expect(images[0].blurDataURL).toBeDefined()
|
|
||||||
expect(images[0].width).toBeDefined()
|
|
||||||
expect(images[0].height).toBeDefined()
|
|
||||||
expect(images[0].format).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('getAllProjects', async () => {
|
|
||||||
const projects = await getAllProjects([
|
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
'slug',
|
|
||||||
'images',
|
|
||||||
'techstack',
|
|
||||||
'links'
|
|
||||||
])
|
|
||||||
expect(projects).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('getAllProjects without fields', async () => {
|
|
||||||
const projects = await getAllProjects()
|
|
||||||
expect(projects).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
@ -1,85 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import yaml from 'js-yaml'
|
|
||||||
import { join } from 'path'
|
|
||||||
import sharp from 'sharp'
|
|
||||||
import type ImageType from '../types/image'
|
|
||||||
import type ProjectType from '../types/project'
|
|
||||||
import { markdownToHtml } from './markdown'
|
|
||||||
|
|
||||||
const contentDirectory = join(process.cwd(), '_content')
|
|
||||||
const imagesDirectory = join(process.cwd(), 'public', 'images')
|
|
||||||
const projects = yaml.load(
|
|
||||||
fs.readFileSync(`${contentDirectory}/projects.yml`, 'utf8')
|
|
||||||
) as Partial<ProjectType>[]
|
|
||||||
|
|
||||||
export function getProjectSlugs() {
|
|
||||||
return projects.map(({ slug }: { slug: string }) => slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pixel GIF code adapted from https://stackoverflow.com/a/33919020/266535
|
|
||||||
// const keyStr =
|
|
||||||
// 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
|
|
||||||
|
|
||||||
// const triplet = (e1: number, e2: number, e3: number) =>
|
|
||||||
// keyStr.charAt(e1 >> 2) +
|
|
||||||
// keyStr.charAt(((e1 & 3) << 4) | (e2 >> 4)) +
|
|
||||||
// keyStr.charAt(((e2 & 15) << 2) | (e3 >> 6)) +
|
|
||||||
// keyStr.charAt(e3 & 63)
|
|
||||||
|
|
||||||
// export const rgbDataURL = ({ r, g, b }: { r: number; g: number; b: number }) =>
|
|
||||||
// `data:image/gif;base64,R0lGODlhAQABAPAA${
|
|
||||||
// triplet(0, r, g) + triplet(b, 255, 255)
|
|
||||||
// }/yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==`
|
|
||||||
|
|
||||||
export async function getProjectImages(slug: string) {
|
|
||||||
const allImages = fs.readdirSync(imagesDirectory, 'utf8')
|
|
||||||
const projectImages = allImages.filter((image) => image.includes(slug))
|
|
||||||
|
|
||||||
let images: ImageType[] = []
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
projectImages.map(async (image) => {
|
|
||||||
const file = `${imagesDirectory}/${image}`
|
|
||||||
const transformer = sharp(file)
|
|
||||||
const { width, height, format } = await transformer.metadata()
|
|
||||||
// const { dominant } = await transformer.stats()
|
|
||||||
// const blurDataURL = rgbDataURL(dominant)
|
|
||||||
|
|
||||||
const imageType: ImageType = {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
format,
|
|
||||||
// blurDataURL,
|
|
||||||
src: `/images/${image}`
|
|
||||||
}
|
|
||||||
images.push(imageType)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
// Sort images by sequentially numbered name to be sure
|
|
||||||
images = images.sort((a, b) => a.src.localeCompare(b.src))
|
|
||||||
return images
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProjectBySlug(slug: string, fields: string[] = []) {
|
|
||||||
const project = projects.find((item) => item.slug === slug)
|
|
||||||
if (!project) return
|
|
||||||
|
|
||||||
// enhance data with additional fields
|
|
||||||
const descriptionHtml = await markdownToHtml(project.description)
|
|
||||||
project.descriptionHtml = descriptionHtml
|
|
||||||
|
|
||||||
const images = await getProjectImages(slug)
|
|
||||||
project.images = images
|
|
||||||
|
|
||||||
return project
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllProjects(
|
|
||||||
fields: string[] = []
|
|
||||||
): Promise<Partial<ProjectType>[]> {
|
|
||||||
const slugs = getProjectSlugs()
|
|
||||||
const projects = await Promise.all(
|
|
||||||
slugs.map(async (slug: string) => await getProjectBySlug(slug, fields))
|
|
||||||
)
|
|
||||||
return projects
|
|
||||||
}
|
|
@ -1,8 +1,8 @@
|
|||||||
declare type ImageType = {
|
declare type ImageType = {
|
||||||
src: string
|
src: string
|
||||||
width: number
|
width?: number
|
||||||
height: number
|
height?: number
|
||||||
format: string
|
format?: string
|
||||||
blurDataURL?: string
|
blurDataURL?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ const customJestConfig: Config = {
|
|||||||
moduleDirectories: ['node_modules', '<rootDir>/src'],
|
moduleDirectories: ['node_modules', '<rootDir>/src'],
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/$1',
|
||||||
'^.+\\.(svg)$': '<rootDir>/tests/__mocks__/svgr-mock.tsx'
|
'^.+\\.(svg)$': '<rootDir>/tests/__mocks__/svgr-mock.tsx'
|
||||||
},
|
},
|
||||||
collectCoverage: true,
|
collectCoverage: true,
|
||||||
@ -38,7 +39,6 @@ const customJestConfig: Config = {
|
|||||||
// https://github.com/vercel/next.js/issues/35634#issuecomment-1115250297
|
// https://github.com/vercel/next.js/issues/35634#issuecomment-1115250297
|
||||||
async function jestConfig() {
|
async function jestConfig() {
|
||||||
const nextJestConfig = await createJestConfig(customJestConfig)()
|
const nextJestConfig = await createJestConfig(customJestConfig)()
|
||||||
// /node_modules/ is the first pattern
|
|
||||||
nextJestConfig.transformIgnorePatterns[0] = '/node_modules/(?!uuid|remark)/'
|
nextJestConfig.transformIgnorePatterns[0] = '/node_modules/(?!uuid|remark)/'
|
||||||
return nextJestConfig
|
return nextJestConfig
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import '@testing-library/jest-dom'
|
|||||||
import 'jest-canvas-mock'
|
import 'jest-canvas-mock'
|
||||||
import giphy from './__fixtures__/giphy.json'
|
import giphy from './__fixtures__/giphy.json'
|
||||||
import { dataLocation } from './__fixtures__/location'
|
import { dataLocation } from './__fixtures__/location'
|
||||||
|
import reposMock from './__fixtures__/repos.json'
|
||||||
import './__mocks__/matchMedia'
|
import './__mocks__/matchMedia'
|
||||||
|
|
||||||
jest.mock('next/navigation', () => ({
|
jest.mock('next/navigation', () => ({
|
||||||
@ -14,7 +15,8 @@ jest.mock('../src/app/actions', () => ({
|
|||||||
getRandomGif: jest
|
getRandomGif: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementation(() => giphy.data.images.original.mp4),
|
.mockImplementation(() => giphy.data.images.original.mp4),
|
||||||
preloadLocation: jest.fn()
|
preloadLocation: jest.fn(),
|
||||||
|
getRepos: jest.fn().mockImplementationOnce(() => reposMock)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const unmockedFetch = global.fetch
|
const unmockedFetch = global.fetch
|
||||||
|
@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
"module": "esnext",
|
|
||||||
"jsx": "preserve",
|
|
||||||
"strict": false,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false, // TODO: Change to strict: true
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [{ "name": "next" }]
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user