More things
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
/* The wiki's back office: every account it holds, in one list, together with
|
||||
what each of them may do. The sidebar offers the door to an admin alone;
|
||||
whoever else reaches this address is turned away before anything is asked —
|
||||
a member sees the same refusal whether or not the list would have answered. */
|
||||
a member sees the same refusal whether or not the list would have answered.
|
||||
The changes members propose are kept on their own page beside this one. */
|
||||
|
||||
import type { AuthRole } from '~/composables/useAuth'
|
||||
|
||||
@@ -182,9 +183,10 @@ useSeoMeta({
|
||||
<template v-else>
|
||||
<section class="hero">
|
||||
<h1 v-html="t('admin.hero')"></h1>
|
||||
<p class="sub">{{ t('admin.sub') }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Every account the wiki holds and what each may do. The requests
|
||||
members send live on their own page, reached from the sidebar. -->
|
||||
<section class="section">
|
||||
<div v-if="failure" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
231
frontend/app/pages/admin/proposals.vue
Normal file
231
frontend/app/pages/admin/proposals.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
/* The changes members want, kept on their own page: every request that waits
|
||||
for whoever holds the role to apply it or refuse it, and the record of the
|
||||
ones already answered. A member's save does not write — it arrives here.
|
||||
The sidebar offers the door to an admin alone; whoever else reaches this
|
||||
address is turned away before anything is asked — a member sees the same
|
||||
refusal whether or not the pile would have answered. */
|
||||
|
||||
import type { AdminProposal } from '~/components/ProposalRow.vue'
|
||||
|
||||
/* matches the home page's transition so the two surfaces slide past
|
||||
each other the way the reader does (see main.scss) */
|
||||
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
||||
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const { loggedIn, isAdmin, authHeaders, signOut } = useAuth()
|
||||
const { report: reportPending } = usePendingRequests()
|
||||
const { show: openSignIn } = useSignInPopup()
|
||||
const { t } = useI18n()
|
||||
|
||||
/* What the last request could not do, in the words to show for it. '' when
|
||||
there is nothing to report: a dead session is signed out of instead of
|
||||
explained, the same way the account page handles one. Shared state rather
|
||||
than a ref, so the first draw in the browser agrees with the one the server
|
||||
already wrote out. */
|
||||
const failure = useState('requests:failure', () => '')
|
||||
|
||||
function describeFailure(err: any): string {
|
||||
const code = err?.statusCode ?? err?.response?.status
|
||||
if (code === 401) {
|
||||
signOut()
|
||||
if (import.meta.client) openSignIn({ notice: t('admin.sessionExpired') })
|
||||
return ''
|
||||
}
|
||||
return err?.data?.message || t('common.apiUnreachable')
|
||||
}
|
||||
|
||||
/* --- edit requests ------------------------------------------------------
|
||||
A member's save does not write: it waits here for whoever holds the role to
|
||||
apply it or refuse it. Decided requests stay as the record of the call. Only
|
||||
an admin's session is answered here, so nothing is asked until the session is
|
||||
known to be one — signing in over this page brings the pile with it, which is
|
||||
what the watch below is for. */
|
||||
const {
|
||||
data: proposals,
|
||||
status: proposalsStatus,
|
||||
refresh: refreshProposals,
|
||||
} = await useAsyncData(
|
||||
'admin-proposals',
|
||||
async () => {
|
||||
failure.value = ''
|
||||
if (!isAdmin.value) return []
|
||||
try {
|
||||
const { proposals: list } = await $fetch<{ proposals: AdminProposal[] }>(
|
||||
`${api}/admin/proposals`,
|
||||
{ headers: authHeaders() }
|
||||
)
|
||||
/* The desk holds the whole pile, so it lays the waiting count on the
|
||||
sidebar's door too — approving or refusing moves it in step, without
|
||||
the API being asked twice. */
|
||||
reportPending(list.filter((p) => p.status === 'pending').length)
|
||||
return list
|
||||
} catch (err: any) {
|
||||
failure.value = describeFailure(err)
|
||||
return []
|
||||
}
|
||||
},
|
||||
{ default: () => [] }
|
||||
)
|
||||
|
||||
watch(isAdmin, (admin) => {
|
||||
if (admin) void refreshProposals()
|
||||
})
|
||||
|
||||
const proposalsLoading = computed(() => proposalsStatus.value === 'pending')
|
||||
const pendingProposals = computed(() =>
|
||||
proposals.value.filter((p) => p.status === 'pending')
|
||||
)
|
||||
const decidedProposals = computed(() =>
|
||||
proposals.value.filter((p) => p.status !== 'pending')
|
||||
)
|
||||
|
||||
/* deciding, one request at a time — while a request is in flight the buttons
|
||||
wait, so two clicks cannot decide the same request twice */
|
||||
const decidingId = ref('')
|
||||
const proposalError = ref('')
|
||||
const expandedProposal = ref('')
|
||||
const historyOpen = ref(false)
|
||||
|
||||
async function decideProposal(p: AdminProposal, action: 'approve' | 'reject') {
|
||||
if (decidingId.value) return
|
||||
proposalError.value = ''
|
||||
decidingId.value = p.id
|
||||
try {
|
||||
await $fetch(`${api}/admin/proposals/${p.id}/${action}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
})
|
||||
await refreshProposals()
|
||||
} catch (err: any) {
|
||||
proposalError.value =
|
||||
describeFailure(err) ||
|
||||
t(action === 'approve' ? 'admin.approveFailed' : 'admin.rejectFailed')
|
||||
} finally {
|
||||
decidingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('requests.metaTitle'),
|
||||
description: () => t('requests.metaDescription'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-main">
|
||||
<!-- Nobody but an admin is given the pile: a visitor without an account is
|
||||
offered the way in, a signed-in member is told the room is not theirs. -->
|
||||
<AuthGate
|
||||
v-if="!loggedIn"
|
||||
:heading="t('requests.gateHeading')"
|
||||
:text="t('requests.gateText')"
|
||||
/>
|
||||
|
||||
<section v-else-if="!isAdmin" class="auth-gate" aria-labelledby="requests-no-access">
|
||||
<span class="auth-gate-mark" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4.5 8-10V5.5L12 2 4 5.5V12c0 5.5 8 10 8 10z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
</span>
|
||||
<h1 id="requests-no-access" class="auth-gate-title">{{ t('admin.forAdmins') }}</h1>
|
||||
<p class="auth-gate-text">
|
||||
{{ t('requests.forAdminsText') }}
|
||||
</p>
|
||||
<div class="auth-gate-actions">
|
||||
<NuxtLink to="/articles" class="btn btn-ghost">{{ t('admin.backToArticles') }}</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<section class="hero">
|
||||
<h1 v-html="t('requests.hero')"></h1>
|
||||
</section>
|
||||
|
||||
<!-- what members want changed: the waiting pile first, then the history
|
||||
of what was answered. Details open the change as a pair of pages —
|
||||
how it read when sent, how it would read if applied. -->
|
||||
<section class="section">
|
||||
<div v-if="failure" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span>{{ failure }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="results-bar">
|
||||
<h2 id="proposals-heading">
|
||||
<template v-if="proposalsLoading">{{ t('common.loading') }}</template>
|
||||
<template v-else>{{ t('admin.proposalsHeading') }}</template>
|
||||
</h2>
|
||||
<span v-if="!proposalsLoading && pendingProposals.length" class="topic-tally">
|
||||
{{ t('admin.proposalWaitingTally', pendingProposals.length) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="proposalError" class="form-error">{{ proposalError }}</p>
|
||||
|
||||
<div v-if="proposalsLoading" class="proposal-list">
|
||||
<div v-for="i in 2" :key="i" class="skeleton proposal-skeleton" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!proposals.length" class="empty">
|
||||
<div class="glyph">✎</div>
|
||||
<h3>{{ t('admin.proposalsEmptyTitle') }}</h3>
|
||||
<p>{{ t('admin.proposalsEmptyText') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<ul v-if="pendingProposals.length" class="proposal-list">
|
||||
<li v-for="p in pendingProposals" :key="p.id" class="proposal-entry is-pending">
|
||||
<ProposalRow
|
||||
:proposal="p"
|
||||
:expanded="expandedProposal === p.id"
|
||||
:busy="!!decidingId"
|
||||
@toggle="expandedProposal = expandedProposal === p.id ? '' : p.id"
|
||||
@decide="decideProposal(p, $event)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="decidedProposals.length">
|
||||
<button
|
||||
type="button"
|
||||
class="proposal-history-toggle"
|
||||
:aria-expanded="String(historyOpen)"
|
||||
@click="historyOpen = !historyOpen"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
:class="{ turned: historyOpen }"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
{{ t('admin.decidedHeading', decidedProposals.length) }}
|
||||
</button>
|
||||
<ul v-if="historyOpen" class="proposal-list">
|
||||
<li v-for="p in decidedProposals" :key="p.id" class="proposal-entry" :class="`is-${p.status}`">
|
||||
<ProposalRow
|
||||
:proposal="p"
|
||||
:expanded="expandedProposal === p.id"
|
||||
:busy="false"
|
||||
@toggle="expandedProposal = expandedProposal === p.id ? '' : p.id"
|
||||
@decide="decideProposal(p, $event)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,44 +5,103 @@ import type { ArticleMeta } from '~/components/ArticleCard.vue'
|
||||
each other the way the reader does (see main.scss) */
|
||||
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
||||
|
||||
/* One batch of a folder's shelf at a time: how many cards the first draw
|
||||
brings, and how far ahead of the row beneath the grid the next batch is
|
||||
asked for. */
|
||||
const PAGE_SIZE = 24
|
||||
const REACH = 600
|
||||
|
||||
interface ShelfResponse {
|
||||
articles: ArticleMeta[]
|
||||
/* how many articles answer the filter in force, not just this batch */
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
interface ShelfFacets {
|
||||
total: number
|
||||
topics: { name: string; count: number }[]
|
||||
languages: { code: string; count: number }[]
|
||||
}
|
||||
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const { data: articles, status, error } = await useFetch<ArticleMeta[]>(
|
||||
`${api}/articles`,
|
||||
{ default: () => [] }
|
||||
)
|
||||
|
||||
/* topics are the tags spread across the shelf — each one keeps the
|
||||
articles that wear it; the same tag written two ways counts as one,
|
||||
and an article counts once however many languages it wears the tag in */
|
||||
const topics = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const article of articles.value ?? []) {
|
||||
const seen = new Set<string>()
|
||||
for (const doc of [article, ...(article.versions ?? [])]) {
|
||||
for (const t of doc.tags ?? []) {
|
||||
const name = t.trim()
|
||||
if (!name) continue
|
||||
const key = name.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort(([a, ac], [b, bc]) => bc - ac || a.localeCompare(b))
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
})
|
||||
|
||||
/* picking a topic filters the article list below — the address bar keeps
|
||||
the tag so the filtered view can still be shared */
|
||||
const tag = ref(String(route.query.tag ?? ''))
|
||||
const lang = ref(String(route.query.lang ?? '').trim().toLowerCase())
|
||||
|
||||
/* The shelf is filed by topic: the page shows one folder per topic, and a
|
||||
folder opens onto the articles wearing that tag. The tag in the address is
|
||||
which folder stands open — while the address carries none, the folders
|
||||
themselves answer. */
|
||||
const inFolder = computed(() => !!tag.value)
|
||||
|
||||
/* The filters are the API's to apply now that a folder's shelf arrives a
|
||||
batch at a time: the same query is what the first batch and the ones after
|
||||
it ask for. `prefer` is not a filter — it never decides which articles
|
||||
answer, only which language version of each stands on its card, the
|
||||
reader's own when the article speaks it (English failing that). */
|
||||
function shelfQuery(offset: number) {
|
||||
const query: Record<string, string | number> = { limit: PAGE_SIZE, offset }
|
||||
if (tag.value) query.tag = tag.value
|
||||
if (lang.value) query.lang = lang.value
|
||||
query.prefer = locale.value
|
||||
return query
|
||||
}
|
||||
|
||||
/* How the shelf is made up — its topics, its languages, how many articles it
|
||||
holds — is counted over every article rather than over the batches on
|
||||
screen, so those counts stay true while the cards are still arriving. The
|
||||
folders themselves are drawn from this tally. */
|
||||
const { data: facets, status: facetsStatus, error: facetsError } = await useFetch<ShelfFacets>(
|
||||
`${api}/articles/facets`,
|
||||
{ default: () => ({ total: 0, topics: [], languages: [] }) }
|
||||
)
|
||||
|
||||
/* An open folder's first batch comes down with the route, so its cards are in
|
||||
the HTML the server sends. A changed filter asks for a new first batch. */
|
||||
const { data: shelf, status, error } = await useFetch<ShelfResponse>(`${api}/articles`, {
|
||||
query: computed(() => shelfQuery(0)),
|
||||
default: () => ({ articles: [], total: 0, hasMore: false }),
|
||||
})
|
||||
|
||||
/* The batches after the first, as the reader reaches the bottom of what is
|
||||
already on the paper. */
|
||||
const laterBatches = ref<ArticleMeta[]>([])
|
||||
/* what those batches brought over, twins included — how far down the API's
|
||||
shelf we have read, which is where the next batch starts */
|
||||
let readBeyond = 0
|
||||
const loadingMore = ref(false)
|
||||
/* an answer with nothing left in it: the shelf is empty from here on */
|
||||
const exhausted = ref(false)
|
||||
|
||||
const articles = computed(() => [...(shelf.value?.articles ?? []), ...laterBatches.value])
|
||||
/* how many articles the open folder answers to, and how many the whole
|
||||
shelf holds whatever the folder says */
|
||||
const matches = computed(() => shelf.value?.total ?? 0)
|
||||
const shelfSize = computed(() => facets.value?.total || matches.value)
|
||||
const hasMore = computed(() => !exhausted.value && (shelf.value?.hasMore ?? false))
|
||||
|
||||
/* the folders are real links and the Back key is a reader too: whatever the
|
||||
address says is what the refs have to answer to */
|
||||
watch(
|
||||
() => route.query,
|
||||
(q) => {
|
||||
tag.value = String(q.tag ?? '')
|
||||
lang.value = String(q.lang ?? '').trim().toLowerCase()
|
||||
}
|
||||
)
|
||||
|
||||
/* picking a folder opens it — the address bar keeps the tag so the open
|
||||
folder can still be shared; closing it back to the folders rewrites the
|
||||
address without one. Either way what was read for the old view is no use */
|
||||
watch([tag, lang], ([tv, lv]) => {
|
||||
laterBatches.value = []
|
||||
readBeyond = 0
|
||||
exhausted.value = false
|
||||
|
||||
const query: Record<string, string> = {}
|
||||
if (tv) query.tag = tv
|
||||
if (lv) query.lang = lv
|
||||
@@ -51,92 +110,96 @@ watch([tag, lang], ([tv, lv]) => {
|
||||
router.replace({ query })
|
||||
})
|
||||
|
||||
/* every language the shelf is written in, with counts — an article counts
|
||||
in each language it exists in, and only once per language */
|
||||
function languagesOf(article: ArticleMeta): string[] {
|
||||
const codes = new Set<string>([articleLanguage(article)])
|
||||
for (const v of article.versions ?? []) codes.add(v.language || 'en')
|
||||
return [...codes]
|
||||
/* every topic the shelf is filed under, with how many articles it holds, and
|
||||
every language it is written in — both counted per article, not per
|
||||
version */
|
||||
const topics = computed(() => facets.value?.topics ?? [])
|
||||
|
||||
const languages = computed(() =>
|
||||
[...(facets.value?.languages ?? [])]
|
||||
.map((l) => ({ ...l, name: languageName(l.code) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
)
|
||||
|
||||
/* a folder's address: the tag it opens for, keeping a language filter the
|
||||
reader already holds so it rides along into the folder */
|
||||
function folderHref(topic: string) {
|
||||
const query: Record<string, string> = { tag: topic }
|
||||
if (lang.value) query.lang = lang.value
|
||||
return { query }
|
||||
}
|
||||
|
||||
const languages = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const article of articles.value ?? []) {
|
||||
for (const code of languagesOf(article)) counts.set(code, (counts.get(code) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort(([a], [b]) => languageName(a).localeCompare(languageName(b)))
|
||||
.map(([code, count]) => ({ code, name: languageName(code), count }))
|
||||
})
|
||||
/* ---------- an open folder's shelf, a batch at a time ---------- */
|
||||
|
||||
const filteredArticles = computed(() => {
|
||||
const list = (articles.value ?? []).filter((a) => {
|
||||
if (
|
||||
tag.value &&
|
||||
!([a, ...(a.versions ?? [])] as ArticleMeta[]).some((d) =>
|
||||
(d.tags ?? []).some((t) => t.trim().toLowerCase() === tag.value.toLowerCase())
|
||||
)
|
||||
)
|
||||
return false
|
||||
if (lang.value && !languagesOf(a).includes(lang.value)) return false
|
||||
return true
|
||||
})
|
||||
/* in a language view each article is shown as the version written —
|
||||
or rather translated — in that language */
|
||||
if (!lang.value) return list
|
||||
return list.map((a) => {
|
||||
const v = (a.versions ?? []).find((x) => (x.language || 'en') === lang.value)
|
||||
return v ? { ...a, ...v, versions: a.versions } : a
|
||||
})
|
||||
})
|
||||
/* Does this browser say when the row under the grid arrives, or does the
|
||||
shelf have to be emptied out in one go? */
|
||||
const observesScroll = typeof IntersectionObserver === 'function'
|
||||
|
||||
/* the search box doubles as a topic compass: the shelf's topics drop
|
||||
down while you search, and picking one filters the list below */
|
||||
const topicQuery = ref('')
|
||||
const dropdownOpen = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
const searchWrap = ref<HTMLElement | null>(null)
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let scrollWatch: IntersectionObserver | null = null
|
||||
|
||||
const visibleTopics = computed(() => {
|
||||
const needle = topicQuery.value.trim().toLowerCase()
|
||||
if (!needle) return topics.value
|
||||
return topics.value.filter((t) => t.name.toLowerCase().includes(needle))
|
||||
})
|
||||
|
||||
watch(topicQuery, () => (activeIndex.value = -1))
|
||||
|
||||
function pickTopic(topic: { name: string }) {
|
||||
tag.value = topic.name
|
||||
topicQuery.value = ''
|
||||
dropdownOpen.value = false
|
||||
/* how far down the API's shelf the batches read so far go */
|
||||
function nextOffset() {
|
||||
return (shelf.value?.articles.length ?? 0) + readBeyond
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (!dropdownOpen.value) {
|
||||
if (e.key === 'ArrowDown') dropdownOpen.value = true
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') dropdownOpen.value = false
|
||||
else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, visibleTopics.value.length - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const topic = visibleTopics.value[activeIndex.value] ?? visibleTopics.value[0]
|
||||
if (topic) pickTopic(topic)
|
||||
function sentinelInReach() {
|
||||
const el = sentinel.value
|
||||
return !!el && el.getBoundingClientRect().top <= window.innerHeight + REACH
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
/* only an open folder pages in: the folder grid arrives whole from the
|
||||
facet tally, and a closed folder has no shelf to read on */
|
||||
if (!inFolder.value || loadingMore.value || status.value === 'pending' || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
/* Pull while the row is in reach — with new cards in place before each
|
||||
round, too: a shelf short enough to fit on one screen never scrolls, so
|
||||
it has to fill itself out. */
|
||||
do {
|
||||
const batch = await $fetch<ShelfResponse>(`${api}/articles`, {
|
||||
query: shelfQuery(nextOffset()),
|
||||
})
|
||||
readBeyond += batch.articles.length
|
||||
if (!batch.articles.length || !batch.hasMore) exhausted.value = true
|
||||
/* an article edited between two batches can be handed out twice: the
|
||||
folder shows each card once */
|
||||
const held = new Set(articles.value.map((a) => a._id))
|
||||
laterBatches.value.push(...batch.articles.filter((a) => !held.has(a._id)))
|
||||
await nextTick()
|
||||
} while (hasMore.value && (!observesScroll || sentinelInReach()))
|
||||
} catch {
|
||||
/* the row waits where it is: reaching it again asks once more */
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* a click anywhere outside the box closes the dropdown */
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (dropdownOpen.value && !searchWrap.value?.contains(e.target as Node))
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
||||
onMounted(() => {
|
||||
if (!observesScroll) return void loadMore()
|
||||
scrollWatch = new IntersectionObserver(
|
||||
(seen) => {
|
||||
if (seen.some((entry) => entry.isIntersecting)) void loadMore()
|
||||
},
|
||||
{ rootMargin: `${REACH}px 0px` }
|
||||
)
|
||||
if (sentinel.value) scrollWatch.observe(sentinel.value)
|
||||
})
|
||||
onBeforeUnmount(() => scrollWatch?.disconnect())
|
||||
|
||||
/* opening a folder is what puts its row under the grid: watch for that row
|
||||
arriving, so reaching it asks for the rest without a scroll first */
|
||||
watch(sentinel, (el) => {
|
||||
if (el) scrollWatch?.observe(el)
|
||||
})
|
||||
|
||||
/* a folder's first batch having landed, a reader still standing at the bottom
|
||||
of it is given the next */
|
||||
watch(shelf, async () => {
|
||||
await nextTick()
|
||||
void loadMore()
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('articles.metaTitle'),
|
||||
@@ -154,62 +217,20 @@ useSeoMeta({
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div v-if="error" class="banner">
|
||||
<div v-if="error || facetsError" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span v-html="t('common.apiDown')"></span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="results-bar">
|
||||
<h2>
|
||||
<template v-if="status === 'pending'">{{ t('common.loading') }}</template>
|
||||
<template v-else-if="tag || lang">
|
||||
{{ t('articles.countOfTotal', { shown: filteredArticles.length, total: articles.length }) }}
|
||||
</template>
|
||||
<template v-else>{{ t('articles.articleCount', articles.length) }}</template>
|
||||
</h2>
|
||||
<span v-if="status !== 'pending' && topics.length" class="topic-tally">
|
||||
{{ t('articles.topicCount', topics.length) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="topics.length" ref="searchWrap" class="topic-search-wrap">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.2-3.2" />
|
||||
<!-- an open folder: the articles wearing its topic -->
|
||||
<template v-else-if="inFolder">
|
||||
<NuxtLink :to="{ query: {} }" replace class="back-link">
|
||||
<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="M19 12H5" />
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="topicQuery"
|
||||
class="topic-search"
|
||||
type="text"
|
||||
role="combobox"
|
||||
:aria-expanded="String(dropdownOpen)"
|
||||
aria-controls="topic-dropdown"
|
||||
aria-autocomplete="list"
|
||||
:placeholder="t('articles.topicSearchPlaceholder')"
|
||||
:aria-label="t('articles.topicSearchAria')"
|
||||
@focus="dropdownOpen = true"
|
||||
@keydown="onInputKeydown"
|
||||
/>
|
||||
<div v-if="dropdownOpen" id="topic-dropdown" class="topic-dropdown" role="listbox">
|
||||
<button
|
||||
v-for="(topic, i) in visibleTopics"
|
||||
:key="topic.name"
|
||||
type="button"
|
||||
class="topic-option"
|
||||
:class="{ active: i === activeIndex }"
|
||||
role="option"
|
||||
:aria-selected="i === activeIndex"
|
||||
@click="pickTopic(topic)"
|
||||
>
|
||||
<span>{{ topic.name }}</span>
|
||||
<span class="topic-count">{{ topic.count }}</span>
|
||||
</button>
|
||||
<p v-if="!visibleTopics.length" class="topic-dropdown-none">
|
||||
{{ t('articles.noTopic', { q: topicQuery.trim() }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ t('articles.backToTopics') }}
|
||||
</NuxtLink>
|
||||
|
||||
<div v-if="languages.length > 1" class="lang-row" role="group" :aria-label="t('articles.filterByLanguage')">
|
||||
<span class="lang-row-label">{{ t('articles.writtenIn') }}</span>
|
||||
@@ -229,20 +250,11 @@ useSeoMeta({
|
||||
|
||||
<div class="results-bar">
|
||||
<h2>
|
||||
<template v-if="tag && lang">{{ t('articles.headingTagLang', { tag, lang: languageName(lang) }) }}</template>
|
||||
<template v-else-if="tag">{{ t('articles.headingTag', { tag }) }}</template>
|
||||
<template v-else-if="lang">{{ t('articles.headingLang', { lang: languageName(lang) }) }}</template>
|
||||
<template v-else>{{ t('articles.headingAll') }}</template>
|
||||
<template v-if="status === 'pending'">{{ t('common.loading') }}</template>
|
||||
<template v-else-if="lang">{{ t('articles.headingTagLang', { tag, lang: languageName(lang) }) }}</template>
|
||||
<template v-else>{{ t('articles.headingTag', { tag }) }}</template>
|
||||
</h2>
|
||||
<span v-if="tag" class="filter-chip">
|
||||
{{ t('common.tagChip', { tag }) }}
|
||||
<button type="button" :aria-label="t('common.clearTag')" @click="tag = ''">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="lang" class="filter-chip">
|
||||
<span v-if="status !== 'pending' && lang" class="filter-chip">
|
||||
{{ t('common.languageChip', { lang: languageName(lang) }) }}
|
||||
<button type="button" :aria-label="t('common.clearLanguage')" @click="lang = ''">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
@@ -256,28 +268,66 @@ useSeoMeta({
|
||||
<div v-for="i in 6" :key="i" class="skeleton" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredArticles.length" class="articles-grid">
|
||||
<ArticleCard v-for="a in filteredArticles" :key="a._id" :article="a" />
|
||||
<div v-else-if="articles.length" class="articles-grid">
|
||||
<ArticleCard v-for="a in articles" :key="a._id" :article="a" />
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<div class="glyph">∅</div>
|
||||
<template v-if="articles.length">
|
||||
<h3>
|
||||
{{ tag
|
||||
? t('articles.emptyNoTopicTitle')
|
||||
: lang
|
||||
? t('articles.emptyNoLangTitle', { lang: languageName(lang) })
|
||||
: t('articles.emptyNoTopicsTitle') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ tag || lang
|
||||
? t('articles.emptyFilteredText')
|
||||
: t('articles.emptyUntaggedText') }}
|
||||
</p>
|
||||
<button v-if="tag || lang" type="button" class="btn btn-ghost" @click="tag = ''; lang = ''">
|
||||
{{ t('articles.emptyShowAll') }}
|
||||
</button>
|
||||
<h3>
|
||||
{{ lang
|
||||
? t('articles.emptyNoLangTitle', { lang: languageName(lang) })
|
||||
: t('articles.emptyNoTopicTitle') }}
|
||||
</h3>
|
||||
<p>{{ t('articles.emptyFilteredText') }}</p>
|
||||
<button v-if="lang" type="button" class="btn btn-ghost" @click="lang = ''">
|
||||
{{ t('articles.emptyShowAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="sentinel" class="shelf-sentinel" role="status">
|
||||
<span v-if="loadingMore">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- the shelf closed: one folder per topic -->
|
||||
<template v-else>
|
||||
<div class="results-bar">
|
||||
<h2>
|
||||
<template v-if="facetsStatus === 'pending'">{{ t('common.loading') }}</template>
|
||||
<template v-else>{{ t('articles.articleCount', shelfSize) }}</template>
|
||||
</h2>
|
||||
<span v-if="facetsStatus !== 'pending' && topics.length" class="topic-tally">
|
||||
{{ t('articles.topicCount', topics.length) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="facetsStatus === 'pending'" class="folder-grid">
|
||||
<div v-for="i in 8" :key="i" class="skeleton folder-skeleton" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="topics.length" class="folder-grid">
|
||||
<NuxtLink
|
||||
v-for="topic in topics"
|
||||
:key="topic.name"
|
||||
:to="folderHref(topic.name)"
|
||||
class="folder-card"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||
</svg>
|
||||
<span class="folder-name">{{ topic.name }}</span>
|
||||
<span class="topic-count folder-count" :title="t('articles.articleCount', topic.count)">
|
||||
{{ topic.count }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<div class="glyph">∅</div>
|
||||
<template v-if="shelfSize">
|
||||
<h3>{{ t('articles.emptyNoTopicsTitle') }}</h3>
|
||||
<p>{{ t('articles.emptyUntaggedText') }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h3>{{ t('articles.emptyNoneTitle') }}</h3>
|
||||
|
||||
@@ -9,41 +9,12 @@ const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { show: openSearch } = useSearch()
|
||||
|
||||
const q = ref(String(route.query.q ?? ''))
|
||||
const tag = ref(String(route.query.tag ?? ''))
|
||||
const lang = ref(String(route.query.lang ?? '').trim().toLowerCase())
|
||||
const langLabel = computed(() => (lang.value ? languageName(lang.value) : ''))
|
||||
|
||||
/* debounce the search text so we don't fetch on every keystroke */
|
||||
const debouncedQ = ref(q.value)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(q, (v) => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => (debouncedQ.value = v.trim()), 250)
|
||||
})
|
||||
|
||||
const { data: articles, status, error } = await useFetch<ArticleMeta[]>(
|
||||
`${api}/articles`,
|
||||
{ query: { q: debouncedQ, tag, lang }, default: () => [] }
|
||||
)
|
||||
|
||||
/* keep the address bar in sync with the search */
|
||||
watch([debouncedQ, tag, lang], ([qv, tv, lv]) => {
|
||||
const query: Record<string, string> = {}
|
||||
if (qv) query.q = qv
|
||||
if (tv) query.tag = tv
|
||||
if (lv) query.lang = lv
|
||||
const current = route.query
|
||||
const same
|
||||
= (current.q ?? '') === (query.q ?? '') && (current.tag ?? '') === (query.tag ?? '')
|
||||
&& (current.lang ?? '') === (query.lang ?? '')
|
||||
if (!same) router.replace({ query })
|
||||
})
|
||||
|
||||
function selectTag(t: string) {
|
||||
tag.value = tag.value === t ? '' : t
|
||||
}
|
||||
/* the bar opens the Ctrl/⌘+K popup rather than searching on the page itself,
|
||||
but it still reads back the query the address carries — the popup's
|
||||
"all results" link lands here with one */
|
||||
const askedQuery = computed(() => String(route.query.q ?? ''))
|
||||
|
||||
/* fetch one article from the API and open it */
|
||||
const randomPending = ref(false)
|
||||
@@ -52,7 +23,14 @@ async function showRandomArticle() {
|
||||
randomPending.value = true
|
||||
try {
|
||||
const article = await $fetch<ArticleMeta>(`${api}/articles/random`)
|
||||
await router.push(versionHref(identityOf(article), article))
|
||||
/* the canonical slug is the article's address, and the middleware there
|
||||
turns it onto the reader's language; an orphaned translation answers to
|
||||
no such address, so that one still goes straight to its own version */
|
||||
await router.push(
|
||||
article.baseSlug
|
||||
? versionHref(identityOf(article), articleLanguage(article))
|
||||
: `/wiki/${encodeURIComponent(article.slug)}`
|
||||
)
|
||||
} catch {
|
||||
// no articles yet or API unreachable — the banner below already covers API issues
|
||||
} finally {
|
||||
@@ -60,38 +38,14 @@ async function showRandomArticle() {
|
||||
}
|
||||
}
|
||||
|
||||
/* "/" focuses the search box */
|
||||
const searchEl = ref<HTMLInputElement | null>(null)
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
|
||||
if (e.key === '/' && !typing) {
|
||||
e.preventDefault()
|
||||
searchEl.value?.focus()
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('home.metaTitle'),
|
||||
description: () => t('home.metaDescription'),
|
||||
})
|
||||
|
||||
/* which empty-shelf line to say, depending on the filters in force */
|
||||
const emptyKey = computed(() =>
|
||||
tag.value && lang.value
|
||||
? 'home.emptyTagLang'
|
||||
: tag.value
|
||||
? 'home.emptyTag'
|
||||
: lang.value
|
||||
? 'home.emptyLang'
|
||||
: 'home.emptyPlain'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-main">
|
||||
<div class="home-main is-home">
|
||||
<section class="hero">
|
||||
<h1 v-html="t('home.hero')"></h1>
|
||||
|
||||
@@ -100,14 +54,14 @@ const emptyKey = computed(() =>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.2-3.2" />
|
||||
</svg>
|
||||
<input
|
||||
ref="searchEl"
|
||||
v-model="q"
|
||||
class="search-input"
|
||||
type="text"
|
||||
:placeholder="t('home.searchPlaceholder')"
|
||||
<button
|
||||
type="button"
|
||||
class="search-input search-trigger"
|
||||
:aria-label="t('common.searchArticles')"
|
||||
/>
|
||||
@click="openSearch()"
|
||||
>
|
||||
{{ askedQuery || t('home.searchPlaceholder') }}
|
||||
</button>
|
||||
<span class="search-kbd">/</span>
|
||||
</div>
|
||||
|
||||
@@ -123,52 +77,5 @@ const emptyKey = computed(() =>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="results-bar">
|
||||
<h2>
|
||||
<template v-if="debouncedQ || tag || lang">
|
||||
{{ t('home.results', articles.length) }}
|
||||
</template>
|
||||
<template v-else>{{ t('home.recentlyUpdated') }}</template>
|
||||
</h2>
|
||||
<span v-if="tag" class="filter-chip">
|
||||
{{ t('common.tagChip', { tag }) }}
|
||||
<button type="button" :aria-label="t('common.clearTag')" @click="tag = ''">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="lang" class="filter-chip">
|
||||
{{ t('common.languageChip', { lang: langLabel }) }}
|
||||
<button type="button" :aria-label="t('common.clearLanguage')" @click="lang = ''">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span v-html="t('common.apiDown')"></span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'pending'" class="articles-grid">
|
||||
<div v-for="i in 6" :key="i" class="skeleton" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="articles.length" class="articles-grid">
|
||||
<ArticleCard v-for="a in articles" :key="a._id" :article="a" />
|
||||
</div>
|
||||
|
||||
<div v-else class="empty">
|
||||
<div class="glyph">∅</div>
|
||||
<h3>{{ t('home.emptyTitle') }}</h3>
|
||||
<p>{{ t(emptyKey, { tag, lang: langLabel }) }}</p>
|
||||
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,7 +20,7 @@ const { record } = useRecentlyViewed()
|
||||
watch(
|
||||
article,
|
||||
(a) => {
|
||||
if (import.meta.client && a?.slug && a?.title) record({ slug: a.slug, title: a.title })
|
||||
if (import.meta.client && a?.slug && a?.title) record(a)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -34,6 +34,14 @@ useSeoMeta({
|
||||
const missingStatus = computed(
|
||||
() => (article.value ? 0 : unreadable.value[versionKey(identity.value, lang.value)] ?? 0)
|
||||
)
|
||||
|
||||
/* this version was never laid on the book, so "back" leads to whatever else is
|
||||
open: the last of the pages still in the stack, if there are any. */
|
||||
const { tabs } = useArticleTabs()
|
||||
const backHref = computed(() => {
|
||||
const last = tabs.value[tabs.value.length - 1]
|
||||
return last ? tabHref(last) : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,7 +52,10 @@ const missingStatus = computed(
|
||||
<div class="glyph">∄</div>
|
||||
<h3>{{ t('reader.noVersionTitle', { lang: languageName(lang) }) }}</h3>
|
||||
<p v-html="th('reader.noVersionText', { path: `/wiki/${lang}/${identity}` })"></p>
|
||||
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
|
||||
<div class="empty-actions">
|
||||
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
|
||||
<NuxtLink v-if="backHref" :to="backHref" class="btn btn-ghost">{{ t('reader.backToArticles') }}</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="glyph">!</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
/* The article route is a thin host for the book that ArticleBookStack draws.
|
||||
The open-article middleware has already laid the article on the library's
|
||||
shelf — that is what the page in view is made of, and what the stack of
|
||||
open pages is filed under — so all that is left to do here is speak for the
|
||||
URL: the title, and the visitor's "recently viewed" line.
|
||||
When nothing lives at the address, this is what says so, over the top of
|
||||
whatever else is open. */
|
||||
/* The short address — /wiki/<slug>, the shape a wiki link to [[Bayes Theorem]]
|
||||
writes and an old bookmark holds — names no language. The open-article
|
||||
middleware reads which document the slug speaks and turns the address onto
|
||||
that version's own, /wiki/en/bayes-theorem, so this page is what is left of
|
||||
it: the words said when the wiki has nothing at the slug, or the API has no
|
||||
answer about it — an address worth writing, over the top of whatever else is
|
||||
open. */
|
||||
definePageMeta({ middleware: 'open-article' })
|
||||
|
||||
const route = useRoute()
|
||||
@@ -14,34 +14,33 @@ const slug = computed(() => String(route.params.slug))
|
||||
const { t } = useI18n()
|
||||
const { th } = useAppLocale()
|
||||
const { get, unreadable } = useArticleLibrary()
|
||||
const article = computed(() => get(slug.value))
|
||||
|
||||
const { record } = useRecentlyViewed()
|
||||
watch(
|
||||
article,
|
||||
(a) => {
|
||||
if (import.meta.client && a?.slug && a?.title) record({ slug: a.slug, title: a.title })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => article.value?.title ?? t('card.kickerArticle'),
|
||||
})
|
||||
|
||||
/* what the shelf could not bring in, the API said why */
|
||||
const missingStatus = computed(() => (article.value ? 0 : unreadable.value[slug.value] ?? 0))
|
||||
const missingStatus = computed(() => (get(slug.value) ? 0 : unreadable.value[slug.value] ?? 0))
|
||||
|
||||
useSeoMeta({ title: () => t('card.kickerArticle') })
|
||||
|
||||
/* a missing address was never laid on the book, so "back" leads to whatever
|
||||
else is open: the last of the pages still in the stack, if there are any. */
|
||||
const { tabs } = useArticleTabs()
|
||||
const backHref = computed(() => {
|
||||
const last = tabs.value[tabs.value.length - 1]
|
||||
return last ? tabHref(last) : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reader-host">
|
||||
<div v-if="!article" class="reader-note">
|
||||
<div class="reader-note">
|
||||
<div class="empty" style="padding-top: 64px">
|
||||
<template v-if="missingStatus === 404">
|
||||
<div class="glyph">∄</div>
|
||||
<h3>{{ t('reader.notFoundTitle') }}</h3>
|
||||
<p v-html="th('reader.notFoundText', { path: `/wiki/${slug}` })"></p>
|
||||
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
|
||||
<div class="empty-actions">
|
||||
<NuxtLink to="/new" class="btn btn-primary">{{ t('reader.writeTopic') }}</NuxtLink>
|
||||
<NuxtLink v-if="backHref" :to="backHref" class="btn btn-ghost">{{ t('reader.backToArticles') }}</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="glyph">!</div>
|
||||
|
||||
Reference in New Issue
Block a user