342 lines
13 KiB
Vue
342 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import type { ArticleMeta } from '~/components/ArticleCard.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' } })
|
|
|
|
/* 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, locale } = useI18n()
|
|
|
|
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
|
|
const current = route.query
|
|
if ((current.tag ?? '') === (query.tag ?? '') && (current.lang ?? '') === (query.lang ?? '')) return
|
|
router.replace({ query })
|
|
})
|
|
|
|
/* 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 }
|
|
}
|
|
|
|
/* ---------- an open folder's shelf, a batch at a time ---------- */
|
|
|
|
/* 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'
|
|
|
|
const sentinel = ref<HTMLElement | null>(null)
|
|
let scrollWatch: IntersectionObserver | null = null
|
|
|
|
/* how far down the API's shelf the batches read so far go */
|
|
function nextOffset() {
|
|
return (shelf.value?.articles.length ?? 0) + readBeyond
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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'),
|
|
description: () => t('articles.metaDescription'),
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="home-main">
|
|
<section class="hero">
|
|
<h1 v-html="t('articles.hero')"></h1>
|
|
<p class="sub">
|
|
{{ t('articles.sub') }}
|
|
</p>
|
|
</section>
|
|
|
|
<section class="section">
|
|
<div v-if="error || facetsError" class="banner">
|
|
<span class="glyph">!</span>
|
|
<span v-html="t('common.apiDown')"></span>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
{{ 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>
|
|
<button
|
|
v-for="l in languages"
|
|
:key="l.code"
|
|
type="button"
|
|
class="lang-chip"
|
|
:class="{ active: lang === l.code }"
|
|
:aria-pressed="lang === l.code"
|
|
@click="lang = lang === l.code ? '' : l.code"
|
|
>
|
|
<span :lang="l.code">{{ l.name }}</span>
|
|
<span class="topic-count">{{ l.count }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="results-bar">
|
|
<h2>
|
|
<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="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">
|
|
<path d="M6 6l12 12M18 6L6 18" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<div v-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>
|
|
{{ 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>
|
|
<p>{{ t('articles.emptyNoneText') }}</p>
|
|
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
</div>
|
|
</template>
|