refactor resources page
This commit is contained in:
parent
4114e21042
commit
23caf67bce
4 changed files with 145 additions and 100 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
|
|
|
|||
105
app/utils/tiptapGeneralResources.ts
Normal file
105
app/utils/tiptapGeneralResources.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue