feat(ui): translation-forward post text + Settings card (#143 step 5)
PostCard + modal ProvenancePanel show the English title/description by default when a translation exists, with a per-card "show original (<lang>)" toggle — translated bodies render as plain text, originals keep their sanitized HTML. New TranslationCard in Settings → Ingestion & filters: enable switch, Interpreter base URL (generic placeholder, no default host), target language, a reachability indicator + untranslated-posts count + "Translate now". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -42,13 +42,23 @@
|
|||||||
· {{ e.post.attachment_count }} files
|
· {{ e.post.attachment_count }} files
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="hasTranslation(e)" class="fc-prov__actions">
|
||||||
|
<a
|
||||||
|
href="#" @click.prevent="toggleOrig(e.provenance_id)"
|
||||||
|
>{{ showOrig[e.provenance_id] ? 'Show translation' : `Show original${langLabel(e)}` }}</a>
|
||||||
|
</div>
|
||||||
<div v-if="e.post.description_html" class="fc-prov__actions">
|
<div v-if="e.post.description_html" class="fc-prov__actions">
|
||||||
<a
|
<a
|
||||||
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
||||||
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Translated body = plain text; original = sanitized HTML (#143). -->
|
||||||
|
<p
|
||||||
|
v-if="showTranslated(e) && expanded[e.provenance_id] && e.post.description_translated"
|
||||||
|
class="fc-prov__desc"
|
||||||
|
>{{ e.post.description_translated }}</p>
|
||||||
<div
|
<div
|
||||||
v-if="e.post.description_html && expanded[e.provenance_id]"
|
v-else-if="e.post.description_html && expanded[e.provenance_id]"
|
||||||
class="fc-prov__desc" v-html="e.post.description_html"
|
class="fc-prov__desc" v-html="e.post.description_html"
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
@@ -117,8 +127,12 @@ const effectiveImage = computed(() => props.image ?? modal.current)
|
|||||||
// 2026-05-28. Reset when the viewed image changes.
|
// 2026-05-28. Reset when the viewed image changes.
|
||||||
const expanded = reactive({})
|
const expanded = reactive({})
|
||||||
function toggleDesc(id) { expanded[id] = !expanded[id] }
|
function toggleDesc(id) { expanded[id] = !expanded[id] }
|
||||||
|
// Per-entry "show the original (untranslated) text" toggle (#143).
|
||||||
|
const showOrig = reactive({})
|
||||||
|
function toggleOrig(id) { showOrig[id] = !showOrig[id] }
|
||||||
watch(() => effectiveId.value, () => {
|
watch(() => effectiveId.value, () => {
|
||||||
for (const k of Object.keys(expanded)) delete expanded[k]
|
for (const k of Object.keys(expanded)) delete expanded[k]
|
||||||
|
for (const k of Object.keys(showOrig)) delete showOrig[k]
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -157,10 +171,23 @@ const show = computed(() => {
|
|||||||
const attachments = computed(() => state.value?.attachments || [])
|
const attachments = computed(() => state.value?.attachments || [])
|
||||||
|
|
||||||
function postDate(e) { return formatPostDate(e.post.date) }
|
function postDate(e) { return formatPostDate(e.post.date) }
|
||||||
|
// Translation-forward (#143): show the English by default when present.
|
||||||
|
function hasTranslation(e) {
|
||||||
|
return !!(e.post.title_translated || e.post.description_translated)
|
||||||
|
}
|
||||||
|
function showTranslated(e) {
|
||||||
|
return hasTranslation(e) && !showOrig[e.provenance_id]
|
||||||
|
}
|
||||||
|
function langLabel(e) {
|
||||||
|
return e.post.translated_source_lang ? ` (${e.post.translated_source_lang})` : ''
|
||||||
|
}
|
||||||
function postTitle(e) {
|
function postTitle(e) {
|
||||||
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render
|
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render
|
||||||
// as plain text (the CSS makes it bold).
|
// as plain text (the CSS makes it bold).
|
||||||
return toPlainText(e.post.title) || `Post ${e.post.external_post_id}`
|
const t = showTranslated(e) && e.post.title_translated
|
||||||
|
? e.post.title_translated
|
||||||
|
: e.post.title
|
||||||
|
return toPlainText(t) || `Post ${e.post.external_post_id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPost(postId, artistId) {
|
function openPost(postId, artistId) {
|
||||||
|
|||||||
@@ -58,16 +58,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fc-post-card__text">
|
<div class="fc-post-card__text">
|
||||||
<h3 v-if="plainTitle" class="fc-post-card__title">{{ plainTitle }}</h3>
|
<h3 v-if="displayTitle" class="fc-post-card__title">{{ displayTitle }}</h3>
|
||||||
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
||||||
Post {{ post.external_post_id }}
|
Post {{ post.external_post_id }}
|
||||||
</h3>
|
</h3>
|
||||||
|
<!-- Translation toggle (#143): English shown by default; reveal the
|
||||||
|
original, labelled with its detected language. -->
|
||||||
|
<button
|
||||||
|
v-if="hasTranslation" type="button" class="fc-post-card__lang"
|
||||||
|
:title="showOriginal ? 'Show the English translation' : `Show the original${sourceLangLabel} text`"
|
||||||
|
@click.stop="showOriginal = !showOriginal"
|
||||||
|
>{{ showOriginal ? 'Show translation' : `Show original${sourceLangLabel}` }}</button>
|
||||||
|
|
||||||
<!-- Faithful (semantic) body render once expanded: backend-sanitized
|
<!-- Faithful (semantic) body render once expanded: backend-sanitized
|
||||||
HTML (headings, lists, links, inline images). Collapsed and the
|
HTML (headings, lists, links, inline images). Collapsed and the
|
||||||
no-detail fallback stay plain text. -->
|
no-detail fallback stay plain text. -->
|
||||||
<div
|
<div
|
||||||
v-if="hasDescription && descExpanded && descHtml"
|
v-if="hasDescription && showHtmlBody"
|
||||||
class="fc-post-card__desc fc-post-card__desc--html"
|
class="fc-post-card__desc fc-post-card__desc--html"
|
||||||
v-html="descHtml"
|
v-html="descHtml"
|
||||||
/>
|
/>
|
||||||
@@ -75,7 +82,7 @@
|
|||||||
v-else-if="hasDescription" ref="descEl"
|
v-else-if="hasDescription" ref="descEl"
|
||||||
class="fc-post-card__desc"
|
class="fc-post-card__desc"
|
||||||
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
|
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
|
||||||
>{{ descText }}</p>
|
>{{ descTextDisplay }}</p>
|
||||||
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
||||||
(no description)
|
(no description)
|
||||||
</p>
|
</p>
|
||||||
@@ -231,6 +238,32 @@ const canExpand = computed(
|
|||||||
() => props.post.description_truncated === true || cssOverflow.value,
|
() => props.post.description_truncated === true || cssOverflow.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- translation-forward (#143): the English is shown by default when a
|
||||||
|
// translation exists; a per-card toggle reveals the original. Translations are
|
||||||
|
// plain text, so showTranslated also decides HTML-vs-plain body rendering.
|
||||||
|
const showOriginal = ref(false)
|
||||||
|
const hasTranslation = computed(
|
||||||
|
() => !!(props.post.post_title_translated || props.post.description_translated),
|
||||||
|
)
|
||||||
|
const showTranslated = computed(() => hasTranslation.value && !showOriginal.value)
|
||||||
|
const sourceLangLabel = computed(() =>
|
||||||
|
props.post.translated_source_lang ? ` (${props.post.translated_source_lang})` : '',
|
||||||
|
)
|
||||||
|
const displayTitle = computed(() =>
|
||||||
|
showTranslated.value && props.post.post_title_translated
|
||||||
|
? toPlainText(props.post.post_title_translated)
|
||||||
|
: plainTitle.value,
|
||||||
|
)
|
||||||
|
const descTextDisplay = computed(() => {
|
||||||
|
if (!showTranslated.value) return descText.value
|
||||||
|
return descExpanded.value
|
||||||
|
? (detail.value?.description_translated_full || props.post.description_translated)
|
||||||
|
: props.post.description_translated
|
||||||
|
})
|
||||||
|
const showHtmlBody = computed(
|
||||||
|
() => !showTranslated.value && descExpanded.value && !!descHtml.value,
|
||||||
|
)
|
||||||
|
|
||||||
function measureOverflow () {
|
function measureOverflow () {
|
||||||
const el = descEl.value
|
const el = descEl.value
|
||||||
cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1
|
cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1
|
||||||
@@ -467,6 +500,19 @@ function formatBytes (n) {
|
|||||||
font-size: 0.85rem; font-weight: 600;
|
font-size: 0.85rem; font-weight: 600;
|
||||||
}
|
}
|
||||||
.fc-post-card__more:hover { text-decoration: underline; }
|
.fc-post-card__more:hover { text-decoration: underline; }
|
||||||
|
/* Translation toggle (#143) — quiet secondary affordance under the title. */
|
||||||
|
.fc-post-card__lang {
|
||||||
|
display: block; margin: 2px 0 6px; padding: 0;
|
||||||
|
background: none; border: 0; cursor: pointer;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
font-size: 0.75rem; font-weight: 600;
|
||||||
|
}
|
||||||
|
.fc-post-card__lang:hover {
|
||||||
|
text-decoration: underline; color: rgb(var(--v-theme-accent));
|
||||||
|
}
|
||||||
|
.fc-post-card__lang:focus-visible {
|
||||||
|
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.fc-post-card__atts {
|
.fc-post-card__atts {
|
||||||
display: flex; flex-wrap: wrap; gap: 8px;
|
display: flex; flex-wrap: wrap; gap: 8px;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="fc-tile-stack">
|
<div class="fc-tile-stack">
|
||||||
<ImportFiltersForm />
|
<ImportFiltersForm />
|
||||||
|
<TranslationCard />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
import ImportFiltersForm from './ImportFiltersForm.vue'
|
import ImportFiltersForm from './ImportFiltersForm.vue'
|
||||||
|
import TranslationCard from './TranslationCard.vue'
|
||||||
import MLBackfillCard from './MLBackfillCard.vue'
|
import MLBackfillCard from './MLBackfillCard.vue'
|
||||||
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
|
||||||
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<MaintenanceTile
|
||||||
|
icon="mdi-translate"
|
||||||
|
title="Post translation (Interpreter)"
|
||||||
|
blurb="Translate non-English post titles + descriptions to English via your self-hosted Interpreter service, so archived posts read naturally."
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center mb-3" style="gap: 10px;">
|
||||||
|
<span class="fc-section-h">Enabled</span>
|
||||||
|
<v-switch
|
||||||
|
v-model="enabled" :loading="busy" hide-details density="compact"
|
||||||
|
color="success" class="ml-auto" @update:model-value="onToggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="fc-muted text-body-2 mb-3">
|
||||||
|
Point this at your Interpreter service. It stays off until a URL is set, and
|
||||||
|
only translates posts whose text isn't already in the target language — the
|
||||||
|
English is shown by default on post cards, with a toggle back to the original.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="baseUrl" label="Interpreter base URL"
|
||||||
|
placeholder="http://interpreter.your-domain/" density="compact"
|
||||||
|
hide-details class="mb-3" :disabled="busy" @change="onSave"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="targetLang" label="Target language" density="compact"
|
||||||
|
hide-details style="max-width: 160px;" class="mb-3" :disabled="busy"
|
||||||
|
@change="onSave"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="d-flex align-center mb-3" style="gap: 8px;">
|
||||||
|
<v-icon size="12" :color="statusColor">mdi-circle</v-icon>
|
||||||
|
<span class="fc-muted text-body-2">{{ statusText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center flex-wrap" style="gap: 10px;">
|
||||||
|
<v-btn
|
||||||
|
size="small" variant="flat" color="accent" rounded="pill"
|
||||||
|
prepend-icon="mdi-translate" :loading="running"
|
||||||
|
:disabled="!enabled || !baseUrl" @click="onRun"
|
||||||
|
>Translate now</v-btn>
|
||||||
|
<span v-if="status" class="fc-muted text-caption">
|
||||||
|
{{ status.untranslated_count }}
|
||||||
|
post{{ status.untranslated_count === 1 ? '' : 's' }} awaiting translation
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="err" type="error" variant="tonal" density="compact"
|
||||||
|
class="mt-3" closable @click:close="err = null"
|
||||||
|
>{{ err }}</v-alert>
|
||||||
|
</MaintenanceTile>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { toast } from '../../utils/toast.js'
|
||||||
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
const store = useImportStore()
|
||||||
|
|
||||||
|
const enabled = ref(false)
|
||||||
|
const baseUrl = ref('')
|
||||||
|
const targetLang = ref('en')
|
||||||
|
const busy = ref(false)
|
||||||
|
const running = ref(false)
|
||||||
|
const status = ref(null)
|
||||||
|
const err = ref(null)
|
||||||
|
|
||||||
|
const statusColor = computed(() => {
|
||||||
|
if (!enabled.value || !baseUrl.value) return 'grey'
|
||||||
|
return status.value?.healthy ? 'success' : 'error'
|
||||||
|
})
|
||||||
|
const statusText = computed(() => {
|
||||||
|
if (!enabled.value) return 'Off'
|
||||||
|
if (!baseUrl.value) return 'No base URL set'
|
||||||
|
if (status.value == null) return 'Checking…'
|
||||||
|
return status.value.healthy ? 'Interpreter reachable' : 'Interpreter unreachable'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
try { status.value = await api.get('/api/settings/translation/status') }
|
||||||
|
catch { status.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await store.loadSettings()
|
||||||
|
const s = store.settings || {}
|
||||||
|
enabled.value = !!s.translation_enabled
|
||||||
|
baseUrl.value = s.interpreter_base_url || ''
|
||||||
|
targetLang.value = s.translation_target_lang || 'en'
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
await loadStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save(patch) {
|
||||||
|
busy.value = true
|
||||||
|
err.value = null
|
||||||
|
try { await store.patchSettings(patch) }
|
||||||
|
catch (e) { err.value = e.message }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggle(val) {
|
||||||
|
await save({ translation_enabled: !!val })
|
||||||
|
toast({ text: val ? 'Translation on' : 'Translation off', type: 'success' })
|
||||||
|
await loadStatus()
|
||||||
|
}
|
||||||
|
async function onSave() {
|
||||||
|
await save({
|
||||||
|
interpreter_base_url: baseUrl.value.trim(),
|
||||||
|
translation_target_lang: (targetLang.value || 'en').trim() || 'en',
|
||||||
|
})
|
||||||
|
await loadStatus()
|
||||||
|
}
|
||||||
|
async function onRun() {
|
||||||
|
running.value = true
|
||||||
|
err.value = null
|
||||||
|
try {
|
||||||
|
await api.post('/api/settings/translation/run')
|
||||||
|
toast({ text: 'Translation sweep started', type: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e.message
|
||||||
|
} finally {
|
||||||
|
running.value = false
|
||||||
|
setTimeout(loadStatus, 1500) // let the sweep make a dent, then refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user