CMS page translation

This commit is contained in:
corsacca 2026-04-27 13:33:31 +01:00
parent 1eec64b45a
commit 847258f9ae
5 changed files with 42 additions and 12 deletions

View file

@ -37,6 +37,11 @@ NUXT_TURNSTILE_SECRET_KEY=your-turnstile-secret-key
NUXT_PUBLIC_FEEDBACK_API_BASE=https://support.gospelambition.org
NUXT_PUBLIC_FEEDBACK_PROJECT_ID=your-feedback-project-id
# DeepL (used by the CMS "Translate from English" workflow on /admin/pages/:id)
# Pro endpoint: https://api.deepl.com | Free-tier endpoint: https://api-free.deepl.com
DEEPL_API_KEY=your-deepl-api-key
DEEPL_API_URL=https://api.deepl.com
# OAuth 2.1 server
# Issuer is derived from NUXT_PUBLIC_SITE_URL; resource is `${siteUrl}/mcp`.
# Generate with: openssl rand -hex 32

View file

@ -202,13 +202,13 @@ for (const l of ENABLED_LANGUAGES) {
// active locale's translation (PUT /translations/:locale) in one click.
// Passing a statusOverride also flips published/draft on the translation.
const saving = ref(false)
async function saveAll(statusOverride?: 'draft' | 'published') {
const locale = activeLocale.value
async function saveAll(statusOverride?: 'draft' | 'published', localeOverride?: string): Promise<boolean> {
const locale = localeOverride ?? activeLocale.value
const f = forms[locale]
if (!f) return
if (!f) return false
if (!f.title.trim()) {
toast.add({ title: 'Title is required', color: 'error' })
return
return false
}
saving.value = true
try {
@ -249,12 +249,14 @@ async function saveAll(statusOverride?: 'draft' | 'published') {
: 'Saved'
toast.add({ title: `${verb} ${locale}`, color: 'success' })
await refresh()
return true
} catch (e: any) {
toast.add({
title: 'Save failed',
description: e?.data?.statusMessage || e?.message,
color: 'error'
})
return false
} finally {
saving.value = false
}
@ -300,16 +302,25 @@ const translateModalOpen = ref(false)
const translateSource = ref('en')
const translateTargets = ref<string[]>([])
const translateOverwrite = ref(false)
const translateStatus = ref<'draft' | 'published'>('draft')
const translating = ref(false)
function openTranslateModal() {
translateSource.value = 'en'
translateTargets.value = ENABLED_LANGUAGES.map(l => l.code).filter(c => c !== 'en')
translateOverwrite.value = false
translateStatus.value = 'draft'
translateModalOpen.value = true
}
async function runTranslate() {
// Persist any unsaved edits in the source-locale tab first — the
// translate endpoint reads from the DB, not from the in-memory form.
const sourceForm = forms[translateSource.value]
if (sourceForm && (sourceForm.dirty || !sourceForm.loaded)) {
const ok = await saveAll(undefined, translateSource.value)
if (!ok) return
}
translating.value = true
try {
const res = await $fetch<{ results: Array<{ locale: string; skipped?: boolean; error?: string }> }>(
@ -319,7 +330,8 @@ async function runTranslate() {
body: {
sourceLocale: translateSource.value,
targetLocales: translateTargets.value,
overwrite: translateOverwrite.value
overwrite: translateOverwrite.value,
status: translateStatus.value
}
}
)
@ -355,7 +367,7 @@ const enabledLanguages = ENABLED_LANGUAGES
<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" disabled title="Workflow not yet tested" @click="openTranslateModal">Translate from English</UButton>
<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>
@ -623,7 +635,8 @@ const enabledLanguages = ENABLED_LANGUAGES
:items="enabledLanguages.map(l => ({ label: l.nativeName, value: l.code }))"
/>
</UFormField>
<UFormField label="Target locales">
<div>
<p class="text-sm font-medium text-(--ui-text) mb-1">Target locales</p>
<div class="flex flex-wrap gap-2">
<UCheckbox
v-for="l in enabledLanguages.filter(x => x.code !== translateSource)"
@ -633,6 +646,15 @@ const enabledLanguages = ENABLED_LANGUAGES
@update:model-value="checked => { if (checked) { translateTargets = Array.from(new Set([...translateTargets, l.code])) } else { translateTargets = translateTargets.filter(c => c !== l.code) } }"
/>
</div>
</div>
<UFormField label="Save translations as">
<USelect
v-model="translateStatus"
:items="[
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' }
]"
/>
</UFormField>
<UCheckbox
v-model="translateOverwrite"

View file

@ -138,7 +138,7 @@ export default defineNuxtConfig({
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',
deeplApiUrl: process.env.DEEPL_API_URL || 'https://api.deepl.com',
public: {
appName: process.env.APP_TITLE || 'My App',
nodeEnv: process.env.NODE_ENV || '',

View file

@ -1,8 +1,9 @@
// 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.
// translation is upserted with the caller-chosen `status` (draft or
// published, default draft). `overwrite` controls whether to replace
// translations that already exist for a given locale.
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
import { requirePermission } from '../../../../utils/rbac'
@ -17,6 +18,7 @@ interface Body {
sourceLocale?: string
targetLocales?: string[]
overwrite?: boolean
status?: 'draft' | 'published'
}
export default defineEventHandler(async (event) => {
@ -34,6 +36,7 @@ export default defineEventHandler(async (event) => {
if (targetLocales.length === 0) {
throw createError({ statusCode: 400, statusMessage: 'targetLocales is empty' })
}
const status: 'draft' | 'published' = body?.status === 'published' ? 'published' : 'draft'
const source = await db
.selectFrom('page_translations')
@ -83,7 +86,7 @@ export default defineEventHandler(async (event) => {
meta_title: translatedMetaTitle || null,
meta_description: translatedMetaDescription || null,
og_image: source.og_image,
status: 'draft'
status
})
results.push({ locale: target })
} catch (e: any) {

View file

@ -58,7 +58,7 @@ export async function translateTexts(
params.append('model_type', 'quality_optimized')
const apiUrl = config.deeplApiUrl || 'https://api-free.deepl.com'
const apiUrl = config.deeplApiUrl || 'https://api.deepl.com'
console.log(`[DeepL] Translating ${texts.length} text(s) → ${targetLang}${sourceLang ? ` from ${sourceLang}` : ''}${glossaryId ? ` (glossary: ${glossaryId})` : ''}`)
const response = await fetch(`${apiUrl}/v2/translate`, {