fix(about): wrap bio richText in collapsible so Lexical editor renders in admin
Some checks failed
ci/woodpecker/push/web Pipeline failed
The bare richText field was silently not rendering in the Payload admin UI. Wrapping in a collapsible preserves the data path while giving the editor a proper container. Also renames image assets and adds media management scripts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BIN
images/at-the-desk.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
images/editorial-blazer.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 128 KiB |
BIN
images/illustrated-portrait.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 641 KiB After Width: | Height: | Size: 641 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
BIN
images/tech-founder-dark.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
images/tech-founder-warm.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
images/thought-leader-city.jpg
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
images/thought-leader-office.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
74
scripts/rename-media.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Rename media — re-uploads each file under its new filename.
|
||||||
|
* Payload replaces the old file + regenerates thumbnails while keeping the same ID.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
|
||||||
|
const imagesDir = path.resolve(process.cwd(), 'images')
|
||||||
|
|
||||||
|
const renames: Array<{ alt: string; newFile: string }> = [
|
||||||
|
{ alt: 'Jason Woltje portrait', newFile: 'jason-portrait.jpg' },
|
||||||
|
{ alt: 'Stylized portrait — thought leader', newFile: 'thought-leader-city.jpg' },
|
||||||
|
{ alt: 'Stylized portrait — social', newFile: 'social-neon.jpg' },
|
||||||
|
{ alt: 'Jason Woltje — tech founder portrait, dark background', newFile: 'tech-founder-dark.jpg' },
|
||||||
|
{ alt: 'Jason Woltje — social media profile, neon gradient', newFile: 'social-neon.jpg' },
|
||||||
|
{ alt: 'Jason Woltje — at the desk, documentary style', newFile: 'at-the-desk.jpg' },
|
||||||
|
{ alt: 'Jason Woltje — illustrated portrait', newFile: 'illustrated-portrait.jpg' },
|
||||||
|
{ alt: 'Jason Woltje — thought leader portrait, city backdrop', newFile: 'thought-leader-city.jpg' },
|
||||||
|
{ alt: 'Jason Woltje — fashion editorial portrait', newFile: 'editorial-blazer.jpg' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
|
||||||
|
const seen = new Set<number>()
|
||||||
|
|
||||||
|
for (const r of renames) {
|
||||||
|
const { docs } = await payload.find({
|
||||||
|
collection: 'media',
|
||||||
|
where: { alt: { equals: r.alt } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
if (docs.length === 0) {
|
||||||
|
console.log(` skip — no media with alt "${r.alt}"`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = docs[0]!
|
||||||
|
if (seen.has(doc.id as number)) continue
|
||||||
|
seen.add(doc.id as number)
|
||||||
|
|
||||||
|
const currentFilename = (doc as any).filename as string
|
||||||
|
if (currentFilename === r.newFile) {
|
||||||
|
console.log(` ↷ id=${doc.id} already named ${r.newFile}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(imagesDir, r.newFile)
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.log(` ⚠ file missing: ${r.newFile}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: 'media',
|
||||||
|
id: doc.id,
|
||||||
|
filePath,
|
||||||
|
data: {},
|
||||||
|
})
|
||||||
|
console.log(` ✓ id=${doc.id}: ${currentFilename} → ${r.newFile}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Fatal:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
179
scripts/update-images.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Update images — jasonwoltje.com
|
||||||
|
* Uploads new photos and assigns them to globals/posts.
|
||||||
|
* Idempotent by alt-text match.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
const imagesDir = path.resolve(process.cwd(), 'images')
|
||||||
|
|
||||||
|
const uploads: Array<{ file: string; alt: string; key: string }> = [
|
||||||
|
{
|
||||||
|
file: 'gpt-image-1.5_creative_tech_founder_portrait_man_with_bald_head_and_brown-ginger_full_beard_bl-0.jpg',
|
||||||
|
alt: 'Jason Woltje — tech founder portrait, dark background',
|
||||||
|
key: 'hero',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'gpt-image-1.5_bold_social_media_profile_portrait_man_with_bald_head_and_brown-ginger_full_bear-0.jpg',
|
||||||
|
alt: 'Jason Woltje — social media profile, neon gradient',
|
||||||
|
key: 'og',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'gpt-image-1.5_documentary_style_environmental_portrait_man_with_bald_head_and_brown-ginger_ful-0.jpg',
|
||||||
|
alt: 'Jason Woltje — at the desk, documentary style',
|
||||||
|
key: 'desk',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'gpt-image-1.5_illustrated_portrait_stylized_modern_digital_art_style_man_with_bald_head_and_br-0.jpg',
|
||||||
|
alt: 'Jason Woltje — illustrated portrait',
|
||||||
|
key: 'illustration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'gpt-image-1.5_authoritative_thought_leader_portrait_man_with_bald_head_and_brown-ginger_full_b-0.jpg',
|
||||||
|
alt: 'Jason Woltje — thought leader portrait, city backdrop',
|
||||||
|
key: 'thought-leader',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'gpt-image-1.5_high-end_fashion_forward_portrait_man_with_bald_head_and_brown-ginger_full_beard-0.jpg',
|
||||||
|
alt: 'Jason Woltje — fashion editorial portrait',
|
||||||
|
key: 'editorial',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ids: Record<string, number> = {}
|
||||||
|
|
||||||
|
console.log('\n── Uploading new images ──────────────────────────────────')
|
||||||
|
for (const u of uploads) {
|
||||||
|
const filePath = path.join(imagesDir, u.file)
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.log(` ⚠ Missing: ${u.file}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await payload.find({
|
||||||
|
collection: 'media',
|
||||||
|
where: { alt: { equals: u.alt } },
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing.totalDocs > 0) {
|
||||||
|
ids[u.key] = existing.docs[0]!.id as number
|
||||||
|
console.log(` ↷ Already exists: ${u.alt} (id=${ids[u.key]})`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update alt of old upload if it used a generic name (from initial seed)
|
||||||
|
const doc = await payload.create({
|
||||||
|
collection: 'media',
|
||||||
|
filePath,
|
||||||
|
data: { alt: u.alt },
|
||||||
|
})
|
||||||
|
ids[u.key] = doc.id as number
|
||||||
|
console.log(` ✓ Uploaded: ${u.alt} (id=${doc.id})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update Home hero ──────────────────────────────────────────────────
|
||||||
|
if (ids['hero']) {
|
||||||
|
console.log('\n── Updating Home hero image ─────────────────────────────')
|
||||||
|
const home = await payload.findGlobal({ slug: 'home', depth: 0 })
|
||||||
|
const heroData = (home as Record<string, unknown>).hero as Record<string, unknown> | undefined
|
||||||
|
await payload.updateGlobal({
|
||||||
|
slug: 'home',
|
||||||
|
data: {
|
||||||
|
hero: {
|
||||||
|
...(heroData ?? {}),
|
||||||
|
heroImage: ids['hero'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(` ✓ Home heroImage → id=${ids['hero']}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update SEO OG image ───────────────────────────────────────────────
|
||||||
|
if (ids['og']) {
|
||||||
|
console.log('\n── Updating SEO defaultOgImage ──────────────────────────')
|
||||||
|
await payload.updateGlobal({
|
||||||
|
slug: 'seo',
|
||||||
|
data: { defaultOgImage: ids['og'] },
|
||||||
|
})
|
||||||
|
console.log(` ✓ SEO defaultOgImage → id=${ids['og']}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update post cover images ──────────────────────────────────────────
|
||||||
|
console.log('\n── Updating post cover images ───────────────────────────')
|
||||||
|
const postCovers: Array<{ slugContains: string; imageKey: string }> = [
|
||||||
|
{ slugContains: 'cascading-traefik', imageKey: 'desk' },
|
||||||
|
{ slugContains: 'migrating-from-it', imageKey: 'thought-leader' },
|
||||||
|
{ slugContains: 'payload-3', imageKey: 'illustration' },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pc of postCovers) {
|
||||||
|
const imageId = ids[pc.imageKey]
|
||||||
|
if (!imageId) continue
|
||||||
|
|
||||||
|
const { docs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: { slug: { contains: pc.slugContains } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
if (docs.length === 0) {
|
||||||
|
console.log(` ⚠ Post not found: slug contains "${pc.slugContains}"`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const post = docs[0]!
|
||||||
|
await payload.update({
|
||||||
|
collection: 'posts',
|
||||||
|
id: post.id,
|
||||||
|
data: { coverImage: imageId },
|
||||||
|
})
|
||||||
|
console.log(` ✓ Post "${post.slug}" coverImage → id=${imageId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assign project hero images ────────────────────────────────────────
|
||||||
|
console.log('\n── Updating project hero images ─────────────────────────')
|
||||||
|
const projectHeroes: Array<{ slugContains: string; imageKey: string }> = [
|
||||||
|
{ slugContains: 'mosaic-stack', imageKey: 'hero' },
|
||||||
|
{ slugContains: 'jasonwoltje', imageKey: 'editorial' },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const ph of projectHeroes) {
|
||||||
|
const imageId = ids[ph.imageKey]
|
||||||
|
if (!imageId) continue
|
||||||
|
|
||||||
|
const { docs } = await payload.find({
|
||||||
|
collection: 'projects',
|
||||||
|
where: { slug: { contains: ph.slugContains } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
if (docs.length === 0) {
|
||||||
|
console.log(` ⚠ Project not found: slug contains "${ph.slugContains}"`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const project = docs[0]!
|
||||||
|
await payload.update({
|
||||||
|
collection: 'projects',
|
||||||
|
id: project.id,
|
||||||
|
data: { heroImage: imageId },
|
||||||
|
})
|
||||||
|
console.log(` ✓ Project "${project.slug}" heroImage → id=${imageId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n════════════════════════════════════════════════════════')
|
||||||
|
console.log(' IMAGE UPDATE COMPLETE')
|
||||||
|
console.log('════════════════════════════════════════════════════════\n')
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Fatal:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -16,7 +16,14 @@ export const About: GlobalConfig = {
|
|||||||
{ name: "portrait", type: "upload", relationTo: "media" },
|
{ name: "portrait", type: "upload", relationTo: "media" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ name: "bio", type: "richText", editor: lexicalEditor({}) },
|
{
|
||||||
|
type: "collapsible",
|
||||||
|
label: "Bio",
|
||||||
|
admin: { initCollapsed: false },
|
||||||
|
fields: [
|
||||||
|
{ name: "bio", type: "richText", editor: lexicalEditor({}) },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "timeline",
|
name: "timeline",
|
||||||
type: "array",
|
type: "array",
|
||||||
|
|||||||