From b2c7b22aa1488506e8a087efe13b28bf73e1c62c Mon Sep 17 00:00:00 2001 From: corsacca Date: Tue, 21 Apr 2026 15:39:53 +0100 Subject: [PATCH] phase 6 --- app/assets/styles/composition/_sidebar.scss | 4 +- app/components/admin/RichTextEditor.vue | 193 ++++++ app/composables/useImageUpload.ts | 66 +++ app/layouts/admin.vue | 3 + app/layouts/auth.vue | 20 +- app/pages/admin/pages/[id].vue | 556 ++++++++++++++++++ app/pages/admin/pages/index.vue | 167 ++++++ app/utils/permissions.ts | 12 +- bun.lock | 1 + config/languages.ts | 16 + nuxt.config.ts | 11 + package.json | 1 + server/api/admin/pages/[id].delete.ts | 24 + server/api/admin/pages/[id].get.ts | 27 + server/api/admin/pages/[id].patch.ts | 46 ++ server/api/admin/pages/[id]/publish.post.ts | 35 ++ server/api/admin/pages/[id]/translate.post.ts | 102 ++++ .../pages/[id]/translations/[locale].put.ts | 69 +++ server/api/admin/pages/index.get.ts | 32 + server/api/admin/pages/index.post.ts | 34 ++ server/api/admin/upload/image.post.ts | 33 ++ server/utils/deepl.ts | 134 +++++ server/utils/renderTiptap.ts | 4 +- 23 files changed, 1577 insertions(+), 13 deletions(-) create mode 100644 app/components/admin/RichTextEditor.vue create mode 100644 app/composables/useImageUpload.ts create mode 100644 app/pages/admin/pages/[id].vue create mode 100644 app/pages/admin/pages/index.vue create mode 100644 server/api/admin/pages/[id].delete.ts create mode 100644 server/api/admin/pages/[id].get.ts create mode 100644 server/api/admin/pages/[id].patch.ts create mode 100644 server/api/admin/pages/[id]/publish.post.ts create mode 100644 server/api/admin/pages/[id]/translate.post.ts create mode 100644 server/api/admin/pages/[id]/translations/[locale].put.ts create mode 100644 server/api/admin/pages/index.get.ts create mode 100644 server/api/admin/pages/index.post.ts create mode 100644 server/api/admin/upload/image.post.ts create mode 100644 server/utils/deepl.ts diff --git a/app/assets/styles/composition/_sidebar.scss b/app/assets/styles/composition/_sidebar.scss index d9d037bc..c0b92f64 100644 --- a/app/assets/styles/composition/_sidebar.scss +++ b/app/assets/styles/composition/_sidebar.scss @@ -5,7 +5,9 @@ } .with-sidebar > :first-child { - flex-grow: 1; + flex-grow: 0; + flex-shrink: 0; + flex-basis: 25ch; } .with-sidebar > :last-child { diff --git a/app/components/admin/RichTextEditor.vue b/app/components/admin/RichTextEditor.vue new file mode 100644 index 00000000..22393ede --- /dev/null +++ b/app/components/admin/RichTextEditor.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/app/composables/useImageUpload.ts b/app/composables/useImageUpload.ts new file mode 100644 index 00000000..10d05412 --- /dev/null +++ b/app/composables/useImageUpload.ts @@ -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 { + 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(null) + + async function upload(file: File): Promise { + 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 } +} diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index ebc1b478..fc3b6e29 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -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' }] : []), diff --git a/app/layouts/auth.vue b/app/layouts/auth.vue index bdd7cdea..a3adcad4 100644 --- a/app/layouts/auth.vue +++ b/app/layouts/auth.vue @@ -1,12 +1,16 @@ + + 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 @@ + + + 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 @@ + + + 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,