diff --git a/app/pages/[...slug].vue b/app/pages/[...slug].vue
index 67cc6392..90c20bc4 100644
--- a/app/pages/[...slug].vue
+++ b/app/pages/[...slug].vue
@@ -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
-//
-// placeholders 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.
+// 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()
-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(`.${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 = {}
- 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(`.${className}`)
+ for (const slot of slots) {
+ if (mountedSlots.has(slot)) continue
+ const raw = slot.getAttribute(dataAttr) || '{}'
+ let props: Record = {}
+ 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)
diff --git a/app/pages/resources/index.vue b/app/pages/resources/index.vue
deleted file mode 100644
index 35520d71..00000000
--- a/app/pages/resources/index.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-
-
-
-
-
-
-
-
- {{ t('Adoption Resources') }}
-
- {{ 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.') }}
-
-
-
-
-
-
diff --git a/app/utils/tiptapExtensions.ts b/app/utils/tiptapExtensions.ts
index 416949ae..f32c9c30 100644
--- a/app/utils/tiptapExtensions.ts
+++ b/app/utils/tiptapExtensions.ts
@@ -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
]
}
diff --git a/app/utils/tiptapGeneralResources.ts b/app/utils/tiptapGeneralResources.ts
new file mode 100644
index 00000000..3da6eea8
--- /dev/null
+++ b/app/utils/tiptapGeneralResources.ts
@@ -0,0 +1,105 @@
+// Custom Tiptap node for the 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 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 = {}
+ 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 = 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')
+ }
+ }
+ }
+ }
+})