Slave/blog #9
@@ -16,6 +16,7 @@
|
||||
<el-menu-item index="/products">Products</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="/blog">Blog</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="/settings">Settings</el-menu-item>
|
||||
|
||||
@@ -5,6 +5,8 @@ export const ROUTE_NAMES = {
|
||||
Products: 'Products',
|
||||
Categories: 'Categories',
|
||||
DiscountCodes: 'DiscountCodes',
|
||||
BlogPosts: 'BlogPosts',
|
||||
BlogPostDetail: 'BlogPostDetail',
|
||||
ShopSettings: 'ShopSettings',
|
||||
Notifications: 'Notifications',
|
||||
MoneroWallet: 'MoneroWallet',
|
||||
|
||||
@@ -41,6 +41,18 @@ const router = createRouter({
|
||||
component: () => import('../views/CmsDiscountCodesView.vue'),
|
||||
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',
|
||||
name: ROUTE_NAMES.OrderDetail,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { ConnectSimplexNotificationsPayload } from '@/types/shopSettings/Co
|
||||
import type { ShopSettings } from '@/types/shopSettings/ShopSettings';
|
||||
import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload';
|
||||
import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload';
|
||||
import type { UpdateBlogEnabledPayload } from '@/types/shopSettings/UpdateBlogEnabledPayload';
|
||||
import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload';
|
||||
|
||||
export const useShopSettingsStore = defineStore('shopSettings', () => {
|
||||
@@ -42,6 +43,14 @@ export const useShopSettingsStore = defineStore('shopSettings', () => {
|
||||
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 { data } = await api.post<ShopSettings>('/shop-settings/simplex-connect', payload);
|
||||
|
||||
@@ -80,6 +89,7 @@ export const useShopSettingsStore = defineStore('shopSettings', () => {
|
||||
updateSimplexLink,
|
||||
updateShippingNote,
|
||||
updateNotifications,
|
||||
updateBlogEnabled,
|
||||
connectSimplexNotifications,
|
||||
uploadShopLogo,
|
||||
uploadShopFavicon
|
||||
|
||||
@@ -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;
|
||||
notifyOnOrderMessage: boolean;
|
||||
simplexNotificationConnected: boolean;
|
||||
blogEnabled: boolean;
|
||||
isSetupComplete: boolean;
|
||||
setupChecklist: SetupChecklist;
|
||||
createdAt: string | null;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface UpdateBlogEnabledPayload {
|
||||
blogEnabled: boolean;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user