More things

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

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>