First commit
This commit is contained in:
146
frontend/app/components/ArticlePane.vue
Normal file
146
frontend/app/components/ArticlePane.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user