147 lines
5.2 KiB
Vue
147 lines
5.2 KiB
Vue
<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>
|