phase 6
This commit is contained in:
parent
b2975650ec
commit
b2c7b22aa1
23 changed files with 1577 additions and 13 deletions
|
|
@ -5,7 +5,9 @@
|
|||
}
|
||||
|
||||
.with-sidebar > :first-child {
|
||||
flex-grow: 1;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 25ch;
|
||||
}
|
||||
|
||||
.with-sidebar > :last-child {
|
||||
|
|
|
|||
193
app/components/admin/RichTextEditor.vue
Normal file
193
app/components/admin/RichTextEditor.vue
Normal 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>
|
||||
66
app/composables/useImageUpload.ts
Normal file
66
app/composables/useImageUpload.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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' }]
|
||||
: []),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
556
app/pages/admin/pages/[id].vue
Normal file
556
app/pages/admin/pages/[id].vue
Normal 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 & 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>
|
||||
167
app/pages/admin/pages/index.vue
Normal file
167
app/pages/admin/pages/index.vue
Normal 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>
|
||||
|
|
@ -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.'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1
bun.lock
1
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
24
server/api/admin/pages/[id].delete.ts
Normal file
24
server/api/admin/pages/[id].delete.ts
Normal 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 }
|
||||
})
|
||||
27
server/api/admin/pages/[id].get.ts
Normal file
27
server/api/admin/pages/[id].get.ts
Normal 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 }
|
||||
})
|
||||
46
server/api/admin/pages/[id].patch.ts
Normal file
46
server/api/admin/pages/[id].patch.ts
Normal 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
|
||||
}
|
||||
})
|
||||
35
server/api/admin/pages/[id]/publish.post.ts
Normal file
35
server/api/admin/pages/[id]/publish.post.ts
Normal 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 }
|
||||
})
|
||||
102
server/api/admin/pages/[id]/translate.post.ts
Normal file
102
server/api/admin/pages/[id]/translate.post.ts
Normal 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 }
|
||||
})
|
||||
69
server/api/admin/pages/[id]/translations/[locale].put.ts
Normal file
69
server/api/admin/pages/[id]/translations/[locale].put.ts
Normal 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
|
||||
})
|
||||
32
server/api/admin/pages/index.get.ts
Normal file
32
server/api/admin/pages/index.get.ts
Normal 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 }
|
||||
})
|
||||
34
server/api/admin/pages/index.post.ts
Normal file
34
server/api/admin/pages/index.post.ts
Normal 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
|
||||
}
|
||||
})
|
||||
33
server/api/admin/upload/image.post.ts
Normal file
33
server/api/admin/upload/image.post.ts
Normal 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
134
server/utils/deepl.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue