More things

This commit is contained in:
2026-10-01 02:29:21 +02:00
parent e1df4d3ae1
commit 13b659662e
54 changed files with 4409 additions and 911 deletions

View File

@@ -16,11 +16,22 @@ 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()
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 : ''
@@ -238,16 +249,20 @@ function slugifyText(text: string): string {
.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 */
/* 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 d = activeDraft()
if (d && d.slug && d.slug !== identity) return `/wiki/${d.language}/${identity}`
return `/wiki/${slugifyText(title.value) || savedSlug}`
const lang = activeDraft()?.language ?? props.article?.language
return versionHref(identity, lang)
}
if (original) return `/wiki/${language.value}/${baseSlug}`
return `/wiki/${slugifyText(title.value) || '…'}`
if (original) return versionHref(baseSlug, language.value)
return versionHref(slugifyText(title.value) || '…', language.value)
})
const savedSlug = props.article?.slug ?? ''
@@ -362,6 +377,41 @@ onBeforeUnmount(() => {
/* 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
@@ -370,13 +420,23 @@ function parseTags(text: string): string[] {
.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) {
return $fetch<ArticleMeta>(url, { method, body, headers: authHeaders() })
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 = ''
if (saving.value) return
/* 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) {
@@ -384,7 +444,9 @@ async function save() {
brand-new ones join the article through its identity */
const pendingDrafts = versionList.value.filter(draftChanged)
if (!pendingDrafts.length) {
await router.push(slugPreview.value)
/* 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())
@@ -395,6 +457,7 @@ async function save() {
}
let savedActive: ArticleMeta | null = null
let lastSaved: ArticleMeta | null = null
let proposalSent = false
for (const d of pendingDrafts) {
const cur = draftCurrent(d)
const body = {
@@ -404,18 +467,33 @@ async function save() {
/* a version written here joins the article it belongs to */
...(d.slug ? {} : { baseSlug: identity, language: d.language }),
}
const saved = d.slug
/* 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`)
d.slug = saved.slug
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) : '/')
await router.push(target ? versionHref(identityOf(target), target.language) : '/')
return
}
@@ -430,15 +508,27 @@ async function save() {
/* 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
}
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))
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
@@ -497,9 +587,7 @@ async function discard() {
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 })
: '/'
const target = back ? versionHref(identity, back.language) : '/'
await router.push(target)
return
}
@@ -509,7 +597,11 @@ async function discard() {
content.value = base.content
errorMsg.value = ''
await router.push(
props.mode === 'create' ? (original ? versionHref(baseSlug, original) : '/') : `/wiki/${savedSlug}`
props.mode === 'create'
? original
? versionHref(baseSlug, original.language)
: '/'
: versionHref(savedSlug, props.article?.language)
)
}
@@ -535,6 +627,10 @@ function onTabKeydown(e: KeyboardEvent) {
// 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
@@ -564,7 +660,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
<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>
<h1>{{ creating ? (original ? t('editor.translateTitle') : t('editor.newTitle')) : t('editor.editTitle') }}</h1>
<div class="editor-head-actions">
<button
ref="discardButtonEl"
@@ -581,8 +677,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
{{ t('editor.discard') }}
</button>
<NuxtLink
v-if="mode === 'edit' && savedSlug"
:to="versionHref(identity, { language: article?.language, slug: savedSlug })"
v-if="props.mode === 'edit' && savedSlug"
:to="versionHref(identity, activeLang)"
class="btn btn-ghost"
>
{{ t('common.cancel') }}
@@ -590,13 +686,15 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
<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') }}
: 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>
@@ -609,11 +707,20 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
</svg>
<span>
{{ t('editor.translating') }}
<NuxtLink :to="versionHref(baseSlug, original)">{{ original.title }}</NuxtLink>
<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">
@@ -782,5 +889,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
@confirm="discard"
@cancel="closeDiscardPrompt"
/>
<ProposalSentModal :open="sentOpen" :creating="creating" @close="closeSentPopup" />
</form>
</template>