Files
mathew/frontend/app/components/ArticlePane.vue
2026-09-30 01:49:35 +02:00

147 lines
5.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/* One page of the open book. Its left edge is a spine — the title, set
vertically, with the × that closes the page — and the article itself
scrolls on its own inside the page. A newer page slides over this one and
leaves only the spine showing, the way a stack of read pages does; click
anywhere on the page — or its spine — to turn back to it.
Everything but the spine of a page that isn't in view is `inert`: it can
still be seen while you slide the stack sideways, but it doesn't take
focus or clicks — the pane's own click handler catches those instead, so
one click turns to the page before its links do anything. */
const props = defineProps<{
slug: string
index: number
active: boolean
}>()
const emit = defineEmits<{
activate: []
close: []
remove: []
}>()
const { get, unreadable } = useArticleLibrary()
const { t, locale } = useI18n()
const { th } = useAppLocale()
const article = computed(() => get(props.slug))
const failed = computed(() => props.slug in unreadable.value)
const title = computed(() => article.value?.title ?? props.slug)
const language = computed(() => articleLanguage(article.value ?? {}))
const languageLabel = computed(() => languageName(language.value))
/* every language this article exists in, the one in view included */
const versions = computed(() => article.value?.versions ?? [])
/* the article behind this document, and the slug its editor answers to:
the identity when the canonical version is still around, this document's
own slug when only translations of it remain */
const identity = computed(() => identityOf(article.value ?? { slug: props.slug }))
const editorSlug = computed(() =>
versions.value.some((v) => v.slug === identity.value) ? identity.value : props.slug
)
const updated = computed(() => {
const d = article.value?.updatedAt ? new Date(article.value.updatedAt) : null
if (!d || Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString(locale.value, { dateStyle: 'medium' })
})
function turnToThisPage() {
if (!props.active) emit('activate')
}
/* a tag read at the foot of the article: “probability” → “Probability” */
function topicLabel(tag: string) {
const t = tag.trim()
return t.charAt(0).toUpperCase() + t.slice(1)
}
</script>
<template>
<section
class="book-pane"
:class="{ active }"
:data-slug="slug"
:data-index="index"
:style="{ '--i': index }"
:aria-label="t('pane.aria', { title })"
@click="turnToThisPage"
>
<div
class="pane-spine"
role="button"
tabindex="0"
:aria-current="active ? 'page' : undefined"
:aria-disabled="active"
:title="t('pane.spineHint', { title })"
@click.stop="turnToThisPage"
@keydown.enter.prevent="turnToThisPage"
>
<button
type="button"
class="spine-close"
:aria-label="t('common.closeTab')"
@click.stop="emit('close')"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<span class="spine-title">{{ title }}</span>
</div>
<div class="pane-body" :inert="active ? null : ''">
<div class="pane-scroll">
<div v-if="failed && !article" class="empty">
<div class="glyph">!</div>
<h3>{{ t('pane.readFailTitle') }}</h3>
<p v-html="th('pane.readFailText', { path: `/wiki/${slug}` })"></p>
</div>
<div v-else-if="!article" class="pane-skeleton">
<div class="skeleton" style="height: 36px; width: 52%; margin-bottom: 14px" />
<div class="skeleton" style="height: 18px; width: 78%; margin-bottom: 36px; border-radius: 8px" />
<div class="skeleton" style="height: 220px" />
</div>
<article v-else class="article-page">
<header class="article-head">
<div class="article-eyebrow">
<span v-if="updated">{{ t('pane.lastEdited', { date: updated }) }}</span>
</div>
<div class="article-title-row">
<h1 class="article-title" :lang="language">{{ article.title }}</h1>
<div v-if="active" class="article-actions">
<ArticleActionsMenu
:identity="identity"
:versions="versions"
:current-slug="slug"
:language="language"
:editor-slug="editorSlug"
@remove="emit('remove')"
/>
</div>
</div>
</header>
<div class="article-body">
<MarkdownView :content="article.content ?? ''" :namespace="slug" intercept-links />
</div>
<footer v-if="article.tags?.length" class="article-topics">
<span class="article-topics-label">{{ t('pane.topicsLabel') }}</span>
<NuxtLink
v-for="t in article.tags"
:key="t"
:to="{ path: '/articles/', query: { tag: t } }"
class="topic-link"
>
{{ topicLabel(t) }}
</NuxtLink>
</footer>
</article>
</div>
</div>
</section>
</template>