doxa-maps-cdn/app/pages/admin/pages/[id].vue
corsacca 6f918a9d85
Let categories have child categories for nested pages. (#6)
* Let categories have child categories for nested pages.

* fixes
2026-05-21 06:00:54 -05:00

993 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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'
type PageTheme = 'default' | 'green'
interface Page {
id: string
slug: string
category_id: string | null
menu_order: number
theme: PageTheme
custom_css: string | null
created: string
updated: string
}
interface CategoryRow {
id: string
slug: string
url: string
parent_path: string | null
parent_label: string | null
parent_id: string | null
menu_order: number
translations: Array<{ locale: string; name: string }>
page_count: number
}
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[]
url: string
category_path: string | null
}
const route = useRoute()
const router = useRouter()
const toast = useToast()
const pageId = computed(() => String(route.params.id))
// Live public URL for the "View page" link in the Publish card. Uses
// the server-computed full URL (data.url) so it stays correct as the
// page moves between categories. English is the default locale, so
// `prefix_except_default` means no prefix.
const publicUrl = computed(() => {
const committedUrl = data.value?.url
if (!committedUrl) return null
return activeLocale.value === 'en'
? `/${committedUrl}`
: `/${activeLocale.value}/${committedUrl}`
})
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] }
const { data, pending, refresh } = await useFetch<PageDetail>(() => `/api/admin/pages/${pageId.value}`)
const { data: categoriesData } = await useFetch<{ rows: CategoryRow[] }>(
'/api/admin/categories',
{ default: () => ({ rows: [] }) }
)
const categories = computed(() => categoriesData.value?.rows ?? [])
function categoryLabel(cat: CategoryRow): string {
const en = cat.translations.find(t => t.locale === 'en')?.name ?? cat.slug
return cat.parent_label ? `${cat.parent_label} / ${en}` : en
}
const categoryItems = computed(() => [
{ label: '— Uncategorized —', value: null as string | null },
...[...categories.value]
.sort((a, b) => a.url.localeCompare(b.url))
.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
])
// Metadata editable in the top bar
const slug = ref('')
const categoryId = ref<string | null>(null)
const theme = ref<PageTheme>('default')
const customCss = ref('')
// Original category id at load time — used to detect moves that also
// trigger an automatic slug-prefix rewrite on the server.
const originalCategoryId = ref<string | null>(null)
watchEffect(() => {
if (data.value) {
slug.value = data.value.page.slug
categoryId.value = data.value.page.category_id
originalCategoryId.value = data.value.page.category_id
theme.value = data.value.page.theme ?? 'default'
customCss.value = data.value.page.custom_css ?? ''
}
})
const selectedCategory = computed(() =>
categoryId.value ? categories.value.find(c => c.id === categoryId.value) : undefined
)
const isCategoryChanged = computed(() => categoryId.value !== originalCategoryId.value)
// Live preview of the full URL — re-derived from the (in-flight) leaf
// slug + selected category so the editor sees the new URL before they
// save. Used in the Slug field's helper text.
const previewFullUrl = computed(() => {
const leaf = slug.value.trim().replace(/^\/+|\/+$/g, '')
if (!leaf) return ''
const prefix = selectedCategory.value?.url
return prefix ? `${prefix}/${leaf}` : leaf
})
const THEME_OPTIONS: Array<{ label: string; value: PageTheme }> = [
{ label: 'Default', value: 'default' },
{ label: 'Green background', value: 'green' }
]
// 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 })
}
// Single save action: page-level metadata (PATCH /pages/:id) + the
// 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', localeOverride?: string): Promise<boolean> {
const locale = localeOverride ?? activeLocale.value
const f = forms[locale]
if (!f) return false
if (!f.title.trim()) {
toast.add({ title: 'Title is required', color: 'error' })
return false
}
saving.value = true
try {
await $fetch(`/api/admin/pages/${pageId.value}`, {
method: 'PATCH',
body: {
slug: slug.value,
category_id: categoryId.value,
theme: theme.value,
custom_css: customCss.value.trim() ? customCss.value : null
}
})
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
const saveResponse = await $fetch<{ sanitization_warnings?: { path: string; reason: string }[] }>(
`/api/admin/pages/${pageId.value}/translations/${locale}`,
{ method: 'PUT', body }
)
if (statusOverride) f.status = statusOverride
f.loaded = true
f.dirty = false
const verb = statusOverride === 'published'
? 'Published'
: statusOverride === 'draft'
? 'Unpublished'
: 'Saved'
toast.add({ title: `${verb} ${locale}`, color: 'success' })
const warnings = saveResponse?.sanitization_warnings ?? []
if (warnings.length > 0) {
const example = warnings[0]?.reason ?? ''
toast.add({
title: 'Some formatting was simplified',
description: `${warnings.length} change${warnings.length === 1 ? '' : 's'} on save${example ? ` — e.g. ${example}` : ''}`,
color: 'warning'
})
}
await refresh()
return true
} catch (e: any) {
const status = e?.statusCode || e?.response?.status
const detail = e?.data?.statusMessage || e?.data?.message || e?.statusMessage || e?.message
toast.add({
title: status ? `Save failed (${status})` : 'Save failed',
description: detail || 'Unexpected error — please try again',
color: 'error'
})
return false
} finally {
saving.value = false
}
}
// 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 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 }> }>(
`/api/admin/pages/${pageId.value}/translate`,
{
method: 'POST',
body: {
sourceLocale: translateSource.value,
targetLocales: translateTargets.value,
overwrite: translateOverwrite.value,
status: translateStatus.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
// ── Version history ─────────────────────────────────────────────
//
// Per-locale snapshot list. Loaded lazily when the drawer opens (or
// the active locale changes while it's open). Restoring a version
// hydrates the form fields with the old content and marks the form
// dirty — nothing hits the DB until the user clicks Save/Publish.
interface VersionSummary {
id: string
created: string
status: 'draft' | 'published'
source: 'admin-ui' | 'mcp' | 'deepl'
title: string
created_by: { id: string; name: string } | null
}
interface VersionDetail extends VersionSummary {
body_json: Record<string, any>
body_html: string
excerpt: string | null
featured_image: string | null
meta_title: string | null
meta_description: string | null
og_image: string | null
}
const historyOpen = ref(false)
const versionsLoading = ref(false)
const versions = ref<VersionSummary[]>([])
const selectedVersionId = ref<string | null>(null)
const selectedVersion = ref<VersionDetail | null>(null)
const versionDetailLoading = ref(false)
async function loadVersions() {
versionsLoading.value = true
selectedVersionId.value = null
selectedVersion.value = null
try {
const res = await $fetch<{ versions: VersionSummary[] }>(
`/api/admin/pages/${pageId.value}/translations/${activeLocale.value}/versions`
)
versions.value = res.versions
} catch (e: any) {
toast.add({
title: 'Could not load history',
description: e?.data?.statusMessage || e?.message,
color: 'error'
})
versions.value = []
} finally {
versionsLoading.value = false
}
}
async function selectVersion(id: string) {
if (selectedVersionId.value === id) return
selectedVersionId.value = id
selectedVersion.value = null
versionDetailLoading.value = true
try {
selectedVersion.value = await $fetch<VersionDetail>(
`/api/admin/pages/${pageId.value}/translations/${activeLocale.value}/versions/${id}`
)
} catch (e: any) {
toast.add({
title: 'Could not load version',
description: e?.data?.statusMessage || e?.message,
color: 'error'
})
} finally {
versionDetailLoading.value = false
}
}
function openHistory() {
historyOpen.value = true
loadVersions()
}
// Refetch when the editor switches locale tabs while the drawer is open.
watch(activeLocale, () => {
if (historyOpen.value) loadVersions()
})
function loadVersionIntoEditor() {
const v = selectedVersion.value
if (!v) return
const f = forms[activeLocale.value]
if (!f) return
f.title = v.title
f.excerpt = v.excerpt ?? ''
f.featured_image = v.featured_image ?? ''
f.meta_title = v.meta_title ?? ''
f.meta_description = v.meta_description ?? ''
f.og_image = v.og_image ?? ''
f.body_json = JSON.parse(JSON.stringify(v.body_json))
f.dirty = true
f.loaded = true
historyOpen.value = false
toast.add({
title: 'Version loaded into editor',
description: 'Click Save or Publish to apply.',
color: 'success'
})
}
const SOURCE_LABELS: Record<string, string> = {
'admin-ui': 'Admin',
mcp: 'MCP',
deepl: 'DeepL'
}
function formatVersionTime(iso: string): string {
try {
const d = new Date(iso)
return d.toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
})
} catch { return iso }
}
</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">
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px] gap-6 items-start">
<!-- Main column: locale tabs -->
<div class="border border-(--ui-border) rounded-lg min-w-0">
<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>
<div class="pt-2 border-t border-(--ui-border) 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>
</div>
<!-- Sidebar: page-level metadata (WordPress-style) -->
<aside class="lg:sticky lg:top-4 space-y-4">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<span class="text-sm font-semibold">Publish</span>
<UBadge
size="xs"
:color="forms[activeLocale]?.status === 'published' ? 'success' : 'neutral'"
variant="subtle"
>
{{ forms[activeLocale]?.status ?? 'draft' }} · {{ activeLocale }}
</UBadge>
</div>
</template>
<div class="flex flex-col gap-2">
<template v-if="forms[activeLocale]?.status === 'published'">
<UButton
block
color="primary"
icon="i-lucide-globe"
:loading="saving"
@click="saveAll('published')"
>Update</UButton>
<UButton
block
variant="ghost"
color="warning"
icon="i-lucide-eye-off"
:loading="saving"
@click="saveAll('draft')"
>Move to draft</UButton>
</template>
<template v-else>
<UButton
block
color="primary"
icon="i-lucide-globe"
:loading="saving"
@click="saveAll('published')"
>Publish</UButton>
<UButton
block
variant="outline"
color="neutral"
:loading="saving"
@click="saveAll()"
>Save draft</UButton>
</template>
<UButton
block
variant="ghost"
color="neutral"
icon="i-lucide-history"
:disabled="!forms[activeLocale]?.loaded"
@click="openHistory"
>History</UButton>
<div v-if="publicUrl" class="pt-2 mt-1 border-t border-(--ui-border)">
<a
:href="publicUrl"
target="_blank"
rel="noopener"
class="text-sm text-(--ui-primary) hover:underline inline-flex items-center gap-1"
>
<UIcon name="i-lucide-external-link" class="size-3.5" />
{{ publicUrl }}
</a>
</div>
</div>
</UCard>
<UCard>
<template #header>
<span class="text-sm font-semibold">Page settings</span>
</template>
<div class="space-y-4">
<UFormField
label="Category"
:description="isCategoryChanged ? 'Moving to a different category will change this page’s URL — the old URL will 404.' : 'Group this page under a category.'"
>
<USelect
v-model="categoryId"
:items="categoryItems"
value-key="value"
class="w-full"
/>
</UFormField>
<UFormField
label="Slug"
:description="previewFullUrl ? `Full URL: /${previewFullUrl}` : 'Leaf segment only — lowercase letters, digits, dashes.'"
>
<div v-if="selectedCategory" class="flex items-center gap-1 min-w-0">
<span class="shrink-0 text-(--ui-text-muted) font-mono text-sm truncate">{{ selectedCategory.url }}/</span>
<UInput v-model="slug" class="flex-1 min-w-0" />
</div>
<UInput v-else v-model="slug" class="w-full" />
</UFormField>
<UFormField label="Page theme" description="Applied to <body>.">
<USelect v-model="theme" :items="THEME_OPTIONS" class="w-full" />
</UFormField>
<UFormField label="Custom CSS" description="Raw CSS injected at end of <body>. Wins over app & theme styles.">
<UTextarea
v-model="customCss"
:rows="6"
placeholder="body { … }"
class="font-mono text-xs w-full"
/>
</UFormField>
</div>
</UCard>
<!-- Per-locale SEO & extras for the active locale. Native <details>
keeps the collapsed-by-default behavior without extra state. -->
<details class="group rounded-(--ui-radius) bg-(--ui-bg) ring ring-(--ui-border) shadow-sm">
<summary class="cursor-pointer select-none flex items-center justify-between px-4 py-3 text-sm font-semibold">
<span>SEO &amp; extras <span class="text-(--ui-text-muted) font-normal">· {{ activeLocale }}</span></span>
<UIcon name="i-lucide-chevron-down" class="size-4 transition-transform group-open:rotate-180" />
</summary>
<div class="px-4 py-3 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[activeLocale]?.excerpt ?? ''"
:rows="2"
@update:model-value="v => setField(activeLocale, 'excerpt', String(v))"
/>
</UFormField>
<UFormField label="Featured image">
<div class="flex items-center gap-2">
<UInput
:model-value="forms[activeLocale]?.featured_image ?? ''"
placeholder="https://…"
class="flex-1"
@update:model-value="v => setField(activeLocale, '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', activeLocale, 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[activeLocale]?.og_image ?? ''"
placeholder="https://…"
class="flex-1"
@update:model-value="v => setField(activeLocale, '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', activeLocale, 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="Meta title" description="Overrides the page title in <title>.">
<UInput
:model-value="forms[activeLocale]?.meta_title ?? ''"
@update:model-value="v => setField(activeLocale, 'meta_title', String(v))"
/>
</UFormField>
<UFormField label="Meta description">
<UInput
:model-value="forms[activeLocale]?.meta_description ?? ''"
@update:model-value="v => setField(activeLocale, 'meta_description', String(v))"
/>
</UFormField>
</div>
</details>
</aside>
</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>
<USlideover
v-model:open="historyOpen"
side="right"
:title="`Version history · ${activeLocale}`"
:ui="{ content: 'w-screen max-w-full sm:max-w-none sm:w-[80vw] lg:w-[70vw]' }"
>
<template #body>
<div class="grid grid-cols-1 md:grid-cols-[280px_minmax(0,1fr)] gap-4 h-full">
<!-- Version list -->
<div class="border border-(--ui-border) rounded-lg overflow-y-auto">
<div v-if="versionsLoading" class="p-4 text-sm text-(--ui-text-muted)">
Loading…
</div>
<div v-else-if="versions.length === 0" class="p-4 text-sm text-(--ui-text-muted)">
No saved versions for this locale yet.
</div>
<ul v-else class="divide-y divide-(--ui-border)">
<li
v-for="v in versions"
:key="v.id"
:class="[
'p-3 cursor-pointer hover:bg-(--ui-bg-elevated) transition-colors',
selectedVersionId === v.id ? 'bg-(--ui-bg-elevated)' : ''
]"
@click="selectVersion(v.id)"
>
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-(--ui-text-muted)">{{ formatVersionTime(v.created) }}</span>
<UBadge
size="xs"
:color="v.status === 'published' ? 'success' : 'neutral'"
variant="subtle"
>{{ v.status }}</UBadge>
</div>
<div class="mt-1 text-sm font-medium truncate">{{ v.title || '(untitled)' }}</div>
<div class="mt-0.5 text-xs text-(--ui-text-muted) flex items-center gap-1.5">
<span>{{ v.created_by?.name ?? 'System' }}</span>
<span>·</span>
<span>{{ SOURCE_LABELS[v.source] ?? v.source }}</span>
</div>
</li>
</ul>
</div>
<!-- Preview pane -->
<div class="border border-(--ui-border) rounded-lg overflow-y-auto">
<div v-if="!selectedVersionId" class="p-6 text-sm text-(--ui-text-muted)">
Select a version to preview.
</div>
<div v-else-if="versionDetailLoading" class="p-6 text-sm text-(--ui-text-muted)">
Loading version…
</div>
<div v-else-if="selectedVersion" class="p-6 space-y-4">
<div>
<h2 class="text-lg font-semibold">{{ selectedVersion.title || '(untitled)' }}</h2>
<div class="mt-1 text-xs text-(--ui-text-muted) flex flex-wrap items-center gap-1.5">
<UBadge
size="xs"
:color="selectedVersion.status === 'published' ? 'success' : 'neutral'"
variant="subtle"
>{{ selectedVersion.status }}</UBadge>
<span>·</span>
<span>{{ formatVersionTime(selectedVersion.created) }}</span>
<span>·</span>
<span>{{ selectedVersion.created_by?.name ?? 'System' }}</span>
<span>·</span>
<span>{{ SOURCE_LABELS[selectedVersion.source] ?? selectedVersion.source }}</span>
</div>
</div>
<div v-if="selectedVersion.excerpt" class="text-sm text-(--ui-text-muted) italic border-l-2 border-(--ui-border) pl-3">
{{ selectedVersion.excerpt }}
</div>
<div v-if="selectedVersion.body_html" class="version-body" v-html="selectedVersion.body_html"></div>
<div v-else class="text-sm text-(--ui-text-muted)">(empty body)</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton color="neutral" variant="ghost" @click="historyOpen = false">Close</UButton>
<UButton
color="primary"
icon="i-lucide-rotate-ccw"
:disabled="!selectedVersion"
@click="loadVersionIntoEditor"
>Load into editor</UButton>
</div>
</template>
</USlideover>
<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>
<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)"
: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>
</div>
<UFormField label="Save translations as">
<USelect
v-model="translateStatus"
:items="[
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' }
]"
/>
</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>
<style scoped>
/* Mirror RichTextEditor's .tiptap-body rules so server-rendered Tiptap
HTML in the version-history preview pane shows the same formatting
as the editor — Tailwind Typography is not installed, so prose
classes are inert. */
.version-body :deep(h1) { font-size: 1.875rem; font-weight: 700; margin: 1rem 0 0.5rem; }
.version-body :deep(h2) { font-size: 1.5rem; font-weight: 700; margin: 1rem 0 0.5rem; }
.version-body :deep(h3) { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem; }
.version-body :deep(p) { margin: 0.5rem 0; }
.version-body :deep(ul) { list-style: disc; padding-left: 1.5rem; }
.version-body :deep(ol) { list-style: decimal; padding-left: 1.5rem; }
.version-body :deep(blockquote) { border-left: 3px solid var(--ui-border); padding-left: 1rem; color: var(--ui-text-muted); }
.version-body :deep(code) { background: var(--ui-bg-elevated); padding: 0.1em 0.3em; border-radius: 0.25rem; }
.version-body :deep(a) { color: var(--ui-primary); text-decoration: underline; }
.version-body :deep(img) { max-width: 100%; height: auto; border-radius: 0.375rem; }
.version-body :deep(iframe) { max-width: 100%; }
.version-body :deep(strong) { font-weight: 700; }
.version-body :deep(em) { font-style: italic; }
.version-body :deep(hr) { margin: 1rem 0; border-color: var(--ui-border); }
/* Custom-node placeholders. renderTiptap outputs empty divs for
UupgsList / GeneralResources (hydrated on the public page); show
them as labeled blocks so editors can see "yes, a block lived here". */
.version-body :deep(.doxa-uupgs-list-slot),
.version-body :deep(.doxa-general-resources-slot) {
display: block;
margin: 0.75rem 0;
padding: 0.75rem 1rem;
border: 1px dashed var(--ui-border-accented, var(--ui-border));
border-radius: 0.5rem;
background: var(--ui-bg-elevated);
color: var(--ui-text-muted);
font-size: 0.875rem;
font-style: italic;
}
.version-body :deep(.doxa-uupgs-list-slot)::before { content: '[ UUPGs list ]'; }
.version-body :deep(.doxa-general-resources-slot)::before { content: '[ General resources ]'; }
/* Verse block — has real content, just needs framing. */
.version-body :deep(.doxa-verse) {
position: relative;
margin: 1rem 0;
padding: 1rem 1rem 1rem 2.5rem;
border-left: 4px solid var(--ui-primary);
background: var(--ui-bg-elevated);
border-radius: 0 0.375rem 0.375rem 0;
}
.version-body :deep(.doxa-verse)::before {
content: '“';
position: absolute;
left: 0.5rem;
top: 0.25rem;
font-size: 2rem;
color: var(--ui-primary);
line-height: 1;
}
.version-body :deep(.doxa-verse[data-reference])::after {
content: '— ' attr(data-reference);
display: block;
margin-top: 0.5rem;
text-align: right;
font-size: 0.8rem;
font-style: italic;
color: var(--ui-text-muted);
}
</style>