Files
mathew/frontend/app/components/ArticleForm.vue
Aran Roig e36fef7290
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 29s
Calc2
2026-10-02 02:14:29 +02:00

896 lines
34 KiB
Vue

<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. A member's save is not a
write at all — neither a change to a page nor a page the wiki does not have
yet: the API keeps either as a proposal for an admin (models/EditProposal.js
on the backend); only an admin's goes straight onto the page. */
const { authHeaders, isAdmin } = useAuth()
const { show: openSignIn } = useSignInPopup()
const { t } = useI18n()
/* saving without the admin role: every save is a request an admin answers, and
the editor says so rather than pretending to publish */
const memberWrite = computed(() => !isAdmin.value)
/* which request the editor is holding: a change to an existing page, or a new
one — the words it says the member differ, and a new page has nothing to view
until an admin approves it */
const creating = computed(() => props.mode === 'create')
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 the article in hand lives at — its identity with the language of
the version in view in front. The title plays no part: a translation's
address is the article's identity whatever it is titled, and the canonical
version's identity *is* its slug, so guessing from the title would send a
retitle off to a new address (and onto a twin article's, when a name was
taken and the slug carries a -2). Only an article the wiki has not been
given yet can be previewed from its title. */
const slugPreview = computed(() => {
if (isVersioned.value) {
const lang = activeDraft()?.language ?? props.article?.language
return versionHref(identity, lang)
}
if (original) return versionHref(baseSlug, language.value)
return versionHref(slugifyText(title.value) || '…', language.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('')
/* a member's save that the API kept as a proposal: a popup confirms the request
and dismisses the editor with it — the sent draft is not left on screen to be
submitted twice. The next edit of the same thing revises the waiting request
rather than filing a second one, which is what `sentProposalId` carries. */
const sentOpen = ref(false)
/* a filed create answers with the request's id: the same draft saved again
revises that request, even once its title — and so the address it asks for —
has moved on since */
const sentProposalId = ref('')
/* where leaving a sent request leads: the very version that was sent, read on
the reader's side of the wiki — the text an admin has not touched yet.
Nothing to read for a page the wiki has not been given: a create lands on
the translation it joins, or on the search page. */
const sentHref = computed(() => {
if (isVersioned.value) {
const d = activeDraft()
if (!d?.slug && !savedSlug) return ''
return versionHref(identity, d?.language ?? props.article?.language)
}
return savedSlug ? versionHref(savedSlug, props.article?.language) : ''
})
function openSentPopup() {
cheatsheet.value = false // popups don't stack in this app
sentOpen.value = true
}
/* the popup is the editor's last word on a filed save: dismissing it — button,
esc or backdrop — is what leaves, taking the reader back to what was sent */
async function closeSentPopup() {
sentOpen.value = false
const target = sentHref.value || (original ? versionHref(baseSlug, original.language) : '')
await router.push(target || '/')
}
function parseTags(text: string): string[] {
return text
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
}
/* the answer as `$fetch` gives it, status included: a 202 means the API filed
the change as a proposal rather than writing it, and the request it filed
comes back along with it so this draft can be sent to the same one again */
async function request(body: Record<string, unknown>, method: 'POST' | 'PUT', url: string) {
const res = await $fetch.raw<ArticleMeta & { proposal?: { _id?: string } }>(url, {
method,
body,
headers: authHeaders(),
})
return { article: res._data, status: res.status, proposalId: res._data?.proposal?._id ?? '' }
}
async function save() {
errorMsg.value = ''
/* nothing to send while the "request sent" popup is up: this draft already
is with the admins, whatever Ctrl+S might otherwise re-submit */
if (saving.value || sentOpen.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) {
/* nothing to send: leave for the page this version is actually read at
(the identity and the language say where — no guessing from titles) */
await router.push(versionHref(identity, activeLang.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
let proposalSent = false
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 }),
}
/* an existing version answers with the write — or, for a member, with
the 202 of a proposal filed; a brand-new one is always published */
const res = d.slug
? await request(body, 'PUT', `${api}/articles/slug/${encodeURIComponent(d.slug)}`)
: await request(body, 'POST', `${api}/articles`)
if (res.status === 202) {
proposalSent = true
d.saved = cur // the draft is filed — with the admins now, not dirty here
continue
}
const saved = res.article
// a retitle moved the document; the answer carries where it lives now
d.slug = saved!.slug
d.saved = cur
lastSaved = saved
if (d.language === activeLang.value) savedActive = saved
}
/* a member's edits are with the admins now: the popup says so where a
published save would have navigated, and closing it ends the editor */
if (proposalSent) {
openSentPopup()
return
}
/* 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.language) : '/')
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),
/* a create the API already filed: saving the draft again revises that
request, whichever address it asked for when it was first sent */
...(creating.value && sentProposalId.value ? { proposalId: sentProposalId.value } : {}),
}
const url = creating.value
? `${api}/articles`
: `${api}/articles/slug/${encodeURIComponent(savedSlug)}`
const { article: saved, status, proposalId } = await request(
body,
creating.value ? 'POST' : 'PUT',
url
)
if (status === 202) {
markSaved() // the draft is filed — the leave guard stays quiet on the way out
if (proposalId) sentProposalId.value = proposalId
openSentPopup()
return
}
// prevent the leave-guard from firing on our own navigation
markSaved()
await router.push(versionHref(identityOf(saved!), saved!.language))
} 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, back.language) : '/'
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.language)
: '/'
: versionHref(savedSlug, props.article?.language)
)
}
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 (sentOpen.value) {
void closeSentPopup() // dismisses the popup and leaves the editor
return
}
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>{{ creating ? (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="props.mode === 'edit' && savedSlug"
:to="versionHref(identity, activeLang)"
class="btn btn-ghost"
>
{{ t('common.cancel') }}
</NuxtLink>
<button class="btn btn-primary" type="submit" :disabled="saving">
{{ saving
? t('editor.saving')
: memberWrite
? t('editor.submitProposal')
: original
? t('editor.publishTranslation')
: creating
? 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.language)">{{ original.title }}</NuxtLink>
{{ t('editor.translatingTail') }}
</span>
</div>
<!-- a member writes nothing onto the wiki themselves, a page nor a new one:
the editor says so up front, and once a save has filed a request the
popup confirms it — closing that popup is what leaves the editor, so
the button cannot be pressed twice on an already-sent draft -->
<div v-if="memberWrite" class="banner banner-info">
<span class="glyph" aria-hidden="true">✎</span>
<span>{{ creating ? t('editor.proposalNoticeCreate') : t('editor.proposalNotice') }}</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')}*`" :article-lang="isVersioned ? activeLang : language" />
</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"
/>
<ProposalSentModal :open="sentOpen" :creating="creating" @close="closeSentPopup" />
</form>
</template>