feat: article editor core
This commit is contained in:
1 parent
d493a181e1
commit
a4734b2c91
55 files changed
+4186
-496
No files matched your search
@@ -1,9 +1,365 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ErrorView from '@/components/ErrorView.vue'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import ArticleDetails from '@/components/ArticleDetails.vue'
|
||||
import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleDraft } from '@/composables/useArticleDraft'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const BODY_MAX_LENGTH = 10000
|
||||
|
||||
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { request } = useApi()
|
||||
const overlay = useOverlay()
|
||||
const toast = useToast()
|
||||
|
||||
const articleId = Number(route.params.id)
|
||||
const draft = useArticleDraft(articleId)
|
||||
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const showSkeleton = ref(false)
|
||||
const article = ref<Article | null>(null)
|
||||
const previewOn = ref(false)
|
||||
const detailsOpen = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticle() {
|
||||
loadState.value = 'loading'
|
||||
showSkeleton.value = false
|
||||
clearSkeletonTimer()
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
|
||||
try {
|
||||
const result = await request<Article>(`/v1/article/${articleId}`)
|
||||
article.value = result
|
||||
draft.load(result.body, new Date(result.updated_at))
|
||||
loadState.value = 'loaded'
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
return
|
||||
}
|
||||
loadState.value = 'error'
|
||||
} finally {
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const bodyLength = computed(() => draft.body.value.length)
|
||||
const overLimit = computed(() => bodyLength.value > BODY_MAX_LENGTH)
|
||||
|
||||
const saveEnabled = computed(
|
||||
() =>
|
||||
draft.state.value === 'dirty' &&
|
||||
!overLimit.value &&
|
||||
loadState.value === 'loaded',
|
||||
)
|
||||
|
||||
const publishDisabled = computed(
|
||||
() => overLimit.value || draft.state.value === 'offline',
|
||||
)
|
||||
|
||||
async function runSave() {
|
||||
const outcome = await draft.save()
|
||||
if (outcome === 'not-found') {
|
||||
toast.add({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: 'That article no longer exists.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function performSave() {
|
||||
if (!saveEnabled.value || !article.value) {
|
||||
return
|
||||
}
|
||||
await runSave()
|
||||
}
|
||||
|
||||
function retrySave() {
|
||||
if (!article.value) {
|
||||
return
|
||||
}
|
||||
runSave()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/article')
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault()
|
||||
performSave()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
loadArticle()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
clearSkeletonTimer()
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (authRedirectPending.value || draft.state.value !== 'dirty') {
|
||||
return true
|
||||
}
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
title: 'Leave without saving?',
|
||||
description: `Your changes to "${article.value?.title ?? ''}" haven't been saved.`,
|
||||
confirmLabel: 'Keep editing',
|
||||
cancelLabel: 'Discard changes',
|
||||
pendingLabel: 'Keep editing',
|
||||
tone: 'neutral',
|
||||
action: async () => {},
|
||||
dismissResolvesTo: 'confirm',
|
||||
},
|
||||
})
|
||||
.open()
|
||||
return !confirmed
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<p>Editor placeholder.</p>
|
||||
<DashboardShell mobile-title="Edit article" full-bleed>
|
||||
<template v-if="loadState === 'loaded'" #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="[
|
||||
{ label: 'Articles', to: '/article' },
|
||||
{ label: article?.title ?? '' },
|
||||
]"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
|
||||
<ErrorView
|
||||
v-else-if="loadState === 'error'"
|
||||
kind="error"
|
||||
@retry="loadArticle"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="loadState === 'loading' && showSkeleton"
|
||||
aria-busy="true"
|
||||
class="flex min-h-0 flex-1 flex-col lg:flex-row"
|
||||
>
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
aria-label="Back to Articles"
|
||||
@click="goBack"
|
||||
/>
|
||||
<USkeleton
|
||||
class="h-5 w-80 rounded-none"
|
||||
data-testid="skeleton-title"
|
||||
/>
|
||||
<USkeleton class="h-5.5 w-15 rounded-none" />
|
||||
</div>
|
||||
<div
|
||||
class="min-h-0 max-w-168 flex-1 space-y-3.5 px-12 py-10"
|
||||
data-testid="skeleton-content"
|
||||
>
|
||||
<USkeleton class="h-3.5 w-full rounded-none" />
|
||||
<USkeleton class="h-3.5 w-11/12 rounded-none" />
|
||||
<USkeleton class="h-3.5 w-4/5 rounded-none" />
|
||||
<USkeleton class="mt-4.5 h-5.5 w-2/5 rounded-none" />
|
||||
<USkeleton class="h-3.5 w-full rounded-none" />
|
||||
<USkeleton class="h-3.5 w-3/4 rounded-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
|
||||
data-testid="skeleton-details"
|
||||
>
|
||||
<USkeleton class="h-3 w-full rounded-none" />
|
||||
<USkeleton class="h-3 w-full rounded-none" />
|
||||
<USkeleton class="h-3 w-3/5 rounded-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="loadState === 'loaded' && article"
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-wrap items-center gap-3 border-b border-default bg-default px-6 py-3.5"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
aria-label="Back to Articles"
|
||||
@click="goBack"
|
||||
/>
|
||||
<div
|
||||
class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5"
|
||||
>
|
||||
<h1
|
||||
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
|
||||
title="The title can't be changed after creation."
|
||||
>
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
<ArticleStatusBadge :online="article.is_online" size="lg" />
|
||||
</div>
|
||||
<SaveStatus
|
||||
:state="draft.state.value"
|
||||
:saved-at="draft.savedAt.value"
|
||||
@retry="retrySave"
|
||||
/>
|
||||
<div class="hidden items-center gap-3 lg:flex">
|
||||
<USwitch
|
||||
v-model="previewOn"
|
||||
label="Preview"
|
||||
:ui="{
|
||||
root: 'items-center gap-2',
|
||||
container: 'h-auto',
|
||||
base: 'h-4.5 w-8 p-0.5',
|
||||
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-3.5',
|
||||
wrapper: 'ms-0',
|
||||
label: 'text-sm font-normal text-toned',
|
||||
}"
|
||||
/>
|
||||
<UButton
|
||||
data-testid="save-button"
|
||||
icon="i-lucide-save"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:disabled="!saveEnabled"
|
||||
@click="performSave"
|
||||
>
|
||||
Save
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:online="article.is_online"
|
||||
:dirty="draft.state.value === 'dirty'"
|
||||
:disabled="publishDisabled"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
aria-label="More actions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewOn" class="flex min-h-0 flex-1">
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto bg-muted px-12 py-10"
|
||||
data-testid="article-preview"
|
||||
>
|
||||
<div class="article-content mx-auto">
|
||||
<h2 class="text-2xl font-semibold text-highlighted">
|
||||
{{ article.title }}
|
||||
</h2>
|
||||
<div v-html="draft.body.value" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="hidden w-70 flex-shrink-0 border-l border-default bg-default p-5 lg:block"
|
||||
>
|
||||
<ArticleDetails
|
||||
:article="article"
|
||||
v-model:open="detailsOpen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ArticleEditor
|
||||
v-else
|
||||
class="min-h-0 flex-1"
|
||||
:model-value="draft.body.value"
|
||||
:article-id="articleId"
|
||||
@update:model-value="draft.edit"
|
||||
>
|
||||
<template #footer>
|
||||
<CharacterCounter
|
||||
variant="footer"
|
||||
:value="bodyLength"
|
||||
:max="BODY_MAX_LENGTH"
|
||||
/>
|
||||
</template>
|
||||
<template #aside>
|
||||
<div class="h-full border-l border-default bg-default p-5">
|
||||
<ArticleDetails
|
||||
:article="article"
|
||||
v-model:open="detailsOpen"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ArticleEditor>
|
||||
|
||||
<div
|
||||
class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-panel-right"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="size-11 justify-center"
|
||||
aria-label="Details"
|
||||
@click="detailsOpen = true"
|
||||
/>
|
||||
<UButton
|
||||
data-testid="save-button-mobile"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 flex-1 justify-center"
|
||||
:disabled="!saveEnabled"
|
||||
@click="performSave"
|
||||
>
|
||||
Save
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-11 flex-1 justify-center"
|
||||
hide-icon
|
||||
:online="article.is_online"
|
||||
:dirty="draft.state.value === 'dirty'"
|
||||
:disabled="publishDisabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
Reference in new issue
Block a user