First commit

This commit is contained in:
2026-09-30 01:49:35 +02:00
commit 6edd03aace
109 changed files with 27020 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { ArticleMeta } from '~/components/ArticleCard.vue'
/* matches the article page's transition so opening/closing the reader
slides one surface away as the next arrives (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 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
}
/* fetch one article from the API and open it */
const randomPending = ref(false)
async function showRandomArticle() {
if (randomPending.value) return
randomPending.value = true
try {
const article = await $fetch<ArticleMeta>(`${api}/articles/random`)
await router.push(versionHref(identityOf(article), article))
} catch {
// no articles yet or API unreachable — the banner below already covers API issues
} finally {
randomPending.value = false
}
}
/* "/" 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">
<section class="hero">
<h1 v-html="t('home.hero')"></h1>
<div class="search-wrap">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<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')"
:aria-label="t('common.searchArticles')"
/>
<span class="search-kbd">/</span>
</div>
<div class="hero-actions">
<NuxtLink to="/new" class="btn btn-primary">{{ t('home.writeNew') }}</NuxtLink>
<button
type="button"
class="btn btn-ghost"
:disabled="randomPending"
@click="showRandomArticle"
>
{{ t('home.random') }}
</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>