Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,8 @@ node_modules

# Postgres
postgres-data
website-next-postgres-data
data/GeoLite2-Country.mmdb

# Website OG images (stored in DB)
public/website-og-images/
3 changes: 3 additions & 0 deletions app/about/page.mdx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatDistanceToNowStrict } from 'date-fns'
import Link from 'next/link'

export const birthDay = new Date('22 Jul 1991')
export const yearsOld = formatDistanceToNowStrict(birthDay, {
Expand Down Expand Up @@ -36,6 +37,8 @@ I've been writing on this website since 2017 (which is 7 years at the time of wr
although I have changed the domain name once in 2022 when I got married and took on my
wife's last name.

I also enjoy discovering other people's websites and exploring them. I maintain a list of <Link href="/websites-i-like">Websites I like</Link>.

## Colophon

This site is built with Next.js and hosted on Vercel. Content is just
Expand Down
98 changes: 98 additions & 0 deletions app/websites-i-like/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { getAllWebsites, getWebsiteImageMap } from 'data/websites.dto'
import { getLogger } from 'lib/logger'
import { ExternalLink } from 'lucide-react'
import { Metadata } from 'next'

export const metadata: Metadata = {
title: 'Websites I Like - Chris Jarling',
description: 'A curated collection of websites I enjoy.',
}

export default async function WebsitesILikePage() {
const websites = await getAllWebsites()

let imageMap: Record<string, { type: string; dataUri: string }> = {}
try {
imageMap = await getWebsiteImageMap()
} catch (error) {
getLogger()
.withError(error as Error)
.error('Failed to load website images')
}

const websitesWithImages = websites.map((website) => {
let hostname: string
try {
hostname = new URL(website.entry.url).hostname
} catch {
hostname = website.entry.url
}
return {
...website,
image: imageMap[website.slug] ?? null,
hostname,
}
})

return (
<>
<div className="prose prose-invert mb-8">
<h1>Websites I Like</h1>
<p>
A list of websites I like for one reason or another. Could be, because
I enjoy how they look or are built or what they write about.
</p>
</div>

<div className="space-y-4 md:grid md:grid-cols-2 md:gap-4 md:space-y-0">
{websitesWithImages.map((website) => (
<a
key={website.slug}
href={website.entry.url}
target="_blank"
rel="noopener noreferrer"
className="group -ml-4 -mr-4 flex gap-4 rounded-md px-4 py-3 transition-all hover:bg-base-900/50 md:ml-0 md:mr-0 md:flex-col md:gap-2"
>
<div className="h-20 w-32 flex-shrink-0 overflow-hidden rounded bg-base-800 md:h-36 md:w-full">
{website.image?.type === 'og' ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={website.image.dataUri}
alt={website.entry.title}
className="h-full w-full object-cover"
/>
) : website.image?.type === 'favicon' ? (
<div className="flex h-full w-full items-center justify-center bg-base-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={website.image.dataUri}
alt={website.entry.title}
className="h-8 w-8"
/>
</div>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-base-600">
No preview
</div>
)}
</div>

<div className="flex flex-col justify-center">
<h2 className="font-medium text-base-300 group-hover:text-base-100">
{website.entry.title}
</h2>
<p className="flex items-center gap-1 text-sm text-base-500">
<ExternalLink className="h-3 w-3" />
{website.hostname}
</p>
</div>
</a>
))}

{websites.length === 0 && (
<p className="text-base-500">No websites yet.</p>
)}
</div>
</>
)
}
2 changes: 2 additions & 0 deletions content/websites/0xdf4.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: '0xDF4'
url: 'https://0xdf4.com/'
2 changes: 2 additions & 0 deletions content/websites/100-rabbits.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: 100 Rabbits
url: 'https://100r.co/site/home.html'
2 changes: 2 additions & 0 deletions content/websites/alexey-guzey.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Alexey Guzey
url: 'https://guzey.com/vibes/'
2 changes: 2 additions & 0 deletions content/websites/alistair-shepherd.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Alistair Shepherd
url: 'https://alistairshepherd.uk/'
2 changes: 2 additions & 0 deletions content/websites/brian-lovin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Brian Lovin
url: 'https://brianlovin.com/'
2 changes: 2 additions & 0 deletions content/websites/brittany-chiang.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Brittany Chiang
url: 'https://brittanychiang.com/'
2 changes: 2 additions & 0 deletions content/websites/cold-takes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Cold Takes
url: 'https://www.cold-takes.com/'
2 changes: 2 additions & 0 deletions content/websites/daniel-miessler.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Daniel Miessler
url: 'https://danielmiessler.com/'
2 changes: 2 additions & 0 deletions content/websites/daniel-sun.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Daniel Sun
url: 'https://danielsun.space/'
2 changes: 2 additions & 0 deletions content/websites/dimitrios-lytras.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Dimitrios Lytras
url: 'https://dnlytras.com/'
2 changes: 2 additions & 0 deletions content/websites/drew-devault.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Drew DeVault
url: 'https://drewdevault.com/'
2 changes: 2 additions & 0 deletions content/websites/emma-goto.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Emma Goto
url: 'https://www.emgoto.com/'
2 changes: 2 additions & 0 deletions content/websites/gwern.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Gwern
url: 'https://gwern.net/changelog'
2 changes: 2 additions & 0 deletions content/websites/hardeeps-iphone-notes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Hardeep's iPhone Notes
url: 'https://hardeeps-iphone-notes.super.site/'
2 changes: 2 additions & 0 deletions content/websites/henry-codes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Henry Codes
url: 'https://henry.codes/work'
2 changes: 2 additions & 0 deletions content/websites/ineza-bonte.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Ineza Bonté
url: 'https://ineza.codes/'
2 changes: 2 additions & 0 deletions content/websites/jahir-fiquitiva.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Jahir Fiquitiva
url: 'https://jahir.dev/'
2 changes: 2 additions & 0 deletions content/websites/jordi-enric.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Jordi Enric
url: 'https://www.jordienric.com/'
2 changes: 2 additions & 0 deletions content/websites/josh-w-comeau.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Josh W Comeau
url: 'https://www.joshwcomeau.com/snippets/'
2 changes: 2 additions & 0 deletions content/websites/maggie-appleton.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Maggie Appleton
url: 'https://maggieappleton.com/'
2 changes: 2 additions & 0 deletions content/websites/maxime-heckel.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Maxime Heckel
url: 'https://maximeheckel.com/'
2 changes: 2 additions & 0 deletions content/websites/nico-espeon.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Nico Espeon
url: 'https://www.nicoespeon.com/'
2 changes: 2 additions & 0 deletions content/websites/not-a-number.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Not a Number
url: 'https://www.nan.fyi/'
2 changes: 2 additions & 0 deletions content/websites/paul-scanlon.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Paul Scanlon
url: 'https://paulie.dev/'
2 changes: 2 additions & 0 deletions content/websites/stefan-judis.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Stefan Judis
url: 'https://www.stefanjudis.com/'
2 changes: 2 additions & 0 deletions content/websites/timo-mamecke.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Timo Mämecke
url: 'https://timomeh.de/'
7 changes: 7 additions & 0 deletions data/cms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,11 @@ export const cms = {
return book
},
},
websites: {
async all() {
const websites = await reader.collections.websites.all()

return websites
},
},
}
32 changes: 32 additions & 0 deletions data/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { customType } from 'drizzle-orm/pg-core'
import {
index,
integer,
pgTable,
timestamp,
uniqueIndex,
varchar,
} from 'drizzle-orm/pg-core'

const bytea = customType<{ data: Buffer }>({
dataType() {
return 'bytea'
},
})

export const eventsTable = pgTable(
'events',
{
Expand All @@ -30,3 +38,27 @@ export const testTable = pgTable('test', {
.defaultNow()
.notNull(),
})

export const websiteImagesTable = pgTable(
'website_images',
{
id: integer().primaryKey().generatedAlwaysAsIdentity(),
slug: varchar({ length: 255 }).notNull(),
imageType: varchar('image_type', { length: 10 }).notNull(),
mimeType: varchar('mime_type', { length: 50 }).notNull(),
imageData: bytea('image_data').notNull(),
originalUrl: varchar('original_url', { length: 2048 }),
createdAt: timestamp('created_at', { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
slugImageTypeIdx: uniqueIndex('website_images_slug_image_type_idx').on(
table.slug,
table.imageType,
),
}),
)
90 changes: 90 additions & 0 deletions data/website-images.repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { and, eq } from 'drizzle-orm'

import { db } from './db/db'
import { websiteImagesTable } from './db/schema'

export type ImageType = 'og' | 'favicon'

export class WebsiteImagesRepository {
constructor() {
throw new Error('Not meant to be instantiated')
}

static async upsert({
slug,
imageType,
mimeType,
imageData,
originalUrl,
}: {
slug: string
imageType: ImageType
mimeType: string
imageData: Buffer
originalUrl?: string | null
}) {
await db
.insert(websiteImagesTable)
.values({
slug,
imageType,
mimeType,
imageData,
originalUrl: originalUrl ?? null,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [websiteImagesTable.slug, websiteImagesTable.imageType],
set: {
mimeType,
imageData,
originalUrl: originalUrl ?? null,
updatedAt: new Date(),
},
})
}

static async getAllAsDataUriMap(): Promise<
Record<string, { type: ImageType; dataUri: string }>
> {
const rows = await db
.select({
slug: websiteImagesTable.slug,
imageType: websiteImagesTable.imageType,
mimeType: websiteImagesTable.mimeType,
imageData: websiteImagesTable.imageData,
})
.from(websiteImagesTable)

const result: Record<string, { type: ImageType; dataUri: string }> = {}

for (const row of rows) {
const existing = result[row.slug]
// OG images take priority over favicons
if (existing && existing.type === 'og') continue

const base64 = row.imageData.toString('base64')
result[row.slug] = {
type: row.imageType as ImageType,
dataUri: `data:${row.mimeType};base64,${base64}`,
}
}

return result
}

static async exists(slug: string, imageType?: ImageType): Promise<boolean> {
const conditions = [eq(websiteImagesTable.slug, slug)]
if (imageType) {
conditions.push(eq(websiteImagesTable.imageType, imageType))
}

const rows = await db
.select({ slug: websiteImagesTable.slug })
.from(websiteImagesTable)
.where(and(...conditions))
.limit(1)

return rows.length > 0
}
}
18 changes: 18 additions & 0 deletions data/websites.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { unstable_cache } from 'next/cache'

import { cms } from './cms'
import { WebsiteImagesRepository } from './website-images.repo'

export async function getAllWebsites() {
const websites = await cms.websites.all()

return websites.sort((a, b) => a.entry.title.localeCompare(b.entry.title))
}

export const getWebsiteImageMap = unstable_cache(
async () => {
return await WebsiteImagesRepository.getAllAsDataUriMap()
},
['website-images'],
{ revalidate: 86400 },
)
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ services:
- RELEASE_VERSION=dev-build
ports:
- 3000:3000
db:
website-next-db:
image: postgres:latest
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=chrisjarling-com-dev
- POSTGRES_DB=website-next-dev
volumes:
- ./postgres-data:/var/lib/postgresql
- ./website-next-postgres-data:/var/lib/postgresql
ports:
- 5433:5432
Loading
Loading