diff options
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 |