First commit
This commit is contained in:
786
frontend/app/components/ArticleForm.vue
Normal file
786
frontend/app/components/ArticleForm.vue
Normal file
@@ -0,0 +1,786 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleMeta } from './ArticleCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'create' | 'edit'
|
||||
article?: ArticleMeta & { content?: string }
|
||||
/* Translating: the version being translated. The form opens with its text
|
||||
and publishes a new language version of the same article. */
|
||||
translateFrom?: ArticleMeta & { content?: string }
|
||||
/* Languages the article being translated already exists in */
|
||||
takenLanguages?: string[]
|
||||
}>()
|
||||
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
/* Writing is the one thing on this page that takes an account: the save carries
|
||||
the session token, and a save the API refuses is the editor's own signal that
|
||||
the session has gone while this draft was open. */
|
||||
const { authHeaders } = useAuth()
|
||||
const { show: openSignIn } = useSignInPopup()
|
||||
const { t } = useI18n()
|
||||
|
||||
const original = props.translateFrom ?? null
|
||||
/* the version group the new document joins, shared by every translation */
|
||||
const baseSlug = original ? original.baseSlug || original.slug : ''
|
||||
|
||||
/* an edit is always an article — every language version of it answers to the
|
||||
same identity, which is what its reader and editor addresses speak */
|
||||
const isVersioned = computed(() => props.mode === 'edit' && !!props.article)
|
||||
const identity = props.article ? props.article.baseSlug || props.article.slug : ''
|
||||
|
||||
/* versions under edit -------------------------------------------------
|
||||
Editing an article happens one language at a time: each version keeps its
|
||||
own draft beside the others, so typing French does not overwrite English —
|
||||
switching turns the page, it does not clear it. */
|
||||
interface VersionDraft {
|
||||
language: string
|
||||
/** saved document's slug; null for a version not written yet */
|
||||
slug: string | null
|
||||
title: string
|
||||
tagsText: string
|
||||
content: string
|
||||
/** the text as it was loaded or last saved — what "dirty" compares against */
|
||||
saved: { title: string; tagsText: string; content: string }
|
||||
/** whether the version's full text has arrived from the API */
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
const drafts = ref<Record<string, VersionDraft>>({})
|
||||
|
||||
function seedDraft(language: string, slug: string | null, doc?: ArticleMeta & { content?: string }): VersionDraft {
|
||||
return {
|
||||
language,
|
||||
slug,
|
||||
title: doc?.title ?? '',
|
||||
tagsText: (doc?.tags ?? []).join(', '),
|
||||
content: doc?.content ?? '',
|
||||
saved: {
|
||||
title: doc?.title ?? '',
|
||||
tagsText: (doc?.tags ?? []).join(', '),
|
||||
content: doc?.content ?? '',
|
||||
},
|
||||
loaded: !!doc,
|
||||
}
|
||||
}
|
||||
|
||||
if (isVersioned.value && props.article) {
|
||||
const doc = props.article
|
||||
const list = doc.versions?.length
|
||||
? doc.versions
|
||||
: [{ language: doc.language ?? 'en', slug: doc.slug, title: doc.title }]
|
||||
for (const v of list) {
|
||||
/* the document already in hand needs no fetch; the rest arrive on demand
|
||||
with only their titles known up front — enough to fill the field while
|
||||
the text comes over */
|
||||
const draft = seedDraft(v.language, v.slug, v.slug === doc.slug ? doc : undefined)
|
||||
if (!draft.loaded && v.title) draft.title = v.title
|
||||
drafts.value[v.language] = draft
|
||||
}
|
||||
}
|
||||
|
||||
/* the language this editor is showing — its address is ?lang= on the route */
|
||||
const initialLang = String(route.query.lang ?? '').trim().toLowerCase()
|
||||
const activeLang = ref(
|
||||
drafts.value[initialLang]?.language ?? props.article?.language ?? 'en'
|
||||
)
|
||||
|
||||
/* one draft is in view and edited through these; the rest wait in `drafts` */
|
||||
const title = ref(activeDraft()?.title ?? original?.title ?? '')
|
||||
const tagsText = ref(activeDraft()?.tagsText ?? (original?.tags ?? []).join(', '))
|
||||
const content = ref(activeDraft()?.content ?? original?.content ?? '')
|
||||
/* the language a fresh (non-versioned) article is written in — the reader's
|
||||
language menu asks for one on the route (/new?translate=x&lang=de): start
|
||||
there, unless the article is already written in it */
|
||||
const requestedLang = String(route.query.lang ?? '').trim().toLowerCase()
|
||||
const preselected =
|
||||
LANGUAGES.some((l) => l.code === requestedLang) &&
|
||||
!(props.takenLanguages ?? []).includes(requestedLang)
|
||||
? requestedLang
|
||||
: ''
|
||||
const language = ref(
|
||||
props.article?.language?.trim().toLowerCase() ||
|
||||
preselected ||
|
||||
nextFreeLanguage(props.takenLanguages ?? [])
|
||||
)
|
||||
|
||||
function activeDraft(): VersionDraft | undefined {
|
||||
return drafts.value[activeLang.value]
|
||||
}
|
||||
|
||||
const versionList = computed(() => Object.values(drafts.value))
|
||||
const freeLanguages = computed(() => LANGUAGES.filter((l) => !drafts.value[l.code]))
|
||||
|
||||
function draftCurrent(d: VersionDraft) {
|
||||
return d.language === activeLang.value
|
||||
? { title: title.value, tagsText: tagsText.value, content: content.value }
|
||||
: { title: d.title, tagsText: d.tagsText, content: d.content }
|
||||
}
|
||||
|
||||
function draftChanged(d: VersionDraft) {
|
||||
if (!d.loaded) return false
|
||||
return JSON.stringify(draftCurrent(d)) !== JSON.stringify(d.saved)
|
||||
}
|
||||
|
||||
const dirtyCount = computed(
|
||||
() => (isVersioned.value ? versionList.value.filter(draftChanged).length : singleDirty.value ? 1 : 0)
|
||||
)
|
||||
|
||||
/* fetch a version's text the first time the editor turns to it */
|
||||
async function hydrate(d: VersionDraft) {
|
||||
if (!d.slug) return
|
||||
/* remember what the fields held on arrival: if anything was typed while the
|
||||
fetch was on its way, the typing wins over what comes back */
|
||||
const untouched = { title: title.value, tagsText: tagsText.value, content: content.value }
|
||||
try {
|
||||
const doc = await $fetch<ArticleMeta & { content?: string }>(
|
||||
`${api}/articles/slug/${encodeURIComponent(d.slug)}`
|
||||
)
|
||||
d.title = doc.title ?? ''
|
||||
d.tagsText = (doc.tags ?? []).join(', ')
|
||||
d.content = doc.content ?? ''
|
||||
d.saved = { title: d.title, tagsText: d.tagsText, content: d.content }
|
||||
d.loaded = true
|
||||
/* if the editor still rests on this version, and nothing was typed into
|
||||
it since, show what arrived */
|
||||
if (
|
||||
d.language === activeLang.value &&
|
||||
JSON.stringify(untouched) ===
|
||||
JSON.stringify({ title: title.value, tagsText: tagsText.value, content: content.value })
|
||||
) {
|
||||
title.value = d.title
|
||||
tagsText.value = d.tagsText
|
||||
content.value = d.content
|
||||
}
|
||||
} catch {
|
||||
/* an unreadable version stays a blank page; saving will say why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
function putDraftFields(d: VersionDraft | undefined) {
|
||||
if (!d) return
|
||||
title.value = d.title
|
||||
tagsText.value = d.tagsText
|
||||
content.value = d.content
|
||||
}
|
||||
|
||||
function stashActive() {
|
||||
const d = activeDraft()
|
||||
if (!d || !d.loaded) return
|
||||
d.title = title.value
|
||||
d.tagsText = tagsText.value
|
||||
d.content = content.value
|
||||
}
|
||||
|
||||
async function switchLang(lang: string) {
|
||||
if (lang === activeLang.value) return
|
||||
stashActive()
|
||||
activeLang.value = lang
|
||||
const d = drafts.value[lang]
|
||||
putDraftFields(d)
|
||||
if (d && !d.loaded) await hydrate(d)
|
||||
void router.replace({ query: { ...route.query, lang } })
|
||||
}
|
||||
|
||||
/* the language dropdown beside the chips -------------------------------- */
|
||||
const addOpen = ref(false)
|
||||
const addWrapEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (addOpen.value && !addWrapEl.value?.contains(e.target as Node)) addOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
||||
|
||||
function addLanguage(code: string) {
|
||||
addOpen.value = false
|
||||
stashActive()
|
||||
/* a fresh version starts empty under the hood and carries the article's
|
||||
tags across — they describe the topic, not the language */
|
||||
drafts.value[code] = seedDraft(code, null, {
|
||||
_id: '',
|
||||
slug: '',
|
||||
title: '',
|
||||
tags: tagsText.value
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
content: '',
|
||||
} as ArticleMeta)
|
||||
activeLang.value = code
|
||||
title.value = ''
|
||||
tagsText.value = drafts.value[code]!.tagsText
|
||||
content.value = ''
|
||||
void router.replace({ query: { ...route.query, lang: code } })
|
||||
}
|
||||
|
||||
/* the version in view is fetched on demand, even on first arrival (?lang=fr) */
|
||||
onMounted(() => {
|
||||
const d = activeDraft()
|
||||
if (isVersioned.value && d && !d.loaded) void hydrate(d)
|
||||
})
|
||||
|
||||
const languages = computed(() =>
|
||||
LANGUAGES.map((l) => ({
|
||||
...l,
|
||||
taken: !!original && (props.takenLanguages ?? []).includes(l.code),
|
||||
}))
|
||||
)
|
||||
|
||||
function slugifyText(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
/* the address being edited or published: versions live at /wiki/<lang>/<slug>,
|
||||
the canonical article at /wiki/<slug> — where a canonical retitle moves it */
|
||||
const slugPreview = computed(() => {
|
||||
if (isVersioned.value) {
|
||||
const d = activeDraft()
|
||||
if (d && d.slug && d.slug !== identity) return `/wiki/${d.language}/${identity}`
|
||||
return `/wiki/${slugifyText(title.value) || savedSlug}`
|
||||
}
|
||||
if (original) return `/wiki/${language.value}/${baseSlug}`
|
||||
return `/wiki/${slugifyText(title.value) || '…'}`
|
||||
})
|
||||
|
||||
const savedSlug = props.article?.slug ?? ''
|
||||
|
||||
/* dirty tracking -------------------------------------------------
|
||||
A versioned edit is dirty when any version's text differs from what it was
|
||||
loaded or saved as; the plain create form compares against what it opened
|
||||
with. */
|
||||
interface FormState {
|
||||
title: string
|
||||
tagsText: string
|
||||
content: string
|
||||
}
|
||||
|
||||
function currentState(): FormState {
|
||||
return { title: title.value, tagsText: tagsText.value, content: content.value }
|
||||
}
|
||||
|
||||
const savedState = ref<FormState>(currentState())
|
||||
const singleDirty = computed(
|
||||
() => !isVersioned.value && JSON.stringify(currentState()) !== JSON.stringify(savedState.value)
|
||||
)
|
||||
const dirty = computed(() =>
|
||||
isVersioned.value ? versionList.value.some(draftChanged) : singleDirty.value
|
||||
)
|
||||
|
||||
function markSaved() {
|
||||
savedState.value = currentState()
|
||||
for (const d of versionList.value) if (draftChanged(d)) d.saved = draftCurrent(d)
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (!dirty.value) return true
|
||||
return window.confirm(t('editor.leaveGuard'))
|
||||
})
|
||||
|
||||
/* preview ---------------------------------------------------------- */
|
||||
/* The textarea's seed stays partly in code: the LaTeX snippet cannot live
|
||||
verbatim in a message catalog (`{` would read as a parameter), so only the
|
||||
words are translated and the maths rides along here. */
|
||||
const SEED_MATH = '$$ \\zeta(s) = \\sum_{n=1}^{\\infty} \\frac{1}{n^s} $$'
|
||||
const contentPlaceholder = computed(
|
||||
() => `# ${t('editor.seedHeading')}\n\n${t('editor.seedTerms')}\n\n${SEED_MATH}`
|
||||
)
|
||||
|
||||
type Mode = 'write' | 'split' | 'preview'
|
||||
const mode = ref<Mode>('split')
|
||||
const previewContent = computed(() => content.value)
|
||||
|
||||
/* fullscreen -------------------------------------------------------- */
|
||||
const fullscreen = ref(false)
|
||||
const editorBodyEl = ref<HTMLElement | null>(null)
|
||||
let fullscreenAnim: Animation | null = null
|
||||
|
||||
/* FLIP the toggle: measure where the editor currently sits, flip the
|
||||
class (static box -> fixed full-screen box), then animate the old
|
||||
rect into the new one so the editor grows into the screen edges —
|
||||
or shrinks back to its place — instead of snapping. */
|
||||
async function toggleFullscreen() {
|
||||
const el = editorBodyEl.value
|
||||
if (!el) return
|
||||
|
||||
const first = el.getBoundingClientRect() // current visual rect, even mid-animation
|
||||
fullscreenAnim?.cancel()
|
||||
|
||||
fullscreen.value = !fullscreen.value
|
||||
await nextTick()
|
||||
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
const last = el.getBoundingClientRect()
|
||||
|
||||
const entering = fullscreen.value
|
||||
fullscreenAnim = el.animate(
|
||||
[
|
||||
{
|
||||
transformOrigin: 'top left',
|
||||
transform: `translate(${first.left - last.left}px, ${first.top - last.top}px) scale(${first.width / last.width}, ${first.height / last.height})`,
|
||||
borderRadius: entering ? '15px' : '0px',
|
||||
},
|
||||
{ transformOrigin: 'top left', transform: 'none', borderRadius: entering ? '0px' : '15px' },
|
||||
],
|
||||
{ duration: 300, easing: 'cubic-bezier(0.32, 0.72, 0, 1)' }
|
||||
)
|
||||
fullscreenAnim.onfinish = () => {
|
||||
fullscreenAnim = null
|
||||
}
|
||||
}
|
||||
|
||||
/* cheatsheet --------------------------------------------------------- */
|
||||
/* Popups don't stack in this app: SearchModal and SettingsModal hide
|
||||
each other on open. Search, settings and the profile can also be
|
||||
opened from anywhere (header, shortcuts) while the editor shows, so
|
||||
the cheatsheet steps aside whenever one of them opens. */
|
||||
const { open: searchOpen } = useSearch()
|
||||
const { open: settingsOpen } = useSettings()
|
||||
const { open: profileOpen } = useProfileMenu()
|
||||
|
||||
const cheatsheet = ref(false)
|
||||
|
||||
watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => {
|
||||
if (search || settings || profile) cheatsheet.value = false
|
||||
})
|
||||
|
||||
// keep the page behind from scrolling while the editor fills the screen
|
||||
watch(fullscreen, (on) => {
|
||||
document.body.style.overflow = on ? 'hidden' : ''
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
/* submit ----------------------------------------------------------- */
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
function parseTags(text: string): string[] {
|
||||
return text
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function request(body: Record<string, unknown>, method: 'POST' | 'PUT', url: string) {
|
||||
return $fetch<ArticleMeta>(url, { method, body, headers: authHeaders() })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
errorMsg.value = ''
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (isVersioned.value) {
|
||||
/* every language version edited since the last save goes up together;
|
||||
brand-new ones join the article through its identity */
|
||||
const pendingDrafts = versionList.value.filter(draftChanged)
|
||||
if (!pendingDrafts.length) {
|
||||
await router.push(slugPreview.value)
|
||||
return
|
||||
}
|
||||
const missing = pendingDrafts.find((d) => !draftCurrent(d).title.trim())
|
||||
if (missing) {
|
||||
errorMsg.value = t('editor.versionNeedsTitle', { name: languageName(missing.language) })
|
||||
void switchLang(missing.language)
|
||||
return
|
||||
}
|
||||
let savedActive: ArticleMeta | null = null
|
||||
let lastSaved: ArticleMeta | null = null
|
||||
for (const d of pendingDrafts) {
|
||||
const cur = draftCurrent(d)
|
||||
const body = {
|
||||
title: cur.title.trim(),
|
||||
content: cur.content,
|
||||
tags: parseTags(cur.tagsText),
|
||||
/* a version written here joins the article it belongs to */
|
||||
...(d.slug ? {} : { baseSlug: identity, language: d.language }),
|
||||
}
|
||||
const saved = d.slug
|
||||
? await request(body, 'PUT', `${api}/articles/slug/${encodeURIComponent(d.slug)}`)
|
||||
: await request(body, 'POST', `${api}/articles`)
|
||||
d.slug = saved.slug
|
||||
d.saved = cur
|
||||
lastSaved = saved
|
||||
if (d.language === activeLang.value) savedActive = saved
|
||||
}
|
||||
/* leave on the page that was in view — the answer itself carries where
|
||||
it lives now, so a canonical retitle follows it instead of stranding */
|
||||
const target = savedActive ?? lastSaved
|
||||
await router.push(target ? versionHref(identityOf(target), target) : '/')
|
||||
return
|
||||
}
|
||||
|
||||
if (!title.value.trim()) {
|
||||
errorMsg.value = t('editor.titleRequired')
|
||||
return
|
||||
}
|
||||
const body = {
|
||||
title: title.value.trim(),
|
||||
content: content.value,
|
||||
language: language.value,
|
||||
/* a translation joins the article it translates rather than standing alone */
|
||||
...(original ? { baseSlug } : {}),
|
||||
tags: parseTags(tagsText.value),
|
||||
}
|
||||
const url =
|
||||
props.mode === 'create'
|
||||
? `${api}/articles`
|
||||
: `${api}/articles/slug/${encodeURIComponent(savedSlug)}`
|
||||
const saved = await request(body, props.mode === 'create' ? 'POST' : 'PUT', url)
|
||||
// prevent the leave-guard from firing on our own navigation
|
||||
markSaved()
|
||||
await router.push(versionHref(identityOf(saved), saved))
|
||||
} catch (err: any) {
|
||||
/* A session the API won't take any more. Signing out here would unmount this
|
||||
very form — and the draft in it — so instead the sign-in popup opens over
|
||||
the editor: whatever is written stays put, and signing back in replaces the
|
||||
dead token so the next save goes through. */
|
||||
if (err?.status === 401 || err?.statusCode === 401) {
|
||||
errorMsg.value = ''
|
||||
openSignIn({
|
||||
notice: err?.data?.message || t('editor.sessionExpired'),
|
||||
})
|
||||
return
|
||||
}
|
||||
errorMsg.value = err?.data?.message || t('editor.saveFailed')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* discard ---------------------------------------------------------- */
|
||||
/* The destructive step lives behind the same kind of popup as settings and
|
||||
the cheatsheet, not a browser alert. */
|
||||
const discardOpen = ref(false)
|
||||
const discardButtonEl = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const discardMessage = computed(() =>
|
||||
props.mode === 'create'
|
||||
? t('editor.discardNew')
|
||||
: dirtyCount.value > 1
|
||||
? t('editor.discardVersions', { n: dirtyCount.value })
|
||||
: t('editor.discardOne', {
|
||||
title: activeDraft()?.saved.title || savedState.value.title || t('editor.thisArticle'),
|
||||
})
|
||||
)
|
||||
|
||||
function askDiscard() {
|
||||
cheatsheet.value = false // popups don't stack in this app
|
||||
discardOpen.value = true
|
||||
}
|
||||
|
||||
function closeDiscardPrompt() {
|
||||
discardOpen.value = false
|
||||
discardButtonEl.value?.focus() // the popup it replaces was removed from the page
|
||||
}
|
||||
|
||||
/* Throw away everything typed since the last save and leave the editor — in
|
||||
every version, and versions added since the last save come off entirely.
|
||||
The baseline is what the form opened with, restoring which keeps our own
|
||||
leave-guard quiet; the way out leads back to the article or the search page. */
|
||||
async function discard() {
|
||||
discardOpen.value = false
|
||||
if (isVersioned.value) {
|
||||
for (const [lang, d] of Object.entries(drafts.value)) {
|
||||
if (!d.slug) delete drafts.value[lang] // a version never saved: gone whole
|
||||
else putDraftFieldsBack(d) // the rest return to their saved text
|
||||
}
|
||||
const back = activeDraft() ?? props.article
|
||||
if (activeDraft()) putDraftFields(activeDraft()) // the fields follow the version in view
|
||||
errorMsg.value = ''
|
||||
const target = back
|
||||
? versionHref(identity, { language: back.language ?? 'en', slug: back.slug })
|
||||
: '/'
|
||||
await router.push(target)
|
||||
return
|
||||
}
|
||||
const base = savedState.value
|
||||
title.value = base.title
|
||||
tagsText.value = base.tagsText
|
||||
content.value = base.content
|
||||
errorMsg.value = ''
|
||||
await router.push(
|
||||
props.mode === 'create' ? (original ? versionHref(baseSlug, original) : '/') : `/wiki/${savedSlug}`
|
||||
)
|
||||
}
|
||||
|
||||
function putDraftFieldsBack(d: VersionDraft) {
|
||||
// reset a draft's working copy to its saved text (the fields follow if active)
|
||||
d.title = d.saved.title
|
||||
d.tagsText = d.saved.tagsText
|
||||
d.content = d.saved.content
|
||||
}
|
||||
|
||||
/* Tab inserts spaces in the textarea ------------------------------- */
|
||||
function onTabKeydown(e: KeyboardEvent) {
|
||||
const el = e.target as HTMLTextAreaElement
|
||||
if (e.key !== 'Tab') return
|
||||
e.preventDefault()
|
||||
const { selectionStart: s, selectionEnd: end } = el
|
||||
content.value = content.value.slice(0, s) + ' ' + content.value.slice(end)
|
||||
nextTick(() => {
|
||||
el.selectionStart = el.selectionEnd = s + 2
|
||||
})
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+S saves; Esc closes the topmost popup first, then leaves fullscreen
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (addOpen.value) {
|
||||
addOpen.value = false
|
||||
return
|
||||
}
|
||||
if (discardOpen.value) {
|
||||
closeDiscardPrompt()
|
||||
return
|
||||
}
|
||||
if (cheatsheet.value) {
|
||||
cheatsheet.value = false
|
||||
return
|
||||
}
|
||||
if (fullscreen.value) {
|
||||
void toggleFullscreen()
|
||||
return
|
||||
}
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
if (!saving.value) save()
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="editor-page" @submit.prevent="save">
|
||||
<div class="editor-head">
|
||||
<h1>{{ mode === 'create' ? (original ? t('editor.translateTitle') : t('editor.newTitle')) : t('editor.editTitle') }}</h1>
|
||||
<div class="editor-head-actions">
|
||||
<button
|
||||
ref="discardButtonEl"
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
:disabled="!dirty"
|
||||
:title="t('editor.discardHint')"
|
||||
@click="askDiscard"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
{{ t('editor.discard') }}
|
||||
</button>
|
||||
<NuxtLink
|
||||
v-if="mode === 'edit' && savedSlug"
|
||||
:to="versionHref(identity, { language: article?.language, slug: savedSlug })"
|
||||
class="btn btn-ghost"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</NuxtLink>
|
||||
<button class="btn btn-primary" type="submit" :disabled="saving">
|
||||
{{ saving
|
||||
? t('editor.saving')
|
||||
: original
|
||||
? t('editor.publishTranslation')
|
||||
: mode === 'create'
|
||||
? t('editor.publishArticle')
|
||||
: dirtyCount > 1
|
||||
? t('editor.saveVersions', { n: dirtyCount })
|
||||
: t('editor.saveChanges') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="original" class="translate-banner">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
<span>
|
||||
{{ t('editor.translating') }}
|
||||
<NuxtLink :to="versionHref(baseSlug, original)">{{ original.title }}</NuxtLink>
|
||||
{{ t('editor.translatingTail') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- the article's language versions: turn the editor to another language,
|
||||
or add one — unsaved text waits on its own version's page -->
|
||||
<div v-if="isVersioned" class="version-bar">
|
||||
<span class="version-bar-label">{{ t('editor.languages') }}</span>
|
||||
<button
|
||||
v-for="d in versionList"
|
||||
:key="d.language"
|
||||
type="button"
|
||||
class="lang-chip"
|
||||
:class="{ active: d.language === activeLang }"
|
||||
:lang="d.language"
|
||||
:aria-pressed="d.language === activeLang"
|
||||
:title="t('editor.editVersion', { name: languageName(d.language) })"
|
||||
@click="switchLang(d.language)"
|
||||
>
|
||||
{{ languageName(d.language) }}
|
||||
<span v-if="draftChanged(d)" class="dirty-dot" :title="t('editor.unsavedChanges')" />
|
||||
</button>
|
||||
<div v-if="freeLanguages.length" ref="addWrapEl" class="version-add">
|
||||
<button
|
||||
type="button"
|
||||
class="lang-chip version-add-btn"
|
||||
:aria-expanded="String(addOpen)"
|
||||
:title="t('editor.addLanguageHint')"
|
||||
@click="addOpen = !addOpen"
|
||||
>
|
||||
{{ t('editor.addLanguage') }}
|
||||
</button>
|
||||
<div v-if="addOpen" class="version-dropdown" role="listbox">
|
||||
<button
|
||||
v-for="l in freeLanguages"
|
||||
:key="l.code"
|
||||
type="button"
|
||||
class="topic-option"
|
||||
role="option"
|
||||
:lang="l.code"
|
||||
@click="addLanguage(l.code)"
|
||||
>
|
||||
<span>{{ l.name }}</span>
|
||||
<span class="topic-count">{{ l.code.toUpperCase() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="article-title">{{ t('editor.titleLabel') }}</label>
|
||||
<input
|
||||
id="article-title"
|
||||
v-model="title"
|
||||
type="text"
|
||||
class="title-input"
|
||||
:placeholder="t('editor.titlePlaceholder')"
|
||||
autofocus
|
||||
/>
|
||||
<div v-if="title.trim()" class="slug-preview">
|
||||
{{ t('editor.urlLabel') }} <code>{{ slugPreview }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="article-tags">
|
||||
{{ t('editor.tagsLabel') }} <span class="hint">{{ t('editor.tagsHint') }}</span>
|
||||
</label>
|
||||
<input id="article-tags" v-model="tagsText" type="text" :placeholder="t('editor.tagsPlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isVersioned" class="field">
|
||||
<label for="article-language">
|
||||
{{ t('editor.languageLabel') }} <span class="hint">{{ t('editor.languageHint') }}</span>
|
||||
</label>
|
||||
<select id="article-language" v-model="language">
|
||||
<option
|
||||
v-for="l in languages"
|
||||
:key="l.code"
|
||||
:value="l.code"
|
||||
:lang="l.code"
|
||||
:disabled="l.taken"
|
||||
:title="l.taken ? t('editor.languageTaken') : undefined"
|
||||
>
|
||||
{{ l.name }}{{ l.taken ? ` ${t('editor.alreadyWritten')}` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="article-content" :lang="isVersioned ? activeLang : undefined">
|
||||
{{ t('editor.contentLabel') }} <span class="hint">{{ t('editor.contentHint') }}</span>
|
||||
</label>
|
||||
<div ref="editorBodyEl" class="editor-body" :class="{ 'is-fullscreen': fullscreen }">
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" class="mode-btn" :class="{ active: mode === 'write' }" @click="mode = 'write'">{{ t('editor.modeWrite') }}</button>
|
||||
<button type="button" class="mode-btn" :class="{ active: mode === 'split' }" @click="mode = 'split'">{{ t('editor.modeSplit') }}</button>
|
||||
<button type="button" class="mode-btn" :class="{ active: mode === 'preview' }" @click="mode = 'preview'">{{ t('editor.modePreview') }}</button>
|
||||
<div class="toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="mode-btn toolbar-icon-btn"
|
||||
:class="{ active: cheatsheet }"
|
||||
:title="t('editor.cheatsheetHint')"
|
||||
:aria-label="t('editor.cheatsheet')"
|
||||
:aria-pressed="cheatsheet"
|
||||
@click="cheatsheet = !cheatsheet"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mode-btn toolbar-icon-btn"
|
||||
:title="fullscreen ? t('editor.exitFullscreenHint') : t('editor.fullscreen')"
|
||||
:aria-label="fullscreen ? t('editor.exitFullscreen') : t('editor.enterFullscreen')"
|
||||
:aria-pressed="fullscreen"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg v-if="!fullscreen" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-grid" :class="`mode-${mode}`">
|
||||
<div class="write-pane">
|
||||
<textarea
|
||||
id="article-content"
|
||||
v-model="content"
|
||||
class="md-input"
|
||||
:lang="isVersioned ? activeLang : undefined"
|
||||
:placeholder="contentPlaceholder"
|
||||
spellcheck="false"
|
||||
@keydown="onTabKeydown"
|
||||
/>
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<div class="preview-label">{{ t('editor.livePreview') }}</div>
|
||||
<MarkdownView :content="previewContent || `*${t('editor.noPreview')}*`" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="form-error">{{ errorMsg }}</p>
|
||||
|
||||
<EditorCheatsheet :open="cheatsheet" @close="cheatsheet = false" />
|
||||
|
||||
<ConfirmModal
|
||||
:open="discardOpen"
|
||||
:title="t('editor.discardConfirm')"
|
||||
:message="discardMessage"
|
||||
:confirm-label="t('editor.discard')"
|
||||
:cancel-label="t('editor.keepEditing')"
|
||||
@confirm="discard"
|
||||
@cancel="closeDiscardPrompt"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
Reference in New Issue
Block a user