refactor resources page

This commit is contained in:
corsacca 2026-04-23 15:16:12 +01:00
parent 4114e21042
commit 23caf67bce
4 changed files with 145 additions and 100 deletions

View file

@ -42,7 +42,9 @@ interface PageResponse {
import { h, render, getCurrentInstance } from 'vue'
import { UUPGS_LIST_PLACEHOLDER_CLASS } from '~/utils/tiptapUupgsList'
import { GENERAL_RESOURCES_PLACEHOLDER_CLASS } from '~/utils/tiptapGeneralResources'
import UupgsList from '~/components/public/UupgsList.vue'
import GeneralResources from '~/components/public/GeneralResources.vue'
const route = useRoute()
const { locale } = useI18n()
@ -114,40 +116,46 @@ const showChildGrid = computed(() =>
useTextHighlight()
// The body_html is rendered via v-html, so the embedded
// <div class="doxa-uupgs-list-slot" data-uupgs-list-props="…"></div>
// placeholders come through as plain DOM. After each render, mount
// the real Vue <UupgsList> component into each slot. Using h() + render()
// with the host app's context so the mounted component still resolves
// useI18n / useRuntimeConfig.
// The body_html is rendered via v-html, so custom-node placeholder divs
// (doxa-uupgs-list-slot, doxa-general-resources-slot, …) come through
// as plain DOM. After each render, mount the real Vue component into
// each slot. Using h() + render() with the host app's context so the
// mounted component still resolves useI18n / useRuntimeConfig.
const instance = getCurrentInstance()
const mountedSlots = new Set<HTMLElement>()
function hydrateUupgsListSlots() {
const SLOT_COMPONENTS: Array<{ className: string; dataAttr: string; component: any }> = [
{ className: UUPGS_LIST_PLACEHOLDER_CLASS, dataAttr: 'data-uupgs-list-props', component: UupgsList },
{ className: GENERAL_RESOURCES_PLACEHOLDER_CLASS, dataAttr: 'data-general-resources-props', component: GeneralResources }
]
function hydrateSlots() {
if (!import.meta.client) return
const slots = document.querySelectorAll<HTMLElement>(`.${UUPGS_LIST_PLACEHOLDER_CLASS}`)
for (const slot of slots) {
if (mountedSlots.has(slot)) continue
const raw = slot.getAttribute('data-uupgs-list-props') || '{}'
let props: Record<string, any> = {}
try {
props = JSON.parse(raw)
} catch (e) {
console.error('[UupgsList slot] failed to parse props', e, raw)
continue
}
const vnode = h(UupgsList, props)
if (instance?.appContext) vnode.appContext = instance.appContext
try {
render(vnode, slot)
mountedSlots.add(slot)
} catch (e) {
console.error('[UupgsList slot] failed to mount', e)
for (const { className, dataAttr, component } of SLOT_COMPONENTS) {
const slots = document.querySelectorAll<HTMLElement>(`.${className}`)
for (const slot of slots) {
if (mountedSlots.has(slot)) continue
const raw = slot.getAttribute(dataAttr) || '{}'
let props: Record<string, any> = {}
try {
props = JSON.parse(raw)
} catch (e) {
console.error(`[${className}] failed to parse props`, e, raw)
continue
}
const vnode = h(component, props)
if (instance?.appContext) vnode.appContext = instance.appContext
try {
render(vnode, slot)
mountedSlots.add(slot)
} catch (e) {
console.error(`[${className}] failed to mount`, e)
}
}
}
}
function unmountUupgsListSlots() {
function unmountSlots() {
for (const slot of mountedSlots) {
try { render(null, slot) } catch { /* ignore */ }
}
@ -157,16 +165,16 @@ function unmountUupgsListSlots() {
// Hydrate once on mount (the watch above fires before the DOM is in
// place so `querySelectorAll` would be empty).
onMounted(() => {
nextTick(hydrateUupgsListSlots)
nextTick(hydrateSlots)
})
// Re-hydrate on client-side navigation when body_html changes.
watch(() => data.value?.body_html, () => {
unmountUupgsListSlots()
nextTick(hydrateUupgsListSlots)
unmountSlots()
nextTick(hydrateSlots)
})
onBeforeUnmount(unmountUupgsListSlots)
onBeforeUnmount(unmountSlots)
</script>
<template>

View file

@ -1,70 +0,0 @@
<script setup lang="ts">
// /resources is not CMS content — the live page is a heading, intro
// paragraph, and the [general_resources] shortcode. The shortcode
// links out to 4 CMS child pages under /resources/, and the WP theme
// renders a sidebar with those children on both the parent and each
// child page. We match that here by fetching the /api/pages/resources
// payload (parent + children) and rendering the same sidebar the
// catch-all [...slug].vue uses.
interface PageResponse {
title: string
menu_parent: { slug: string; title: string }
children: Array<{ slug: string; title: string }>
}
const { t, locale } = useI18n()
const localePath = useLocalePath()
const { data } = await useAsyncData<PageResponse | null>(
() => `resources-${locale.value}`,
async () => {
try {
return await $fetch<PageResponse>('/api/pages/resources', {
query: { locale: locale.value }
})
} catch {
return null
}
},
{ watch: [() => locale.value] }
)
const hasSidebar = computed(() => (data.value?.children.length ?? 0) > 0)
useHead(() => ({ title: t('Adoption Resources') }))
</script>
<template>
<div class="container page-content">
<div :class="hasSidebar ? 'with-sidebar' : ''">
<aside v-if="hasSidebar && data" class="sidebar">
<nav class="stack" aria-label="Child pages navigation">
<div>
<ul class="stack | max-width-xs" role="list">
<li>
<NuxtLink
class="font-size-lg current-link"
:to="localePath(`/${data.menu_parent.slug}`)"
>{{ data.menu_parent.title }}</NuxtLink>
</li>
<li v-for="child in data.children" :key="child.slug">
<NuxtLink :to="localePath(`/${child.slug}`)">
{{ child.title }}
</NuxtLink>
</li>
</ul>
</div>
</nav>
</aside>
<article class="stack stack--lg">
<h1>{{ t('Adoption Resources') }}</h1>
<p>
{{ t('These resources are available to any church looking to learn more about Unengaged, Unreached People Groups and grow in their understanding of global mission. Use them to educate your congregation, deepen your church’s commitment to prayer, and explore ways your community can play a part in bringing the gospel to those who have never heard.') }}
</p>
<GeneralResources />
</article>
</div>
</div>
</template>

View file

@ -17,6 +17,7 @@ import Superscript from '@tiptap/extension-superscript'
import Youtube from '@tiptap/extension-youtube'
import { Div } from './tiptapDiv'
import { UupgsListNode } from './tiptapUupgsList'
import { GeneralResourcesNode } from './tiptapGeneralResources'
import { Verse } from './tiptapVerse'
export function buildTiptapExtensions() {
@ -33,6 +34,7 @@ export function buildTiptapExtensions() {
Youtube,
Div,
UupgsListNode,
GeneralResourcesNode,
Verse
]
}

View file

@ -0,0 +1,105 @@
// Custom Tiptap node for the <general-resources> shortcode/placeholder.
// Mirrors the UupgsListNode pattern: the editor treats it as an atom
// block; generateHTML serializes to a neutral placeholder div; the
// catch-all page ([...slug].vue) scans for the placeholder after v-html
// mounts and renders the real Vue <GeneralResources> component in its
// place (preserving the host app's i18n / runtime-config context via
// h() + render()).
//
// Shared between editor (app/components/admin/RichTextEditor.vue),
// server renderer (server/utils/renderTiptap.ts), and migration
// converter (scripts/lib/htmlToTiptap.ts) via tiptapExtensions.ts.
import { Node } from '@tiptap/core'
export const GENERAL_RESOURCES_PLACEHOLDER_CLASS = 'doxa-general-resources-slot'
export const GENERAL_RESOURCES_EDITOR_CHIP_CLASS = 'doxa-general-resources-editor-chip'
export const GeneralResourcesNode = Node.create({
name: 'generalResources',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
useDocuments: {
default: false,
parseHTML: (el: Element) => el.hasAttribute('useDocuments') || el.hasAttribute('use-documents')
},
layout: {
default: 'on-sidebar-page',
parseHTML: (el: Element) => el.getAttribute('layout') || 'on-sidebar-page'
}
}
},
parseHTML() {
return [{ tag: 'general-resources' }]
},
renderHTML({ node }) {
const props: Record<string, any> = {}
if (node.attrs.useDocuments) props.useDocuments = true
if (node.attrs.layout && node.attrs.layout !== 'on-sidebar-page') props.layout = node.attrs.layout
return ['div', {
class: GENERAL_RESOURCES_PLACEHOLDER_CLASS,
'data-general-resources-props': JSON.stringify(props)
}]
},
// Editor-only chip so authors can see the block in the document flow
// without a live render. Same pattern as UupgsListNode.
addNodeView() {
return ({ node }) => {
const dom = document.createElement('div')
dom.className = GENERAL_RESOURCES_EDITOR_CHIP_CLASS
dom.setAttribute('data-drag-handle', '')
dom.contentEditable = 'false'
const paintFrom = (n: typeof node) => {
const attrs: Record<string, any> = n.attrs
const parts: string[] = []
parts.push(attrs.useDocuments ? 'documents list' : 'general resources list')
if (attrs.layout && attrs.layout !== 'on-sidebar-page') parts.push(`layout: ${attrs.layout}`)
const detail = parts.join(' · ')
dom.innerHTML = ''
const header = document.createElement('div')
header.className = `${GENERAL_RESOURCES_EDITOR_CHIP_CLASS}__header`
const icon = document.createElement('span')
icon.className = `${GENERAL_RESOURCES_EDITOR_CHIP_CLASS}__icon`
icon.textContent = '📚'
const title = document.createElement('span')
title.className = `${GENERAL_RESOURCES_EDITOR_CHIP_CLASS}__title`
title.textContent = 'General resources'
header.appendChild(icon)
header.appendChild(title)
const detailEl = document.createElement('div')
detailEl.className = `${GENERAL_RESOURCES_EDITOR_CHIP_CLASS}__detail`
detailEl.textContent = detail
dom.appendChild(header)
dom.appendChild(detailEl)
}
paintFrom(node)
return {
dom,
update(updatedNode) {
if (updatedNode.type.name !== node.type.name) return false
paintFrom(updatedNode)
return true
},
selectNode() {
dom.classList.add('is-selected')
},
deselectNode() {
dom.classList.remove('is-selected')
}
}
}
}
})