292 lines
11 KiB
Vue
292 lines
11 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' } })
|
|
|
|
const api = `${useRuntimeConfig().public.apiBase}/api`
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { t } = 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())
|
|
watch([tag, lang], ([tv, lv]) => {
|
|
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 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]
|
|
}
|
|
|
|
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 }))
|
|
})
|
|
|
|
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
|
|
})
|
|
})
|
|
|
|
/* 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 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/* 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))
|
|
|
|
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" 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" />
|
|
</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>
|
|
|
|
<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="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>
|
|
</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: 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="filteredArticles.length" class="articles-grid">
|
|
<ArticleCard v-for="a in filteredArticles" :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>
|
|
</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>
|