More things
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
const { collapsed, toggleSidebar, restoreSidebar } = useSidebar()
|
||||
const { show: openSearch } = useSearch()
|
||||
const { show: openSettings } = useSettings()
|
||||
const { tabs, lastSlug } = useArticleTabs()
|
||||
const { tabs, lastIdentity } = useArticleTabs()
|
||||
const { user, isAdmin, signOut } = useAuth()
|
||||
const { pending: waitingRequests, refresh: refreshPending } = usePendingRequests()
|
||||
const { show: openSignIn } = useSignInPopup()
|
||||
const { show: openProfile } = useProfileMenu()
|
||||
const { t } = useI18n()
|
||||
@@ -15,21 +16,43 @@ const router = useRouter()
|
||||
lays their pages side by side, older ones parked with only their spines
|
||||
showing. The stack stays open all session, but the view of it is only drawn
|
||||
in the reader — so this button is the way back: press it wherever you are
|
||||
(the graph, the list, the editor) and every article you have open comes back
|
||||
into view, stacked as you left it. */
|
||||
const inReader = computed(() => wikiSlugFromPath(route.path) !== null)
|
||||
(the graph, the list, the editor, a missing article) and every article you
|
||||
have open comes back into view, stacked as you left it. In view only when the
|
||||
route names one of the open pages: a missing article reads like the reader
|
||||
but holds none of them, so the button still turns back from there. */
|
||||
const inReader = computed(() => tabAt(tabs.value, route.path) !== null)
|
||||
|
||||
function showOpenArticles() {
|
||||
if (inReader.value) return // the pages are already spread out in view
|
||||
/* turn back to the page that was last in view — every tab knows its own
|
||||
address, language versions included */
|
||||
const target =
|
||||
tabs.value.find((t) => t.slug === lastSlug.value) ?? tabs.value[tabs.value.length - 1]
|
||||
tabs.value.find((t) => t.identity === lastIdentity.value) ?? tabs.value[tabs.value.length - 1]
|
||||
if (target) void router.push(tabHref(target))
|
||||
}
|
||||
|
||||
/* the shortcut hint beside the search bar — "/" keeps focus on the home
|
||||
page's inline box there, so the popup's universal key is Ctrl/⌘+K */
|
||||
/* How many requests are waiting is an admin's business: ask once the session
|
||||
is known to be one — and when a session stops being one, the door goes blank
|
||||
with the links — and again on every navigation, so the number on the door is
|
||||
no older than the page under it. While an admin decides at the desk, the
|
||||
desk itself keeps the count exact (see usePendingRequests). */
|
||||
watch(
|
||||
isAdmin,
|
||||
() => {
|
||||
if (import.meta.client) void refreshPending()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
if (import.meta.client && isAdmin.value) void refreshPending()
|
||||
}
|
||||
)
|
||||
|
||||
/* the shortcut hint beside the search bar — refined on the client to the
|
||||
device's chord ("/" opens the popup too, just not while a field holds
|
||||
the caret) */
|
||||
const kbdHint = ref('/')
|
||||
onMounted(() => {
|
||||
kbdHint.value = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl K'
|
||||
@@ -100,6 +123,7 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
|
||||
<NuxtLink
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
end
|
||||
class="nav-link"
|
||||
:title="t('sidebar.adminHint')"
|
||||
>
|
||||
@@ -109,6 +133,20 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.admin') }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="isAdmin"
|
||||
to="/admin/proposals"
|
||||
class="nav-link sidebar-requests"
|
||||
:title="waitingRequests ? t('sidebar.requestsWaiting', waitingRequests) : t('sidebar.requestsHint')"
|
||||
>
|
||||
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path d="m9 15 2 2 4-4" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.requests') }}</span>
|
||||
<span v-if="waitingRequests" class="requests-count">{{ waitingRequests }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/new" class="btn btn-primary btn-sm" :title="t('sidebar.newArticle')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
|
||||
@@ -24,6 +24,9 @@ const emit = defineEmits<{
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
/* deleting is an admin's call: a member's changes to an article go through
|
||||
proposals, and dropping the page outright is not among what they may do */
|
||||
const { isAdmin } = useAuth()
|
||||
const { open, hide } = useArticleActionsMenu()
|
||||
const { open: langMenuOpen } = useLanguageMenu()
|
||||
const { open: searchOpen } = useSearch()
|
||||
@@ -106,22 +109,24 @@ watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) =>
|
||||
:editor-slug="editorSlug"
|
||||
/>
|
||||
|
||||
<div class="kebab-sep" />
|
||||
<template v-if="isAdmin">
|
||||
<div class="kebab-sep" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="kebab-item kebab-danger"
|
||||
role="menuitem"
|
||||
@click="hide(); emit('remove')"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="kebab-item kebab-danger"
|
||||
role="menuitem"
|
||||
@click="hide(); emit('remove')"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
A page turned to slides over the older ones, which park along the left
|
||||
edge with only their spines showing; click anywhere on a page — spine or
|
||||
body — to turn back to it, or × to close it. Routes stay the source of
|
||||
truth — every page is a plain /wiki/<slug> URL — and useArticleTabs keeps
|
||||
the stack of pages.
|
||||
truth — every page is a plain /wiki/<lang>/<slug> URL — and useArticleTabs
|
||||
keeps the stack of pages, one page per article however many languages the
|
||||
article speaks: turning one to another language turns this page over, it
|
||||
does not add one to the row.
|
||||
|
||||
Pages further along the book than the one in view wait off the right edge
|
||||
of the desk, where you would have to scroll sideways to reach them. Their
|
||||
@@ -31,7 +33,7 @@ const { open: actionsMenuOpen } = useArticleActionsMenu()
|
||||
const { collapsed } = useSidebar()
|
||||
|
||||
const activeTab = computed(() => tabAt(tabs.value, route.path))
|
||||
const activeSlug = computed(() => activeTab.value?.slug ?? null)
|
||||
const activeIdentity = computed(() => activeTab.value?.identity ?? null)
|
||||
const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0)
|
||||
|
||||
const trackEl = ref<HTMLElement | null>(null)
|
||||
@@ -44,8 +46,8 @@ function liesFlat() {
|
||||
/** Which page of the book is the one in view, or -1 when the route names
|
||||
* none of them. Only the pages beyond it can be waiting for you. */
|
||||
const openIndex = computed(() => {
|
||||
const slug = activeSlug.value
|
||||
return slug === null ? -1 : tabs.value.findIndex((t) => t.slug === slug)
|
||||
const identity = activeIdentity.value
|
||||
return identity === null ? -1 : tabs.value.findIndex((t) => t.identity === identity)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -57,10 +59,10 @@ const openIndex = computed(() => {
|
||||
async function alignToActive(behavior: ScrollBehavior = 'smooth') {
|
||||
await nextTick()
|
||||
const track = trackEl.value
|
||||
const slug = activeSlug.value
|
||||
if (!track || !slug || !liesFlat()) return
|
||||
const identity = activeIdentity.value
|
||||
if (!track || !identity || !liesFlat()) return
|
||||
|
||||
const page = track.querySelector<HTMLElement>(`[data-slug="${CSS.escape(slug)}"]`)
|
||||
const page = track.querySelector<HTMLElement>(`[data-tab="${CSS.escape(identity)}"]`)
|
||||
const i = Number(page?.dataset.index)
|
||||
if (!page || !Number.isFinite(i)) return
|
||||
|
||||
@@ -77,10 +79,10 @@ async function alignToActive(behavior: ScrollBehavior = 'smooth') {
|
||||
off the row, not guessed at: slide the book and a spine leaves the stack as
|
||||
its page comes into view, and joins it again as the page slides out. */
|
||||
|
||||
/** The slugs of the pages whose spines are in the dock. */
|
||||
const waitingSlugs = ref<string[]>([])
|
||||
/** The identities of the pages whose spines are in the dock. */
|
||||
const waitingPages = ref<string[]>([])
|
||||
/** Those pages, in the order they were opened. */
|
||||
const waiting = computed(() => tabs.value.filter((t) => waitingSlugs.value.includes(t.slug)))
|
||||
const waiting = computed(() => tabs.value.filter((t) => waitingPages.value.includes(t.identity)))
|
||||
|
||||
/**
|
||||
* Fill the dock with the pages beyond the one in view whose spines have no
|
||||
@@ -91,22 +93,24 @@ function measureDock() {
|
||||
const track = trackEl.value
|
||||
const open = openIndex.value
|
||||
if (!track || open === -1 || !liesFlat()) {
|
||||
waitingSlugs.value = []
|
||||
waitingPages.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const edge = track.getBoundingClientRect().right
|
||||
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
|
||||
const held = new Set(waitingSlugs.value)
|
||||
const held = new Set(waitingPages.value)
|
||||
for (const page of Array.from(track.querySelectorAll<HTMLElement>('.book-pane'))) {
|
||||
const i = Number(page.dataset.index)
|
||||
const slug = page.dataset.slug
|
||||
if (!slug || !Number.isFinite(i) || i <= open) continue
|
||||
const identity = page.dataset.tab
|
||||
if (!identity || !Number.isFinite(i) || i <= open) continue
|
||||
const left = page.getBoundingClientRect().left
|
||||
if (left > edge - 6) held.add(slug)
|
||||
else if (left < edge - spine - 10) held.delete(slug)
|
||||
if (left > edge - 6) held.add(identity)
|
||||
else if (left < edge - spine - 10) held.delete(identity)
|
||||
}
|
||||
waitingSlugs.value = tabs.value.filter((t, i) => i > open && held.has(t.slug)).map((t) => t.slug)
|
||||
waitingPages.value = tabs.value
|
||||
.filter((t, i) => i > open && held.has(t.identity))
|
||||
.map((t) => t.identity)
|
||||
}
|
||||
|
||||
/* the row moves on scroll, so the dock is measured once a frame while it does */
|
||||
@@ -121,7 +125,7 @@ function watchDock() {
|
||||
onBeforeUnmount(() => cancelAnimationFrame(measuring))
|
||||
|
||||
/* turning to an article slides the book round to it */
|
||||
watch(activeSlug, () => void alignToActive('smooth'))
|
||||
watch(activeIdentity, () => void alignToActive('smooth'))
|
||||
/* and the row changing shape — however it changed — changes which pages are
|
||||
waiting out beyond the edge of the desk */
|
||||
watch(tabs, () => void nextTick(measureDock))
|
||||
@@ -162,18 +166,21 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function turnTo(slug: string) {
|
||||
const tab = tabs.value.find((t) => t.slug === slug)
|
||||
if (tab && slug !== activeSlug.value) void router.push(tabHref(tab))
|
||||
function turnTo(identity: string) {
|
||||
const tab = tabs.value.find((t) => t.identity === identity)
|
||||
if (tab && identity !== activeIdentity.value) void router.push(tabHref(tab))
|
||||
}
|
||||
|
||||
async function removeArticle(slug: string) {
|
||||
const title = tabs.value.find((t) => t.slug === slug)?.title ?? slug
|
||||
if (!window.confirm(t('book.deleteConfirm', { title }))) return
|
||||
/* the page's × deletes the very version it holds — the document its address
|
||||
asks for, which for a translation is its own — and closes the page */
|
||||
async function removeArticle(identity: string) {
|
||||
const tab = tabs.value.find((t) => t.identity === identity)
|
||||
if (!tab) return
|
||||
if (!window.confirm(t('book.deleteConfirm', { title: tab.title }))) return
|
||||
try {
|
||||
await $fetch(`${api}/articles/slug/${encodeURIComponent(slug)}`, { method: 'DELETE' })
|
||||
forget(slug)
|
||||
closeTab(slug)
|
||||
await $fetch(`${api}/articles/slug/${encodeURIComponent(tab.slug)}`, { method: 'DELETE' })
|
||||
forget(tab.slug)
|
||||
closeTab(identity)
|
||||
} catch {
|
||||
window.alert(t('book.deleteFailed'))
|
||||
}
|
||||
@@ -185,7 +192,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return
|
||||
if (searchOpen.value || settingsOpen.value || profileOpen.value || langMenuOpen.value || actionsMenuOpen.value)
|
||||
return
|
||||
const current = tabAt(tabs.value, route.path)?.slug
|
||||
const current = tabAt(tabs.value, route.path)?.identity
|
||||
if (current) closeTab(current)
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
||||
@@ -202,18 +209,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
>
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.slug"
|
||||
:key="tab.identity"
|
||||
class="book-chip"
|
||||
:class="{ active: tab.slug === activeSlug }"
|
||||
:class="{ active: tab.identity === activeIdentity }"
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
:aria-selected="tab.slug === activeSlug"
|
||||
:aria-selected="tab.identity === activeIdentity"
|
||||
:title="t('book.chipHint', { title: tab.title })"
|
||||
@click="turnTo(tab.slug)"
|
||||
@keydown.enter.prevent="turnTo(tab.slug)"
|
||||
@click="turnTo(tab.identity)"
|
||||
@keydown.enter.prevent="turnTo(tab.identity)"
|
||||
>
|
||||
<span class="chip-title">{{ tab.title }}</span>
|
||||
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
|
||||
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
@@ -224,28 +231,29 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
|
||||
<ArticlePane
|
||||
v-for="(tab, i) in tabs"
|
||||
:key="tab.slug"
|
||||
:key="tab.identity"
|
||||
:identity="tab.identity"
|
||||
:slug="tab.slug"
|
||||
:index="i"
|
||||
:active="tab.slug === activeSlug"
|
||||
@activate="turnTo(tab.slug)"
|
||||
@close="closeTab(tab.slug)"
|
||||
@remove="removeArticle(tab.slug)"
|
||||
:active="tab.identity === activeIdentity"
|
||||
@activate="turnTo(tab.identity)"
|
||||
@close="closeTab(tab.identity)"
|
||||
@remove="removeArticle(tab.identity)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
|
||||
<div
|
||||
v-for="tab in waiting"
|
||||
:key="tab.slug"
|
||||
:key="tab.identity"
|
||||
class="dock-spine"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="t('book.dockHint', { title: tab.title })"
|
||||
@click="turnTo(tab.slug)"
|
||||
@keydown.enter.prevent="turnTo(tab.slug)"
|
||||
@click="turnTo(tab.identity)"
|
||||
@keydown.enter.prevent="turnTo(tab.identity)"
|
||||
>
|
||||
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
|
||||
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
|
||||
@@ -35,9 +35,7 @@ const multiLang = computed(() => langs.value.length > 1)
|
||||
const langCodes = computed(() => langs.value.map((v) => v.language.toUpperCase()).join(' · '))
|
||||
const langNames = computed(() => langs.value.map((v) => languageName(v.language)).join(', '))
|
||||
/* where the card leads: the language address of the version it shows */
|
||||
const href = computed(() =>
|
||||
versionHref(identityOf(props.article), { language: language.value, slug: props.article.slug })
|
||||
)
|
||||
const href = computed(() => versionHref(identityOf(props.article), language.value))
|
||||
|
||||
const updated = computed(() => {
|
||||
const d = props.article.updatedAt ? new Date(props.article.updatedAt) : null
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -91,7 +91,7 @@ watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) =>
|
||||
<NuxtLink
|
||||
v-for="v in existing"
|
||||
:key="v.slug"
|
||||
:to="versionHref(identity, v)"
|
||||
:to="versionHref(identity, v.language)"
|
||||
class="lang-menu-item"
|
||||
:lang="v.language"
|
||||
role="menuitem"
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
/* One page of the open book. Its left edge is a spine — the title, set
|
||||
vertically, with the × that closes the page — and the article itself
|
||||
scrolls on its own inside the page. A newer page slides over this one and
|
||||
leaves only the spine showing, the way a stack of read pages does; click
|
||||
anywhere on the page — or its spine — to turn back to it.
|
||||
/* One page of the open book: one article, in whichever language it is turned
|
||||
to. Its left edge is a spine — the title, set vertically, with the × that
|
||||
closes the page — and the article itself scrolls on its own inside the page.
|
||||
A newer page slides over this one and leaves only the spine showing, the way
|
||||
a stack of read pages does; click anywhere on the page — or its spine — to
|
||||
turn back to it. Turning the article to another language changes the text on
|
||||
this page and nothing else: it is the same page of the book, read again.
|
||||
|
||||
Everything but the spine of a page that isn't in view is `inert`: it can
|
||||
still be seen while you slide the stack sideways, but it doesn't take
|
||||
focus or clicks — the pane's own click handler catches those instead, so
|
||||
one click turns to the page before its links do anything. */
|
||||
const props = defineProps<{
|
||||
/** the article this page holds — its canonical slug */
|
||||
identity: string
|
||||
/** the version in view, as the library files it */
|
||||
slug: string
|
||||
index: number
|
||||
active: boolean
|
||||
@@ -32,12 +37,20 @@ const language = computed(() => articleLanguage(article.value ?? {}))
|
||||
const languageLabel = computed(() => languageName(language.value))
|
||||
/* every language this article exists in, the one in view included */
|
||||
const versions = computed(() => article.value?.versions ?? [])
|
||||
/* the article behind this document, and the slug its editor answers to:
|
||||
the identity when the canonical version is still around, this document's
|
||||
own slug when only translations of it remain */
|
||||
const identity = computed(() => identityOf(article.value ?? { slug: props.slug }))
|
||||
/* the slug the editor answers to: the identity when the canonical version is
|
||||
still around, this document's own slug when only translations of it remain */
|
||||
const editorSlug = computed(() =>
|
||||
versions.value.some((v) => v.slug === identity.value) ? identity.value : props.slug
|
||||
versions.value.some((v) => v.slug === props.identity) ? props.identity : props.slug
|
||||
)
|
||||
|
||||
/* turned to another language, the page begins again at the top of the text
|
||||
it now holds — the article is the same, its words are not */
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
watch(
|
||||
() => props.slug,
|
||||
(to, from) => {
|
||||
if (from) scrollEl.value?.scrollTo({ top: 0 })
|
||||
}
|
||||
)
|
||||
|
||||
const updated = computed(() => {
|
||||
@@ -61,7 +74,7 @@ function topicLabel(tag: string) {
|
||||
<section
|
||||
class="book-pane"
|
||||
:class="{ active }"
|
||||
:data-slug="slug"
|
||||
:data-tab="identity"
|
||||
:data-index="index"
|
||||
:style="{ '--i': index }"
|
||||
:aria-label="t('pane.aria', { title })"
|
||||
@@ -91,7 +104,7 @@ function topicLabel(tag: string) {
|
||||
</div>
|
||||
|
||||
<div class="pane-body" :inert="active ? null : ''">
|
||||
<div class="pane-scroll">
|
||||
<div ref="scrollEl" class="pane-scroll">
|
||||
<div v-if="failed && !article" class="empty">
|
||||
<div class="glyph">!</div>
|
||||
<h3>{{ t('pane.readFailTitle') }}</h3>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
/* Centered profile popup, opened by the account row in the sidebar (and closed
|
||||
with esc, a backdrop click, or navigation — the same shape as the settings
|
||||
popup). It holds the account itself: the profile picture, the username and
|
||||
the password, and the way out. */
|
||||
popup). It holds the account itself: the profile picture, the username, the
|
||||
password, the interface language the account reads the wiki in, and the
|
||||
way out. */
|
||||
const { open, hide } = useProfileMenu()
|
||||
const { hide: hideSearch } = useSearch()
|
||||
const { hide: hideSettings } = useSettings()
|
||||
const { hide: hideSignIn, show: openSignIn } = useSignInPopup()
|
||||
const { user, signOut, updateUsername, changePassword, setAvatar } = useAuth()
|
||||
const { t } = useI18n()
|
||||
const { user, signOut, updateUsername, changePassword, setAvatar, updateLocale } = useAuth()
|
||||
const { t, locale } = useI18n()
|
||||
const { choose } = useAppLocale()
|
||||
const route = useRoute()
|
||||
|
||||
const panelEl = ref<HTMLElement | null>(null)
|
||||
@@ -166,6 +168,24 @@ function leave() {
|
||||
hide()
|
||||
}
|
||||
|
||||
/* --- the interface language ---
|
||||
Turn the language Mathew speaks in. The device's own choice (the cookie
|
||||
setLocale keeps) lands at once, and the account saves it too, so the next
|
||||
device says the same hello. An account that won't take it — a session gone
|
||||
quiet — doesn't undo the switch made here. */
|
||||
const language = ref('en')
|
||||
|
||||
async function setLanguage(code: string) {
|
||||
if (language.value === code) return
|
||||
language.value = code
|
||||
await choose(code)
|
||||
try {
|
||||
await updateLocale(code)
|
||||
} catch {
|
||||
/* the device-level switch stands on its own */
|
||||
}
|
||||
}
|
||||
|
||||
/* sync the form from the account whenever the popup opens */
|
||||
watch(
|
||||
open,
|
||||
@@ -178,6 +198,7 @@ watch(
|
||||
username.value = user.value?.username ?? ''
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
language.value = locale.value
|
||||
avatarBusy.value = false
|
||||
avatarError.value = ''
|
||||
nameError.value = ''
|
||||
@@ -340,6 +361,30 @@ watch(
|
||||
<p v-else-if="passwordSaved" class="profile-saved">{{ t('profile.passwordChanged') }}</p>
|
||||
</section>
|
||||
|
||||
<!-- the interface language -->
|
||||
<section class="profile-group" aria-labelledby="profile-language">
|
||||
<div class="profile-group-head">
|
||||
<span id="profile-language" class="profile-group-title">{{ t('profile.language') }}</span>
|
||||
<span class="profile-group-hint">{{ t('profile.languageHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="theme-switch" role="radiogroup" :aria-label="t('profile.languageAria')">
|
||||
<button
|
||||
v-for="l in APP_LANGUAGES"
|
||||
:key="l.code"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="theme-option"
|
||||
:class="{ active: language === l.code }"
|
||||
:aria-checked="language === l.code"
|
||||
:lang="l.code"
|
||||
@click="setLanguage(l.code)"
|
||||
>
|
||||
<span>{{ l.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="profile-group">
|
||||
<button type="button" class="btn btn-danger btn-sm profile-signout" @click="leave()">
|
||||
<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">
|
||||
|
||||
225
frontend/app/components/ProposalRow.vue
Normal file
225
frontend/app/components/ProposalRow.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
/* One request on the dashboard's list: who wants what, which parts of the page
|
||||
it touches, and — while it waits — the buttons that answer it. Opening the
|
||||
details lays the change out as a pair of pages: how the article read when the
|
||||
request was sent, and how it would read with the change applied. A request
|
||||
for a page the wiki does not have yet is laid out the same way against the
|
||||
address it asks for, which is empty until somebody approves it. */
|
||||
|
||||
export interface ProposalArticle {
|
||||
id: string
|
||||
slug: string
|
||||
identity: string
|
||||
title: string
|
||||
language: string
|
||||
content: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface AdminProposal {
|
||||
id: string
|
||||
/** a change to a page, or a request for one that does not exist yet */
|
||||
kind: 'edit' | 'create'
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
createdAt: string
|
||||
decidedAt: string | null
|
||||
/** what a creation asks the page to live at, and which one it joins */
|
||||
slug: string | null
|
||||
baseSlug: string | null
|
||||
proposed: { title: string; content: string; tags: string[]; language: string }
|
||||
base: { title: string; content: string; tags: string[] }
|
||||
proposer: { username: string; avatar: string }
|
||||
decidedByUsername: string | null
|
||||
article: ProposalArticle | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
proposal: AdminProposal
|
||||
expanded: boolean
|
||||
/** another row's decision is in flight: the buttons wait */
|
||||
busy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: []
|
||||
decide: [action: 'approve' | 'reject']
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
/* "12 Mar 2026", in the interface language and read in UTC — the server and
|
||||
the browser may run on different clocks, and the first render has to say
|
||||
the same day on both. */
|
||||
function onDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
timeZone: 'UTC',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (props.proposal.status === 'approved') return t('admin.statusApproved')
|
||||
if (props.proposal.status === 'rejected') return t('admin.statusRejected')
|
||||
return t('admin.statusPending')
|
||||
})
|
||||
|
||||
/* asking for a page the wiki does not have yet, rather than changing one */
|
||||
const creating = computed(() => props.proposal.kind === 'create')
|
||||
|
||||
/* who is said to want what, in the tense the request's answer leaves behind */
|
||||
const summaryLead = computed(() => {
|
||||
const waiting = props.proposal.status === 'pending'
|
||||
if (creating.value) return t(waiting ? 'admin.wantsToCreate' : 'admin.wantedToCreate')
|
||||
return t(waiting ? 'admin.wantsToChange' : 'admin.wantedToChange')
|
||||
})
|
||||
|
||||
const articleHref = computed(() => {
|
||||
const a = props.proposal.article
|
||||
return a ? versionHref(a.identity, a.language) : ''
|
||||
})
|
||||
|
||||
/* where a creation would live: the address it asks for, in the shape a reader
|
||||
follows — the language it is written in, over the article it names (a fresh
|
||||
one names itself) */
|
||||
const requestedPath = computed(() => {
|
||||
const p = props.proposal
|
||||
if (!p.slug) return ''
|
||||
return versionHref(p.baseSlug || p.slug, p.proposed.language)
|
||||
})
|
||||
|
||||
/* which parts of the page the request touches — enough to judge the row
|
||||
without opening it; the text of the change waits in the details */
|
||||
const changedFields = computed(() => {
|
||||
const p = props.proposal
|
||||
if (creating.value) return t('admin.newArticle')
|
||||
const fields: string[] = []
|
||||
if (p.base.title !== p.proposed.title) fields.push(t('admin.fieldTitle'))
|
||||
if (p.base.content !== p.proposed.content) fields.push(t('admin.fieldContent'))
|
||||
if (p.base.tags.join('\u0000') !== p.proposed.tags.join('\u0000')) fields.push(t('admin.fieldTags'))
|
||||
return fields.join(' · ') || t('admin.fieldNone')
|
||||
})
|
||||
|
||||
/* the article moved after the request was sent: applying the change would
|
||||
overwrite whatever was written since, so the row warns about it. A request
|
||||
for a new page cannot be overtaken — and once it is granted, the article it
|
||||
brought about agrees with it by definition. */
|
||||
const stale = computed(() => {
|
||||
const p = props.proposal
|
||||
if (creating.value || !p.article) return false
|
||||
return p.article.title !== p.base.title || p.article.content !== p.base.content
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="proposal-row">
|
||||
<span class="account-avatar" aria-hidden="true">
|
||||
<img v-if="proposal.proposer.avatar" :src="proposal.proposer.avatar" alt="" />
|
||||
<template v-else>{{ proposal.proposer.username.charAt(0).toUpperCase() }}</template>
|
||||
</span>
|
||||
|
||||
<span class="proposal-summary">
|
||||
<strong class="proposal-proposer">{{ proposal.proposer.username }}</strong>
|
||||
{{ summaryLead }}
|
||||
<!-- a granted creation has an article to point at; a waiting one has the
|
||||
title it asked for and no page to walk to yet -->
|
||||
<NuxtLink v-if="proposal.article" :to="articleHref" class="proposal-article-link">
|
||||
{{ proposal.article.title }}
|
||||
</NuxtLink>
|
||||
<strong v-else-if="creating" class="proposal-new">{{ proposal.proposed.title }}</strong>
|
||||
<em v-else>{{ t('admin.articleGone') }}</em>
|
||||
<span v-if="creating" class="proposal-kind">{{ t('admin.newArticle') }}</span>
|
||||
</span>
|
||||
|
||||
<span class="proposal-status" :class="`status-${proposal.status}`">{{ statusLabel }}</span>
|
||||
|
||||
<span class="proposal-date">{{ onDate(proposal.createdAt) }}</span>
|
||||
|
||||
<span v-if="proposal.status !== 'pending'" class="proposal-decided">
|
||||
{{
|
||||
t('admin.decidedBy', {
|
||||
action: proposal.status === 'approved' ? t('admin.actionApproved') : t('admin.actionRejected'),
|
||||
username: proposal.decidedByUsername ?? '—',
|
||||
date: onDate(proposal.decidedAt),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
|
||||
<span class="proposal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm proposal-details-btn"
|
||||
:aria-expanded="String(expanded)"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
{{ expanded ? t('admin.hideDetails') : t('admin.details') }}
|
||||
</button>
|
||||
<template v-if="proposal.status === 'pending'">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm"
|
||||
:disabled="busy"
|
||||
@click="emit('decide', 'reject')"
|
||||
>
|
||||
{{ t('admin.reject') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-approve btn-sm"
|
||||
:disabled="busy"
|
||||
@click="emit('decide', 'approve')"
|
||||
>
|
||||
{{ t('admin.approve') }}
|
||||
</button>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded" class="proposal-diff">
|
||||
<div class="proposal-diff-col">
|
||||
<h4 class="diff-heading is-before">
|
||||
{{ creating ? t('admin.asNewPage') : t('admin.asSent') }}
|
||||
</h4>
|
||||
<!-- a creation is asked for an address the wiki has nothing on: the
|
||||
reviewer reads where the page would go instead of a page that is
|
||||
not there -->
|
||||
<template v-if="creating">
|
||||
<p class="diff-title"><code>{{ requestedPath }}</code></p>
|
||||
<div class="diff-body">
|
||||
<p class="diff-absent">{{ t('admin.nothingHereYet') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="diff-title"><del>{{ proposal.base.title }}</del></p>
|
||||
<p v-if="proposal.base.tags.length" class="diff-tags">
|
||||
<span v-for="tag in proposal.base.tags" :key="tag" class="diff-tag">{{ tag }}</span>
|
||||
</p>
|
||||
<div class="diff-body">
|
||||
<MarkdownView
|
||||
:content="proposal.base.content || '*…*'"
|
||||
:namespace="`proposal-${proposal.id}-base`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="proposal-diff-col">
|
||||
<h4 class="diff-heading is-after">{{ t('admin.asProposed') }}</h4>
|
||||
<p class="diff-title" :lang="proposal.proposed.language">
|
||||
<strong>{{ proposal.proposed.title }}</strong>
|
||||
</p>
|
||||
<p v-if="proposal.proposed.tags.length" class="diff-tags">
|
||||
<span v-for="tag in proposal.proposed.tags" :key="tag" class="diff-tag">{{ tag }}</span>
|
||||
</p>
|
||||
<div class="diff-body">
|
||||
<MarkdownView
|
||||
:content="proposal.proposed.content || '*…*'"
|
||||
:namespace="`proposal-${proposal.id}-proposed`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
62
frontend/app/components/ProposalSentModal.vue
Normal file
62
frontend/app/components/ProposalSentModal.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/* The member's "request sent" confirmation, in ConfirmModal's shape: teleported
|
||||
overlay, dismissed by the backdrop or its one button (Esc is left to the
|
||||
caller, which orders its popups). It says the save went to the admins as a
|
||||
proposal — and dismissing it is what walks out of the editor, so a sent
|
||||
draft never sits there waiting to be submitted a second time. */
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/* a request for a page the wiki does not have yet says different words */
|
||||
creating?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/* the wording lives here rather than in withDefaults static defaults so it
|
||||
follows the interface language as it changes */
|
||||
const titleText = computed(() => t('editor.proposalSentTitle'))
|
||||
const messageText = computed(() =>
|
||||
props.creating ? t('editor.proposalSentCreate') : t('editor.proposalSent')
|
||||
)
|
||||
const doneText = computed(() => t('common.done'))
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const doneButtonEl = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
/* land on the one button when the popup opens, so Enter always means
|
||||
"seen it" — the way out of the editor */
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
await nextTick()
|
||||
doneButtonEl.value?.focus()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="confirm-popup">
|
||||
<div v-if="open" class="confirm-overlay" @click.self="emit('close')">
|
||||
<div class="confirm-modal" role="dialog" aria-modal="true" :aria-label="titleText">
|
||||
<div class="sent-check" aria-hidden="true">✓</div>
|
||||
<h2 class="confirm-title">{{ titleText }}</h2>
|
||||
<p class="confirm-message">{{ messageText }}</p>
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
ref="doneButtonEl"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ doneText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -19,7 +19,7 @@ const others = computed(() =>
|
||||
<div class="rv-title">{{ t('recent.title') }}</div>
|
||||
<ul>
|
||||
<li v-for="v in others" :key="v.slug">
|
||||
<NuxtLink :to="`/wiki/${v.slug}`">{{ v.title }}</NuxtLink>
|
||||
<NuxtLink :to="viewedHref(v)">{{ v.title }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleMeta } from './ArticleCard.vue'
|
||||
|
||||
/* Centered search popup: the sidebar search bar (or Ctrl/⌘+K, or "/"
|
||||
on pages without their own search box) opens it, then it live-searches
|
||||
/* Centered search popup: the sidebar and home search bars (or Ctrl/⌘+K,
|
||||
or "/" anywhere but a text field) open it, then it live-searches
|
||||
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes. */
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { th } = useAppLocale()
|
||||
const { th, locale } = useAppLocale()
|
||||
const { open, show, hide, toggle } = useSearch()
|
||||
const { hide: hideSettings } = useSettings()
|
||||
const { hide: hideProfile } = useProfileMenu()
|
||||
@@ -59,11 +59,16 @@ watch(trimmed, () => {
|
||||
async function runSearch() {
|
||||
const seq = ++requestSeq
|
||||
try {
|
||||
const res = await $fetch<ArticleMeta[]>(`${api}/articles`, {
|
||||
query: { q: trimmed.value },
|
||||
/* the popup never shows more than MAX_RESULTS, so that is what it asks
|
||||
the shelf for rather than every match it has; `prefer` asks each answer
|
||||
to stand with the reader's own language when the article speaks it, and
|
||||
`preferFirst` ranks the articles that truly speak it above the ones
|
||||
shown only through the fallback */
|
||||
const page = await $fetch<{ articles: ArticleMeta[] }>(`${api}/articles`, {
|
||||
query: { q: trimmed.value, limit: MAX_RESULTS, prefer: locale.value, preferFirst: '1' },
|
||||
})
|
||||
if (seq !== requestSeq) return // a newer search already won
|
||||
results.value = res.slice(0, MAX_RESULTS)
|
||||
results.value = page.articles
|
||||
failed.value = false
|
||||
} catch {
|
||||
if (seq !== requestSeq) return
|
||||
@@ -86,9 +91,8 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
/* "/" also opens the popup — except on the home page, where the key
|
||||
focuses the hero's inline search, and while typing in a field */
|
||||
if (e.key === '/' && !open.value && route.path !== '/') {
|
||||
/* "/" also opens the popup anywhere — except while typing in a field */
|
||||
if (e.key === '/' && !open.value) {
|
||||
const t = e.target
|
||||
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return
|
||||
e.preventDefault()
|
||||
@@ -105,7 +109,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* where a result leads: the language address of the version it shows */
|
||||
function articleHref(a: ArticleMeta): string {
|
||||
return versionHref(identityOf(a), { language: articleLanguage(a), slug: a.slug })
|
||||
return versionHref(identityOf(a), articleLanguage(a))
|
||||
}
|
||||
|
||||
function openArticle(article: ArticleMeta) {
|
||||
@@ -250,7 +254,7 @@ function highlight(text: string): Part[] {
|
||||
<div class="search-quick-title">{{ t('recent.title') }}</div>
|
||||
<ul class="search-results">
|
||||
<li v-for="v in viewed.slice(0, 6)" :key="v.slug">
|
||||
<NuxtLink :to="`/wiki/${v.slug}`" class="search-result">
|
||||
<NuxtLink :to="viewedHref(v)" class="search-result">
|
||||
<span class="sr-title">{{ v.title }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
/* Centered settings popup, opened by the sidebar cog (and closed with esc,
|
||||
a backdrop click, or navigation). It holds the preferences — the light /
|
||||
dark theme switch, the accent colour, and the interface language — which
|
||||
drive the data-theme and data-accent attributes that the pre-paint script
|
||||
in nuxt.config.ts restores from localStorage. The language lives in the
|
||||
mathew-locale cookie and, with an account behind the session, on the
|
||||
account itself, so it follows the person across devices. */
|
||||
dark theme switch and the accent colour — which drive the data-theme and
|
||||
data-accent attributes that the pre-paint script in nuxt.config.ts
|
||||
restores from localStorage. The interface language lives in the profile
|
||||
popup instead, since it follows the person across devices. */
|
||||
const { open, hide } = useSettings()
|
||||
const { hide: hideSearch } = useSearch()
|
||||
const { hide: hideSignIn } = useSignInPopup()
|
||||
const { hide: hideProfile } = useProfileMenu()
|
||||
const { loggedIn, updateLocale } = useAuth()
|
||||
const { t, locale } = useI18n()
|
||||
const { choose } = useAppLocale()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const theme = ref<'light' | 'dark'>('light')
|
||||
const accent = ref('violet')
|
||||
const language = ref('en')
|
||||
const panelEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const themes = [
|
||||
@@ -38,7 +34,7 @@ const accents = [
|
||||
{ value: 'slate', labelKey: 'accentSlate', from: '#334155', to: '#64748b' },
|
||||
] as const
|
||||
|
||||
/* read the live theme, accent + language whenever the popup opens so the
|
||||
/* read the live theme + accent whenever the popup opens so the
|
||||
switches are in sync */
|
||||
watch(
|
||||
open,
|
||||
@@ -51,7 +47,6 @@ watch(
|
||||
theme.value =
|
||||
(document.documentElement.getAttribute('data-theme') as 'light' | 'dark') || 'light'
|
||||
accent.value = document.documentElement.getAttribute('data-accent') || 'violet'
|
||||
language.value = locale.value
|
||||
await nextTick()
|
||||
panelEl.value?.focus()
|
||||
},
|
||||
@@ -79,23 +74,6 @@ function setAccent(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Turn the interface language. The device's own choice (the cookie setLocale
|
||||
keeps) lands at once; with an account it is saved there too, so the next
|
||||
device says the same hello. An account that won't take it — a session gone
|
||||
quiet — doesn't undo the switch made here. */
|
||||
async function setLanguage(code: string) {
|
||||
if (language.value === code) return
|
||||
language.value = code
|
||||
await choose(code)
|
||||
if (loggedIn.value) {
|
||||
try {
|
||||
await updateLocale(code)
|
||||
} catch {
|
||||
/* the device-level switch stands on its own */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --- esc closes, navigation closes --- */
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open.value) hide()
|
||||
@@ -198,29 +176,6 @@ watch(
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-group" aria-labelledby="settings-language">
|
||||
<div class="settings-group-head">
|
||||
<span id="settings-language" class="settings-group-title">{{ t('settings.language') }}</span>
|
||||
<span class="settings-group-hint">{{ t('settings.languageHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="theme-switch" role="radiogroup" :aria-label="t('settings.languageAria')">
|
||||
<button
|
||||
v-for="l in APP_LANGUAGES"
|
||||
:key="l.code"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="theme-option"
|
||||
:class="{ active: language === l.code }"
|
||||
:aria-checked="language === l.code"
|
||||
:lang="l.code"
|
||||
@click="setLanguage(l.code)"
|
||||
>
|
||||
<span>{{ l.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user