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

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