-
+
+
+
diff --git a/app/pages/admin/pages/[id].vue b/app/pages/admin/pages/[id].vue
new file mode 100644
index 00000000..a0791109
--- /dev/null
+++ b/app/pages/admin/pages/[id].vue
@@ -0,0 +1,556 @@
+
+
+
+
+
+
+ Back
+
Edit page
+
+
+ Translate from English
+ Delete page
+
+
+
+
+ Loading…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Save metadata
+
+
+
+
+
+
+
+ {{ l.flag }}
+ {{ l.nativeName }}
+
+ {{ forms[l.code]?.status }}
+
+
+
+
+
+
+ setField(l.code, 'title', String(v))"
+ />
+
+
+
+ setField(l.code, 'body_json', v)"
+ />
+
+
+
+
+ SEO & extras
+
+
+
+
+ setField(l.code, 'excerpt', String(v))"
+ />
+
+
+
+
+
+
+ setField(l.code, 'meta_title', String(v))"
+ />
+
+
+ setField(l.code, 'meta_description', String(v))"
+ />
+
+
+
+
+
+
+
+ Unsaved changes
+ Saved
+ Not translated yet
+
+
+ Unpublish
+ Save draft
+ Publish
+
+
+
+
+
+
+
+
+
+ This permanently deletes the page {{ data?.page.slug }} and all its translations.
+ This cannot be undone.
+
+
+
+
+ Cancel
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+ { if (checked) { translateTargets = Array.from(new Set([...translateTargets, l.code])) } else { translateTargets = translateTargets.filter(c => c !== l.code) } }"
+ />
+
+
+
+
+
+
+
+ Cancel
+ Translate
+
+
+
+
+
diff --git a/app/pages/admin/pages/index.vue b/app/pages/admin/pages/index.vue
new file mode 100644
index 00000000..bda05e31
--- /dev/null
+++ b/app/pages/admin/pages/index.vue
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
CMS Pages
+
Manage translated content for about, resources, privacy, and other CMS-driven pages.
+
+
+ New page
+
+
+
+
+ Failed to load pages: {{ error.message }}
+ Retry
+
+
+
+ Loading…
+
+
+
+ No CMS pages yet. Click New page to create your first one.
+
+
+
+
+
+
+ | Title (EN) |
+ Slug |
+ Parent |
+ Translations |
+ Updated |
+ Actions |
+
+
+
+
+ |
+ {{ row.title_en }}
+ Untitled
+ |
+ {{ row.slug }} |
+ {{ row.parent_slug || '—' }} |
+
+
+ {{ row.published_count }} / {{ row.translation_count }} published
+
+ |
+ {{ formatDate(row.updated) }} |
+
+
+ Edit
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+ Create
+
+
+
+
+
diff --git a/app/utils/permissions.ts b/app/utils/permissions.ts
index 659c47cc..c4a8cb64 100644
--- a/app/utils/permissions.ts
+++ b/app/utils/permissions.ts
@@ -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
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
+}
diff --git a/nuxt.config.ts b/nuxt.config.ts
index fbf21ac2..cf32b2f0 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -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: {
diff --git a/package.json b/package.json
index 659fa453..139206bb 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/server/api/admin/pages/[id].delete.ts b/server/api/admin/pages/[id].delete.ts
new file mode 100644
index 00000000..f03841e8
--- /dev/null
+++ b/server/api/admin/pages/[id].delete.ts
@@ -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 }
+})
diff --git a/server/api/admin/pages/[id].get.ts b/server/api/admin/pages/[id].get.ts
new file mode 100644
index 00000000..548c9b13
--- /dev/null
+++ b/server/api/admin/pages/[id].get.ts
@@ -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 }
+})
diff --git a/server/api/admin/pages/[id].patch.ts b/server/api/admin/pages/[id].patch.ts
new file mode 100644
index 00000000..99b27f7d
--- /dev/null
+++ b/server/api/admin/pages/[id].patch.ts
@@ -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 = {}
+ 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
+ }
+})
diff --git a/server/api/admin/pages/[id]/publish.post.ts b/server/api/admin/pages/[id]/publish.post.ts
new file mode 100644
index 00000000..876c9dfb
--- /dev/null
+++ b/server/api/admin/pages/[id]/publish.post.ts
@@ -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(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 }
+})
diff --git a/server/api/admin/pages/[id]/translate.post.ts b/server/api/admin/pages/[id]/translate.post.ts
new file mode 100644
index 00000000..572eb303
--- /dev/null
+++ b/server/api/admin/pages/[id]/translate.post.ts
@@ -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(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 }
+})
diff --git a/server/api/admin/pages/[id]/translations/[locale].put.ts b/server/api/admin/pages/[id]/translations/[locale].put.ts
new file mode 100644
index 00000000..3908451c
--- /dev/null
+++ b/server/api/admin/pages/[id]/translations/[locale].put.ts
@@ -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
+ 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(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
+})
diff --git a/server/api/admin/pages/index.get.ts b/server/api/admin/pages/index.get.ts
new file mode 100644
index 00000000..4b8e0415
--- /dev/null
+++ b/server/api/admin/pages/index.get.ts
@@ -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`MAX(CASE WHEN page_translations.locale = 'en' THEN page_translations.title END)`.as('title_en'),
+ sql`COUNT(page_translations.id)::int`.as('translation_count'),
+ sql`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 }
+})
diff --git a/server/api/admin/pages/index.post.ts b/server/api/admin/pages/index.post.ts
new file mode 100644
index 00000000..98260603
--- /dev/null
+++ b/server/api/admin/pages/index.post.ts
@@ -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
+ }
+})
diff --git a/server/api/admin/upload/image.post.ts b/server/api/admin/upload/image.post.ts
new file mode 100644
index 00000000..3a06555e
--- /dev/null
+++ b/server/api/admin/upload/image.post.ts
@@ -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 }
+})
diff --git a/server/utils/deepl.ts b/server/utils/deepl.ts
new file mode 100644
index 00000000..19d8750d
--- /dev/null
+++ b/server/utils/deepl.ts
@@ -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
+}
+
+export function isDeepLConfigured(): boolean {
+ const config = useRuntimeConfig()
+ return Boolean(config.deeplApiKey)
+}
+
+export async function translateText(
+ text: string,
+ targetLanguage: string,
+ sourceLanguage?: string
+): Promise {
+ const translated = await translateTexts([text], targetLanguage, sourceLanguage)
+ return translated[0] ?? ''
+}
+
+export async function translateTexts(
+ texts: string[],
+ targetLanguage: string,
+ sourceLanguage?: string
+): Promise {
+ 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 {
+ 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
+}
diff --git a/server/utils/renderTiptap.ts b/server/utils/renderTiptap.ts
index 1afbe622..6a04ece8 100644
--- a/server/utils/renderTiptap.ts
+++ b/server/utils/renderTiptap.ts
@@ -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,