From 0d25abab798c005266a1c0b4eeba957d232d4328 Mon Sep 17 00:00:00 2001 From: vnugent Date: Sat, 16 Dec 2023 02:40:03 -0500 Subject: move blog admin state to pinia store plugin --- front-end/src/main.ts | 9 +- front-end/src/store/cmnextAdminPlugin.ts | 176 +++++++++++++++++++++ front-end/src/store/index.ts | 17 ++ front-end/src/store/sharedTypes.ts | 123 ++++++++++++++ front-end/src/views/Blog/blog-api/index.ts | 22 --- front-end/src/views/Blog/ckeditor/Editor.vue | 15 +- front-end/src/views/Blog/ckeditor/uploadAdapter.ts | 4 +- front-end/src/views/Blog/components/Channels.vue | 40 ++--- .../views/Blog/components/Channels/ChannelEdit.vue | 15 +- .../Blog/components/Channels/ChannelTable.vue | 11 +- front-end/src/views/Blog/components/Content.vue | 85 +++++----- .../Blog/components/Content/ContentEditor.vue | 29 ++-- .../views/Blog/components/Content/ContentTable.vue | 42 +++-- .../src/views/Blog/components/ContentSearch.vue | 12 +- front-end/src/views/Blog/components/FeedFields.vue | 6 +- front-end/src/views/Blog/components/Posts.vue | 63 ++++---- .../src/views/Blog/components/Posts/PostEdit.vue | 52 +++--- .../src/views/Blog/components/Posts/PostTable.vue | 11 +- .../components/podcast-helpers/EpisodeAdder.vue | 12 +- .../components/podcast-helpers/podcast-form.ts | 4 +- front-end/src/views/Blog/index.vue | 114 ++++++------- 21 files changed, 574 insertions(+), 288 deletions(-) create mode 100644 front-end/src/store/cmnextAdminPlugin.ts create mode 100644 front-end/src/store/sharedTypes.ts delete mode 100644 front-end/src/views/Blog/blog-api/index.ts (limited to 'front-end/src') diff --git a/front-end/src/main.ts b/front-end/src/main.ts index 31447a1..cda44fa 100644 --- a/front-end/src/main.ts +++ b/front-end/src/main.ts @@ -28,12 +28,14 @@ import "@fontsource/source-sans-pro" /* FONT AWESOME CONFIG */ import { library } from '@fortawesome/fontawesome-svg-core' -import { faBullhorn, faCertificate, faCheck, faChevronLeft, faChevronRight, faComment, faCopy, faFolderOpen, faKey, faLink, faMinusCircle, faPencil, faPhotoFilm, faPlus, faRotateLeft, faSignInAlt, faSpinner, faSync, faTrash, faUser } from '@fortawesome/free-solid-svg-icons' +import { faBullhorn, faCertificate, faCheck, faChevronLeft, faChevronRight, faCode, faComment, faCopy, faFile, faFileDownload, faFileZipper, faFolderOpen, faHeadphones, faImage, faKey, faLink, faMinusCircle, faPencil, faPhotoFilm, faPlus, faRotateLeft, faSignInAlt, faSpinner, faSync, faTrash, faUser, faVideo } from '@fortawesome/free-solid-svg-icons' import { faGithub, faDiscord, faMarkdown } from '@fortawesome/free-brands-svg-icons' //Add required icons for the app library.add(faSignInAlt, faGithub, faDiscord, faSpinner, faCertificate, faKey, faSync, faPlus, faMinusCircle, faUser, faCheck, faTrash, faCopy, - faPencil, faLink, faPhotoFilm, faRotateLeft, faMarkdown, faBullhorn, faFolderOpen, faComment, faChevronLeft, faChevronRight); + faPencil, faLink, faPhotoFilm, faRotateLeft, faMarkdown, faBullhorn, faFolderOpen, faComment, faChevronLeft, faChevronRight, faFileDownload, + faCode, faFile, faVideo, faImage, faHeadphones, faFileZipper + ); //Add icons to library import router from './router' @@ -49,6 +51,7 @@ import { profilePlugin } from './store/userProfile' import { mfaSettingsPlugin } from './store/mfaSettingsPlugin' import { pageProtectionPlugin } from './store/pageProtectionPlugin' import { socialMfaPlugin } from './store/socialMfaPlugin' +import { cmnextAdminPlugin } from './store/cmnextAdminPlugin' //Setup the vnlib api configureApi({ @@ -95,6 +98,8 @@ createVnApp({ .use(mfaSettingsPlugin('/account/mfa', '/account/pki')) //Setup social mfa plugin .use(socialMfaPlugin) + //Setup blog state + .use(cmnextAdminPlugin(router, 'https://cdn.ckeditor.com/ckeditor5/40.0.0/super-build/ckeditor.js', 15)) //Add the home-page component router.addRoute({ diff --git a/front-end/src/store/cmnextAdminPlugin.ts b/front-end/src/store/cmnextAdminPlugin.ts new file mode 100644 index 0000000..a4741ab --- /dev/null +++ b/front-end/src/store/cmnextAdminPlugin.ts @@ -0,0 +1,176 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import 'pinia' +import { MaybeRef, Ref, computed, ref, toRef } from 'vue'; +import { PiniaPluginContext, PiniaPlugin } from 'pinia' +import { find, isEqual } from 'lodash-es'; +import { useRouter } from 'vue-router'; +import { useContent, ContentApi, ContentMeta, + PostMeta, PostApi, BlogChannel, ChannelApi, usePosts, useChannels, + createBlogContext +} from '@vnuge/cmnext-admin'; +import { useRouteQuery } from '@vueuse/router'; +import { useAxios } from '@vnuge/vnlib.browser'; +import { useScriptTag } from '@vueuse/core'; +import { type ReactiveBlogStore, createReactiveBlogApi, QueryType, SortType } from './sharedTypes'; +import { AxiosProgressEvent } from 'axios'; + +export type PostStore = ReactiveBlogStore & PostApi + +export interface ChannelStore extends ReactiveBlogStore, ChannelApi { + editId: string + readonly editChannel: BlogChannel | undefined; +} + +export interface ContentStore extends ReactiveBlogStore, ContentApi { +} + +export interface BlogAdminState{ + content: ContentStore + posts: PostStore + channels: ChannelStore + uploadProgress: number; + waitForEditor(): Promise; + queryState:{ + sort: SortType; + search: string; + pageSize: number; + } +} + +declare module 'pinia' { + export interface PiniaCustomProperties extends BlogAdminState { + } +} + +export const cmnextAdminPlugin = (router: ReturnType, ckEditorUrl: string, pageSize: MaybeRef): PiniaPlugin => { + + return ({ store }: PiniaPluginContext): BlogAdminState => { + + //setup filter search query + const search = useRouteQuery(QueryType.Filter, '', { mode: 'replace', router }); + + //Get sort order query + const sort = useRouteQuery(QueryType.Sort, SortType.CreatedTime, { mode: 'replace', router }); + + const uploadProgress = ref(0) + + const axios = useAxios({ + onUploadProgress: (e: AxiosProgressEvent) => { + uploadProgress.value = Math.round((e.loaded * 100) / e.total!) + }, + //Set to 60 second timeout + timeout: 60 * 1000 + }) + + const initCkEditor = () => { + //Setup cke editor + if ('CKEDITOR' in window === false) { + //Load scripts + const ckEditorTag = useScriptTag(ckEditorUrl) + //Store the wait result on the window for the editor script to wait + const loadPromise = ckEditorTag.load(true); + + return async (): Promise => { + await loadPromise; + } + } + return (): Promise => Promise.resolve() + } + + const blogContext = createBlogContext({ + axios, + channelUrl: '/blog/channels', + postUrl: '/blog/posts', + contentUrl: '/blog/content', + }) + + const channels = (() => { + + //Create channel api + const api = createReactiveBlogApi( + useChannels(blogContext), + { + query: QueryType.Channel, + channelId: undefined, + router, + sort, + search, + pageSize + } + ) + + //route query for the selected channel + const editId = useRouteQuery(QueryType.ChannelEdit, '', { mode: 'push', router }); + + //Compute the selected items from their ids + const editChannel = computed(() => find(api.all.value, c => isEqual(c.id, editId.value))) + + return{ + ...api, + editId, + editChannel + } + })() + + const getContentStore = (): ContentStore => { + //Create post api + return createReactiveBlogApi( + useContent(blogContext, channels.selectedId), + { + query: QueryType.Content, + channelId: toRef(channels.selectedId), + router, + sort, + search, + pageSize + }, + ) + } + + const getPostStore = (): PostStore => { + + //Create post api + return createReactiveBlogApi( + usePosts(blogContext, channels.selectedId), + { + query: QueryType.Post, + channelId: toRef(channels.selectedId), + router, + sort, + search, + pageSize + } + ) + } + + //Load the editor script + const waitForEditor = initCkEditor() + + return { + content: getContentStore(), + posts: getPostStore(), + channels, + uploadProgress, + waitForEditor, + queryState: { + sort, + search, + pageSize + } + } + } +} \ No newline at end of file diff --git a/front-end/src/store/index.ts b/front-end/src/store/index.ts index 924bf1b..1b2d7ee 100644 --- a/front-end/src/store/index.ts +++ b/front-end/src/store/index.ts @@ -1,8 +1,25 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + import { useSession } from "@vnuge/vnlib.browser"; import { set } from "@vueuse/core"; import { defineStore } from "pinia"; import { computed, shallowRef } from "vue"; +export { SortType, QueryType } from './sharedTypes' + /** * Loads the main store for the application */ diff --git a/front-end/src/store/sharedTypes.ts b/front-end/src/store/sharedTypes.ts new file mode 100644 index 0000000..b243608 --- /dev/null +++ b/front-end/src/store/sharedTypes.ts @@ -0,0 +1,123 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { type BlogEntity, type NamedBlogEntity, useFilteredPages, SortedFilteredPaged } from "@vnuge/cmnext-admin"; +import { MaybeRef, Ref } from "vue"; +import { computed, shallowRef, watch } from 'vue'; +import { apiCall } from '@vnuge/vnlib.browser'; +import { useToggle } from '@vueuse/core'; +import { filter, find, includes, isEmpty, isEqual, toLower } from 'lodash-es'; +import type { Router } from 'vue-router'; +import { useRouteQuery } from '@vueuse/router'; + +export enum QueryType { + Post = 'post', + Channel = 'channel', + Content = 'content', + ChannelEdit = 'ecid', + Filter = 'filter', + Sort = 'sort', + PageSize = 'size', +} + +export enum SortType { + CreatedTime = 'created', + ModifiedTime = 'date', +} + +export interface ReactiveBlogStore { + readonly all: T[]; + selectedId: string; + readonly selected : T | undefined; + refresh(): void; + createReactiveSearch(sec: Ref): Ref + createPages(): SortedFilteredPaged; +} + + +export interface BlogApiArgs{ + readonly query: QueryType; + readonly channelId?: Ref; + readonly router: Router; + readonly pageSize: MaybeRef; + readonly sort: Ref; + readonly search: Ref; +} + +export interface TBlogApi { + getAllItems(): Promise; +} + + +export const createReactiveBlogApi = >(api:TApi, args: BlogApiArgs) +: ReactiveBlogStore & TApi => { + + const { query, router, channelId, pageSize, sort, search } = args + + //route query for the selected post + const selectedId = useRouteQuery(query, '', { mode: 'replace', router }); + + const all = shallowRef([]) + + //manual refresh + const [onRefresh, refresh] = useToggle() + + //Compute the selected items from their ids + const selected = computed(() => find(all.value, c => isEqual(c.id, selectedId.value))); + + const createReactiveSearch = (sec: Ref): Ref => { + return computed(() => { + return filter(all.value, c => includes(toLower(c.name), toLower(sec.value)) || includes(toLower(c.id), toLower(sec.value))); + }) + } + + const createPages = (): SortedFilteredPaged => { + //Configure pagination + return useFilteredPages({ items: all, search, sort }, pageSize) + } + + const loadItems = () => { + apiCall(async () => { + all.value = await api.getAllItems() + }) + } + + if(channelId) { + //Watch for selected channel id changes + watch([onRefresh, channelId], ([_, cid]) => { + //Must have selected a channel + if (isEmpty(cid)) { + all.value = [] + } + else{ + loadItems() + } + }) + } + else{ + //Watch for refresh only + watch([onRefresh], loadItems) + } + + return { + ...api, + refresh, + selectedId, + selected, + all, + createReactiveSearch, + createPages + } +} diff --git a/front-end/src/views/Blog/blog-api/index.ts b/front-end/src/views/Blog/blog-api/index.ts deleted file mode 100644 index 678883b..0000000 --- a/front-end/src/views/Blog/blog-api/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { ComputedChannels, ComputedContent, ComputedPosts } from '@vnuge/cmnext-admin' - -export interface BlogState { - readonly channels: ComputedChannels, - readonly posts: ComputedPosts, - readonly content: ComputedContent -} \ No newline at end of file diff --git a/front-end/src/views/Blog/ckeditor/Editor.vue b/front-end/src/views/Blog/ckeditor/Editor.vue index 5bbf1cb..e1ee2ce 100644 --- a/front-end/src/views/Blog/ckeditor/Editor.vue +++ b/front-end/src/views/Blog/ckeditor/Editor.vue @@ -76,18 +76,19 @@ import { tryOnMounted } from '@vueuse/shared'; import { apiCall } from '@vnuge/vnlib.browser'; import { Popover, PopoverButton, PopoverPanel, Switch } from '@headlessui/vue' import { Converter } from 'showdown' -import { BlogState } from '../blog-api' import { useCkConfig } from './build.ts' import { useUploadAdapter } from './uploadAdapter'; import ContentSearch from '../components/ContentSearch.vue'; +import { useStore } from '../../../store'; const emit = defineEmits(['change', 'load', 'mode-change']) const props = defineProps<{ - blog: BlogState, podcastMode: boolean }>() +const store = useStore() + let editor = {} const propRefs = toRefs(props) //Init new shodown converter @@ -135,13 +136,9 @@ tryOnMounted(() => defer(() => //Load the editor once the component is mounted apiCall(async ({ toaster }) => { - //Entry script creates promise that resolves when the editor script is loaded - if(window.editorLoadResult){ - //Wait for the editor script to load - await (window.editorLoadResult as Promise) - } + await store.waitForEditor() - if (!window['CKEDITOR']) { + if ('CKEDITOR' in window === false) { toaster.general.error({ title: 'Script Error', text: 'The CKEditor script failed to load, check script permissions.' @@ -155,7 +152,7 @@ tryOnMounted(() => defer(() => //Init the ck config const config = useCkConfig([ //Add the upload adapter - useUploadAdapter(props.blog.content, apiCall, toaster.general) + useUploadAdapter(store.content, apiCall, toaster.general) ]); //Init editor when loading is complete diff --git a/front-end/src/views/Blog/ckeditor/uploadAdapter.ts b/front-end/src/views/Blog/ckeditor/uploadAdapter.ts index 1c22842..74918b9 100644 --- a/front-end/src/views/Blog/ckeditor/uploadAdapter.ts +++ b/front-end/src/views/Blog/ckeditor/uploadAdapter.ts @@ -13,12 +13,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { ComputedContent } from "@vnuge/cmnext-admin"; import { IToaster } from "@vnuge/vnlib.browser"; import { isNil } from "lodash-es"; import type { AxiosRequestConfig } from "axios"; import type { Editor } from "@ckeditor/ckeditor5-core"; import type { UploadAdapter, UploadResponse, FileLoader } from '@ckeditor/ckeditor5-upload' +import { ContentStore } from "../../../store/cmnextAdminPlugin"; export type ApiCall = (callback: (data: any) => Promise) => Promise; export type CKEditorPlugin = (editor: Editor) => void; @@ -29,7 +29,7 @@ export type CKEditorPlugin = (editor: Editor) => void; * @param apiCall A callback function that wraps the api call * @returns A CKEditor plugin initializer */ -export const useUploadAdapter = (content: ComputedContent, apiCall: ApiCall, toaster?: IToaster): CKEditorPlugin =>{ +export const useUploadAdapter = (content: ContentStore, apiCall: ApiCall, toaster?: IToaster): CKEditorPlugin =>{ const createUploadAdapter = (loader: FileLoader): UploadAdapter => { diff --git a/front-end/src/views/Blog/components/Channels.vue b/front-end/src/views/Blog/components/Channels.vue index 2a160b3..bf29067 100644 --- a/front-end/src/views/Blog/components/Channels.vue +++ b/front-end/src/views/Blog/components/Channels.vue @@ -2,14 +2,13 @@