aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2023-12-16 02:40:03 -0500
committerLibravatar vnugent <public@vaughnnugent.com>2023-12-16 02:40:03 -0500
commit0d25abab798c005266a1c0b4eeba957d232d4328 (patch)
tree427bd36e33fcd4960e3a2bc7d73b77dc7779b214
parent4b8ae76132d2342f40cec703b3d5145ea075c451 (diff)
move blog admin state to pinia store plugin
-rw-r--r--front-end/src/main.ts9
-rw-r--r--front-end/src/store/cmnextAdminPlugin.ts176
-rw-r--r--front-end/src/store/index.ts17
-rw-r--r--front-end/src/store/sharedTypes.ts123
-rw-r--r--front-end/src/views/Blog/blog-api/index.ts22
-rw-r--r--front-end/src/views/Blog/ckeditor/Editor.vue15
-rw-r--r--front-end/src/views/Blog/ckeditor/uploadAdapter.ts4
-rw-r--r--front-end/src/views/Blog/components/Channels.vue40
-rw-r--r--front-end/src/views/Blog/components/Channels/ChannelEdit.vue15
-rw-r--r--front-end/src/views/Blog/components/Channels/ChannelTable.vue11
-rw-r--r--front-end/src/views/Blog/components/Content.vue85
-rw-r--r--front-end/src/views/Blog/components/Content/ContentEditor.vue29
-rw-r--r--front-end/src/views/Blog/components/Content/ContentTable.vue42
-rw-r--r--front-end/src/views/Blog/components/ContentSearch.vue12
-rw-r--r--front-end/src/views/Blog/components/FeedFields.vue6
-rw-r--r--front-end/src/views/Blog/components/Posts.vue63
-rw-r--r--front-end/src/views/Blog/components/Posts/PostEdit.vue52
-rw-r--r--front-end/src/views/Blog/components/Posts/PostTable.vue11
-rw-r--r--front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue12
-rw-r--r--front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts4
-rw-r--r--front-end/src/views/Blog/index.vue114
-rw-r--r--lib/admin/package-lock.json49
-rw-r--r--lib/admin/package.json1
-rw-r--r--lib/admin/src/channels/computedChannels.ts65
-rw-r--r--lib/admin/src/channels/index.ts3
-rw-r--r--lib/admin/src/channels/useChannels.ts (renamed from lib/admin/src/channels/channels.ts)51
-rw-r--r--lib/admin/src/content/computedContent.ts92
-rw-r--r--lib/admin/src/content/index.ts3
-rw-r--r--lib/admin/src/content/useContent.ts31
-rw-r--r--lib/admin/src/index.ts60
-rw-r--r--lib/admin/src/ordering/index.ts4
-rw-r--r--lib/admin/src/posts/computedPosts.ts56
-rw-r--r--lib/admin/src/posts/index.ts3
-rw-r--r--lib/admin/src/posts/usePost.ts68
-rw-r--r--lib/admin/src/types.ts163
35 files changed, 737 insertions, 774 deletions
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 <https://www.gnu.org/licenses/>.
+
+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<PostMeta> & PostApi
+
+export interface ChannelStore extends ReactiveBlogStore<BlogChannel>, ChannelApi {
+ editId: string
+ readonly editChannel: BlogChannel | undefined;
+}
+
+export interface ContentStore extends ReactiveBlogStore<ContentMeta>, ContentApi {
+}
+
+export interface BlogAdminState{
+ content: ContentStore
+ posts: PostStore
+ channels: ChannelStore
+ uploadProgress: number;
+ waitForEditor(): Promise<void>;
+ queryState:{
+ sort: SortType;
+ search: string;
+ pageSize: number;
+ }
+}
+
+declare module 'pinia' {
+ export interface PiniaCustomProperties extends BlogAdminState {
+ }
+}
+
+export const cmnextAdminPlugin = (router: ReturnType<typeof useRouter>, ckEditorUrl: string, pageSize: MaybeRef<number>): PiniaPlugin => {
+
+ return ({ store }: PiniaPluginContext): BlogAdminState => {
+
+ //setup filter search query
+ const search = useRouteQuery<string>(QueryType.Filter, '', { mode: 'replace', router });
+
+ //Get sort order query
+ const sort = useRouteQuery<SortType>(QueryType.Sort, SortType.CreatedTime, { mode: 'replace', router });
+
+ const uploadProgress = ref<number>(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<void> => {
+ await loadPromise;
+ }
+ }
+ return (): Promise<void> => Promise.resolve()
+ }
+
+ const blogContext = createBlogContext({
+ axios,
+ channelUrl: '/blog/channels',
+ postUrl: '/blog/posts',
+ contentUrl: '/blog/content',
+ })
+
+ const channels = (() => {
+
+ //Create channel api
+ const api = createReactiveBlogApi<BlogChannel, ChannelApi>(
+ useChannels(blogContext),
+ {
+ query: QueryType.Channel,
+ channelId: undefined,
+ router,
+ sort,
+ search,
+ pageSize
+ }
+ )
+
+ //route query for the selected channel
+ const editId = useRouteQuery<string>(QueryType.ChannelEdit, '', { mode: 'push', router });
+
+ //Compute the selected items from their ids
+ const editChannel = computed<BlogChannel | undefined>(() => find(api.all.value, c => isEqual(c.id, editId.value)))
+
+ return{
+ ...api,
+ editId,
+ editChannel
+ }
+ })()
+
+ const getContentStore = (): ContentStore => {
+ //Create post api
+ return createReactiveBlogApi<ContentMeta, ContentApi>(
+ useContent(blogContext, channels.selectedId),
+ {
+ query: QueryType.Content,
+ channelId: toRef(channels.selectedId),
+ router,
+ sort,
+ search,
+ pageSize
+ },
+ )
+ }
+
+ const getPostStore = (): PostStore => {
+
+ //Create post api
+ return createReactiveBlogApi<PostMeta, PostApi>(
+ 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 <https://www.gnu.org/licenses/>.
+
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 <https://www.gnu.org/licenses/>.
+
+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<T> {
+ readonly all: T[];
+ selectedId: string;
+ readonly selected : T | undefined;
+ refresh(): void;
+ createReactiveSearch(sec: Ref<string>): Ref<T[] | undefined>
+ createPages(): SortedFilteredPaged<T>;
+}
+
+
+export interface BlogApiArgs{
+ readonly query: QueryType;
+ readonly channelId?: Ref<string>;
+ readonly router: Router;
+ readonly pageSize: MaybeRef<number>;
+ readonly sort: Ref<SortType>;
+ readonly search: Ref<string>;
+}
+
+export interface TBlogApi<T extends BlogEntity> {
+ getAllItems(): Promise<T[]>;
+}
+
+
+export const createReactiveBlogApi = <T extends NamedBlogEntity, TApi extends TBlogApi<T>>(api:TApi, args: BlogApiArgs)
+: ReactiveBlogStore<T> & TApi => {
+
+ const { query, router, channelId, pageSize, sort, search } = args
+
+ //route query for the selected post
+ const selectedId = useRouteQuery<string>(query, '', { mode: 'replace', router });
+
+ const all = shallowRef<T[]>([])
+
+ //manual refresh
+ const [onRefresh, refresh] = useToggle()
+
+ //Compute the selected items from their ids
+ const selected = computed<T | undefined>(() => find(all.value, c => isEqual(c.id, selectedId.value)));
+
+ const createReactiveSearch = (sec: Ref<string>): Ref<T[] | undefined> => {
+ return computed(() => {
+ return filter(all.value, c => includes(toLower(c.name), toLower(sec.value)) || includes(toLower(c.id), toLower(sec.value)));
+ })
+ }
+
+ const createPages = (): SortedFilteredPaged<T> => {
+ //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 <https://www.gnu.org/licenses/>.
-
-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<boolean>)
- }
+ 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 <https://www.gnu.org/licenses/>.
-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<any>) => Promise<any>;
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 @@
<div id="channel-editor">
<EditorTable title="Manage channels" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
<template #table>
- <ChannelTable
- :channels="items"
- @open-edit="openEdit"
+ <ChannelTable
+ :items="items"
+ @open-edit="openEdit"
/>
</template>
<template #editor>
<ChannelEdit
- :blog="$props.blog"
@close="closeEdit"
@on-submit="onSubmit"
@on-delete="onDelete"
@@ -21,32 +20,25 @@
<script setup lang="ts">
import { computed } from 'vue';
-import { BlogState } from '../blog-api';
import { isEmpty, filter as _filter } from 'lodash-es';
import { apiCall } from '@vnuge/vnlib.browser';
-import { BlogChannel, ChannelFeed, useFilteredPages } from '@vnuge/cmnext-admin';
+import { BlogChannel, ChannelFeed } from '@vnuge/cmnext-admin';
+import { useStore } from '../../../store';
import ChannelEdit from './Channels/ChannelEdit.vue';
import ChannelTable from './Channels/ChannelTable.vue';
import EditorTable from './EditorTable.vue';
const emit = defineEmits(['close', 'reload'])
-const props = defineProps<{
- blog: BlogState,
-}>()
+const store = useStore()
+const { items, pagination } = store.channels.createPages()
-const { updateChannel, addChannel, deleteChannel, getQuery } = props.blog.channels;
-const { channelEdit } = getQuery()
+const showEdit = computed(() => !isEmpty(store.channels.editChannel))
-//Setup channel filter
-const { items, pagination } = useFilteredPages(props.blog.channels, 15)
-
-const showEdit = computed(() => !isEmpty(channelEdit.value))
-
-const openEdit = (channel: BlogChannel) => channelEdit.value = channel.id;
+const openEdit = (channel: BlogChannel) => store.channels.editId = channel.id;
const closeEdit = (update?:boolean) => {
- channelEdit.value = ''
+ store.channels.editId = ''
//reload channels
if(update){
emit('reload')
@@ -56,7 +48,7 @@ const closeEdit = (update?:boolean) => {
}
const openNew = () => {
- channelEdit.value = 'new'
+ store.channels.editId = 'new'
//Reset page to top
window.scrollTo(0, 0)
}
@@ -64,18 +56,18 @@ const openNew = () => {
const onSubmit = async ({channel, feed} : { channel:BlogChannel, feed? : ChannelFeed}) => {
//Check for new channel, or updating old channel
- if(channelEdit.value === 'new'){
+ if(store.channels.editId === 'new'){
//Exec create call
await apiCall(async () => {
- await addChannel(channel, feed);
+ await store.channels.add(channel, feed);
//Close the edit panel
closeEdit(true);
})
}
- else if(!isEmpty(channelEdit.value)){
+ else if(!isEmpty(store.channels.editId)){
//Exec update call
await apiCall(async () => {
- await updateChannel(channel, feed);
+ await store.channels.update(channel, feed);
//Close the edit panel
closeEdit(true);
})
@@ -86,7 +78,7 @@ const onSubmit = async ({channel, feed} : { channel:BlogChannel, feed? : Channel
const onDelete = async (channel : BlogChannel) => {
//Exec delete call
await apiCall(async () => {
- await deleteChannel(channel);
+ await store.channels.delete(channel);
//Close the edit panel
closeEdit(true);
})
diff --git a/front-end/src/views/Blog/components/Channels/ChannelEdit.vue b/front-end/src/views/Blog/components/Channels/ChannelEdit.vue
index b84adf0..0363746 100644
--- a/front-end/src/views/Blog/components/Channels/ChannelEdit.vue
+++ b/front-end/src/views/Blog/components/Channels/ChannelEdit.vue
@@ -62,22 +62,19 @@
<script setup lang="ts">
import { computed } from 'vue';
-import { BlogState } from '../../blog-api';
-import { forEach, isEmpty, cloneDeep, isNil } from 'lodash-es';
+import { forEach, isEmpty, cloneDeep, isNil, set } from 'lodash-es';
import { reactiveComputed } from '@vueuse/core';
import { useConfirm } from '@vnuge/vnlib.browser';
-import FeedFields from '../FeedFields.vue';
import { BlogChannel, ChannelFeed, useXmlProperties } from '@vnuge/cmnext-admin';
import { getChannelForm } from '../../form-helpers';
+import { useStore } from '../../../../store';
+import FeedFields from '../FeedFields.vue';
const emit = defineEmits(['close', 'onSubmit', 'onDelete'])
-
-const props = defineProps<{
- blog: BlogState
-}>()
+const store = useStore()
//Disallow empty channels
-const channel = computed(() => props.blog.channels.editChannel.value || {} as BlogChannel)
+const channel = computed(() => store.channels.editChannel || {} as BlogChannel)
const editMode = computed(() => !isNil(channel.value.id))
const { getChannelValidator, channelSchema, feedSchema, getFeedValidator } = getChannelForm(editMode);
@@ -98,7 +95,7 @@ const feedEnabled = computed(() => !isEmpty(feedBuffer.url))
const disableFeed = () => {
//Clear the feed
- forEach(feedBuffer, (_value, key) => feedBuffer[key] = null)
+ forEach(feedBuffer, (_value, key) => set(feedBuffer, key, null))
//Reset the feed validator
feedVal.reset();
}
diff --git a/front-end/src/views/Blog/components/Channels/ChannelTable.vue b/front-end/src/views/Blog/components/Channels/ChannelTable.vue
index cdf15e0..ff27371 100644
--- a/front-end/src/views/Blog/components/Channels/ChannelTable.vue
+++ b/front-end/src/views/Blog/components/Channels/ChannelTable.vue
@@ -9,7 +9,7 @@
</tr>
</thead>
<tbody>
- <tr v-for="channel in channels" :key="channel.id" class="table-row">
+ <tr v-for="channel in $props.items" :key="channel.id" class="table-row">
<td>
{{ channel.name }}
</td>
@@ -33,14 +33,9 @@
<script setup lang="ts">
import { BlogChannel } from '@vnuge/cmnext-admin';
-import { toRefs } from 'vue';
-const emit = defineEmits(['open-edit'])
-
-const props = defineProps<{
- channels:BlogChannel[]
-}>()
-const { channels } = toRefs(props)
+const emit = defineEmits(['open-edit'])
+defineProps<{ items: BlogChannel[] }>()
const feedEnabled = (channel: BlogChannel) => channel.feed ? 'Enabled' : 'Disabled'
const openEdit = (channel: BlogChannel) => emit('open-edit', channel)
diff --git a/front-end/src/views/Blog/components/Content.vue b/front-end/src/views/Blog/components/Content.vue
index d8a9f24..888b595 100644
--- a/front-end/src/views/Blog/components/Content.vue
+++ b/front-end/src/views/Blog/components/Content.vue
@@ -2,11 +2,12 @@
<div id="content-editor" class="">
<EditorTable title="Manage content" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
<template #table>
- <ContentTable
- :content="items"
+ <ContentTable
+ :items="items"
@open-edit="openEdit"
@copy-link="copyLink"
@delete="onDelete"
+ @download="onDownload"
/>
</template>
<template #editor>
@@ -16,7 +17,7 @@
<span
role="progressbar"
aria-labelledby="ProgressLabel"
- :aria-valuenow="progress"
+ :aria-valuenow="uploadProgress"
class="relative block bg-gray-200 rounded-full dark:bg-dark-500"
>
<span class="absolute inset-0 flex items-center justify-center text-[10px]/4">
@@ -27,58 +28,46 @@
</span>
</div>
<ContentEditor
- :blog="$props.blog"
@submit="onSubmit"
@close="closeEdit"
@delete="onDelete"
/>
</template>
</EditorTable>
+ <a class="hidden" ref="downloadAnchor"></a>
</div>
</template>
<script setup lang="ts">
-import { computed, toRefs } from 'vue';
-import { BlogState } from '../blog-api';
+import { computed, shallowRef } from 'vue';
import { isEmpty } from 'lodash-es';
import { apiCall, useConfirm } from '@vnuge/vnlib.browser';
-import { useClipboard } from '@vueuse/core';
-import { ContentMeta, useFilteredPages } from '@vnuge/cmnext-admin';
+import { get, useClipboard } from '@vueuse/core';
+import { ContentMeta } from '@vnuge/cmnext-admin';
+import { useStore } from '../../../store';
+import { storeToRefs } from 'pinia';
import EditorTable from './EditorTable.vue';
import ContentEditor from './Content/ContentEditor.vue';
import ContentTable from './Content/ContentTable.vue';
const emit = defineEmits(['reload'])
-const props = defineProps<{
- blog: BlogState,
- progress: number
-}>()
+const store = useStore()
+const { uploadProgress } = storeToRefs(store)
+const { items, pagination } = store.content.createPages()
-const { progress } = toRefs(props)
-
-//Get the computed content
-const { selectedId,
- updateContent,
- uploadContent,
- deleteContent,
- updateContentName,
- getPublicUrl
- } = props.blog.content;
-
- //Setup content filter
- const { items, pagination } = useFilteredPages(props.blog.content, 15)
const { reveal } = useConfirm()
+ const downloadAnchor = shallowRef<HTMLAnchorElement>()
-const showEdit = computed(() => !isEmpty(selectedId.value));
-const loadingProgress = computed(() => `${progress?.value}%`);
-const progressWidth = computed(() => ({ width: `${progress?.value}%` }));
-const showProgress = computed(() => progress?.value > 0 && progress?.value < 100);
+const showEdit = computed(() => !isEmpty(store.content.selectedId));
+const loadingProgress = computed(() => `${uploadProgress.value}%`);
+const progressWidth = computed(() => ({ width: `${uploadProgress.value}%` }));
+const showProgress = computed(() => uploadProgress.value > 0 && uploadProgress.value < 100);
-const openEdit = async (item: ContentMeta) => selectedId.value = item.id
+const openEdit = async (item: ContentMeta) => store.content.selectedId = item.id
const closeEdit = (update?: boolean) => {
- selectedId.value = ''
+ store.content.selectedId = ''
//reload channels
if (update) {
emit('reload')
@@ -88,7 +77,7 @@ const closeEdit = (update?: boolean) => {
}
const openNew = () => {
- selectedId.value = 'new'
+ store.content.selectedId = 'new'
//Reset page to top
window.scrollTo(0, 0)
}
@@ -102,7 +91,7 @@ interface OnSubmitValue{
const { copy } = useClipboard()
const copyLink = async (item : ContentMeta) =>{
apiCall(async ({toaster}) =>{
- const url = await getPublicUrl(item);
+ const url = await store.content.getPublicUrl(item);
await copy(url);
toaster.general.info({ title: 'Copied link to clipboard' })
});
@@ -111,7 +100,7 @@ const copyLink = async (item : ContentMeta) =>{
const onSubmit = async (value : OnSubmitValue) => {
//Check for new channel, or updating old channel
- if (selectedId.value === 'new') {
+ if (store.content.selectedId === 'new') {
//Exec create call
await apiCall(async () => {
@@ -120,21 +109,21 @@ const onSubmit = async (value : OnSubmitValue) => {
}
//endpoint returns the content
- await uploadContent(value.file, value.item.name!);
+ await store.content.uploadContent(value.file, value.item.name!);
//Close the edit panel
closeEdit(true);
})
}
- else if (!isEmpty(selectedId.value)) {
+ else if (!isEmpty(store.content.selectedId)) {
//Exec update call
await apiCall(async () => {
//If no file was attached, just update the file name
if(value.file?.name){
- await updateContent(value.item, value.file);
+ await store.content.updateContent(value.item, value.file);
}
else{
- await updateContentName(value.item, value.item.name!);
+ await store.content.updateContentName(value.item, value.item.name!);
}
//Close the edit panel
closeEdit(true);
@@ -159,13 +148,29 @@ const onDelete = async (item: ContentMeta) => {
//Exec delete call
await apiCall(async () => {
- await deleteContent(item);
+ await store.content.delete(item);
//Close the edit panel
closeEdit(true);
})
//Refresh content after delete
- props.blog.content.refresh();
+ store.content.refresh();
+}
+
+const onDownload = async (item: ContentMeta) => {
+ //Exec download call
+ await apiCall(async () => {
+ //Download the file blob from the server
+ const fileBlob = await store.content.downloadContent(item)
+
+ //Create a url for the blob and open the save link
+ const url = window.URL.createObjectURL(fileBlob);
+
+ const anchor = get(downloadAnchor)!;
+ anchor.href = url;
+ anchor.download = item.name!;
+ anchor.click();
+ })
}
diff --git a/front-end/src/views/Blog/components/Content/ContentEditor.vue b/front-end/src/views/Blog/components/Content/ContentEditor.vue
index 608cd1b..392e706 100644
--- a/front-end/src/views/Blog/components/Content/ContentEditor.vue
+++ b/front-end/src/views/Blog/components/Content/ContentEditor.vue
@@ -105,35 +105,34 @@ import { computed, ref, watch } from 'vue';
import { reactiveComputed, useFileDialog, useDropZone } from '@vueuse/core';
import { ContentMeta } from '@vnuge/cmnext-admin';
import { useConfirm, useVuelidateWrapper, useFormToaster, useWait } from '@vnuge/vnlib.browser';
-import { defaultTo, first, isEmpty, round } from 'lodash-es';
+import { clone, defaultTo, first, isEmpty, round } from 'lodash-es';
import { required, helpers, maxLength } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core';
-import { BlogState } from '../../blog-api';
+import { useStore } from '../../../../store';
const emit = defineEmits(['close', 'submit', 'delete']);
-const props = defineProps<{
- blog: BlogState
-}>();
const { reveal } = useConfirm();
const { waiting } = useWait();
-const { content, channels } = props.blog;
+const { content, channels } = useStore()
const newFileDropZone = ref<HTMLElement>();
-const selectedId = computed(() => content.selectedId.value);
-const selectedContent = computed<ContentMeta>(() => defaultTo(content.selectedItem.value, {} as ContentMeta));
-const metaBuffer = reactiveComputed<ContentMeta>(() => ({ ...selectedContent.value}));
-const isChannelSelected = computed(() => channels.selectedItem.value?.id?.length ?? 0 > 0);
+const selectedId = computed(() => content.selectedId);
+const selectedContent = computed<ContentMeta>(() => defaultTo(content.selected, {} as ContentMeta));
+const metaBuffer = reactiveComputed<Required<ContentMeta>>(() => clone(selectedContent.value) as Required<ContentMeta>);
+const isChannelSelected = computed(() => channels.selected?.id?.length ?? 0 > 0);
const isNewUpload = computed(() => selectedId.value === 'new');
-const v$ = useVuelidate({
- name: {
+const validationArgs = {
+ name: {
required,
- maxLen:maxLength(50),
+ maxLen: maxLength(50),
reg: helpers.withMessage('The file name contains invalid characters', helpers.regex(/^[a-zA-Z0-9 \-\.]*$/))
- },
-}, metaBuffer)
+ },
+}
+
+const v$ = useVuelidate(validationArgs, metaBuffer)
const { validate } = useVuelidateWrapper(v$);
diff --git a/front-end/src/views/Blog/components/Content/ContentTable.vue b/front-end/src/views/Blog/components/Content/ContentTable.vue
index 3afe320..cc94c9f 100644
--- a/front-end/src/views/Blog/components/Content/ContentTable.vue
+++ b/front-end/src/views/Blog/components/Content/ContentTable.vue
@@ -5,14 +5,19 @@
<th>Id</th>
<th>Date</th>
<th>Content Type</th>
- <th>Length</th>
+ <th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
- <tr v-for="item in content" :key="item.id" class="table-row">
+ <tr v-for="item in $props.items" :key="item.id" class="table-row">
<td>
- {{ getItemName(item) }}
+ <span class="mr-2">
+ <fa-icon size="sm" :icon="getContentIconType(item)" />
+ </span>
+ <span>
+ {{ getItemName(item) }}
+ </span>
</td>
<td>
{{ getItemId(item) }}
@@ -21,7 +26,7 @@
{{ getDateString(item.date) }}
</td>
<td>
- {{ item.content_type }}
+ <span>{{ item.content_type }}</span>
</td>
<td>
{{ getItemLength(item) }}
@@ -37,7 +42,10 @@
<button class="btn xs no-border" @click="copy(item.id)">
<fa-icon icon="copy" />
</button>
- <button class="btn xs no-border red" @click="deleteItem(item)">
+ <button class="btn xs no-border" @click="download(item)">
+ <fa-icon icon="file-download" />
+ </button>
+ <button class="btn xs no-border red" @click="deleteItem(item)">
<fa-icon icon="trash" />
</button>
</fieldset>
@@ -47,19 +55,13 @@
</template>
<script setup lang="ts">
-import { toRefs } from 'vue';
-import { filter as _filter, truncate } from 'lodash-es';
+import { filter as _filter, defaultTo, includes, truncate } from 'lodash-es';
import { useClipboard } from '@vueuse/core';
import { useWait } from '@vnuge/vnlib.browser';
import { ContentMeta } from '@vnuge/cmnext-admin';
-const emit = defineEmits(['open-edit', 'copy-link', 'delete'])
-
-const props = defineProps<{
- content: ContentMeta[]
-}>()
-
-const { content } = toRefs(props)
+const emit = defineEmits(['open-edit', 'copy-link', 'delete', 'download'])
+defineProps<{ items: ContentMeta[] }>()
const { waiting } = useWait()
const { copy } = useClipboard()
@@ -72,8 +74,20 @@ const getItemLength = (item: ContentMeta) : string =>{
const getItemId = (item: ContentMeta) => truncate(item.id || '', { length: 20 })
const getItemName = (item : ContentMeta) => truncate(item.name || '', { length: 30 })
+const getContentIconType = (item: ContentMeta) => {
+ const type = defaultTo(item.content_type, '')
+ if (includes(type, 'image')) return 'image'
+ if (includes(type, 'video')) return 'video'
+ if (includes(type, 'audio')) return 'headphones'
+ if (includes(type, 'html')) return 'code'
+ if (includes(type, 'zip')) return 'file-zipper'
+ return 'file'
+}
+
const openEdit = async (item: ContentMeta) => emit('open-edit', item)
const copyLink = (item : ContentMeta) => emit('copy-link', item)
const deleteItem = (item : ContentMeta) => emit('delete', item)
+const download = (item : ContentMeta) => emit('download', item)
+
</script> \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/ContentSearch.vue b/front-end/src/views/Blog/components/ContentSearch.vue
index 78cfa1a..7871196 100644
--- a/front-end/src/views/Blog/components/ContentSearch.vue
+++ b/front-end/src/views/Blog/components/ContentSearch.vue
@@ -40,19 +40,15 @@ import { apiCall, useWait } from '@vnuge/vnlib.browser';
import { computed, Ref, ref } from 'vue';
import { map, slice, truncate } from 'lodash-es';
import { ContentMeta } from '@vnuge/cmnext-admin';
-import { BlogState } from '../blog-api';
+import { useStore } from '../../../store';
const emit = defineEmits(['selected'])
+const store = useStore()
-const props = defineProps<{
- blog: BlogState,
-}>()
-
-const { createReactiveSearch, getPublicUrl } = props.blog.content
const { waiting } = useWait()
const search = ref('')
-const searcher = createReactiveSearch(search);
+const searcher = store.content.createReactiveSearch(search);
interface ContentResult extends ContentMeta {
readonly shortId: string,
@@ -67,7 +63,7 @@ const searchResults = computed<ContentResult[]>(() => {
//Copies the link to the clipboard from the server to insert into the editor
const copyLink = (result : ContentMeta, copy : (text: string) => Promise<void> ) => {
apiCall(async () =>{
- const link = await getPublicUrl(result);
+ const link = await store.content.getPublicUrl(result);
await copy(link);
})
}
diff --git a/front-end/src/views/Blog/components/FeedFields.vue b/front-end/src/views/Blog/components/FeedFields.vue
index 0397c48..918f449 100644
--- a/front-end/src/views/Blog/components/FeedFields.vue
+++ b/front-end/src/views/Blog/components/FeedFields.vue
@@ -24,7 +24,7 @@
<div v-if="editMode" class="flex flex-col">
<div v-if="$props.blog" class="mb-2">
- <EpAdder :blog="$props.blog" @submit="onAddEnclosure" />
+ <EpAdder @submit="onAddEnclosure" />
</div>
<div class="">
@@ -38,13 +38,11 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue';
import { FeedProperty, UseXmlProperties } from '@vnuge/cmnext-admin';
-import { BlogState } from '../blog-api';
-import EpAdder from './podcast-helpers/EpisodeAdder.vue';
+const EpAdder = defineAsyncComponent(() => import('./podcast-helpers/EpisodeAdder.vue'));
const JsonEditorVue = defineAsyncComponent(() => import('json-editor-vue'))
const props = defineProps<{
properties: UseXmlProperties,
- blog?: BlogState
}>()
const { getXml, saveJson, getModel, addProperties } = props.properties
diff --git a/front-end/src/views/Blog/components/Posts.vue b/front-end/src/views/Blog/components/Posts.vue
index d52a0ac..801fa3c 100644
--- a/front-end/src/views/Blog/components/Posts.vue
+++ b/front-end/src/views/Blog/components/Posts.vue
@@ -3,14 +3,13 @@
<EditorTable title="Manage posts" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
<template #table>
<PostTable
- :posts="items"
+ :items="items"
@open-edit="openEdit"
@delete="onDelete"
/>
</template>
<template #editor>
<PostEditor
- :blog="$props.blog"
@submit="onSubmit"
@close="closeEdit"
@delete="onDelete"
@@ -23,34 +22,26 @@
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue';
import { isEmpty } from 'lodash-es';
-import { PostMeta, useFilteredPages } from '@vnuge/cmnext-admin';
+import { PostMeta } from '@vnuge/cmnext-admin';
import { apiCall, debugLog, useConfirm } from '@vnuge/vnlib.browser';
-import { BlogState } from '../blog-api';
+import { useStore } from '../../../store';
import EditorTable from './EditorTable.vue';
import PostTable from './Posts/PostTable.vue';
-
const PostEditor = defineAsyncComponent(() => import('./Posts/PostEdit.vue'))
const emit = defineEmits(['reload'])
+const store = useStore()
-const props = defineProps<{
- blog: BlogState
-}>()
-
-const { selectedId, publishPost, updatePost, deletePost } = props.blog.posts;
-const { updatePostContent } = props.blog.content;
const { reveal } = useConfirm()
-const showEdit = computed(() => !isEmpty(selectedId.value));
-
-//Init paginated items for the table and use filtered items
-const { pagination, items } = useFilteredPages(props.blog.posts, 15)
+const showEdit = computed(() => !isEmpty(store.posts.selectedId));
+const { items, pagination } = store.posts.createPages();
//Open with the post id
-const openEdit = async (post: PostMeta) => selectedId.value = post.id;
+const openEdit = async (post: PostMeta) => store.posts.selectedId = post.id;
const closeEdit = (update?: boolean) => {
- selectedId.value = ''
+ store.posts.selectedId = ''
//reload channels
if (update) {
emit('reload')
@@ -61,40 +52,46 @@ const closeEdit = (update?: boolean) => {
const openNew = () => {
//Reset the edit post
- selectedId.value = 'new'
+ store.posts.selectedId = 'new'
//Reset page to top
window.scrollTo(0, 0)
}
-const onSubmit = async ({post, content } : { post:PostMeta, content:string }) => {
+const onSubmit = async ({post, content } : { post: PostMeta, content: string }) => {
debugLog('submitting', post, content);
//Check for new channel, or updating old channel
- if (selectedId.value === 'new') {
+ if (store.posts.selectedId === 'new') {
//Exec create call
- await apiCall(async () => {
+ await apiCall(async ({toaster}) => {
//endpoint returns the content
- const newMeta = await publishPost(post);
+ const newMeta = await store.posts.add(post);
//Publish the content
- await updatePostContent(newMeta, content)
+ await store.content.updatePostContent(newMeta, content)
- //Close the edit panel
- closeEdit(true);
+ toaster.general.success({
+ id: 'post-create-success',
+ title: 'Created',
+ text: `Post '${post.title}' created`,
+ })
})
}
- else if (!isEmpty(selectedId.value)) {
+ else if (!isEmpty(store.posts.selectedId)) {
//Exec update call
- await apiCall(async () => {
- await updatePost(post);
+ await apiCall(async ( {toaster} ) => {
+ await store.posts.update(post);
//Publish the content
- await updatePostContent(post, content)
+ await store.content.updatePostContent(post, content)
- //Close the edit panel
- closeEdit(true);
+ toaster.general.info({
+ id: 'post-update-success',
+ title: 'Saved',
+ text: `Post '${post.title}' updated`,
+ })
})
}
//Notify error state
@@ -117,12 +114,12 @@ const onDelete = async (post: PostMeta) => {
//Exec delete call
await apiCall(async () => {
- await deletePost(post);
+ await store.posts.delete(post);
//Close the edit panel
closeEdit(true);
})
- props.blog.posts.refresh();
+ store.posts.refresh();
}
</script>
diff --git a/front-end/src/views/Blog/components/Posts/PostEdit.vue b/front-end/src/views/Blog/components/Posts/PostEdit.vue
index 4106256..4f7285b 100644
--- a/front-end/src/views/Blog/components/Posts/PostEdit.vue
+++ b/front-end/src/views/Blog/components/Posts/PostEdit.vue
@@ -4,7 +4,10 @@
<div class="button-group">
<!-- Submit the post form -->
<button class="btn primary" form="post-edit-form">Save</button>
- <button class="btn" @click="onClose">Cancel</button>
+ <button class="btn" @click="onClose">Back</button>
+ </div>
+ <div class="pl-3 text-xs text-color-background">
+ ctrl + s
</div>
</div>
<div class="mx-auto">
@@ -25,51 +28,47 @@
/>
<div id="post-content-editor" class="px-6" :class="{'invalid':v$.content.$invalid}">
- <Editor :podcast-mode="podcastMode" :blog="$props.blog" @change="onContentChanged" @mode-change="onModeChange" @load="onEditorLoad" />
+ <Editor :podcast-mode="podcastMode" @change="onContentChanged" @mode-change="onModeChange" @load="onEditorLoad" />
</div>
- <FeedFields :properties="postProperties" :blog="$props.blog" />
+ <FeedFields :properties="postProperties" />
<div class="mx-auto my-4">
<div class="button-group">
<!-- Submit the post form -->
<button class="btn primary" form="post-edit-form">Save</button>
- <button class="btn" @click="onClose">Cancel</button>
+ <button class="btn" @click="onClose">Back</button>
<button v-if="!isNew" class="btn red" @click="onDelete">Delete Forever</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
-import { computed, defineAsyncComponent, ref } from 'vue';
-import { BlogState } from '../../blog-api';
-import { reactiveComputed } from '@vueuse/core';
-import { isNil, isString, split } from 'lodash-es';
+import { computed, defineAsyncComponent, ref, toRef } from 'vue';
+import { reactiveComputed, useMagicKeys } from '@vueuse/core';
+import { isNil, isString, split, debounce } from 'lodash-es';
import { PostMeta, useXmlProperties } from '@vnuge/cmnext-admin';
import { apiCall, useUser } from '@vnuge/vnlib.browser';
import { getPostForm } from '../../form-helpers';
+import { useStore } from '../../../../store';
import FeedFields from '../FeedFields.vue';
-
const Editor = defineAsyncComponent(() => import('../../ckeditor/Editor.vue'));
const emit = defineEmits(['close', 'submit', 'delete']);
-const props = defineProps<{
- blog: BlogState
-}>()
+const store = useStore()
const { getProfile } = useUser();
const { schema, getValidator } = getPostForm();
-const { posts, content } = props.blog;
const podcastMode = ref(false)
-const isNew = computed(() => isNil(posts.selectedItem.value));
+const isNew = computed(() => isNil(store.posts.selected));
/* Post meta may load delayed from the api so it must be computed
and reactive, it may also be empty when a new post is created */
const postBuffer = reactiveComputed<PostMeta>(() => {
return {
- ...posts.selectedItem.value,
+ ...store.posts.selected,
content: ''
} as PostMeta
});
@@ -77,7 +76,7 @@ const postBuffer = reactiveComputed<PostMeta>(() => {
const { v$, validate } = getValidator(postBuffer);
//Wrap the post properties in an xml feed editor
-const postProperties = useXmlProperties(posts.selectedItem);
+const postProperties = useXmlProperties(toRef(store.posts.selected));
const onSubmit = async () =>{
if(!await validate()){
@@ -117,7 +116,7 @@ const onContentChanged = (content: string) => {
v$.value.content.$model = content;
}
-const onDelete = () => emit('delete', posts.selectedItem.value)
+const onDelete = () => emit('delete', store.posts.selected)
const setMeAsAuthor = () => {
apiCall(async () => {
@@ -132,8 +131,12 @@ const onModeChange = (e: boolean) => {
const onEditorLoad = async (editor : any) =>{
+ if(isNil(store.posts.selected)){
+ return;
+ }
+
//Get the initial content
- const postContent = await content.getSelectedPostContent();
+ const postContent = await store.content.getPostContent(store.posts.selected);
//Set the initial content
if(!isNil(postContent)){
@@ -148,6 +151,19 @@ const onEditorLoad = async (editor : any) =>{
}
}
+const throttleOnSubmit = debounce(onSubmit, 200);
+
+//Setup ctrl+s to submit the form(save)
+useMagicKeys({
+ passive: false,
+ onEventFired(e) {
+ if (e.ctrlKey && e.key === 's' && e.type === 'keydown'){
+ e.preventDefault()
+ throttleOnSubmit()
+ }
+ },
+})
+
</script>
<style lang="scss">
diff --git a/front-end/src/views/Blog/components/Posts/PostTable.vue b/front-end/src/views/Blog/components/Posts/PostTable.vue
index c1583a6..576501f 100644
--- a/front-end/src/views/Blog/components/Posts/PostTable.vue
+++ b/front-end/src/views/Blog/components/Posts/PostTable.vue
@@ -10,7 +10,7 @@
</tr>
</thead>
<tbody>
- <tr v-for="post in posts" :key="post.id" class="table-row">
+ <tr v-for="post in $props.items" :key="post.id" class="table-row">
<td class="truncate max-w-[16rem]">
{{ post.title }}
</td>
@@ -42,19 +42,14 @@
</template>
<script setup lang="ts">
-import { toRefs, watch } from 'vue';
+import { watch } from 'vue';
import { filter as _filter, truncate } from 'lodash-es';
import { useClipboard } from '@vueuse/core';
import { PostMeta } from '@vnuge/cmnext-admin';
import { useGeneralToaster } from '@vnuge/vnlib.browser';
const emit = defineEmits(['reload', 'open-edit', 'delete'])
-
-const props = defineProps<{
- posts: PostMeta[],
-}>()
-
-const { posts } = toRefs(props)
+defineProps<{ items: PostMeta[] }>()
const { copy, copied } = useClipboard()
const { info } = useGeneralToaster()
diff --git a/front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue b/front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue
index 6d1e473..e8b9ddd 100644
--- a/front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue
+++ b/front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue
@@ -43,7 +43,7 @@
<div class="">
Search for content by its id or file name.
</div>
- <ContentSearch :blog="$props.blog" @selected="onContentSelected"/>
+ <ContentSearch @selected="onContentSelected"/>
</div>
</PopoverPanel>
</Popover>
@@ -72,7 +72,6 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
-import { BlogState } from '../../blog-api';
import { PodcastEntity, getPodcastForm } from './podcast-form'
import {
Dialog,
@@ -87,15 +86,12 @@ import {
import ContentSearch from '../ContentSearch.vue'
import { apiCall, debugLog } from '@vnuge/vnlib.browser';
import { ContentMeta } from '@vnuge/cmnext-admin';
+import { useStore } from '../../../../store';
const emit = defineEmits(['submit'])
-
-const props = defineProps<{
- blog: BlogState,
-}>()
+const store = useStore()
const isOpen = ref(false)
-const { getPublicUrl } = props.blog.content;
const { schema, setEnclosureContent, getValidator, exportProperties } = getPodcastForm()
const buffer = reactive<PodcastEntity>({} as PodcastEntity)
@@ -127,7 +123,7 @@ const onCancel = () => setIsOpen(false)
const onContentSelected = (content: ContentMeta) =>{
apiCall(async () =>{
//Get the content link from the server
- const url = await getPublicUrl(content)
+ const url = await store.content.getPublicUrl(content)
//set the form content
setEnclosureContent(buffer, content, `/${url}`)
diff --git a/front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts b/front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts
index 56b898c..8e340b2 100644
--- a/front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts
+++ b/front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts
@@ -15,10 +15,10 @@
import { computed, Ref } from 'vue';
import { helpers, required, maxLength, alphaNum, numeric } from "@vuelidate/validators"
-import useVuelidate from "@vuelidate/core"
+import { useVuelidate } from "@vuelidate/core"
import { MaybeRef } from '@vueuse/core';
import { useVuelidateWrapper } from '@vnuge/vnlib.browser';
-import { ContentMeta, FeedProperty } from '@vnuge/cmnext-admin';
+import type { ContentMeta, FeedProperty } from '@vnuge/cmnext-admin';
export interface EnclosureEntity{
fileId: string;
diff --git a/front-end/src/views/Blog/index.vue b/front-end/src/views/Blog/index.vue
index b4aa47d..cc47513 100644
--- a/front-end/src/views/Blog/index.vue
+++ b/front-end/src/views/Blog/index.vue
@@ -61,9 +61,9 @@
<fa-icon icon="bullhorn" />
</div>
- <select id="channel-select" class="" v-model="channel">
+ <select id="channel-select" class="" v-model="store.channels.selectedId">
<option value="">Select Channel</option>
- <option v-for="c in channels.items.value" :value="c.id">
+ <option v-for="c in store.channels.all" :value="c.id">
{{ c.name }}
</option>
</select>
@@ -71,7 +71,7 @@
<div class="flex flex-row w-full max-w-md gap-4 ml-auto mr-4 filter">
<div class="my-auto">Filter</div>
- <input class="w-full rounded input primary" v-model="search"/>
+ <input class="w-full rounded input primary" v-model="store.queryState.search"/>
</div>
<div class="flex flex-row py-2 mr-auto">
@@ -90,15 +90,15 @@
</div>
<TabPanel>
- <Channels :blog="blogState" />
+ <Channels />
</TabPanel>
<TabPanel>
- <Posts :blog="blogState" />
+ <Posts />
</TabPanel>
<TabPanel>
- <Content :progress="progress" :blog="blogState" />
+ <Content />
</TabPanel>
</TabPanels>
@@ -108,81 +108,37 @@
</template>
<script setup lang="ts">
-import { computed, ref } from 'vue';
-import { useScriptTag } from '@vueuse/core';
+import { computed } from 'vue';
import { useRouteQuery } from '@vueuse/router';
-import { AxiosProgressEvent } from 'axios';
import { TabGroup, TabList, Tab, TabPanels, TabPanel, Switch } from '@headlessui/vue'
-import { first } from 'lodash-es';
-import { useRoute, useRouter } from 'vue-router';
-import { useUser, useAxios } from '@vnuge/vnlib.browser';
-import { createBlogContext, useComputedChannels, useComputedPosts, useComputedContent, SortType } from '@vnuge/cmnext-admin';
-import { BlogState } from './blog-api';
-import { useStore } from '../../store';
+import { defer, first } from 'lodash-es';
+import { useStore, SortType } from '../../store';
import Channels from './components/Channels.vue';
import Posts from './components/Posts.vue';
import Content from './components/Content.vue';
+
//Protect page
const store = useStore()
store.setPageTitle('Blog Admin')
-if(!window.CKEDITOR){
- //Load scripts
- const ckEditorTag = useScriptTag("https://cdn.ckeditor.com/ckeditor5/40.0.0/super-build/ckeditor.js")
- //Store the wait result on the window for the editor script to wait
- window.editorLoadResult = ckEditorTag.load(true);
-}
-
-const { userName, getProfile } = useUser()
-const progress = ref<number>(0)
-
-//Load user profile and forget if not set
-if(!userName.value){
- getProfile()
-}
-
-const firstLetter = computed(() => first(userName.value))
-
+const firstLetter = computed(() => first(store.userName))
const tabIdQ = useRouteQuery<string>('tabid', '', { mode: 'push' })
-const axios = useAxios({
- onUploadProgress: (e:AxiosProgressEvent) => {
- progress.value = Math.round((e.loaded * 100) / e.total!)
- },
- //Set to 60 second timeout
- timeout:60 * 1000
-})
-
-
-const context = createBlogContext({
- axios,
- route: useRoute(),
- router: useRouter(),
- channelUrl: '/blog/channels',
- postUrl: '/blog/posts',
- contentUrl: '/blog/content'
-})
-
-const { search, sort, channel } = context.getQuery();
-
-const channels = useComputedChannels(context)
-const posts = useComputedPosts(context)
-const content = useComputedContent(context)
-
-const blogState = { channels, posts, content } as BlogState
-
//Map queries to their respective computed values
const tabId = computed(() => tabIdQ.value ? parseInt(tabIdQ.value) : 0);
const lastModified = computed({
- get :() => sort.value === SortType.ModifiedTime,
+ get :() => store.queryState.sort === SortType.ModifiedTime,
set: (value:boolean) => {
- sort.value = value ? SortType.ModifiedTime : SortType.CreatedTime
+ store.queryState.sort = value ? SortType.ModifiedTime : SortType.CreatedTime
}
})
const onTabChange = (id:number) => tabIdQ.value = id.toString(10)
+//Load channels on page load
+defer(() => store.channels.refresh());
+
</script>
<style lang="scss">
@@ -191,6 +147,14 @@ const onTabChange = (id:number) => tabIdQ.value = id.toString(10)
@apply flex flex-row flex-auto min-h-[50rem] border rounded-sm max-w-[82rem] mx-auto;
@apply dark:border-dark-600 dark:text-gray-300 border-gray-200;
+ .text-color-foreground{
+ @apply dark:text-white text-black;
+ }
+
+ .text-color-background{
+ @apply text-gray-500;
+ }
+
.username-box{
@apply grid w-10 h-10 text-sm rounded-lg place-content-center;
@apply text-gray-600 bg-gray-100 dark:text-gray-300 dark:bg-dark-600;
@@ -264,7 +228,7 @@ const onTabChange = (id:number) => tabIdQ.value = id.toString(10)
}
.dynamic-form.field-description{
- @apply pt-1 p-2 pb-4 text-sm;
+ @apply pt-1 p-2 pb-4 text-sm text-gray-500;
}
}
@@ -303,6 +267,7 @@ const onTabChange = (id:number) => tabIdQ.value = id.toString(10)
.ck-editor .ck-content,
.ck-editor .ck-source-editing-area{
@apply min-h-[32rem] resize-y dark:bg-dark-800 px-4 dark:border-dark-300 leading-6;
+ @apply text-sm;
a {
@apply text-blue-500;
@@ -317,12 +282,37 @@ const onTabChange = (id:number) => tabIdQ.value = id.toString(10)
}
h1, h2{
- @apply border-b pb-3 mb-4;
+ @apply border-b pb-3 mb-2;
}
ul, ol{
@apply pl-6 pr-3 my-3;
}
+
+ /* Change some font sizing and spacing up */
+ h1{
+ @apply text-3xl;
+ }
+
+ h2{
+ @apply text-2xl;
+ }
+
+ h3{
+ @apply text-xl;
+ }
+
+ h4{
+ @apply text-lg;
+ }
+
+ h5{
+ @apply text-base;
+ }
+
+ h6{
+ @apply text-sm;
+ }
}
.ck-source-editing-area textarea{
diff --git a/lib/admin/package-lock.json b/lib/admin/package-lock.json
index db2d4b1..2a65348 100644
--- a/lib/admin/package-lock.json
+++ b/lib/admin/package-lock.json
@@ -14,9 +14,8 @@
"@typescript-eslint/eslint-plugin": "^6.4.x"
},
"peerDependencies": {
- "@vnuge/vnlib.browser": "https://www.vaughnnugent.com/public/resources/software/builds/vnlib.browser/141493f6d9f25d4b8d78c87afea3fa773630ba14/@vnuge-vnlib.browser/release.tgz",
+ "@vnuge/vnlib.browser": "https://www.vaughnnugent.com/public/resources/software/builds/vnlib.browser/0b1acb55ae395723772251318254ba131e987107/@vnuge-vnlib.browser/release.tgz",
"@vueuse/core": "^10.x",
- "@vueuse/router": "^10.x",
"axios": "^1.x",
"jose": "^5.1.x",
"lodash-es": "^4.x",
@@ -439,8 +438,8 @@
},
"node_modules/@vnuge/vnlib.browser": {
"version": "0.1.13",
- "resolved": "https://www.vaughnnugent.com/public/resources/software/builds/vnlib.browser/141493f6d9f25d4b8d78c87afea3fa773630ba14/@vnuge-vnlib.browser/release.tgz",
- "integrity": "sha512-XEVJxIhhC0ud8nSs6IwF7hoG2nvbhO6Jun+2rba3WQvOpWTlTEZEMV1NrgZkzi+/jupJnlVwBPgv85RVPGtA6w==",
+ "resolved": "https://www.vaughnnugent.com/public/resources/software/builds/vnlib.browser/0b1acb55ae395723772251318254ba131e987107/@vnuge-vnlib.browser/release.tgz",
+ "integrity": "sha512-q0FuQtm/BrqnoKBv5YJ3GAOT6QRBOAK3/yZSuvXFY2QyJc/kMY/bs6nEVVFdjvbYNzroxswYzGvCxx5TyIyPOg==",
"license": "MIT",
"peer": true,
"peerDependencies": {
@@ -622,48 +621,6 @@
"url": "https://github.com/sponsors/antfu"
}
},
- "node_modules/@vueuse/router": {
- "version": "10.7.0",
- "resolved": "https://registry.npmjs.org/@vueuse/router/-/router-10.7.0.tgz",
- "integrity": "sha512-8NQ12V3dtiIEKyd32RzzXLOqU+NC5/9cf7kv5cGOQzJB+Ne8j+1VmRz4ciOhOyHuiBJSfjwSqKDTzvsczZUBlQ==",
- "peer": true,
- "dependencies": {
- "@vueuse/shared": "10.7.0",
- "vue-demi": ">=0.14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "vue-router": ">=4.0.0-rc.1"
- }
- },
- "node_modules/@vueuse/router/node_modules/vue-demi": {
- "version": "0.14.6",
- "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz",
- "integrity": "sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==",
- "hasInstallScript": true,
- "peer": true,
- "bin": {
- "vue-demi-fix": "bin/vue-demi-fix.js",
- "vue-demi-switch": "bin/vue-demi-switch.js"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- },
- "peerDependencies": {
- "@vue/composition-api": "^1.0.0-rc.1",
- "vue": "^3.0.0-0 || ^2.6.0"
- },
- "peerDependenciesMeta": {
- "@vue/composition-api": {
- "optional": true
- }
- }
- },
"node_modules/@vueuse/shared": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.7.0.tgz",
diff --git a/lib/admin/package.json b/lib/admin/package.json
index ebe5446..11a6915 100644
--- a/lib/admin/package.json
+++ b/lib/admin/package.json
@@ -28,7 +28,6 @@
"peerDependencies": {
"@vueuse/core": "^10.x",
- "@vueuse/router": "^10.x",
"lodash-es": "^4.x",
"vue": "^3.x",
"axios": "^1.x",
diff --git a/lib/admin/src/channels/computedChannels.ts b/lib/admin/src/channels/computedChannels.ts
deleted file mode 100644
index dbf7cd5..0000000
--- a/lib/admin/src/channels/computedChannels.ts
+++ /dev/null
@@ -1,65 +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 <https://www.gnu.org/licenses/>.
-
-import { apiCall } from '@vnuge/vnlib.browser';
-import { Ref, computed, ref, watch } from 'vue'
-import { find, isEmpty, isEqual } from 'lodash-es';
-import { BlogChannel, ChannelApi, ComputedBlogApi, BlogAdminContext } from '../types.js'
-import { useChannels } from './channels.js';
-
-export interface ComputedChannels extends ChannelApi, ComputedBlogApi<BlogChannel> {
- readonly editChannel: Readonly<Ref<BlogChannel | undefined>>;
-}
-
-/**
- * Create a computed channels object to manage channels
- * @param channelUrl the path to the channel api
- * @returns The computed channels object
- */
-export const useComputedChannels = (context: BlogAdminContext): ComputedChannels => {
-
- const channels = useChannels(context)
- const { channel, channelEdit } = context.getQuery()
-
- const items = ref<BlogChannel[]>([]);
-
- const loadChannels = async () => {
- items.value = await apiCall(channels.getChannels) ?? [];
- }
-
- const selectedItem = computed<BlogChannel | undefined>(() => {
- return find(items.value, c => isEqual(c.id, channel.value));
- });
-
- const editChannel = computed<BlogChannel | undefined>(() => {
- return find(items.value, c => isEqual(c.id, channelEdit.value));
- })
-
- //Initial load
- loadChannels();
-
- //Load channels when the edit id changes to empty
- watch(channelEdit, (newId) => isEmpty(newId) ? loadChannels() : null);
-
- return {
- ...channels,
- items,
- selectedItem,
- editChannel,
- selectedId:channel,
- getQuery: context.getQuery,
- }
-}
-
diff --git a/lib/admin/src/channels/index.ts b/lib/admin/src/channels/index.ts
index d45eb72..8a14ab3 100644
--- a/lib/admin/src/channels/index.ts
+++ b/lib/admin/src/channels/index.ts
@@ -13,5 +13,4 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-export * from './channels'
-export * from './computedChannels' \ No newline at end of file
+export * from './useChannels' \ No newline at end of file
diff --git a/lib/admin/src/channels/channels.ts b/lib/admin/src/channels/useChannels.ts
index 3efb6b7..b9201b3 100644
--- a/lib/admin/src/channels/channels.ts
+++ b/lib/admin/src/channels/useChannels.ts
@@ -13,7 +13,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-import { isEqual, toSafeInteger } from 'lodash-es';
+import { isArray, isEqual, toSafeInteger } from 'lodash-es';
import { BlogChannel, ChannelFeed, ChannelApi, BlogAdminContext } from '../types.js'
/**
@@ -31,29 +31,40 @@ export const useChannels = (context: BlogAdminContext): ChannelApi => {
return channel;
}
- const getChannels = async (): Promise<BlogChannel[]> => {
- const { data } = await axios.get(getUrl());
- return data;
- }
-
const deleteChannel = async (channel: BlogChannel) => {
//Call delete with the channel id query
await axios.delete(`${getUrl()}?channel=${channel.id}`);
}
- const addChannel = async (channel: BlogChannel, feed?: ChannelFeed): Promise<BlogChannel> => {
- //Clone the item to avoid modifying the original
- const add = sanitizeNumbers({ ...channel, feed });
- //Call post with the channel data
- return await axios.post(getUrl(), add);
- }
-
- const updateChannel = async (channel: BlogChannel, feed?: ChannelFeed): Promise<BlogChannel> => {
- //Manually assign the feed or null, and clone the item to avoid modifying the original
- const update = sanitizeNumbers({ ...channel, feed });
- //Call put with the channel data
- return await axios.patch(getUrl(), update);
- }
+ return {
+ async getAllItems() {
+ const { data } = await axios.get(getUrl());
+ return data;
+ },
+
+ async add(item: BlogChannel, feed?: ChannelFeed) {
+ //Clone the item to avoid modifying the original
+ const add = sanitizeNumbers({ ...item, feed });
+ //Call post with the channel data
+ return await axios.post(getUrl(), add);
+ },
- return { getChannels, deleteChannel, addChannel, updateChannel };
+ async update(item: BlogChannel, feed?: ChannelFeed){
+ //Manually assign the feed or null, and clone the item to avoid modifying the original
+ const update = sanitizeNumbers({ ...item, feed });
+ //Call put with the channel data
+ return await axios.patch(getUrl(), update);
+ },
+
+ async delete(item: BlogChannel | BlogChannel[]){
+ //invoke delete for each item
+ if(isArray(item)){
+ await Promise.all(item.map(deleteChannel));
+ }
+ else{
+ //Call delete with the channel id query
+ await deleteChannel(item)
+ }
+ },
+ };
}
diff --git a/lib/admin/src/content/computedContent.ts b/lib/admin/src/content/computedContent.ts
deleted file mode 100644
index 75a25f8..0000000
--- a/lib/admin/src/content/computedContent.ts
+++ /dev/null
@@ -1,92 +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 <https://www.gnu.org/licenses/>.
-
-import { Ref, computed, ref } from "vue";
-import { find, filter, includes, isEqual, isNil, toLower } from 'lodash-es';
-import { apiCall } from "@vnuge/vnlib.browser"
-import { ContentMeta, BlogEntity, ContentApi, ComputedBlogApi, BlogAdminContext } from "../types.js";
-import { watchAndCompute } from "../helpers.js";
-import { useContent } from "./useContent.js";
-
-export interface ComputedContent extends ContentApi, ComputedBlogApi<ContentMeta> {
- /**
- * Gets the raw content for a the currently selected post
- */
- getSelectedPostContent(): Promise<string | undefined>;
- /**
- * Filter post items by the given reactive filter
- * @param filter The reactive filter used to filter the content
- */
- createReactiveSearch(filter: Ref<string>): Ref<ContentMeta[] | undefined>;
- /**
- * triggers a refresh of the content
- */
- refresh(): void
-}
-
-/**
- * Gets a computed object with the content and selected content
- * @param context The blog admin context
- * @returns A computed object with the content and selected content
- */
-export const useComputedContent = (context: BlogAdminContext): ComputedContent => {
-
- //Get the content api from the context
- const contentApi = useContent(context);
- const trigger = ref(0);
-
- const { content, post, channel } = context.getQuery();
-
- //Watch for channel and selected id changes and get the content
- const items = watchAndCompute([channel, content, post, trigger], async () => {
- //Get all content if the channel is set, otherwise return empty array
- return channel.value ? await apiCall(contentApi.getAllContent) ?? [] : [];
- }, []);
-
- const selectedItem = computed<ContentMeta | undefined>(() => {
- if (!isNil(channel.value) && content.value && content.value !== 'new') {
- return find(items.value, c => isEqual(c.id, content.value));
- }
- return {} as ContentMeta;
- })
-
- const getSelectedPostContent = async (): Promise<string | undefined> => {
- if (!isNil(channel.value) && post.value && post.value !== 'new') {
- return await apiCall(() => contentApi.getPostContent({ id: post.value } as BlogEntity));
- }
- return '';
- }
-
- const createReactiveSearch = (sec: Ref<string>): Ref<ContentMeta[] | undefined> => {
- return computed(() => {
- return filter(items.value, c => includes(toLower(c.name), toLower(sec.value)) || includes(toLower(c.id), toLower(sec.value)));
- })
- }
-
- const refresh = () => {
- trigger.value++;
- }
-
- return {
- ...contentApi,
- items,
- selectedItem,
- getSelectedPostContent,
- createReactiveSearch,
- selectedId: content,
- getQuery: context.getQuery,
- refresh
- };
-}
diff --git a/lib/admin/src/content/index.ts b/lib/admin/src/content/index.ts
index 802c002..d014136 100644
--- a/lib/admin/src/content/index.ts
+++ b/lib/admin/src/content/index.ts
@@ -13,5 +13,4 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-export * from './useContent'
-export * from './computedContent' \ No newline at end of file
+export * from './useContent' \ No newline at end of file
diff --git a/lib/admin/src/content/useContent.ts b/lib/admin/src/content/useContent.ts
index 47d27b8..f125b54 100644
--- a/lib/admin/src/content/useContent.ts
+++ b/lib/admin/src/content/useContent.ts
@@ -14,9 +14,11 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { includes, isArray, isEmpty, join, map } from 'lodash-es';
-import { WebMessage } from "@vnuge/vnlib.browser"
-import { AxiosRequestConfig } from 'axios';
-import { PostMeta, ContentMeta, ContentApi, BlogEntity, BlogAdminContext } from "../types.js";
+import { get } from '@vueuse/core';
+import { type WebMessage } from "@vnuge/vnlib.browser"
+import { type AxiosRequestConfig } from 'axios';
+import { type MaybeRef } from 'vue';
+import type { PostMeta, ContentMeta, ContentApi, BlogEntity, BlogAdminContext } from "../types";
/**
@@ -25,15 +27,13 @@ import { PostMeta, ContentMeta, ContentApi, BlogEntity, BlogAdminContext } from
* @param channel The channel to get the content for
* @returns A content api object
*/
-export const useContent = (context : BlogAdminContext): ContentApi => {
+export const useContent = (context : BlogAdminContext, channel: MaybeRef<string>): ContentApi => {
const axios = context.getAxios();
- const { channel } = context.getQuery();
-
const getUrl = (): string => {
const url = context.getContentUrl();
//Return the url with the channel id query
- return `${url}?channel=${channel.value}`;
+ return `${url}?channel=${get(channel)}`;
}
const getContentType = (file: File): string => {
@@ -67,7 +67,7 @@ export const useContent = (context : BlogAdminContext): ContentApi => {
return await _getContent(post.id);
}
- const getAllContent = async (): Promise<ContentMeta[]> => {
+ const getAllItems = async (): Promise<ContentMeta[]> => {
const url = getUrl();
const response = await axios.get<ContentMeta[]>(url);
return response.data;
@@ -155,19 +155,26 @@ export const useContent = (context : BlogAdminContext): ContentApi => {
}
const getContent = async (id: string): Promise<ContentMeta | undefined> => {
- const index = await getAllContent();
+ const index = await getAllItems();
return index.find(x => x.id === id);
}
+ const downloadContent = async (content: ContentMeta): Promise<Blob> => {
+ const url = getUrl();
+ const { data } = await axios.get(`${url}&id=${content.id}`, { responseType: 'blob' });
+ return data;
+ }
+
return {
getPostContent,
- getAllContent,
- deleteContent,
+ getAllItems,
+ delete:deleteContent,
uploadContent,
updateContentName,
updatePostContent,
updateContent,
getPublicUrl,
- getContent
+ getContent,
+ downloadContent,
};
}
diff --git a/lib/admin/src/index.ts b/lib/admin/src/index.ts
index 7733f5c..61d2c63 100644
--- a/lib/admin/src/index.ts
+++ b/lib/admin/src/index.ts
@@ -14,71 +14,36 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//Export apis and types
-export * from './types';
+
export * from './ordering'
export * from './feedProperties'
-export * from './posts'
-export * from './content'
-export * from './channels'
+export { usePosts } from './posts'
+export { useContent } from './content'
+export { useChannels } from './channels'
+
+export type * from './types';
-import { MaybeRef } from "vue";
import { get } from '@vueuse/core'
-import { useRouteQuery } from "@vueuse/router";
-import { QueryState, QueryType, SortType, BlogAdminContext } from "./types";
-import { RouteLocationNormalized, Router } from 'vue-router';
-import { AxiosInstance } from 'axios';
+import type { MaybeRef } from "vue";
+import type { BlogAdminContext } from "./types";
+import type { Axios } from 'axios';
export interface BlogAdminConfig {
- readonly axios: AxiosInstance;
- readonly router: Router;
- readonly route: RouteLocationNormalized;
+ readonly axios: Axios;
readonly postUrl: MaybeRef<string>;
readonly contentUrl: MaybeRef<string>;
readonly channelUrl: MaybeRef<string>;
readonly defaultPageSize?: number;
}
-const createQueryState = (router :Router , route : RouteLocationNormalized): QueryState => {
-
- //setup filter search query
- const search = useRouteQuery<string>(QueryType.Filter, '', { mode: 'replace', route, router });
-
- //Get sort order query
- const sort = useRouteQuery<SortType>(QueryType.Sort, SortType.CreatedTime, { mode: 'replace', route, router });
-
- //Selected channel id
- const channel = useRouteQuery<string>(QueryType.Channel, '', { mode: 'replace', route, router });
-
- //Edits are in push mode because they are used to navigate to edit pages
-
- const channelEdit = useRouteQuery<string>(QueryType.ChannelEdit, '', { mode: 'push', route, router });
-
- const content = useRouteQuery<string>(QueryType.Content, '', { mode: 'push', route, router });
- //Get the selected post id from the route
- const post = useRouteQuery<string>(QueryType.Post, '', { mode: 'push', route, router });
-
- return {
- post,
- channel,
- content,
- channelEdit,
- search,
- sort
- }
-}
-
/**
* Create a blog context object from the given configuration
* @param param0 The blog configuration object
* @returns A blog context object to pass to the blog admin components
*/
-export const createBlogContext = ({ channelUrl, postUrl, contentUrl, router, route, axios }: BlogAdminConfig): BlogAdminContext => {
-
- const queryState = createQueryState(router, route);
-
- const getQuery = (): QueryState => queryState;
+export const createBlogContext = ({ channelUrl, postUrl, contentUrl, axios }: BlogAdminConfig): BlogAdminContext => {
- const getAxios = () : AxiosInstance => axios;
+ const getAxios = (): Axios => axios;
const getPostUrl = (): string => get(postUrl)
@@ -88,7 +53,6 @@ export const createBlogContext = ({ channelUrl, postUrl, contentUrl, router, rou
return{
getAxios,
- getQuery,
getPostUrl,
getChannelUrl,
getContentUrl,
diff --git a/lib/admin/src/ordering/index.ts b/lib/admin/src/ordering/index.ts
index a2f266f..c252573 100644
--- a/lib/admin/src/ordering/index.ts
+++ b/lib/admin/src/ordering/index.ts
@@ -16,7 +16,7 @@
import { MaybeRefOrGetter, computed } from 'vue';
import { useOffsetPagination } from '@vueuse/core';
import { filter, includes, isEmpty, orderBy, slice, toLower } from 'lodash-es';
-import { CanPaginate, NamedBlogEntity, SortedFilteredPaged } from '../types';
+import type { CanPaginate, NamedBlogEntity, SortedFilteredPaged } from '../types';
/**
* Allows filtering, sorting, and paginating a collection of blog items
@@ -26,7 +26,7 @@ import { CanPaginate, NamedBlogEntity, SortedFilteredPaged } from '../types';
export const useFilteredPages = <T extends NamedBlogEntity>(pageable: CanPaginate<T>, pageSize: MaybeRefOrGetter<number>): SortedFilteredPaged<T> => {
//Get filterable items, and the query state to filter by
- const { sort, search } = pageable.getQuery();
+ const { sort, search } = pageable;
const filtered = computed<T[]>(() => {
diff --git a/lib/admin/src/posts/computedPosts.ts b/lib/admin/src/posts/computedPosts.ts
deleted file mode 100644
index 640226f..0000000
--- a/lib/admin/src/posts/computedPosts.ts
+++ /dev/null
@@ -1,56 +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 <https://www.gnu.org/licenses/>.
-
-import { computed, ref } from "vue";
-import { isEqual, find } from 'lodash-es';
-import { apiCall } from "@vnuge/vnlib.browser";
-import { PostMeta, ComputedPosts, BlogAdminContext } from "../types";
-import { usePostApi } from "./usePost";
-import { watchAndCompute } from "../helpers";
-
-/**
- * Creates a computed post api for reactive blog apis
- * @param context The blog admin context
- * @returns The computed post api
- */
-export const useComputedPosts = (context: BlogAdminContext): ComputedPosts => {
- //Post api around the post url and channel
- const postApi = usePostApi(context);
- const trigger = ref(0);
-
- const { channel, post } = context.getQuery();
-
- //Get all posts
- const items = watchAndCompute([channel, post, trigger], async () => {
- return channel.value ? await apiCall(postApi.getPosts) ?? [] : [];
- }, [])
-
- const selectedItem = computed<PostMeta | undefined>(() => {
- return find(items.value, p => isEqual(p.id, post.value));
- })
-
- const refresh = () => {
- trigger.value++;
- }
-
- return {
- ...postApi,
- items,
- selectedItem,
- selectedId:post,
- getQuery: context.getQuery,
- refresh
- };
-} \ No newline at end of file
diff --git a/lib/admin/src/posts/index.ts b/lib/admin/src/posts/index.ts
index 0105265..9bb70d4 100644
--- a/lib/admin/src/posts/index.ts
+++ b/lib/admin/src/posts/index.ts
@@ -13,5 +13,4 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-export * from './usePost'
-export * from './computedPosts' \ No newline at end of file
+export * from './usePost' \ No newline at end of file
diff --git a/lib/admin/src/posts/usePost.ts b/lib/admin/src/posts/usePost.ts
index 1d93b6b..421ff83 100644
--- a/lib/admin/src/posts/usePost.ts
+++ b/lib/admin/src/posts/usePost.ts
@@ -14,28 +14,23 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { isArray, orderBy } from 'lodash-es';
-import { WebMessage } from "@vnuge/vnlib.browser"
-import { PostMeta, PostApi, BlogAdminContext } from "../types";
+import { get } from '@vueuse/core';
+import { type WebMessage } from "@vnuge/vnlib.browser"
+import { type MaybeRef } from 'vue';
+import type { PostMeta, PostApi, BlogAdminContext } from "../types";
/**
* Gets a reactive post api for the given channel
* @param context The blog admin context
* @returns The configured post api
*/
-export const usePostApi = (context : BlogAdminContext): PostApi => {
+export const usePosts = (context: BlogAdminContext, channel: MaybeRef<string>): PostApi => {
const axios = context.getAxios();
- const { channel } = context.getQuery();
-
const getUrl = (): string => {
const url = context.getPostUrl();
//Return the url with the channel id query
- return `${url}?channel=${channel.value}`;
- }
-
- const getPosts = async (): Promise<PostMeta[]> => {
- const { data } = await axios.get(getUrl());
- return isArray(data) ? orderBy(data, 'date', 'desc') : [];
+ return `${url}?channel=${get(channel)}`;
}
const deletePost = (post: PostMeta): Promise<void> => {
@@ -43,28 +38,39 @@ export const usePostApi = (context : BlogAdminContext): PostApi => {
return axios.delete(`${getUrl()}&post=${post.id}`);
}
- const publishPost = async (post: PostMeta): Promise<PostMeta> => {
- //Call post with the post data
- const { data } = await axios.post<WebMessage<PostMeta>>(getUrl(), post);
- return data.getResultOrThrow();
- }
+ return {
+
+ async delete(item: PostMeta | PostMeta[]){
+ //invoke delete for each item
+ if(isArray(item)){
+ await Promise.all(item.map(deletePost));
+ }
+ else{
+ //Call delete with the post id query
+ await deletePost(item)
+ }
+ },
- const updatePost = async (post: PostMeta): Promise<PostMeta> => {
- //Call patch with the updated post content, must have an id set as an existing post
- const { data } = await axios.patch<WebMessage<PostMeta>>(getUrl(), post);
- return data.getResultOrThrow();
- }
+ async add(item: PostMeta) {
+ //Call post with the post data
+ const { data } = await axios.post<WebMessage<PostMeta>>(getUrl(), item);
+ return data.getResultOrThrow();
+ },
- const getSinglePost = async (postId: string): Promise<PostMeta> => {
- const { data } = await axios.get(`${getUrl()}&post=${postId}`);
- return data;
- }
+ async getAllItems(){
+ const { data } = await axios.get(getUrl());
+ return isArray(data) ? orderBy(data, 'date', 'desc') : [];
+ },
- return {
- getPosts,
- deletePost,
- publishPost,
- updatePost,
- getSinglePost
+ async update(item: PostMeta) {
+ //Call patch with the updated post content, must have an id set as an existing post
+ const { data } = await axios.patch<WebMessage<PostMeta>>(getUrl(), item);
+ return data.getResultOrThrow();
+ },
+
+ async getSinglePost(postId: string) {
+ const { data } = await axios.get(`${getUrl()}&post=${postId}`);
+ return data;
+ }
};
} \ No newline at end of file
diff --git a/lib/admin/src/types.ts b/lib/admin/src/types.ts
index 60b0064..e352d34 100644
--- a/lib/admin/src/types.ts
+++ b/lib/admin/src/types.ts
@@ -13,25 +13,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-import { UseOffsetPaginationReturn } from '@vueuse/core';
-import { AxiosInstance, AxiosRequestConfig } from 'axios';
-import { Dictionary } from 'lodash';
-import { Ref } from 'vue';
-
-export enum QueryType {
- Post = 'post',
- Channel = 'channel',
- Content = 'content',
- ChannelEdit = 'ecid',
- Filter = 'filter',
- Sort = 'sort',
- PageSize = 'size',
-}
-
-export enum SortType {
- CreatedTime = 'created',
- ModifiedTime = 'date',
-}
+import type { UseOffsetPaginationReturn } from '@vueuse/core';
+import type { Axios, AxiosRequestConfig } from 'axios';
+import type { Dictionary } from 'lodash';
+import type { Ref } from 'vue';
/**
* A base blog entity that has a globally unique id and a date
@@ -110,60 +95,46 @@ export interface PostMeta extends NamedBlogEntity, XmlPropertyContainer {
html_description?: string;
}
-/**
- * Represents the channel api and its operations
- */
-export interface ChannelApi {
- /**
- * Gets all blog channels from the server
- * @returns An array of blog channels
- */
- getChannels: () => Promise<BlogChannel[]>;
+export interface BlogApi<T extends BlogEntity> {
/**
- * Delets a blog channel from the catalog
- * @param channel The channel to delete
+ * Gets all blog entities from the server
+ * @returns An array of entities
*/
- deleteChannel: (channel: BlogChannel) => Promise<void>;
+ getAllItems(): Promise<T[]>;
+
/**
- * Adds a channel to the catalog
- * @param channel The channel to add
+ * Deletes an entity from the server
+ * @param item The entity to delete
*/
- addChannel: (channel: BlogChannel, feed?: ChannelFeed) => Promise<BlogChannel>;
+ delete(item: T): Promise<void>;
+
/**
- * Updates a channel in the catalog
- * @param channel The channel to update
+ * Deletes an array of entities from the server
+ * @param item The entities to delete
*/
- updateChannel: (channel: BlogChannel, feed?: ChannelFeed) => Promise<BlogChannel>;
-}
+ delete(item: T[]): Promise<void>;
-export interface PostApi {
/**
- * Gets all blog posts from the server
- * @param channel The channel to get posts from
- * @returns An array of blog posts
- */
- getPosts: () => Promise<PostMeta[]>;
- /**
- * Deletes a post from the given channel by its id
- * @param channel The channel the post belongs to
- * @param post The post to delete
- * @returns The response from the server
+ * Adds an entity to the server
+ * @param item The entity to add
*/
- deletePost: (post: PostMeta) => Promise<void>;
+ add(item: T): Promise<T>;
+
/**
- * Publishes a new post to the given channel
- * @param channel The blog channel to publish to
- * @param post The post to publish
- * @returns The response from the server
+ * Updates an entity on the server
+ * @param item The entity to update
*/
- publishPost: (post: PostMeta) => Promise<PostMeta>;
- /**
- * Updates a post in the given channel
- * @param channel The channel the post belongs to
- * @param post The post to update
- * @returns The response from the server
- */
- updatePost: (post: PostMeta) => Promise<PostMeta>;
+ update(item: T): Promise<T>;
+}
+
+/**
+ * Represents the channel api and its operations
+ */
+export interface ChannelApi extends BlogApi<BlogChannel> {
+ add(item: BlogChannel, feed?: ChannelFeed): Promise<BlogChannel>;
+}
+
+export interface PostApi extends BlogApi<PostMeta> {
/**
* Gets the post meta data for a single post by its id
* @param postId The id of the post to get
@@ -174,34 +145,33 @@ export interface PostApi {
export interface ContentApi {
/**
+ * Gets all blog entities from the server
+ * @returns An array of entities
+ */
+ getAllItems(): Promise<ContentMeta[]>;
+ /**
+ * Deletes an entity from the server
+ * @param item The entity to delete
+ */
+ delete(item: ContentMeta): Promise<void>;
+ /**
+ * Deletes an array of entities from the server
+ * @param item The entities to delete
+ */
+ delete(item: ContentMeta[]): Promise<void>;
+ /**
* Gets the content for a post as text
* @param post The post to get the content for
* @returns A promise that resolves to the content string
*/
getPostContent(post: BlogEntity): Promise<string>;
/**
- * Gets all content meta objects for the current channel
- * @returns A promise that resolves to an array of content meta objects
- */
- getAllContent(): Promise<ContentMeta[]>;
- /**
* Gets a single content meta object by its id
* @param id The id of the content meta object to get
* @returns A promise that resolves to the content meta object
*/
getContent(id: string): Promise<ContentMeta | undefined>;
/**
- * Deletes a content meta object from the server in the current channel
- * @param content The content meta object to delete
- * @returns A promise that resolves when the content has been deleted
- */
- deleteContent(content: ContentMeta): Promise<void>;
- /**
- * Deletes an array of content meta objects from the server in the current channel
- * @param content The content items to delete
- */
- deleteContent(content: ContentMeta[]): Promise<void>;
- /**
* Uploads a content file to the server in the current channel
* @param content The content file to upload
* @param name The name of the content file
@@ -234,6 +204,11 @@ export interface ContentApi {
* @param data The new content data file
*/
updateContent(content: ContentMeta, data: File, config?:AxiosRequestConfig): Promise<ContentMeta>;
+ /**
+ * Downloads the content data file for the given content meta object
+ * @param content The content meta object to download
+ */
+ downloadContent(content: ContentMeta): Promise<Blob>;
}
/**
@@ -244,46 +219,20 @@ export interface CanPaginate<T> {
* A reactive collection of items within the store
*/
readonly items: Readonly<Ref<T[]>>;
- /**
- * Gets the global query state
- */
- getQuery(): Readonly<QueryState>
-}
-
-export interface ComputedBlogApi<T> extends CanPaginate<T>{
- readonly selectedId: Ref<string>;
- readonly selectedItem : Readonly<Ref<T | undefined>>;
-}
-export interface ComputedPosts extends PostApi, ComputedBlogApi<PostMeta> {
- /**
- * Triggers a refresh of the posts
- */
- refresh(): void;
-}
+ readonly sort: Readonly<Ref<string>>;
-/**
- * The current state of the query
- */
-export interface QueryState {
- readonly post: Ref<string>;
- readonly channel: Ref<string>;
- readonly content: Ref<string>;
- readonly channelEdit: Ref<string>;
- readonly search: Ref<string>;
- readonly sort: Ref<SortType>;
+ readonly search: Readonly<Ref<string>>;
}
-
export interface SortedFilteredPaged<T>{
readonly items : Readonly<Ref<T[]>>;
readonly pagination: UseOffsetPaginationReturn;
}
export interface BlogAdminContext {
- getQuery(): QueryState;
getPostUrl(): string;
getContentUrl(): string;
getChannelUrl(): string;
- getAxios(): AxiosInstance;
+ getAxios(): Axios;
} \ No newline at end of file