aboutsummaryrefslogtreecommitdiff
path: root/front-end
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 /front-end
parent4b8ae76132d2342f40cec703b3d5145ea075c451 (diff)
move blog admin state to pinia store plugin
Diffstat (limited to 'front-end')
-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
21 files changed, 574 insertions, 288 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{