624 lines
16 KiB
Vue
624 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
|
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
|
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 EditorStatusBar from '@/components/EditorStatusBar.vue'
|
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|
import ImageUploader from '@/components/ImageUploader.vue'
|
|
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
|
import type { Article } from '@/composables/useArticles'
|
|
import { useArticleDraft } from '@/composables/useArticleDraft'
|
|
import type { MediaItem } from '@/composables/useMedia'
|
|
import { bodyCounts } from '@/lib/bodyCounts'
|
|
import { bodyImageCount, bodyImageIds } from '@/lib/bodyImageIds'
|
|
import { formatSize } from '@/lib/mediaFormat'
|
|
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
|
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
|
import { useSeenImagesStore } from '@/stores/seenImages'
|
|
import { useVariantCatalogStore } from '@/stores/variantCatalog'
|
|
|
|
const SKELETON_DELAY_MS = 150
|
|
|
|
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { request } = useApi()
|
|
const overlay = useLocalizedOverlay()
|
|
const toast = useLocalizedToast()
|
|
|
|
const articleId = Number(route.params.id)
|
|
const draft = useArticleDraft(articleId)
|
|
const seenImages = useSeenImagesStore()
|
|
const variantCatalog = useVariantCatalogStore()
|
|
const canManageVariants = useCanManageVariants()
|
|
|
|
const loadState = ref<LoadState>('loading')
|
|
const showSkeleton = ref(false)
|
|
const article = ref<Article | null>(null)
|
|
const previewOn = ref(false)
|
|
const detailsOpen = ref(false)
|
|
const publishPending = ref<string | undefined>(undefined)
|
|
const editorRef = ref<InstanceType<typeof ArticleEditor> | null>(null)
|
|
const imageUploaderOpen = ref(false)
|
|
const imageUploaderFile = ref<File | null>(null)
|
|
const imageIdsInArticle = ref<number[]>([])
|
|
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}?format=html`)
|
|
article.value = result
|
|
draft.load(result.body, new Date(result.updated_at))
|
|
loadState.value = 'loaded'
|
|
if (canManageVariants.value && bodyHasVariantImage(result.body)) {
|
|
variantCatalog.load()
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
loadState.value = 'notFound'
|
|
return
|
|
}
|
|
loadState.value = 'error'
|
|
} finally {
|
|
clearSkeletonTimer()
|
|
showSkeleton.value = false
|
|
}
|
|
}
|
|
|
|
const counts = computed(() => bodyCounts(draft.body.value))
|
|
|
|
const saveEnabled = computed(() => draft.state.value === 'dirty' && loadState.value === 'loaded')
|
|
|
|
const publishDisabled = computed(() => 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',
|
|
get title() {
|
|
return t('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')
|
|
}
|
|
|
|
async function setOnline(makeOnline: boolean) {
|
|
if (!article.value) {
|
|
return
|
|
}
|
|
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
|
|
try {
|
|
const updated = await request<Article>(
|
|
`/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
|
|
{ method: 'PATCH' },
|
|
)
|
|
article.value = updated
|
|
toast.add(() => ({
|
|
id: `article-${makeOnline ? 'published' : 'unpublished'}-${articleId}`,
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return makeOnline ? t('Article published') : t('Article unpublished')
|
|
},
|
|
get description() {
|
|
return makeOnline
|
|
? t('\u0022{0}\u0022 is now online.', [updated.title])
|
|
: t('\u0022{0}\u0022 is now offline.', [updated.title])
|
|
},
|
|
}))
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
loadState.value = 'notFound'
|
|
return
|
|
}
|
|
toast.add(() => ({
|
|
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${articleId}`,
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-x',
|
|
get title() {
|
|
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
|
|
},
|
|
get description() {
|
|
return t("The server didn't respond. Nothing changed.")
|
|
},
|
|
duration: 0,
|
|
actions: [
|
|
{
|
|
get label() {
|
|
return t('Retry')
|
|
},
|
|
onClick: () => setOnline(makeOnline),
|
|
},
|
|
],
|
|
}))
|
|
} finally {
|
|
publishPending.value = undefined
|
|
}
|
|
}
|
|
|
|
function handlePublish() {
|
|
setOnline(true)
|
|
}
|
|
|
|
function handleUnpublish() {
|
|
setOnline(false)
|
|
}
|
|
|
|
function deleteDescription() {
|
|
const count = bodyImageCount(article.value?.body ?? '')
|
|
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
|
|
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
|
|
article.value?.title ?? '',
|
|
imagesPhrase,
|
|
])
|
|
}
|
|
|
|
async function performDelete() {
|
|
try {
|
|
await request(`/v1/article/${articleId}`, { method: 'DELETE' })
|
|
toast.add(() => ({
|
|
id: `article-deleted-${articleId}`,
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return t('Article deleted')
|
|
},
|
|
get description() {
|
|
return t('\u0022{0}\u0022 is gone.', [article.value?.title ?? ''])
|
|
},
|
|
}))
|
|
router.push('/article')
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
loadState.value = 'notFound'
|
|
return
|
|
}
|
|
throw new Error(
|
|
t("Couldn't delete the article, so it's still there. Try again in a moment."),
|
|
)
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!article.value) {
|
|
return
|
|
}
|
|
await overlay
|
|
.create(ConfirmDialog, {
|
|
props: {
|
|
get title() {
|
|
return t('Delete this article?')
|
|
},
|
|
get description() {
|
|
return deleteDescription()
|
|
},
|
|
get confirmLabel() {
|
|
return t('Delete article')
|
|
},
|
|
get cancelLabel() {
|
|
return t('Cancel')
|
|
},
|
|
get pendingLabel() {
|
|
return t('Deleting…')
|
|
},
|
|
tone: 'danger',
|
|
action: performDelete,
|
|
},
|
|
})
|
|
.open()
|
|
}
|
|
|
|
function openImageUploader(file: File | null) {
|
|
imageUploaderFile.value = file
|
|
imageIdsInArticle.value = bodyImageIds(draft.body.value)
|
|
imageUploaderOpen.value = true
|
|
}
|
|
|
|
function onRequestImage() {
|
|
openImageUploader(null)
|
|
}
|
|
|
|
function onEditorDrop(event: DragEvent) {
|
|
const file = event.dataTransfer?.files?.[0]
|
|
if (!file) {
|
|
return
|
|
}
|
|
openImageUploader(file)
|
|
}
|
|
|
|
function announceImageAdded() {
|
|
toast.add(() => ({
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return t('Image added')
|
|
},
|
|
get description() {
|
|
return t('Save to keep it in the article.')
|
|
},
|
|
}))
|
|
}
|
|
|
|
function onImageUploaded(item: MediaItem) {
|
|
seenImages.remember([item])
|
|
editorRef.value?.insertImage(item.url, '', item.id)
|
|
announceImageAdded()
|
|
}
|
|
|
|
function onImageInserted(item: MediaItem, alt: string, src: string) {
|
|
seenImages.remember([item])
|
|
editorRef.value?.insertImage(src, alt, item.id)
|
|
announceImageAdded()
|
|
}
|
|
|
|
async function performRemoveImage(imageId: number) {
|
|
editorRef.value?.removeImage(imageId)
|
|
toast.add(() => ({
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return t('Image removed')
|
|
},
|
|
get description() {
|
|
return t("It's still in your library.")
|
|
},
|
|
}))
|
|
}
|
|
|
|
function removalPreview(imageId: number) {
|
|
const seen = seenImages.find(imageId)
|
|
return {
|
|
src: editorRef.value?.imageSrc(imageId) ?? '',
|
|
filename: seen?.filename,
|
|
size: seen ? formatSize(seen.size) : undefined,
|
|
}
|
|
}
|
|
|
|
async function onRequestRemoveImage(imageId: number) {
|
|
await overlay
|
|
.create(ConfirmDialog, {
|
|
props: {
|
|
get title() {
|
|
return t('Remove this image from the article?')
|
|
},
|
|
get description() {
|
|
return t(
|
|
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
|
|
)
|
|
},
|
|
get confirmLabel() {
|
|
return t('Remove image')
|
|
},
|
|
get cancelLabel() {
|
|
return t('Cancel')
|
|
},
|
|
get pendingLabel() {
|
|
return t('Removing…')
|
|
},
|
|
tone: 'neutral',
|
|
preview: removalPreview(imageId),
|
|
action: () => performRemoveImage(imageId),
|
|
},
|
|
})
|
|
.open()
|
|
}
|
|
|
|
function onImageUploaderClosed() {
|
|
imageUploaderFile.value = null
|
|
}
|
|
|
|
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: {
|
|
get title() {
|
|
return t('Leave without saving?')
|
|
},
|
|
get description() {
|
|
return t("Your changes to \u0022{0}\u0022 haven't been saved.", [
|
|
article.value?.title ?? '',
|
|
])
|
|
},
|
|
get confirmLabel() {
|
|
return t('Keep editing')
|
|
},
|
|
get cancelLabel() {
|
|
return t('Discard changes')
|
|
},
|
|
get pendingLabel() {
|
|
return t('Keep editing')
|
|
},
|
|
tone: 'neutral',
|
|
action: async () => {},
|
|
dismissResolvesTo: 'confirm',
|
|
},
|
|
})
|
|
.open()
|
|
return !confirmed
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<DashboardShell :mobile-title="t('Edit article')" full-bleed>
|
|
<template v-if="loadState === 'loaded'" #breadcrumb>
|
|
<UBreadcrumb
|
|
:items="[
|
|
{
|
|
get label() {
|
|
return t('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="t('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="t('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="t('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="t('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-2.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"
|
|
>
|
|
{{ t('Save') }}
|
|
</UButton>
|
|
<PublishControl
|
|
class="h-8 gap-1.5 px-3"
|
|
:online="article.is_online"
|
|
:dirty="draft.state.value === 'dirty'"
|
|
:pending="publishPending"
|
|
:disabled="publishDisabled"
|
|
@publish="handlePublish"
|
|
@unpublish="handleUnpublish"
|
|
/>
|
|
<UButton
|
|
icon="i-lucide-trash-2"
|
|
color="error"
|
|
variant="ghost"
|
|
class="h-8 gap-1.5 px-3"
|
|
@click="handleDelete"
|
|
>
|
|
{{ t('Delete') }}
|
|
</UButton>
|
|
</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>
|
|
<div v-else class="min-h-0 flex-1" @dragover.prevent @drop.prevent="onEditorDrop">
|
|
<ArticleEditor
|
|
ref="editorRef"
|
|
class="h-full"
|
|
:model-value="draft.body.value"
|
|
:article-id="articleId"
|
|
@update:model-value="draft.edit"
|
|
@request-image="onRequestImage"
|
|
@request-remove-image="onRequestRemoveImage"
|
|
>
|
|
<template #footer>
|
|
<EditorStatusBar
|
|
:words="counts.words"
|
|
:characters="counts.characters"
|
|
:state="draft.state.value"
|
|
/>
|
|
</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>
|
|
|
|
<ImageUploader
|
|
v-model:open="imageUploaderOpen"
|
|
:initial-file="imageUploaderFile"
|
|
:in-article-ids="imageIdsInArticle"
|
|
@uploaded="onImageUploaded"
|
|
@inserted="onImageInserted"
|
|
@close="onImageUploaderClosed"
|
|
/>
|
|
|
|
<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="t('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"
|
|
>
|
|
{{ t('Save') }}
|
|
</UButton>
|
|
<PublishControl
|
|
class="h-11 flex-1 justify-center"
|
|
hide-icon
|
|
:online="article.is_online"
|
|
:dirty="draft.state.value === 'dirty'"
|
|
:pending="publishPending"
|
|
:disabled="publishDisabled"
|
|
@publish="handlePublish"
|
|
@unpublish="handleUnpublish"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</DashboardShell>
|
|
</template>
|