This commit is contained in:
corsacca 2026-04-21 15:39:53 +01:00
parent b2975650ec
commit b2c7b22aa1
23 changed files with 1577 additions and 13 deletions

View file

@ -5,7 +5,9 @@
}
.with-sidebar > :first-child {
flex-grow: 1;
flex-grow: 0;
flex-shrink: 0;
flex-basis: 25ch;
}
.with-sidebar > :last-child {

View file

@ -0,0 +1,193 @@
<script setup lang="ts">
// Minimal Tiptap editor for CMS body content. Extension set matches
// server/utils/renderTiptap.ts so content round-trips cleanly to the
// public renderer. v-model is the Tiptap JSON document (ProseMirror
// doc) — save straight into page_translations.body_json.
//
// Based on campaigns-sever's RichTextEditor.vue but slimmed down — no
// verse/spacer/bible extensions (not needed in marketing content) and
// no custom paste-transform (the WP → Nuxt migration scrape runs
// server-side via its own HTML → Tiptap conversion).
import { useEditor, EditorContent } from '@tiptap/vue-3'
import type { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
import { TextStyle } from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import Typography from '@tiptap/extension-typography'
import Subscript from '@tiptap/extension-subscript'
import Superscript from '@tiptap/extension-superscript'
import Youtube from '@tiptap/extension-youtube'
import { uploadImage } from '~/composables/useImageUpload'
const props = withDefaults(defineProps<{
modelValue: Record<string, any>
placeholder?: string
}>(), {
placeholder: 'Start writing…'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void
}>()
const fileInputRef = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit.configure({ link: { openOnClick: false, autolink: true } }),
Image,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyle,
Color,
Highlight,
Typography,
Subscript,
Superscript,
Youtube
],
onUpdate({ editor: e }) {
emit('update:modelValue', e.getJSON())
}
})
// External updates (DeepL translation refresh, locale tab switch) —
// replace editor content when the prop drifts from the editor state.
watch(
() => props.modelValue,
(value) => {
const current = editor.value?.getJSON()
if (!value || !editor.value) return
if (JSON.stringify(current) === JSON.stringify(value)) return
editor.value.commands.setContent(value, { emitUpdate: false })
},
{ deep: true }
)
onBeforeUnmount(() => editor.value?.destroy())
function cmd(fn: (e: Editor) => void) {
if (editor.value) fn(editor.value)
}
function isActive(name: string, attrs?: Record<string, any>): boolean {
return editor.value?.isActive(name, attrs) ?? false
}
function isActiveAttrs(attrs: Record<string, any>): boolean {
return editor.value?.isActive(attrs) ?? false
}
function promptForLink() {
const prev = editor.value?.getAttributes('link').href || ''
const url = window.prompt('Link URL', prev)
if (url === null) return
if (url === '') {
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
async function insertImage() {
fileInputRef.value?.click()
}
async function onFileChosen(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploading.value = true
try {
const { url } = await uploadImage(file)
editor.value?.chain().focus().setImage({ src: url, alt: file.name }).run()
} catch (err: any) {
window.alert(err?.message || 'Image upload failed')
} finally {
uploading.value = false
}
}
function insertYoutube() {
const url = window.prompt('YouTube URL')
if (!url) return
editor.value?.commands.setYoutubeVideo({ src: url })
}
// Clicks on the padding area around the ProseMirror content don't move
// the cursor by default. Forward those clicks to the editor and focus
// at end-of-doc so the click anywhere inside the body area starts editing.
function onBodyClick(e: MouseEvent) {
if (!editor.value) return
const target = e.target as HTMLElement | null
if (target?.closest('.ProseMirror')) return
editor.value.chain().focus('end').run()
}
</script>
<template>
<div class="rich-text-editor border border-(--ui-border) rounded-md bg-(--ui-bg)">
<div v-if="editor" class="toolbar flex flex-wrap gap-1 border-b border-(--ui-border) p-2">
<UButton size="xs" variant="ghost" :color="isActive('bold') ? 'primary' : 'neutral'" icon="i-lucide-bold" aria-label="Bold" @click="cmd(e => e.chain().focus().toggleBold().run())" />
<UButton size="xs" variant="ghost" :color="isActive('italic') ? 'primary' : 'neutral'" icon="i-lucide-italic" aria-label="Italic" @click="cmd(e => e.chain().focus().toggleItalic().run())" />
<UButton size="xs" variant="ghost" :color="isActive('strike') ? 'primary' : 'neutral'" icon="i-lucide-strikethrough" aria-label="Strike" @click="cmd(e => e.chain().focus().toggleStrike().run())" />
<UButton size="xs" variant="ghost" :color="isActive('highlight') ? 'primary' : 'neutral'" icon="i-lucide-highlighter" aria-label="Highlight" @click="cmd(e => e.chain().focus().toggleHighlight().run())" />
<div class="w-px bg-(--ui-border) mx-1" />
<UButton size="xs" variant="ghost" :color="isActive('heading', { level: 1 }) ? 'primary' : 'neutral'" label="H1" @click="cmd(e => e.chain().focus().toggleHeading({ level: 1 }).run())" />
<UButton size="xs" variant="ghost" :color="isActive('heading', { level: 2 }) ? 'primary' : 'neutral'" label="H2" @click="cmd(e => e.chain().focus().toggleHeading({ level: 2 }).run())" />
<UButton size="xs" variant="ghost" :color="isActive('heading', { level: 3 }) ? 'primary' : 'neutral'" label="H3" @click="cmd(e => e.chain().focus().toggleHeading({ level: 3 }).run())" />
<UButton size="xs" variant="ghost" :color="isActive('paragraph') ? 'primary' : 'neutral'" label="P" @click="cmd(e => e.chain().focus().setParagraph().run())" />
<div class="w-px bg-(--ui-border) mx-1" />
<UButton size="xs" variant="ghost" :color="isActive('bulletList') ? 'primary' : 'neutral'" icon="i-lucide-list" aria-label="Bullet list" @click="cmd(e => e.chain().focus().toggleBulletList().run())" />
<UButton size="xs" variant="ghost" :color="isActive('orderedList') ? 'primary' : 'neutral'" icon="i-lucide-list-ordered" aria-label="Ordered list" @click="cmd(e => e.chain().focus().toggleOrderedList().run())" />
<UButton size="xs" variant="ghost" :color="isActive('blockquote') ? 'primary' : 'neutral'" icon="i-lucide-quote" aria-label="Blockquote" @click="cmd(e => e.chain().focus().toggleBlockquote().run())" />
<UButton size="xs" variant="ghost" :color="isActive('codeBlock') ? 'primary' : 'neutral'" icon="i-lucide-code" aria-label="Code block" @click="cmd(e => e.chain().focus().toggleCodeBlock().run())" />
<div class="w-px bg-(--ui-border) mx-1" />
<UButton size="xs" variant="ghost" :color="isActiveAttrs({ textAlign: 'left' }) ? 'primary' : 'neutral'" icon="i-lucide-align-left" aria-label="Align left" @click="cmd(e => e.chain().focus().setTextAlign('left').run())" />
<UButton size="xs" variant="ghost" :color="isActiveAttrs({ textAlign: 'center' }) ? 'primary' : 'neutral'" icon="i-lucide-align-center" aria-label="Align center" @click="cmd(e => e.chain().focus().setTextAlign('center').run())" />
<UButton size="xs" variant="ghost" :color="isActiveAttrs({ textAlign: 'right' }) ? 'primary' : 'neutral'" icon="i-lucide-align-right" aria-label="Align right" @click="cmd(e => e.chain().focus().setTextAlign('right').run())" />
<div class="w-px bg-(--ui-border) mx-1" />
<UButton size="xs" variant="ghost" :color="isActive('link') ? 'primary' : 'neutral'" icon="i-lucide-link" aria-label="Link" @click="promptForLink" />
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-image" aria-label="Image" :loading="uploading" @click="insertImage" />
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-youtube" aria-label="YouTube" @click="insertYoutube" />
<div class="w-px bg-(--ui-border) mx-1" />
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-undo-2" aria-label="Undo" @click="cmd(e => e.chain().focus().undo().run())" />
<UButton size="xs" variant="ghost" color="neutral" icon="i-lucide-redo-2" aria-label="Redo" @click="cmd(e => e.chain().focus().redo().run())" />
</div>
<EditorContent
:editor="editor"
class="tiptap-body prose max-w-none p-4 min-h-64 focus:outline-none cursor-text"
@click="onBodyClick"
/>
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="onFileChosen"
>
</div>
</template>
<style scoped>
.tiptap-body :deep(h1) { font-size: 1.875rem; font-weight: 700; margin: 1rem 0 0.5rem; }
.tiptap-body :deep(h2) { font-size: 1.5rem; font-weight: 700; margin: 1rem 0 0.5rem; }
.tiptap-body :deep(h3) { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem; }
.tiptap-body :deep(p) { margin: 0.5rem 0; }
.tiptap-body :deep(ul) { list-style: disc; padding-left: 1.5rem; }
.tiptap-body :deep(ol) { list-style: decimal; padding-left: 1.5rem; }
.tiptap-body :deep(blockquote) { border-left: 3px solid var(--ui-border); padding-left: 1rem; color: var(--ui-text-muted); }
.tiptap-body :deep(code) { background: var(--ui-bg-elevated); padding: 0.1em 0.3em; border-radius: 0.25rem; }
.tiptap-body :deep(a) { color: var(--ui-primary); text-decoration: underline; }
.tiptap-body :deep(img) { max-width: 100%; height: auto; border-radius: 0.375rem; }
.tiptap-body :deep(iframe) { max-width: 100%; }
.tiptap-body :deep(.ProseMirror:focus) { outline: none; }
</style>

View file

@ -0,0 +1,66 @@
// Image-upload composable for admin. Posts a single image file to
// /api/admin/upload/image and resolves with its signed URL. Used by the
// RichTextEditor image button and by the featured/OG image fields on
// the page editor. Reports progress via the optional `onProgress`
// callback for a nicer UX.
export interface UploadResponse {
url: string
key: string
filename: string
}
export async function uploadImage(
file: File,
onProgress?: (percent: number) => void
): Promise<UploadResponse> {
const formData = new FormData()
formData.append('file', file)
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/admin/upload/image')
xhr.responseType = 'json'
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response as UploadResponse)
} else {
const msg = xhr.response?.statusMessage || xhr.response?.message || `Upload failed (${xhr.status})`
reject(new Error(msg))
}
})
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
xhr.send(formData)
})
}
export function useImageUpload() {
const uploading = ref(false)
const progress = ref(0)
const error = ref<string | null>(null)
async function upload(file: File): Promise<UploadResponse> {
uploading.value = true
progress.value = 0
error.value = null
try {
return await uploadImage(file, p => (progress.value = p))
} catch (e: any) {
error.value = e?.message || 'Upload failed'
throw e
} finally {
uploading.value = false
}
}
return { upload, uploading, progress, error }
}

View file

@ -6,6 +6,9 @@ const mobileOpen = ref(false)
const navItems = computed(() => [
{ to: '/admin', label: 'Dashboard', icon: 'i-lucide-layout-dashboard' },
...(hasPermission('pages.view')
? [{ to: '/admin/pages', label: 'Pages', icon: 'i-lucide-file-text' }]
: []),
...(hasPermission('users.view')
? [{ to: '/admin/users', label: 'Users', icon: 'i-lucide-users' }]
: []),

View file

@ -1,12 +1,16 @@
<template>
<div class="min-h-screen flex flex-col bg-(--ui-bg)">
<div class="flex justify-end p-4">
<ThemeToggle />
</div>
<div class="flex-1 flex items-center justify-center px-4 pb-12">
<div class="w-full max-w-md">
<slot />
<UApp>
<div class="min-h-screen flex flex-col bg-(--ui-bg)">
<div class="flex justify-end p-4">
<ThemeToggle />
</div>
<div class="flex-1 flex items-center justify-center px-4 pb-12">
<div class="w-full max-w-md">
<slot />
</div>
</div>
</div>
</div>
</UApp>
</template>
<style src="~/assets/css/admin.css"></style>

View file

@ -0,0 +1,556 @@
<script setup lang="ts">
// Admin: CMS page editor. Per-locale tabs, Tiptap editor, featured
// image + OG image uploaders, meta_title / meta_description, and
// Save / Publish / Unpublish actions. A DeepL button fan-outs English
// into the other enabled locales.
definePageMeta({
layout: 'admin',
middleware: ['auth', 'admin']
})
import { ENABLED_LANGUAGES } from '~~/config/languages'
import { uploadImage } from '~/composables/useImageUpload'
interface Page {
id: string
slug: string
parent_slug: string | null
menu_order: number
created: string
updated: string
}
interface Translation {
id: string
page_id: string
locale: string
title: string
body_json: Record<string, any>
excerpt: string | null
featured_image: string | null
meta_title: string | null
meta_description: string | null
og_image: string | null
status: 'draft' | 'published'
updated: string
}
interface PageDetail {
page: Page
translations: Translation[]
}
const route = useRoute()
const router = useRouter()
const toast = useToast()
const pageId = computed(() => String(route.params.id))
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] }
const { data, pending, refresh } = await useFetch<PageDetail>(() => `/api/admin/pages/${pageId.value}`)
// Metadata editable in the top bar
const slug = ref('')
const parentSlug = ref<string>('')
const menuOrder = ref(0)
watchEffect(() => {
if (data.value) {
slug.value = data.value.page.slug
parentSlug.value = data.value.page.parent_slug ?? ''
menuOrder.value = data.value.page.menu_order
}
})
// Tabs state — one tab per enabled language
const activeLocale = ref<string>('en')
interface LocaleForm {
title: string
excerpt: string
featured_image: string
meta_title: string
meta_description: string
og_image: string
body_json: Record<string, any>
status: 'draft' | 'published'
dirty: boolean
loaded: boolean
}
function blankForm(): LocaleForm {
return {
title: '',
excerpt: '',
featured_image: '',
meta_title: '',
meta_description: '',
og_image: '',
body_json: structuredClone(EMPTY_DOC),
status: 'draft',
dirty: false,
loaded: false
}
}
const forms = reactive<Record<string, LocaleForm>>({})
for (const l of ENABLED_LANGUAGES) forms[l.code] = blankForm()
// Hydrate forms from server data
watch(data, (value) => {
if (!value) return
for (const l of ENABLED_LANGUAGES) {
const existing = value.translations.find(t => t.locale === l.code)
if (existing) {
forms[l.code] = {
title: existing.title,
excerpt: existing.excerpt ?? '',
featured_image: existing.featured_image ?? '',
meta_title: existing.meta_title ?? '',
meta_description: existing.meta_description ?? '',
og_image: existing.og_image ?? '',
body_json: existing.body_json ?? structuredClone(EMPTY_DOC),
status: existing.status,
dirty: false,
loaded: true
}
} else {
forms[l.code] = { ...blankForm(), loaded: false }
}
}
}, { immediate: true })
function markDirty(locale: string) {
const f = forms[locale]
if (f) f.dirty = true
}
function setField(locale: string, field: keyof LocaleForm, value: unknown) {
const f = forms[locale]
if (!f) return
;(f as any)[field] = value
f.dirty = true
}
// Per-field watchers aren't tractable — use v-on on each input by calling markDirty.
// For Tiptap we watch body_json for changes.
for (const l of ENABLED_LANGUAGES) {
watch(() => forms[l.code]?.body_json, () => markDirty(l.code), { deep: true })
}
const savingMeta = ref(false)
async function saveMetadata() {
savingMeta.value = true
try {
await $fetch(`/api/admin/pages/${pageId.value}`, {
method: 'PATCH',
body: {
slug: slug.value,
parent_slug: parentSlug.value || null,
menu_order: menuOrder.value
}
})
toast.add({ title: 'Page metadata saved', color: 'success' })
await refresh()
} catch (e: any) {
toast.add({
title: 'Could not save metadata',
description: e?.data?.statusMessage || e?.message,
color: 'error'
})
} finally {
savingMeta.value = false
}
}
const savingTranslation = ref(false)
async function saveTranslation(locale: string, statusOverride?: 'draft' | 'published') {
const f = forms[locale]
if (!f) return
if (!f.title.trim()) {
toast.add({ title: 'Title is required', color: 'error' })
return
}
savingTranslation.value = true
try {
const body: Record<string, unknown> = {
title: f.title,
body_json: f.body_json,
excerpt: f.excerpt || null,
featured_image: f.featured_image || null,
meta_title: f.meta_title || null,
meta_description: f.meta_description || null,
og_image: f.og_image || null
}
if (statusOverride) body.status = statusOverride
await $fetch(`/api/admin/pages/${pageId.value}/translations/${locale}`, {
method: 'PUT',
body
})
if (statusOverride) f.status = statusOverride
f.loaded = true
f.dirty = false
toast.add({ title: `Saved ${locale}`, color: 'success' })
await refresh()
} catch (e: any) {
toast.add({
title: 'Save failed',
description: e?.data?.statusMessage || e?.message,
color: 'error'
})
} finally {
savingTranslation.value = false
}
}
async function setStatus(locale: string, status: 'draft' | 'published') {
const f = forms[locale]
if (!f) return
if (f.dirty) {
// Save current content along with the publish action so published
// state never diverges from the draft the editor is looking at
await saveTranslation(locale, status)
return
}
try {
await $fetch(`/api/admin/pages/${pageId.value}/publish`, {
method: 'POST',
body: { locale, status }
})
f.status = status
toast.add({ title: status === 'published' ? 'Published' : 'Unpublished', color: 'success' })
await refresh()
} catch (e: any) {
toast.add({
title: 'Status change failed',
description: e?.data?.statusMessage || e?.message,
color: 'error'
})
}
}
// Image uploads for featured_image + og_image
async function uploadField(field: 'featured_image' | 'og_image', locale: string, file: File) {
try {
const { url } = await uploadImage(file)
const f = forms[locale]
if (f) {
f[field] = url
f.dirty = true
}
} catch (e: any) {
toast.add({ title: 'Upload failed', description: e?.message, color: 'error' })
}
}
// Deletion confirmation
const deleteModalOpen = ref(false)
const deleting = ref(false)
async function deletePage() {
deleting.value = true
try {
await $fetch(`/api/admin/pages/${pageId.value}`, { method: 'DELETE' })
toast.add({ title: 'Page deleted', color: 'success' })
router.push('/admin/pages')
} catch (e: any) {
toast.add({
title: 'Delete failed',
description: e?.data?.statusMessage || e?.message,
color: 'error'
})
} finally {
deleting.value = false
deleteModalOpen.value = false
}
}
// DeepL translate modal
const translateModalOpen = ref(false)
const translateSource = ref('en')
const translateTargets = ref<string[]>([])
const translateOverwrite = ref(false)
const translating = ref(false)
function openTranslateModal() {
translateSource.value = 'en'
translateTargets.value = ENABLED_LANGUAGES.map(l => l.code).filter(c => c !== 'en')
translateOverwrite.value = false
translateModalOpen.value = true
}
async function runTranslate() {
translating.value = true
try {
const res = await $fetch<{ results: Array<{ locale: string; skipped?: boolean; error?: string }> }>(
`/api/admin/pages/${pageId.value}/translate`,
{
method: 'POST',
body: {
sourceLocale: translateSource.value,
targetLocales: translateTargets.value,
overwrite: translateOverwrite.value
}
}
)
const skipped = res.results.filter(r => r.skipped).length
const failed = res.results.filter(r => r.error).length
const succeeded = res.results.filter(r => !r.error && !r.skipped).length
toast.add({
title: 'Translation complete',
description: `${succeeded} translated${skipped ? `, ${skipped} skipped` : ''}${failed ? `, ${failed} failed` : ''}`,
color: failed ? 'warning' : 'success'
})
translateModalOpen.value = false
await refresh()
} catch (e: any) {
toast.add({
title: 'Translation failed',
description: e?.data?.statusMessage || e?.message,
color: 'error'
})
} finally {
translating.value = false
}
}
const enabledLanguages = ENABLED_LANGUAGES
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UButton icon="i-lucide-arrow-left" variant="ghost" color="neutral" to="/admin/pages">Back</UButton>
<h1 class="text-xl font-semibold">Edit page</h1>
</div>
<div class="flex items-center gap-2">
<UButton variant="outline" color="primary" icon="i-lucide-languages" @click="openTranslateModal">Translate from English</UButton>
<UButton variant="outline" color="error" icon="i-lucide-trash-2" @click="deleteModalOpen = true">Delete page</UButton>
</div>
</div>
<UCard v-if="pending && !data">
<p class="text-(--ui-text-muted)">Loading…</p>
</UCard>
<template v-else-if="data">
<UCard>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<UFormField label="Slug" description="URL path (e.g. about/vision)">
<UInput v-model="slug" />
</UFormField>
<UFormField label="Parent slug" description="Leave blank for top-level.">
<UInput v-model="parentSlug" />
</UFormField>
<UFormField label="Menu order">
<UInput v-model.number="menuOrder" type="number" />
</UFormField>
</div>
<div class="mt-4 flex justify-end">
<UButton size="sm" color="primary" :loading="savingMeta" @click="saveMetadata">Save metadata</UButton>
</div>
</UCard>
<!-- Locale tabs -->
<div class="border border-(--ui-border) rounded-lg">
<div class="flex flex-wrap gap-1 p-2 border-b border-(--ui-border) bg-(--ui-bg-elevated)">
<UButton
v-for="l in enabledLanguages"
:key="l.code"
:color="activeLocale === l.code ? 'primary' : 'neutral'"
:variant="activeLocale === l.code ? 'solid' : 'ghost'"
size="sm"
@click="activeLocale = l.code"
>
<span class="mr-1">{{ l.flag }}</span>
{{ l.nativeName }}
<UBadge
v-if="forms[l.code]?.loaded"
class="ml-2"
size="xs"
:color="forms[l.code]?.status === 'published' ? 'success' : 'neutral'"
variant="subtle"
>
{{ forms[l.code]?.status }}
</UBadge>
</UButton>
</div>
<div
v-for="l in enabledLanguages"
v-show="activeLocale === l.code"
:key="l.code"
class="p-4 space-y-4"
>
<UFormField label="Title" required>
<UInput
:model-value="forms[l.code]?.title ?? ''"
@update:model-value="v => setField(l.code, 'title', String(v))"
/>
</UFormField>
<UFormField label="Body">
<RichTextEditor
v-if="forms[l.code]"
:model-value="forms[l.code]!.body_json"
@update:model-value="v => setField(l.code, 'body_json', v)"
/>
</UFormField>
<details class="group border border-(--ui-border) rounded-md">
<summary class="cursor-pointer select-none flex items-center justify-between px-3 py-2 text-sm font-medium hover:bg-(--ui-bg-elevated)">
<span>SEO &amp; extras</span>
<UIcon name="i-lucide-chevron-down" class="size-4 transition-transform group-open:rotate-180" />
</summary>
<div class="p-4 space-y-4 border-t border-(--ui-border)">
<UFormField label="Excerpt" description="Shown in child-page cards when a parent page has no body.">
<UTextarea
:model-value="forms[l.code]?.excerpt ?? ''"
:rows="2"
@update:model-value="v => setField(l.code, 'excerpt', String(v))"
/>
</UFormField>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<UFormField label="Featured image">
<div class="flex items-center gap-2">
<UInput
:model-value="forms[l.code]?.featured_image ?? ''"
placeholder="https://…"
class="flex-1"
@update:model-value="v => setField(l.code, 'featured_image', String(v))"
/>
<label class="shrink-0">
<input
type="file"
accept="image/*"
class="hidden"
@change="e => { const f = (e.target as HTMLInputElement).files?.[0]; if (f) uploadField('featured_image', l.code, f); (e.target as HTMLInputElement).value = '' }"
>
<UButton variant="outline" color="neutral" size="sm" icon="i-lucide-upload" as="span">Upload</UButton>
</label>
</div>
</UFormField>
<UFormField label="OG image">
<div class="flex items-center gap-2">
<UInput
:model-value="forms[l.code]?.og_image ?? ''"
placeholder="https://…"
class="flex-1"
@update:model-value="v => setField(l.code, 'og_image', String(v))"
/>
<label class="shrink-0">
<input
type="file"
accept="image/*"
class="hidden"
@change="e => { const f = (e.target as HTMLInputElement).files?.[0]; if (f) uploadField('og_image', l.code, f); (e.target as HTMLInputElement).value = '' }"
>
<UButton variant="outline" color="neutral" size="sm" icon="i-lucide-upload" as="span">Upload</UButton>
</label>
</div>
</UFormField>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<UFormField label="Meta title" description="Overrides the page title in <title>.">
<UInput
:model-value="forms[l.code]?.meta_title ?? ''"
@update:model-value="v => setField(l.code, 'meta_title', String(v))"
/>
</UFormField>
<UFormField label="Meta description">
<UInput
:model-value="forms[l.code]?.meta_description ?? ''"
@update:model-value="v => setField(l.code, 'meta_description', String(v))"
/>
</UFormField>
</div>
</div>
</details>
<div class="flex justify-between items-center pt-2 border-t border-(--ui-border)">
<div class="text-sm text-(--ui-text-muted)">
<template v-if="forms[l.code]?.dirty">Unsaved changes</template>
<template v-else-if="forms[l.code]?.loaded">Saved</template>
<template v-else>Not translated yet</template>
</div>
<div class="flex gap-2">
<UButton
v-if="forms[l.code]?.status === 'published'"
variant="outline"
color="warning"
icon="i-lucide-eye-off"
:loading="savingTranslation"
@click="setStatus(l.code, 'draft')"
>Unpublish</UButton>
<UButton
variant="outline"
color="neutral"
:loading="savingTranslation"
@click="saveTranslation(l.code)"
>Save draft</UButton>
<UButton
color="primary"
icon="i-lucide-globe"
:loading="savingTranslation"
@click="setStatus(l.code, 'published')"
>Publish</UButton>
</div>
</div>
</div>
</div>
</template>
<UModal v-model:open="deleteModalOpen" title="Delete page?">
<template #body>
<p>
This permanently deletes the page <strong>{{ data?.page.slug }}</strong> and all its translations.
This cannot be undone.
</p>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="deleteModalOpen = false">Cancel</UButton>
<UButton color="error" :loading="deleting" @click="deletePage">Delete</UButton>
</div>
</template>
</UModal>
<UModal v-model:open="translateModalOpen" title="Translate with DeepL">
<template #body>
<div class="space-y-3">
<UFormField label="Source locale">
<USelect
v-model="translateSource"
:items="enabledLanguages.map(l => ({ label: l.nativeName, value: l.code }))"
/>
</UFormField>
<UFormField label="Target locales">
<div class="flex flex-wrap gap-2">
<UCheckbox
v-for="l in enabledLanguages.filter(x => x.code !== translateSource)"
:key="l.code"
:model-value="translateTargets.includes(l.code)"
:label="`${l.flag} ${l.nativeName}`"
@update:model-value="checked => { if (checked) { translateTargets = Array.from(new Set([...translateTargets, l.code])) } else { translateTargets = translateTargets.filter(c => c !== l.code) } }"
/>
</div>
</UFormField>
<UCheckbox
v-model="translateOverwrite"
label="Overwrite existing translations (draft or published)"
/>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="translateModalOpen = false">Cancel</UButton>
<UButton color="primary" :disabled="translateTargets.length === 0" :loading="translating" @click="runTranslate">Translate</UButton>
</div>
</template>
</UModal>
</div>
</template>

View file

@ -0,0 +1,167 @@
<script setup lang="ts">
// Admin: list of CMS pages. Minimal table with slug, English title,
// parent, publish status (published / total translations), last
// updated, and a button to open each row in the editor.
definePageMeta({
layout: 'admin',
middleware: ['auth', 'admin']
})
interface PageRow {
id: string
slug: string
parent_slug: string | null
menu_order: number
updated: string
title_en: string | null
translation_count: number
published_count: number
}
const toast = useToast()
const router = useRouter()
const { data, pending, refresh, error } = await useFetch<{ rows: PageRow[] }>(
'/api/admin/pages',
{ default: () => ({ rows: [] }) }
)
// Modal state for creating a new page
const createModalOpen = ref(false)
const newSlug = ref('')
const newParentSlug = ref('')
const newMenuOrder = ref(0)
const creating = ref(false)
async function createPage() {
creating.value = true
try {
const page = await $fetch<{ id: string }>('/api/admin/pages', {
method: 'POST',
body: {
slug: newSlug.value.trim(),
parent_slug: newParentSlug.value.trim() || null,
menu_order: newMenuOrder.value || 0
}
})
toast.add({ title: 'Page created', color: 'success' })
createModalOpen.value = false
newSlug.value = ''
newParentSlug.value = ''
newMenuOrder.value = 0
router.push(`/admin/pages/${page.id}`)
} catch (e: any) {
toast.add({
title: 'Could not create page',
description: e?.data?.statusMessage || e?.message || 'Unknown error',
color: 'error'
})
} finally {
creating.value = false
}
}
function formatDate(s: string) {
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleString()
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">CMS Pages</h1>
<p class="text-sm text-(--ui-text-muted)">Manage translated content for about, resources, privacy, and other CMS-driven pages.</p>
</div>
<UButton
icon="i-lucide-plus"
color="primary"
@click="createModalOpen = true"
>
New page
</UButton>
</div>
<UCard v-if="error">
<p class="text-(--ui-text-muted)">Failed to load pages: {{ error.message }}</p>
<UButton size="sm" variant="outline" class="mt-2" @click="refresh()">Retry</UButton>
</UCard>
<UCard v-else-if="pending && !data?.rows.length">
<p class="text-(--ui-text-muted)">Loading…</p>
</UCard>
<UCard v-else-if="!data?.rows.length">
<p class="text-(--ui-text-muted)">No CMS pages yet. Click <strong>New page</strong> to create your first one.</p>
</UCard>
<div v-else class="overflow-x-auto border border-(--ui-border) rounded-lg">
<table class="w-full text-sm">
<thead class="bg-(--ui-bg-elevated) text-(--ui-text-muted)">
<tr>
<th class="text-left px-3 py-2 font-medium">Title (EN)</th>
<th class="text-left px-3 py-2 font-medium">Slug</th>
<th class="text-left px-3 py-2 font-medium">Parent</th>
<th class="text-left px-3 py-2 font-medium">Translations</th>
<th class="text-left px-3 py-2 font-medium">Updated</th>
<th class="text-right px-3 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in data.rows"
:key="row.id"
class="border-t border-(--ui-border) hover:bg-(--ui-bg-elevated)"
>
<td class="px-3 py-2">
<span v-if="row.title_en">{{ row.title_en }}</span>
<em v-else class="text-(--ui-text-muted)">Untitled</em>
</td>
<td class="px-3 py-2 font-mono">{{ row.slug }}</td>
<td class="px-3 py-2">{{ row.parent_slug || '—' }}</td>
<td class="px-3 py-2">
<UBadge :color="row.published_count > 0 ? 'success' : 'neutral'" variant="subtle">
{{ row.published_count }} / {{ row.translation_count }} published
</UBadge>
</td>
<td class="px-3 py-2 text-(--ui-text-muted)">{{ formatDate(row.updated) }}</td>
<td class="px-3 py-2 text-right">
<UButton
size="xs"
variant="outline"
icon="i-lucide-pencil"
:to="`/admin/pages/${row.id}`"
>
Edit
</UButton>
</td>
</tr>
</tbody>
</table>
</div>
<UModal v-model:open="createModalOpen" title="New CMS page">
<template #body>
<div class="space-y-4">
<UFormField label="Slug" required description="e.g. about/vision">
<UInput v-model="newSlug" placeholder="about/vision" />
</UFormField>
<UFormField label="Parent slug" description="Leave blank for top-level pages.">
<UInput v-model="newParentSlug" placeholder="about" />
</UFormField>
<UFormField label="Menu order" description="Sort order in sidebar navigation.">
<UInput v-model.number="newMenuOrder" type="number" />
</UFormField>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="createModalOpen = false">Cancel</UButton>
<UButton color="primary" :loading="creating" :disabled="!newSlug.trim()" @click="createPage">Create</UButton>
</div>
</template>
</UModal>
</div>
</template>

View file

@ -8,7 +8,9 @@ export const PERMISSIONS = [
'users.view',
'users.manage',
'roles.view',
'roles.manage'
'roles.manage',
'pages.view',
'pages.manage'
] as const
export type Permission = typeof PERMISSIONS[number]
@ -36,6 +38,14 @@ export const PERMISSION_META: Record<string, { title: string; description: strin
'roles.manage': {
title: 'Manage roles',
description: 'Create, edit, and delete custom roles.'
},
'pages.view': {
title: 'View CMS pages',
description: 'See the CMS pages list and read existing translations.'
},
'pages.manage': {
title: 'Manage CMS pages',
description: 'Create, edit, publish, translate, and delete CMS pages.'
}
}

View file

@ -25,6 +25,7 @@
"@tiptap/extension-youtube": "^3.22.4",
"@tiptap/html": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"@tiptap/vue-3": "^3.22.4",
"bcrypt": "^6.0.0",
"fuse.js": "^7.3.0",
"intl-tel-input": "^26.7.5",

View file

@ -50,3 +50,19 @@ export function generateI18nLocales() {
]
}))
}
export function getLanguage(code: string): Language | undefined {
return LANGUAGES.find(l => l.code === code)
}
export function getDeeplTargetCode(code: string): string {
return getLanguage(code)?.deeplTarget ?? code.toUpperCase()
}
export function getDeeplSourceCode(code: string): string {
return getLanguage(code)?.deeplSource ?? code.toUpperCase()
}
export function getGlossaryId(code: string): string | undefined {
return getLanguage(code)?.glossaryId
}

View file

@ -74,6 +74,8 @@ export default defineNuxtConfig({
s3BucketName: process.env.S3_BUCKET_NAME || '',
formApiKey: process.env.FORM_API_KEY || '',
prayBaseUrl: process.env.NUXT_PRAY_BASE_URL || 'https://pray.doxa.life',
deeplApiKey: process.env.DEEPL_API_KEY || '',
deeplApiUrl: process.env.DEEPL_API_URL || 'https://api-free.deepl.com',
public: {
appName: process.env.APP_TITLE || 'My App',
nodeEnv: process.env.NODE_ENV || '',
@ -85,6 +87,15 @@ export default defineNuxtConfig({
compatibilityDate: '2025-01-15',
vite: {
optimizeDeps: {
include: [
'@vue/devtools-core',
'@vue/devtools-kit'
]
}
},
eslint: {
config: {
stylistic: {

View file

@ -31,6 +31,7 @@
"@tiptap/extension-youtube": "^3.22.4",
"@tiptap/html": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"@tiptap/vue-3": "^3.22.4",
"bcrypt": "^6.0.0",
"fuse.js": "^7.3.0",
"intl-tel-input": "^26.7.5",

View file

@ -0,0 +1,24 @@
// Admin: delete a CMS page and (via cascade) all its translations.
import { defineEventHandler, getRouterParam, createError } from 'h3'
import { requirePermission } from '../../../utils/rbac'
import { deletePage } from '../../../database/pages'
import { db } from '../../../utils/database'
import { logDelete } from '../../../utils/activity-logger'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.manage')
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const existing = await db
.selectFrom('pages')
.select(['id', 'slug'])
.where('id', '=', id)
.executeTakeFirst()
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
await deletePage(id)
logDelete('pages', id, event, { slug: existing.slug })
return { ok: true }
})

View file

@ -0,0 +1,27 @@
// Admin: fetch a single page with all its translations (any status).
import { defineEventHandler, getRouterParam, createError } from 'h3'
import { requirePermission } from '../../../utils/rbac'
import { db } from '../../../utils/database'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.view')
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const page = await db
.selectFrom('pages')
.selectAll()
.where('id', '=', id)
.executeTakeFirst()
if (!page) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
const translations = await db
.selectFrom('page_translations')
.selectAll()
.where('page_id', '=', id)
.orderBy('locale', 'asc')
.execute()
return { page, translations }
})

View file

@ -0,0 +1,46 @@
// Admin: update a page's metadata (slug, parent_slug, menu_order).
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
import { requirePermission } from '../../../utils/rbac'
import { db } from '../../../utils/database'
import { logUpdate } from '../../../utils/activity-logger'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.manage')
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const body = await readBody<{ slug?: string; parent_slug?: string | null; menu_order?: number }>(event)
const set: Record<string, unknown> = {}
if (body?.slug != null) {
const slug = String(body.slug).trim().replace(/^\/+|\/+$/g, '')
if (!slug || !/^[a-z0-9][a-z0-9/-]*$/.test(slug)) {
throw createError({ statusCode: 400, statusMessage: 'slug must be lowercase letters, digits, dashes, and slashes' })
}
set.slug = slug
}
if (body?.parent_slug !== undefined) {
set.parent_slug = body.parent_slug ? String(body.parent_slug).trim() : null
}
if (body?.menu_order !== undefined) {
set.menu_order = Number(body.menu_order) || 0
}
set.updated = new Date()
try {
const updated = await db
.updateTable('pages')
.set(set as any)
.where('id', '=', id)
.returningAll()
.executeTakeFirstOrThrow()
logUpdate('pages', id, event, { changes: set })
return updated
} catch (e: any) {
if (e?.code === '23505') {
throw createError({ statusCode: 409, statusMessage: 'A page with that slug already exists' })
}
throw e
}
})

View file

@ -0,0 +1,35 @@
// Admin: set a translation's status to published / draft.
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
import { requirePermission } from '../../../../utils/rbac'
import { setTranslationStatus } from '../../../../database/pages'
import { ENABLED_LANGUAGE_CODES } from '../../../../../config/languages'
import { logUpdate } from '../../../../utils/activity-logger'
interface Body {
locale?: string
status?: 'draft' | 'published'
}
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.manage')
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const body = await readBody<Body>(event)
const locale = body?.locale
const status = body?.status
if (!locale || !ENABLED_LANGUAGE_CODES.includes(locale)) {
throw createError({ statusCode: 400, statusMessage: 'locale is not enabled' })
}
if (status !== 'draft' && status !== 'published') {
throw createError({ statusCode: 400, statusMessage: 'status must be draft or published' })
}
await setTranslationStatus(id, locale, status)
logUpdate('page_translations', id, event, {
event: status === 'published' ? 'PUBLISH' : 'UNPUBLISH',
locale
})
return { ok: true }
})

View file

@ -0,0 +1,102 @@
// Admin: one-click translate an EN (or other source) translation of a
// CMS page into one or more target locales. Uses DeepL for title,
// excerpt, meta_title, meta_description, and body_json. Each target
// translation is upserted as a draft (overwrite controllable via
// `overwrite` flag); the editor reviews + publishes manually.
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
import { requirePermission } from '../../../../utils/rbac'
import { db } from '../../../../utils/database'
import { upsertTranslation } from '../../../../database/pages'
import { translateTexts, translateTiptapContent, isDeepLConfigured } from '../../../../utils/deepl'
import { ENABLED_LANGUAGE_CODES } from '../../../../../config/languages'
import { logUpdate } from '../../../../utils/activity-logger'
interface Body {
sourceLocale?: string
targetLocales?: string[]
overwrite?: boolean
}
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.manage')
if (!isDeepLConfigured()) {
throw createError({ statusCode: 500, statusMessage: 'DEEPL_API_KEY is not configured' })
}
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
const body = await readBody<Body>(event)
const sourceLocale = body?.sourceLocale ?? 'en'
const targetLocales = (body?.targetLocales ?? []).filter(l => ENABLED_LANGUAGE_CODES.includes(l) && l !== sourceLocale)
if (targetLocales.length === 0) {
throw createError({ statusCode: 400, statusMessage: 'targetLocales is empty' })
}
const source = await db
.selectFrom('page_translations')
.selectAll()
.where('page_id', '=', id)
.where('locale', '=', sourceLocale)
.executeTakeFirst()
if (!source) {
throw createError({ statusCode: 404, statusMessage: `No ${sourceLocale} translation to translate from` })
}
const results: Array<{ locale: string; skipped?: boolean; error?: string }> = []
for (const target of targetLocales) {
// Respect overwrite=false on existing non-draft translations so published content isn't silently replaced
if (!body?.overwrite) {
const existing = await db
.selectFrom('page_translations')
.select(['id', 'status'])
.where('page_id', '=', id)
.where('locale', '=', target)
.executeTakeFirst()
if (existing) {
results.push({ locale: target, skipped: true })
continue
}
}
try {
const textFields = [
source.title ?? '',
source.excerpt ?? '',
source.meta_title ?? '',
source.meta_description ?? ''
]
const [translatedTitle, translatedExcerpt, translatedMetaTitle, translatedMetaDescription]
= await translateTexts(textFields, target, sourceLocale)
const translatedBody = await translateTiptapContent(source.body_json as any, target, sourceLocale)
await upsertTranslation({
page_id: id,
locale: target,
title: translatedTitle || source.title,
body_json: translatedBody as any,
excerpt: translatedExcerpt || null,
featured_image: source.featured_image,
meta_title: translatedMetaTitle || null,
meta_description: translatedMetaDescription || null,
og_image: source.og_image,
status: 'draft'
})
results.push({ locale: target })
} catch (e: any) {
console.error(`[DeepL] translate page ${id} → ${target} failed:`, e?.message || e)
results.push({ locale: target, error: e?.message || 'translation failed' })
}
}
logUpdate('pages', id, event, {
event: 'TRANSLATE',
source_locale: sourceLocale,
target_locales: targetLocales,
results
})
return { results }
})

View file

@ -0,0 +1,69 @@
// Admin: upsert a page's translation for a given locale. Saves as
// draft unless `status: 'published'` is passed. All fields optional
// except `title` and `body_json`.
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
import { requirePermission } from '../../../../../utils/rbac'
import { upsertTranslation } from '../../../../../database/pages'
import { ENABLED_LANGUAGE_CODES } from '../../../../../../config/languages'
import { logUpdate } from '../../../../../utils/activity-logger'
import { db } from '../../../../../utils/database'
interface Body {
title?: string
body_json?: Record<string, any>
excerpt?: string | null
featured_image?: string | null
meta_title?: string | null
meta_description?: string | null
og_image?: string | null
status?: 'draft' | 'published'
}
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.manage')
const id = getRouterParam(event, 'id')
const locale = getRouterParam(event, 'locale')
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
if (!locale || !ENABLED_LANGUAGE_CODES.includes(locale)) {
throw createError({ statusCode: 400, statusMessage: 'locale is not enabled' })
}
const page = await db
.selectFrom('pages')
.select('id')
.where('id', '=', id)
.executeTakeFirst()
if (!page) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
const body = await readBody<Body>(event)
const title = (body?.title ?? '').trim()
if (!title) throw createError({ statusCode: 400, statusMessage: 'title is required' })
if (!body?.body_json || typeof body.body_json !== 'object') {
throw createError({ statusCode: 400, statusMessage: 'body_json is required' })
}
if (body.status && body.status !== 'draft' && body.status !== 'published') {
throw createError({ statusCode: 400, statusMessage: 'status must be draft or published' })
}
const translation = await upsertTranslation({
page_id: id,
locale,
title,
body_json: body.body_json,
excerpt: body.excerpt ?? null,
featured_image: body.featured_image ?? null,
meta_title: body.meta_title ?? null,
meta_description: body.meta_description ?? null,
og_image: body.og_image ?? null,
status: body.status
})
logUpdate('page_translations', translation.id, event, {
page_id: id,
locale,
status: translation.status
})
return translation
})

View file

@ -0,0 +1,32 @@
// Admin: list all CMS pages with counts of published/draft translations.
// Mirrors the columns the /admin/pages table needs.
import { defineEventHandler } from 'h3'
import { requirePermission } from '../../../utils/rbac'
import { db } from '../../../utils/database'
import { sql } from 'kysely'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.view')
const rows = await db
.selectFrom('pages')
.leftJoin('page_translations', 'page_translations.page_id', 'pages.id')
.select([
'pages.id',
'pages.slug',
'pages.parent_slug',
'pages.menu_order',
'pages.updated',
sql<string | null>`MAX(CASE WHEN page_translations.locale = 'en' THEN page_translations.title END)`.as('title_en'),
sql<number>`COUNT(page_translations.id)::int`.as('translation_count'),
sql<number>`COUNT(CASE WHEN page_translations.status = 'published' THEN 1 END)::int`.as('published_count')
])
.groupBy(['pages.id', 'pages.slug', 'pages.parent_slug', 'pages.menu_order', 'pages.updated'])
.orderBy('pages.parent_slug', 'asc')
.orderBy('pages.menu_order', 'asc')
.orderBy('pages.slug', 'asc')
.execute()
return { rows }
})

View file

@ -0,0 +1,34 @@
// Admin: create a new CMS page (no translations). Returns the row.
// Translation is added separately via PUT /api/admin/pages/[id]/translations/[locale].
import { defineEventHandler, readBody, createError } from 'h3'
import { requirePermission } from '../../../utils/rbac'
import { createPage } from '../../../database/pages'
import { logCreate } from '../../../utils/activity-logger'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.manage')
const body = await readBody<{ slug?: string; parent_slug?: string | null; menu_order?: number }>(event)
const slug = (body?.slug ?? '').trim().replace(/^\/+|\/+$/g, '')
if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'slug is required' })
}
if (!/^[a-z0-9][a-z0-9/-]*$/.test(slug)) {
throw createError({ statusCode: 400, statusMessage: 'slug must be lowercase letters, digits, dashes, and slashes' })
}
const parent_slug = body?.parent_slug ? String(body.parent_slug).trim() : null
const menu_order = Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0
try {
const page = await createPage({ slug, parent_slug, menu_order })
logCreate('pages', page.id, event, { slug, parent_slug, menu_order })
return page
} catch (e: any) {
if (e?.code === '23505') {
throw createError({ statusCode: 409, statusMessage: 'A page with that slug already exists' })
}
throw e
}
})

View file

@ -0,0 +1,33 @@
// Admin: image upload endpoint. Accepts a multipart form-data POST with
// the file under field `file`, uploads to S3 via the existing storage
// util, and returns a signed URL + S3 key so the Tiptap editor can
// insert the image. Mirrors the contract campaigns-sever's
// useImageUpload composable expects.
import { defineEventHandler, readMultipartFormData, createError } from 'h3'
import { requirePermission } from '../../../utils/rbac'
import { uploadToS3, validateImageType, validateFileSize } from '../../../utils/storage'
const MAX_IMAGE_MB = 10
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.manage')
const form = await readMultipartFormData(event)
const file = form?.find(p => p.name === 'file')
if (!file || !file.data) {
throw createError({ statusCode: 400, statusMessage: 'file is required' })
}
const contentType = file.type || 'application/octet-stream'
if (!validateImageType(contentType)) {
throw createError({ statusCode: 415, statusMessage: 'Unsupported image type' })
}
if (!validateFileSize(file.data.length, MAX_IMAGE_MB)) {
throw createError({ statusCode: 413, statusMessage: `Image exceeds ${MAX_IMAGE_MB}MB limit` })
}
const filename = file.filename || 'upload'
const result = await uploadToS3(file.data, filename, contentType)
return { url: result.url, key: result.key, filename: result.filename }
})

134
server/utils/deepl.ts Normal file
View file

@ -0,0 +1,134 @@
// DeepL translation utility. Slim port of
// campaigns-sever/server/utils/deepl.ts — drops the Bible / verse-node
// handling since marketing-rebuild has no verse content. Supports text,
// batched text, and Tiptap JSON documents.
import { getDeeplTargetCode, getDeeplSourceCode, getGlossaryId } from '~~/config/languages'
interface DeepLTranslation {
detected_source_language: string
text: string
}
interface DeepLResponse {
translations: DeepLTranslation[]
}
export interface TiptapNode {
type: string
content?: TiptapNode[]
text?: string
marks?: any[]
attrs?: Record<string, any>
}
export function isDeepLConfigured(): boolean {
const config = useRuntimeConfig()
return Boolean(config.deeplApiKey)
}
export async function translateText(
text: string,
targetLanguage: string,
sourceLanguage?: string
): Promise<string> {
const translated = await translateTexts([text], targetLanguage, sourceLanguage)
return translated[0] ?? ''
}
export async function translateTexts(
texts: string[],
targetLanguage: string,
sourceLanguage?: string
): Promise<string[]> {
if (texts.length === 0) return []
const config = useRuntimeConfig()
const apiKey = config.deeplApiKey
if (!apiKey) throw new Error('DEEPL_API_KEY is not configured')
const targetLang = getDeeplTargetCode(targetLanguage)
const sourceLang = sourceLanguage ? getDeeplSourceCode(sourceLanguage) : undefined
const params = new URLSearchParams({ target_lang: targetLang })
for (const text of texts) params.append('text', text)
if (sourceLang) params.append('source_lang', sourceLang)
const glossaryId = getGlossaryId(targetLanguage)
if (glossaryId) params.append('glossary_id', glossaryId)
params.append('model_type', 'quality_optimized')
const apiUrl = config.deeplApiUrl || 'https://api-free.deepl.com'
console.log(`[DeepL] Translating ${texts.length} text(s) → ${targetLang}${sourceLang ? ` from ${sourceLang}` : ''}${glossaryId ? ` (glossary: ${glossaryId})` : ''}`)
const response = await fetch(`${apiUrl}/v2/translate`, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
})
if (!response.ok) {
const body = await response.text()
throw new Error(`DeepL API error: ${response.status} - ${body}`)
}
const data = await response.json() as DeepLResponse
if (!data.translations) throw new Error('No translations returned from DeepL')
return data.translations.map(t => t.text)
}
// Extract plain text nodes from a Tiptap JSON tree so we can send them
// to DeepL as a batch, then write the translations back at the same
// positions. Mirrors campaigns-sever's implementation minus the
// verse-node skip (no verses in marketing content).
export function extractTexts(
node: TiptapNode,
path: number[] = []
): Array<{ path: number[]; text: string }> {
const results: Array<{ path: number[]; text: string }> = []
if (node.type === 'text' && node.text) {
results.push({ path: [...path], text: node.text })
}
if (node.content && Array.isArray(node.content)) {
node.content.forEach((child, index) => {
results.push(...extractTexts(child, [...path, index]))
})
}
return results
}
export function setTextAtPath(node: TiptapNode, path: number[], text: string): void {
if (path.length === 0) {
node.text = text
return
}
const [index, ...rest] = path as [number, ...number[]]
if (node.content && node.content[index]) {
setTextAtPath(node.content[index], rest, text)
}
}
export async function translateTiptapContent(
contentJson: TiptapNode,
targetLanguage: string,
sourceLanguage?: string
): Promise<TiptapNode> {
const cloned: TiptapNode = JSON.parse(JSON.stringify(contentJson))
const entries = extractTexts(cloned)
if (entries.length === 0) return cloned
const CHUNK_SIZE = 100
const allTranslated: string[] = []
for (let i = 0; i < entries.length; i += CHUNK_SIZE) {
if (i > 0) await new Promise(r => setTimeout(r, 200))
const chunk = entries.slice(i, i + CHUNK_SIZE)
const translated = await translateTexts(chunk.map(e => e.text), targetLanguage, sourceLanguage)
allTranslated.push(...translated)
}
entries.forEach((entry, i) => setTextAtPath(cloned, entry.path, allTranslated[i]!))
return cloned
}

View file

@ -8,7 +8,6 @@
import { generateHTML } from '@tiptap/html'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
import { TextStyle } from '@tiptap/extension-text-style'
@ -20,8 +19,7 @@ import Superscript from '@tiptap/extension-superscript'
import Youtube from '@tiptap/extension-youtube'
const EXTENSIONS = [
StarterKit,
Link.configure({ openOnClick: false, autolink: true }),
StarterKit.configure({ link: { openOnClick: false, autolink: true } }),
Image,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyle,