More things
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user