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,272 @@
<script setup lang="ts">
/* 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. */
import type { AuthRole } from '~/composables/useAuth'
export interface AdminAccount {
id: string
username: string
avatar: string
role: AuthRole
createdAt: string
}
/* 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 { user, loggedIn, isAdmin, authHeaders, signOut } = useAuth()
const { show: openSignIn } = useSignInPopup()
const { t, locale } = 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 profile popup 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('admin: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')
}
/* 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 list with
it, which is what the watch below is for. */
const {
data: accounts,
status,
refresh,
} = await useAsyncData(
'admin-accounts',
async () => {
failure.value = ''
if (!isAdmin.value) return []
try {
const { accounts: list } = await $fetch<{ accounts: AdminAccount[] }>(
`${api}/admin/users`,
{ headers: authHeaders() }
)
return list
} catch (err: any) {
failure.value = describeFailure(err)
return []
}
},
{ default: () => [] }
)
watch(isAdmin, (admin) => {
if (admin) void refresh()
})
const loading = computed(() => status.value === 'pending')
const adminCount = computed(() => accounts.value.filter((a) => a.role === 'admin').length)
/* --- handing the role on, taking it back, closing an account ---
One change at a time: while a request is in flight the menu waits, so two
clicks cannot disagree about what an account is. */
const changingId = ref('')
const removing = ref(false)
const actionError = ref('')
async function setRole(account: AdminAccount, role: AuthRole) {
if (changingId.value || removing.value) return
actionError.value = ''
changingId.value = account.id
try {
const { account: updated } = await $fetch<{ account: AdminAccount }>(
`${api}/admin/users/${account.id}/role`,
{ method: 'PUT', headers: authHeaders(), body: { role } }
)
/* the whole list is written again rather than the one row changed in
place: useAsyncData keeps it shallow, so a row would not notice */
accounts.value = accounts.value.map((a) => (a.id === updated.id ? updated : a))
} catch (err: any) {
actionError.value = describeFailure(err) || t('admin.roleChangeFailed')
} finally {
changingId.value = ''
}
}
/* Deleting is the one thing on this page that does not come back, so the menu
hands it to the yes/no popup first: the account awaiting an answer, null
when nothing is. */
const removalTarget = ref<AdminAccount | null>(null)
async function removeAccount() {
const account = removalTarget.value
if (!account || removing.value || changingId.value) return
actionError.value = ''
removing.value = true
try {
await $fetch(`${api}/admin/users/${account.id}`, {
method: 'DELETE',
headers: authHeaders()
})
accounts.value = accounts.value.filter((a) => a.id !== account.id)
removalTarget.value = null
} catch (err: any) {
actionError.value = describeFailure(err) || t('admin.deleteFailed')
} finally {
removing.value = false
}
}
/* esc answers the yes/no popup the way its Cancel button does: the dashboard has
no other use for the key. */
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape' || !removalTarget.value || removing.value) return
removalTarget.value = null
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
/* "12 Mar 2026", in the interface language and read in UTC — the server and
the browser may run on different clocks, and the first render has to say the
same day on both. */
function joined(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat(locale.value, {
timeZone: 'UTC',
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
useSeoMeta({
title: () => t('admin.metaTitle'),
description: () => t('admin.metaDescription'),
})
</script>
<template>
<div class="home-main">
<!-- Nobody but an admin is given the list: 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('admin.gateHeading')"
:text="t('admin.gateText')"
/>
<section v-else-if="!isAdmin" class="auth-gate" aria-labelledby="admin-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="admin-no-access" class="auth-gate-title">{{ t('admin.forAdmins') }}</h1>
<p class="auth-gate-text">
{{ t('admin.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('admin.hero')"></h1>
<p class="sub">{{ t('admin.sub') }}</p>
</section>
<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>
<template v-if="loading">{{ t('common.loading') }}</template>
<template v-else>
{{ t('admin.accountCount', accounts.length) }}
</template>
</h2>
<span v-if="!loading && accounts.length" class="topic-tally">
{{ t('admin.adminTally', adminCount) }} ·
{{ t('admin.memberTally', accounts.length - adminCount) }}
</span>
</div>
<p v-if="actionError" class="form-error">{{ actionError }}</p>
<div v-if="loading" class="account-list">
<div v-for="i in 4" :key="i" class="skeleton skeleton-row" />
</div>
<ul v-else-if="accounts.length" class="account-list">
<li v-for="account in accounts" :key="account.id" class="account-entry">
<span class="account-avatar" aria-hidden="true">
<img v-if="account.avatar" :src="account.avatar" alt="" />
<template v-else>{{ account.username.charAt(0).toUpperCase() }}</template>
</span>
<span class="account-entry-name">
{{ account.username }}
<span v-if="account.id === user?.id" class="account-entry-you">{{ t('admin.you') }}</span>
</span>
<span class="role-pill" :class="{ 'is-admin': account.role === 'admin' }">
{{ account.role === 'admin' ? t('admin.roleAdmin') : t('admin.roleMember') }}
</span>
<span class="account-entry-since">{{ t('admin.joined', { date: joined(account.createdAt) }) }}</span>
<!-- an admin changing their own role, or closing their own account,
is what the API will not do — so the row says it where the ⋮
would go, instead of offering a menu that could only fail -->
<span v-if="account.id === user?.id" class="account-entry-note">
{{ t('admin.selfNote') }}
</span>
<AccountActionsMenu
v-else
class="account-entry-kebab"
:account-id="account.id"
:username="account.username"
:role="account.role"
:busy="changingId === account.id || removing"
@set-role="setRole(account, $event)"
@remove="removalTarget = account"
/>
</li>
</ul>
<div v-else class="empty">
<div class="glyph">∅</div>
<h3>{{ t('admin.emptyTitle') }}</h3>
<p>{{ t('admin.emptyText') }}</p>
</div>
</template>
</section>
</template>
<ConfirmModal
:open="removalTarget !== null"
:title="t('admin.confirmDeleteTitle')"
:message="removalTarget
? t('admin.confirmDeleteMessage', { username: removalTarget.username })
: ''"
:confirm-label="t('admin.confirmDeleteButton')"
:cancel-label="t('admin.confirmKeepButton')"
@confirm="removeAccount"
@cancel="removalTarget = null"
/>
</div>
</template>

View File

@@ -0,0 +1,291 @@
<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>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { GraphEdgeDto, GraphNodeDto } from '~/components/GraphView.vue'
export interface GraphDto {
nodes: GraphNodeDto[]
edges: GraphEdgeDto[]
}
/* 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 { t } = useI18n()
const { data, status, error } = await useFetch<GraphDto>(`${api}/articles/graph`, {
default: () => ({ nodes: [], edges: [] }),
})
useSeoMeta({
title: () => t('graph.metaTitle'),
description: () => t('graph.metaDescription'),
})
</script>
<template>
<div class="graph-stage">
<div v-if="error" class="graph-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="t('common.apiDown')"></span>
</div>
</div>
<GraphView
v-else-if="status !== 'pending' && data.nodes.length"
:nodes="data.nodes"
:edges="data.edges"
/>
<div v-else-if="status !== 'pending'" class="graph-state">
<div class="empty">
<div class="glyph">∅</div>
<h3>{{ t('graph.emptyTitle') }}</h3>
<p>{{ t('graph.emptyText') }}</p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
</div>
</div>
</div>
</template>

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>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import type { ArticleMeta } from '~/components/ArticleCard.vue'
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const { t } = useI18n()
const { th } = useAppLocale()
/* ?translate=<slug> — start the editor from an existing article instead of
from scratch: the text opens ready to rewrite, and the save publishes a new
language version of that same article. */
const translateSlug = computed(() => String(route.query.translate ?? '').trim())
const { data: original, status } = await useFetch<ArticleMeta & { content?: string }>(
() => `${api}/articles/slug/${encodeURIComponent(translateSlug.value)}`,
{ enabled: computed(() => Boolean(translateSlug.value)), default: null }
)
const takenLanguages = computed(() => (original.value?.versions ?? []).map((v) => v.language))
useSeoMeta({
title: () => (original.value ? t('editor.metaTranslate', { title: original.value!.title }) : t('editor.newTitle')),
})
</script>
<template>
<!-- the editor itself only shows with an account behind it (AuthGate) -->
<AuthGate :heading="t('editor.gateStart')">
<ArticleForm
v-if="original"
mode="create"
:translate-from="original"
:taken-languages="takenLanguages"
:key="`translate-${original._id}`"
/>
<template v-else-if="translateSlug">
<div v-if="status === 'pending'" style="padding-top: 48px">
<div class="skeleton" style="height: 40px; width: 40%; margin-bottom: 14px; border-radius: 8px" />
<div class="skeleton" style="height: 420px" />
</div>
<div v-else class="empty" style="padding-top: 96px">
<div class="glyph">∄</div>
<h3>{{ t('editor.nothingToTranslate') }}</h3>
<p v-html="th('editor.noArticleAt', { path: `/wiki/${translateSlug}` })"></p>
<NuxtLink to="/" class="btn btn-primary">{{ t('editor.backToSearch') }}</NuxtLink>
</div>
</template>
<ArticleForm v-else mode="create" />
</AuthGate>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
/* One language version's address — /wiki/fr/bayes-theorem — is a thin host for
the same book page the article itself opens on. The open-article middleware
has already laid this version on the library's shelf, so all that is left to
do here is speak for the URL: the title, the visitor's "recently viewed"
line, and the words said when nothing lives at the address — this article,
or none in this language. */
definePageMeta({ middleware: 'open-article' })
const route = useRoute()
const identity = computed(() => String(route.params.slug))
const lang = computed(() => String(route.params.lang))
const { t } = useI18n()
const { th } = useAppLocale()
const { getVersion, unreadable } = useArticleLibrary()
const article = computed(() => getVersion(identity.value, lang.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 ? `${article.value.title} · ${languageName(lang.value)}` : t('card.kickerArticle'),
})
/* what the shelf could not bring in, the API said why */
const missingStatus = computed(
() => (article.value ? 0 : unreadable.value[versionKey(identity.value, lang.value)] ?? 0)
)
</script>
<template>
<div class="reader-host">
<div v-if="!article" class="reader-note">
<div class="empty" style="padding-top: 64px">
<template v-if="missingStatus === 404">
<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>
</template>
<template v-else>
<div class="glyph">!</div>
<h3>{{ t('reader.loadFailTitle') }}</h3>
<p v-html="t('common.apiShort')"></p>
<NuxtLink to="/" class="btn btn-ghost">{{ t('common.backHome') }}</NuxtLink>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ArticleMeta } from '~/components/ArticleCard.vue'
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const route = useRoute()
const slug = computed(() => String(route.params.slug))
const { t } = useI18n()
const { th } = useAppLocale()
const { data: article, status, error } = await useFetch<ArticleMeta & { content?: string }>(
`${api}/articles/slug/${encodeURIComponent(slug.value)}`,
{ key: `article-${slug.value}-edit` }
)
useSeoMeta({ title: () => t('editor.metaEditing', { title: article.value?.title ?? slug.value }) })
</script>
<template>
<div>
<div v-if="error" class="empty" style="padding-top: 96px">
<div class="glyph">∄</div>
<h3>{{ t('editor.nothingToEdit') }}</h3>
<p v-html="th('editor.noArticleAt', { path: `/wiki/${slug}` })"></p>
<NuxtLink to="/new" class="btn btn-primary">{{ t('editor.createInstead') }}</NuxtLink>
</div>
<div v-else-if="status === 'pending'" style="padding-top: 48px">
<div class="skeleton" style="height: 40px; width: 40%; margin-bottom: 14px; border-radius: 8px" />
<div class="skeleton" style="height: 420px" />
</div>
<AuthGate v-else-if="article" :heading="t('editor.gateEdit', { title: article.title })">
<ArticleForm mode="edit" :article="article" :key="article._id" />
</AuthGate>
</div>
</template>

View File

@@ -0,0 +1,55 @@
<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. */
definePageMeta({ middleware: 'open-article' })
const route = useRoute()
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))
</script>
<template>
<div class="reader-host">
<div v-if="!article" 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>
</template>
<template v-else>
<div class="glyph">!</div>
<h3>{{ t('reader.loadFailTitle') }}</h3>
<p v-html="t('common.apiShort')"></p>
<NuxtLink to="/" class="btn btn-ghost">{{ t('common.backHome') }}</NuxtLink>
</template>
</div>
</div>
</div>
</template>