Slave/blog #9

Merged
nobswebdev merged 14 commits from slave/blog into master 2026-09-16 20:15:08 +00:00
11 changed files with 809 additions and 0 deletions
Showing only changes of commit 762af5c4f3 - Show all commits
+1
View File
@@ -16,6 +16,7 @@
<el-menu-item index="/products">Products</el-menu-item> <el-menu-item index="/products">Products</el-menu-item>
<el-menu-item index="/categories">Categories</el-menu-item> <el-menu-item index="/categories">Categories</el-menu-item>
<el-menu-item index="/discount-codes">Discount codes</el-menu-item> <el-menu-item index="/discount-codes">Discount codes</el-menu-item>
<el-menu-item index="/blog">Blog</el-menu-item>
<el-menu-item index="/orders">Orders</el-menu-item> <el-menu-item index="/orders">Orders</el-menu-item>
<el-menu-item index="/wallet">Wallet</el-menu-item> <el-menu-item index="/wallet">Wallet</el-menu-item>
<el-menu-item index="/settings">Settings</el-menu-item> <el-menu-item index="/settings">Settings</el-menu-item>
+2
View File
@@ -5,6 +5,8 @@ export const ROUTE_NAMES = {
Products: 'Products', Products: 'Products',
Categories: 'Categories', Categories: 'Categories',
DiscountCodes: 'DiscountCodes', DiscountCodes: 'DiscountCodes',
BlogPosts: 'BlogPosts',
BlogPostDetail: 'BlogPostDetail',
ShopSettings: 'ShopSettings', ShopSettings: 'ShopSettings',
Notifications: 'Notifications', Notifications: 'Notifications',
MoneroWallet: 'MoneroWallet', MoneroWallet: 'MoneroWallet',
+12
View File
@@ -41,6 +41,18 @@ const router = createRouter({
component: () => import('../views/CmsDiscountCodesView.vue'), component: () => import('../views/CmsDiscountCodesView.vue'),
meta: { requiresAuth: true, title: 'CMS - Discount codes' } meta: { requiresAuth: true, title: 'CMS - Discount codes' }
}, },
{
path: '/blog/:id',
name: ROUTE_NAMES.BlogPostDetail,
component: () => import('../views/CmsBlogPostDetailView.vue'),
meta: { requiresAuth: true, title: 'CMS - Blog post', activeMenu: '/blog' }
},
{
path: '/blog',
name: ROUTE_NAMES.BlogPosts,
component: () => import('../views/CmsBlogPostsView.vue'),
meta: { requiresAuth: true, title: 'CMS - Blog' }
},
{ {
path: '/orders/:id', path: '/orders/:id',
name: ROUTE_NAMES.OrderDetail, name: ROUTE_NAMES.OrderDetail,
+129
View File
@@ -0,0 +1,129 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { ROUTE_NAMES } from '@/consts/routeNames';
import { api } from '@/plugins/axios';
import type { BlogPost } from '@/types/blog/BlogPost';
import type { UpdateBlogPostPayload } from '@/types/blog/UpdateBlogPostPayload';
import type { PaginatedResponse } from '@/types/PaginatedResponse';
import { getBlogPostTitle } from '@/utils/blog/getBlogPostTitle';
export const useBlogPostsStore = defineStore('blogPosts', () => {
const route = useRoute();
const blogPosts = ref<BlogPost[]>([]);
const currentBlogPostId = computed<string | null>(() => {
if (route.name !== ROUTE_NAMES.BlogPostDetail) {
return null;
}
const id = route.params.id;
return typeof id === 'string' && id ? id : null;
});
const currentBlogPost = computed<BlogPost | null>(() => {
if (!currentBlogPostId.value) {
return null;
}
return blogPosts.value.find(post => post.id === currentBlogPostId.value) ?? null;
});
const currentBlogPostDisplayTitle = computed(() =>
currentBlogPost.value ? getBlogPostTitle(currentBlogPost.value.title) : null
);
const upsertBlogPost = (data: BlogPost): void => {
const idx = blogPosts.value.findIndex(post => post.id === data.id);
if (idx !== -1) {
blogPosts.value[idx] = data;
} else {
blogPosts.value.unshift(data);
}
};
const fetchBlogPosts = async ({
page,
limit
}: {
page: number;
limit: number;
}): Promise<PaginatedResponse<BlogPost>> => {
const { data } = await api.get<PaginatedResponse<BlogPost>>('/blog-posts', {
params: { page, limit }
});
return data;
};
const fetchBlogPostById = async (id: string): Promise<BlogPost> => {
const { data } = await api.get<BlogPost>(`/blog-posts/${id}`);
upsertBlogPost(data);
return data;
};
const createDraftBlogPost = async (): Promise<BlogPost> => {
const { data } = await api.post<BlogPost>('/blog-posts');
upsertBlogPost(data);
return data;
};
const updateBlogPost = async (id: string, payload: UpdateBlogPostPayload): Promise<BlogPost> => {
const { data } = await api.patch<BlogPost>(`/blog-posts/${id}`, payload);
upsertBlogPost(data);
return data;
};
const deleteBlogPost = async (id: string): Promise<void> => {
await api.delete(`/blog-posts/${id}`);
blogPosts.value = blogPosts.value.filter(post => post.id !== id);
};
const uploadThumbnail = async (id: string, file: File): Promise<BlogPost> => {
const formData = new FormData();
formData.append('file', file);
const { data } = await api.post<BlogPost>(`/blog-posts/${id}/thumbnail`, formData);
upsertBlogPost(data);
return data;
};
const uploadBanner = async (id: string, file: File): Promise<BlogPost> => {
const formData = new FormData();
formData.append('file', file);
const { data } = await api.post<BlogPost>(`/blog-posts/${id}/banner`, formData);
upsertBlogPost(data);
return data;
};
return {
blogPosts,
currentBlogPost,
currentBlogPostDisplayTitle,
currentBlogPostId,
fetchBlogPosts,
fetchBlogPostById,
createDraftBlogPost,
updateBlogPost,
deleteBlogPost,
uploadThumbnail,
uploadBanner
};
});
+10
View File
@@ -5,6 +5,7 @@ import type { ConnectSimplexNotificationsPayload } from '@/types/shopSettings/Co
import type { ShopSettings } from '@/types/shopSettings/ShopSettings'; import type { ShopSettings } from '@/types/shopSettings/ShopSettings';
import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload'; import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload';
import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload'; import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload';
import type { UpdateBlogEnabledPayload } from '@/types/shopSettings/UpdateBlogEnabledPayload';
import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload'; import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload';
export const useShopSettingsStore = defineStore('shopSettings', () => { export const useShopSettingsStore = defineStore('shopSettings', () => {
@@ -42,6 +43,14 @@ export const useShopSettingsStore = defineStore('shopSettings', () => {
return data; return data;
}; };
const updateBlogEnabled = async (payload: UpdateBlogEnabledPayload): Promise<ShopSettings> => {
const { data } = await api.patch<ShopSettings>('/shop-settings/blog-enabled', payload);
settings.value = data;
return data;
};
const connectSimplexNotifications = async (payload: ConnectSimplexNotificationsPayload): Promise<ShopSettings> => { const connectSimplexNotifications = async (payload: ConnectSimplexNotificationsPayload): Promise<ShopSettings> => {
const { data } = await api.post<ShopSettings>('/shop-settings/simplex-connect', payload); const { data } = await api.post<ShopSettings>('/shop-settings/simplex-connect', payload);
@@ -80,6 +89,7 @@ export const useShopSettingsStore = defineStore('shopSettings', () => {
updateSimplexLink, updateSimplexLink,
updateShippingNote, updateShippingNote,
updateNotifications, updateNotifications,
updateBlogEnabled,
connectSimplexNotifications, connectSimplexNotifications,
uploadShopLogo, uploadShopLogo,
uploadShopFavicon uploadShopFavicon
+10
View File
@@ -0,0 +1,10 @@
export interface BlogPost {
id: string;
title: string;
thumbnailUrl: string | null;
bannerUrl: string | null;
contentHtml: string;
isDraft: boolean;
createdAt: string;
updatedAt: string;
}
@@ -0,0 +1,5 @@
export interface UpdateBlogPostPayload {
title: string;
contentHtml: string;
isDraft: boolean;
}
@@ -17,6 +17,7 @@ export interface ShopSettings {
notifyOnNewOrder: boolean; notifyOnNewOrder: boolean;
notifyOnOrderMessage: boolean; notifyOnOrderMessage: boolean;
simplexNotificationConnected: boolean; simplexNotificationConnected: boolean;
blogEnabled: boolean;
isSetupComplete: boolean; isSetupComplete: boolean;
setupChecklist: SetupChecklist; setupChecklist: SetupChecklist;
createdAt: string | null; createdAt: string | null;
@@ -0,0 +1,3 @@
export interface UpdateBlogEnabledPayload {
blogEnabled: boolean;
}
+467
View File
@@ -0,0 +1,467 @@
<template>
<div>
<div class="flex items-center mb-16">
<el-button link type="primary" @click="router.push({ name: ROUTE_NAMES.BlogPosts })"> ← Blog </el-button>
</div>
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading blog post…">
<el-empty v-if="!loading && loadError" description="Blog post not found" />
<template v-if="!loading && !loadError && currentBlogPost">
<div v-loading="deleteSaving" element-loading-text="Deleting blog post…">
<h3 class="m-0 mb-16">Edit blog post</h3>
<div v-if="currentBlogPost?.isDraft" class="mb-16">
<el-alert
type="info"
title="Draft"
description="This post is hidden from the storefront until you publish it."
:closable="false"
show-icon
/>
</div>
<el-card class="mb-24" shadow="never">
<template #header>
<span>Details</span>
</template>
<el-form ref="postFormRef" label-position="top" :model="postForm" :rules="postRules">
<el-form-item label="Live" class="mb-4">
<el-switch v-model="publishSwitch" />
</el-form-item>
<el-form-item label="Title" prop="title">
<el-input
v-model="postForm.title"
:maxlength="validationBlogPostTitleMaxLength"
show-word-limit
@input="postFormRef?.clearValidate('title')"
/>
</el-form-item>
<el-form-item label="Content">
<rich-text-editor
v-model="postForm.contentHtml"
placeholder="Write your post…"
class="w-full"
/>
</el-form-item>
<el-button type="primary" :loading="postSaving" @click="submitPost"> Save post </el-button>
</el-form>
</el-card>
<el-card class="mb-24" shadow="never">
<template #header>
<span>Thumbnail</span>
</template>
<div class="mb-16 flex flex-col items-start gap-4">
<el-text tag="p" size="small" class="secondary-text w-full">
{{ blogImageHint }}
</el-text>
<el-text tag="p" size="small" class="secondary-text w-full">
Shown on the blog index. Recommended: 16:9 aspect ratio.
</el-text>
</div>
<div v-if="thumbnailPreviewUrl" class="mb-16">
<div class="blog-image-preview-wrap blog-image-preview-wrap--thumb">
<img :src="thumbnailPreviewUrl" alt="" class="blog-image-preview" />
</div>
</div>
<div class="flex items-center gap-12 min-w-0">
<el-upload
ref="thumbnailUploadRef"
:show-file-list="false"
:auto-upload="false"
:limit="1"
:accept="blogImageAccept"
:disabled="thumbnailUploadSaving"
:on-change="onThumbnailChange"
:on-remove="onThumbnailRemove"
>
<el-button type="default">Select image</el-button>
</el-upload>
<template v-if="thumbnailPendingFile">
<el-text size="small" class="flex-ellipsis" :title="thumbnailPendingFile.name">
{{ thumbnailPendingFile.name }}
</el-text>
<el-button
type="primary"
:loading="thumbnailUploadSaving"
@click="submitThumbnailUpload"
>
Upload
</el-button>
</template>
</div>
</el-card>
<el-card class="mb-24" shadow="never">
<template #header>
<span>Banner</span>
</template>
<div class="mb-16 flex flex-col items-start gap-4">
<el-text tag="p" size="small" class="secondary-text w-full">
{{ blogImageHint }}
</el-text>
<el-text tag="p" size="small" class="secondary-text w-full">
Shown at the top of the post detail page. Recommended: 8:3 aspect ratio (e.g.
1600×600).
</el-text>
</div>
<div v-if="bannerPreviewUrl" class="mb-16">
<div class="blog-image-preview-wrap blog-image-preview-wrap--banner">
<img :src="bannerPreviewUrl" alt="" class="blog-image-preview" />
</div>
</div>
<div class="flex items-center gap-12 min-w-0">
<el-upload
ref="bannerUploadRef"
:show-file-list="false"
:auto-upload="false"
:limit="1"
:accept="blogImageAccept"
:disabled="bannerUploadSaving"
:on-change="onBannerChange"
:on-remove="onBannerRemove"
>
<el-button type="default">Select image</el-button>
</el-upload>
<template v-if="bannerPendingFile">
<el-text size="small" class="flex-ellipsis" :title="bannerPendingFile.name">
{{ bannerPendingFile.name }}
</el-text>
<el-button type="primary" :loading="bannerUploadSaving" @click="submitBannerUpload">
Upload
</el-button>
</template>
</div>
</el-card>
<el-button type="danger" plain :loading="deleteSaving" @click="confirmDeletePost">
Delete post
</el-button>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { config } from '@/config';
import { ROUTE_NAMES } from '@/consts/routeNames';
import { useBlogPostsStore } from '@/stores/blogPosts';
import { buildUploadHint } from '@/utils/upload/buildUploadHint';
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
import { resolveUploadPublicUrl } from '@/utils/upload/resolveUploadPublicUrl';
import { validateUpload } from '@/utils/upload/validateUpload';
import {
ElMessage,
ElMessageBox,
type FormInstance,
type FormRules,
type UploadInstance,
type UploadProps
} from 'element-plus';
import { storeToRefs } from 'pinia';
import { computed, onBeforeMount, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const {
blogImage: { accept: blogImageAccept, maxFileBytes: blogImageMaxFileBytes },
validation: { blogPostTitleMaxLength: validationBlogPostTitleMaxLength }
} = config;
const route = useRoute();
const router = useRouter();
const blogPostsStore = useBlogPostsStore();
const { currentBlogPost, currentBlogPostDisplayTitle } = storeToRefs(blogPostsStore);
const { fetchBlogPostById, updateBlogPost, deleteBlogPost, uploadThumbnail, uploadBanner } = blogPostsStore;
const loading = ref(false);
const loadError = ref(false);
const postFormRef = ref<FormInstance>();
const postForm = reactive({
title: '',
contentHtml: '',
isDraft: true
});
const postSaving = ref(false);
const deleteSaving = ref(false);
const thumbnailUploadSaving = ref(false);
const bannerUploadSaving = ref(false);
const thumbnailPendingFile = ref<File | null>(null);
const bannerPendingFile = ref<File | null>(null);
const thumbnailUploadRef = ref<UploadInstance>();
const bannerUploadRef = ref<UploadInstance>();
const postRules: FormRules = {
title: [
{ required: true, message: 'Title is required', trigger: 'blur' },
{
max: validationBlogPostTitleMaxLength,
message: `At most ${validationBlogPostTitleMaxLength} characters`,
trigger: 'blur'
}
]
};
onBeforeMount(async () => {
loadError.value = false;
const id = route.params.id as string;
if (!id) {
loadError.value = true;
return;
}
loading.value = true;
try {
await fetchBlogPostById(id);
if (currentBlogPost.value) {
postForm.title = currentBlogPost.value.title;
postForm.contentHtml = currentBlogPost.value.contentHtml;
postForm.isDraft = currentBlogPost.value.isDraft;
} else {
loadError.value = true;
}
} catch {
loadError.value = true;
} finally {
loading.value = false;
}
});
const blogImageHint = computed(() =>
buildUploadHint({
allowedMimesCsv: blogImageAccept,
maxFileBytes: blogImageMaxFileBytes
})
);
const thumbnailPreviewUrl = computed(() => {
const thumbnailUrl = currentBlogPost.value?.thumbnailUrl;
return thumbnailUrl ? resolveUploadPublicUrl(thumbnailUrl) : null;
});
const bannerPreviewUrl = computed(() => {
const bannerUrl = currentBlogPost.value?.bannerUrl;
return bannerUrl ? resolveUploadPublicUrl(bannerUrl) : null;
});
const publishSwitch = computed<boolean>({
get: () => !postForm.isDraft,
set: (published: boolean) => {
postForm.isDraft = !published;
}
});
const submitPost = async (): Promise<void> => {
const form = postFormRef.value;
if (!form) {
return;
}
try {
await form.validate();
} catch {
return;
}
postSaving.value = true;
try {
await updateBlogPost(route.params.id as string, {
title: postForm.title.trim(),
contentHtml: postForm.contentHtml,
isDraft: postForm.isDraft
});
ElMessage.success('Blog post saved');
} catch (e) {
const message = resolveAxiosErrorMessage(e, 'Could not save blog post');
ElMessage.error(message);
} finally {
postSaving.value = false;
}
};
const onThumbnailChange: UploadProps['onChange'] = uploadFile => {
const file = uploadFile.raw;
if (!file) {
return;
}
const validationError = validateUpload(file, {
allowedMimesCsv: blogImageAccept,
maxFileBytes: blogImageMaxFileBytes
});
if (validationError) {
ElMessage.error(validationError);
thumbnailUploadRef.value?.clearFiles();
thumbnailPendingFile.value = null;
return;
}
thumbnailPendingFile.value = file;
};
const onThumbnailRemove: UploadProps['onRemove'] = () => {
thumbnailPendingFile.value = null;
};
const submitThumbnailUpload = async (): Promise<void> => {
if (!thumbnailPendingFile.value) {
return;
}
thumbnailUploadSaving.value = true;
try {
await uploadThumbnail(route.params.id as string, thumbnailPendingFile.value);
thumbnailPendingFile.value = null;
thumbnailUploadRef.value?.clearFiles();
ElMessage.success('Thumbnail uploaded');
} catch (e) {
const message = resolveAxiosErrorMessage(e, 'Could not upload thumbnail');
ElMessage.error(message);
} finally {
thumbnailUploadSaving.value = false;
}
};
const onBannerChange: UploadProps['onChange'] = uploadFile => {
const file = uploadFile.raw;
if (!file) {
return;
}
const validationError = validateUpload(file, {
allowedMimesCsv: blogImageAccept,
maxFileBytes: blogImageMaxFileBytes
});
if (validationError) {
ElMessage.error(validationError);
bannerUploadRef.value?.clearFiles();
bannerPendingFile.value = null;
return;
}
bannerPendingFile.value = file;
};
const onBannerRemove: UploadProps['onRemove'] = () => {
bannerPendingFile.value = null;
};
const submitBannerUpload = async (): Promise<void> => {
if (!bannerPendingFile.value) {
return;
}
bannerUploadSaving.value = true;
try {
await uploadBanner(route.params.id as string, bannerPendingFile.value);
bannerPendingFile.value = null;
bannerUploadRef.value?.clearFiles();
ElMessage.success('Banner uploaded');
} catch (e) {
const message = resolveAxiosErrorMessage(e, 'Could not upload banner');
ElMessage.error(message);
} finally {
bannerUploadSaving.value = false;
}
};
const confirmDeletePost = async (): Promise<void> => {
const postId = currentBlogPost.value?.id;
if (!postId) {
return;
}
try {
await ElMessageBox.confirm(
`Delete «${currentBlogPostDisplayTitle.value}»? This cannot be undone.`,
'Delete blog post',
{
type: 'warning',
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel'
}
);
} catch {
return;
}
deleteSaving.value = true;
try {
await deleteBlogPost(postId);
ElMessage.success('Blog post deleted');
router.push({ name: ROUTE_NAMES.BlogPosts });
} catch (e) {
const message = resolveAxiosErrorMessage(e, 'Could not delete blog post');
ElMessage.error(message);
} finally {
deleteSaving.value = false;
}
};
</script>
<style scoped>
.blog-image-preview-wrap {
overflow: hidden;
max-width: 100%;
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-light);
}
.blog-image-preview-wrap--thumb {
width: 22rem;
aspect-ratio: 16 / 9;
}
.blog-image-preview-wrap--banner {
width: min(100%, 40rem);
aspect-ratio: 8 / 3;
}
.blog-image-preview {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
+169
View File
@@ -0,0 +1,169 @@
<template>
<div>
<div class="cms-page-header mb-24">
<h3 class="m-0">Blog</h3>
<div class="flex items-center gap-16">
<div class="flex items-center gap-8">
<span>Blog enabled</span>
<el-text v-if="settingsLoading" size="small" class="secondary-text">Loading…</el-text>
<el-switch
v-else
:model-value="settings?.blogEnabled ?? false"
:loading="blogEnabledSaving"
:disabled="!settings"
@change="onBlogEnabledChange"
/>
</div>
<el-button type="primary" :loading="createDraftLoading" @click="handleNewPostDraft">
New post
</el-button>
</div>
</div>
<div class="cms-table-scroll">
<el-table
v-loading="loading"
:data="blogPosts"
row-key="id"
stripe
empty-text="No blog posts yet"
class="clickable-table"
@row-click="row => router.push({ name: ROUTE_NAMES.BlogPostDetail, params: { id: row.id } })"
>
<el-table-column prop="title" label="Title" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ getBlogPostTitle(row.title) }}
</template>
</el-table-column>
<el-table-column label="Status" width="100">
<template #default="{ row }">
<el-tag :type="row.isDraft ? 'info' : 'success'" size="small">
{{ row.isDraft ? 'Draft' : 'Live' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Updated" width="180">
<template #default="{ row }">
{{ formatDate(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column width="100" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
@click.stop="router.push({ name: ROUTE_NAMES.BlogPostDetail, params: { id: row.id } })"
>
Open
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<cms-list-pagination v-model:page="page" v-model:limit="limit" :total="total" @change="loadBlogPosts" />
</div>
</template>
<script setup lang="ts">
import { ROUTE_NAMES } from '@/consts/routeNames';
import { useBlogPostsStore } from '@/stores/blogPosts';
import { useShopSettingsStore } from '@/stores/shopSettings';
import { getBlogPostTitle } from '@/utils/blog/getBlogPostTitle';
import { formatDate } from '@/utils/formatDate';
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
import { ElMessage } from 'element-plus';
import { storeToRefs } from 'pinia';
import { onBeforeMount, ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const blogPostsStore = useBlogPostsStore();
const shopSettingsStore = useShopSettingsStore();
const { blogPosts } = storeToRefs(blogPostsStore);
const { fetchBlogPosts, createDraftBlogPost } = blogPostsStore;
const { settings } = storeToRefs(shopSettingsStore);
const { fetchShopSettings, updateBlogEnabled } = shopSettingsStore;
const loading = ref(false);
const settingsLoading = ref(false);
const createDraftLoading = ref(false);
const blogEnabledSaving = ref(false);
const page = ref(1);
const limit = ref(20);
const total = ref(0);
onBeforeMount(async () => {
await Promise.all([loadBlogPosts(), loadShopSettings()]);
});
const loadShopSettings = async (): Promise<void> => {
settingsLoading.value = true;
try {
await fetchShopSettings();
} catch {
ElMessage.error('Failed to load shop settings');
} finally {
settingsLoading.value = false;
}
};
const onBlogEnabledChange = async (value: string | number | boolean): Promise<void> => {
const enabled = Boolean(value);
blogEnabledSaving.value = true;
try {
await updateBlogEnabled({ blogEnabled: enabled });
} catch (e) {
const message = resolveAxiosErrorMessage(e, 'Failed to update blog setting');
ElMessage.error(message);
} finally {
blogEnabledSaving.value = false;
}
};
const loadBlogPosts = async (): Promise<void> => {
loading.value = true;
try {
const data = await fetchBlogPosts({
page: page.value,
limit: limit.value
});
blogPosts.value = data.items;
total.value = data.total;
page.value = data.page;
limit.value = data.limit;
} catch {
ElMessage.error('Failed to load blog posts');
} finally {
loading.value = false;
}
};
const handleNewPostDraft = async (): Promise<void> => {
createDraftLoading.value = true;
try {
const created = await createDraftBlogPost();
ElMessage.success('Draft created — add details and publish when ready');
router.push({ name: ROUTE_NAMES.BlogPostDetail, params: { id: created.id } });
} catch (e) {
const message = resolveAxiosErrorMessage(e, 'Could not create draft post');
ElMessage.error(message);
} finally {
createDraftLoading.value = false;
}
};
</script>