<script lang="ts" setup> import { IconBookRead, IconSave, IconSettings, IconSendPlaneFill, VButton, VPageHeader, VSpace, Toast, Dialog, IconEye, } from "@halo-dev/components"; import PostSettingModal from "./components/PostSettingModal.vue"; import type { Post, PostRequest } from "@halo-dev/api-client"; import { computed, nextTick, onMounted, provide, ref, toRef, type ComputedRef, } from "vue"; import cloneDeep from "lodash.clonedeep"; import { apiClient } from "@/utils/api-client"; import { useRouteQuery } from "@vueuse/router"; import { useRouter } from "vue-router"; import { randomUUID } from "@/utils/id"; import { useContentCache } from "@console/composables/use-content-cache"; import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points"; import type { EditorProvider } from "@halo-dev/console-shared"; import { useLocalStorage } from "@vueuse/core"; import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue"; import { useI18n } from "vue-i18n"; import UrlPreviewModal from "@/components/preview/UrlPreviewModal.vue"; import { usePostUpdateMutate } from "./composables/use-post-update-mutate"; import { contentAnnotations } from "@/constants/annotations"; import { useAutoSaveContent } from "@console/composables/use-auto-save-content"; import { useContentSnapshot } from "@console/composables/use-content-snapshot"; import { useSaveKeybinding } from "@console/composables/use-save-keybinding"; import { useSessionKeepAlive } from "@/composables/use-session-keep-alive"; const router = useRouter(); const { t } = useI18n(); const { mutateAsync: postUpdateMutate } = usePostUpdateMutate(); // Editor providers const { editorProviders } = useEditorExtensionPoints(); const currentEditorProvider = ref<EditorProvider>(); const storedEditorProviderName = useLocalStorage("editor-provider-name", ""); const handleChangeEditorProvider = async (provider: EditorProvider) => { currentEditorProvider.value = provider; storedEditorProviderName.value = provider.name; formState.value.post.metadata.annotations = { ...formState.value.post.metadata.annotations, [contentAnnotations.PREFERRED_EDITOR]: provider.name, }; formState.value.content.rawType = provider.rawType; if (isUpdateMode.value) { const { data } = await postUpdateMutate(formState.value.post); formState.value.post = data; } }; // fixme: PostRequest type may be wrong interface PostRequestWithContent extends PostRequest { content: { raw: string; content: string; rawType: string; }; } // Post form const initialFormState: PostRequestWithContent = { post: { spec: { title: "", slug: "", template: "", cover: "", deleted: false, publish: false, publishTime: undefined, pinned: false, allowComment: true, visible: "PUBLIC", priority: 0, excerpt: { autoGenerate: true, raw: "", }, categories: [], tags: [], htmlMetas: [], }, apiVersion: "content.halo.run/v1alpha1", kind: "Post", metadata: { name: randomUUID(), annotations: {}, }, }, content: { raw: "", content: "", rawType: "HTML", }, }; const formState = ref<PostRequestWithContent>(cloneDeep(initialFormState)); const settingModal = ref(false); const saving = ref(false); const publishing = ref(false); const isUpdateMode = computed(() => { return !!formState.value.post.metadata.creationTimestamp; }); // provide some data to editor provide<ComputedRef<string | undefined>>( "owner", computed(() => formState.value.post.spec.owner) ); provide<ComputedRef<string | undefined>>( "publishTime", computed(() => formState.value.post.spec.publishTime) ); provide<ComputedRef<string | undefined>>( "permalink", computed(() => formState.value.post.status?.permalink) ); const handleSave = async (options?: { mute?: boolean }) => { try { if (!options?.mute) { saving.value = true; } // Set default title and slug if (!formState.value.post.spec.title) { formState.value.post.spec.title = t("core.post_editor.untitled"); } if (!formState.value.post.spec.slug) { formState.value.post.spec.slug = new Date().getTime().toString(); } if (isUpdateMode.value) { const { data } = await apiClient.post.updatePostContent({ name: formState.value.post.metadata.name, content: formState.value.content, }); formState.value.post = data; } else { // Clear new post content cache handleClearCache(); const { data } = await apiClient.post.draftPost({ postRequest: formState.value, }); formState.value.post = data; name.value = data.metadata.name; } if (!options?.mute) { Toast.success(t("core.common.toast.save_success")); } handleClearCache(formState.value.post.metadata.name as string); await handleFetchContent(); await handleFetchSnapshot(); } catch (e) { console.error("Failed to save post", e); Toast.error(t("core.common.toast.save_failed_and_retry")); } finally { saving.value = false; } }; const returnToView = useRouteQuery<string>("returnToView"); const handlePublish = async () => { try { publishing.value = true; if (isUpdateMode.value) { const { name: postName } = formState.value.post.metadata; const { permalink } = formState.value.post.status || {}; await apiClient.post.updatePostContent({ name: postName, content: formState.value.content, }); await apiClient.post.publishPost({ name: postName, }); if (returnToView.value === "true" && permalink) { window.location.href = permalink; } else { router.back(); } } else { const { data } = await apiClient.post.draftPost({ postRequest: formState.value, }); await apiClient.post.publishPost({ name: data.metadata.name, }); // Clear new post content cache handleClearCache(); router.push({ name: "Posts" }); } Toast.success(t("core.common.toast.publish_success"), { duration: 2000, }); handleClearCache(name.value as string); } catch (error) { console.error("Failed to publish post", error); Toast.error(t("core.common.toast.publish_failed_and_retry")); } finally { publishing.value = false; } }; const handlePublishClick = () => { if (isUpdateMode.value) { handlePublish(); } else { settingModal.value = true; } }; const handleFetchContent = async () => { if (!formState.value.post.spec.headSnapshot) { return; } const { data } = await apiClient.post.fetchPostHeadContent({ name: formState.value.post.metadata.name, }); formState.value.content = Object.assign(formState.value.content, data); // get editor provider if (!currentEditorProvider.value) { const preferredEditor = editorProviders.value.find( (provider) => provider.name === formState.value.post.metadata.annotations?.[ contentAnnotations.PREFERRED_EDITOR ] ); const provider = preferredEditor || editorProviders.value.find( (provider) => provider.rawType === data.rawType ); if (provider) { currentEditorProvider.value = provider; formState.value.post.metadata.annotations = { ...formState.value.post.metadata.annotations, [contentAnnotations.PREFERRED_EDITOR]: provider.name, }; const { data } = await postUpdateMutate(formState.value.post); formState.value.post = data; } else { Dialog.warning({ title: t("core.common.dialog.titles.warning"), description: t("core.common.dialog.descriptions.editor_not_found", { raw_type: data.rawType, }), confirmText: t("core.common.buttons.confirm"), showCancel: false, onConfirm: () => { router.back(); }, }); } await nextTick(); } }; const handleOpenSettingModal = async () => { const { data: latestPost } = await apiClient.extension.post.getcontentHaloRunV1alpha1Post({ name: formState.value.post.metadata.name, }); formState.value.post = latestPost; settingModal.value = true; }; // Post settings const onSettingSaved = (post: Post) => { // Set route query parameter if (!isUpdateMode.value) { name.value = post.metadata.name; } formState.value.post = post; settingModal.value = false; if (!isUpdateMode.value) { handleSave(); } }; const onSettingPublished = (post: Post) => { formState.value.post = post; settingModal.value = false; handlePublish(); }; // Get post data when the route contains the name parameter const name = useRouteQuery<string>("name"); onMounted(async () => { if (name.value) { // fetch post const { data: post } = await apiClient.extension.post.getcontentHaloRunV1alpha1Post({ name: name.value as string, }); formState.value.post = post; // fetch post content await handleFetchContent(); } else { // Set default editor const provider = editorProviders.value.find( (provider) => provider.name === storedEditorProviderName.value ) || editorProviders.value[0]; if (provider) { currentEditorProvider.value = provider; formState.value.content.rawType = provider.rawType; } formState.value.post.metadata.annotations = { [contentAnnotations.PREFERRED_EDITOR]: provider.name, }; } handleResetCache(); }); const headSnapshot = computed(() => { return formState.value.post.spec.headSnapshot; }); const { version, handleFetchSnapshot } = useContentSnapshot(headSnapshot); // Post content cache const { currentCache, handleSetContentCache, handleResetCache, handleClearCache, } = useContentCache( "post-content-cache", name, toRef(formState.value.content, "raw"), version ); useAutoSaveContent(currentCache, toRef(formState.value.content, "raw"), () => { // Do not save when the setting modal is open if (settingModal.value) { return; } handleSave({ mute: true }); }); // Post preview const previewModal = ref(false); const previewPending = ref(false); const handlePreview = async () => { previewPending.value = true; await handleSave({ mute: true }); previewModal.value = true; previewPending.value = false; }; useSaveKeybinding(handleSave); // Keep session alive useSessionKeepAlive(); // Upload image async function handleUploadImage(file: File) { if (!isUpdateMode.value) { await handleSave(); } const { data } = await apiClient.uc.attachment.createAttachmentForPost({ file, postName: formState.value.post.metadata.name, waitForPermalink: true, }); return data; } </script> <template> <PostSettingModal v-model:visible="settingModal" :post="formState.post" :publish-support="!isUpdateMode" :only-emit="!isUpdateMode" @saved="onSettingSaved" @published="onSettingPublished" /> <UrlPreviewModal v-if="isUpdateMode" v-model:visible="previewModal" :title="formState.post.spec.title" :url="`/preview/posts/${formState.post.metadata.name}`" /> <VPageHeader :title="$t('core.post.title')"> <template #icon> <IconBookRead class="mr-2 self-center" /> </template> <template #actions> <VSpace> <EditorProviderSelector v-if="editorProviders.length > 1" :provider="currentEditorProvider" :allow-forced-select="!isUpdateMode" @select="handleChangeEditorProvider" /> <VButton size="sm" type="default" :loading="previewPending" @click="handlePreview" > <template #icon> <IconEye class="h-full w-full" /> </template> {{ $t("core.common.buttons.preview") }} </VButton> <VButton :loading="saving" size="sm" type="default" @click="handleSave"> <template #icon> <IconSave class="h-full w-full" /> </template> {{ $t("core.common.buttons.save") }} </VButton> <VButton v-if="isUpdateMode" size="sm" type="default" @click="handleOpenSettingModal" > <template #icon> <IconSettings class="h-full w-full" /> </template> {{ $t("core.common.buttons.setting") }} </VButton> <VButton type="secondary" :loading="publishing" @click="handlePublishClick" > <template #icon> <IconSendPlaneFill class="h-full w-full" /> </template> {{ $t("core.common.buttons.publish") }} </VButton> </VSpace> </template> </VPageHeader> <div class="editor border-t" style="height: calc(100vh - 3.5rem)"> <component :is="currentEditorProvider.component" v-if="currentEditorProvider" v-model:raw="formState.content.raw" v-model:content="formState.content.content" :upload-image="handleUploadImage" class="h-full" @update="handleSetContentCache" /> </div> </template>