diff options
author | vnugent <public@vaughnnugent.com> | 2023-12-16 02:40:03 -0500 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2023-12-16 02:40:03 -0500 |
commit | 0d25abab798c005266a1c0b4eeba957d232d4328 (patch) | |
tree | 427bd36e33fcd4960e3a2bc7d73b77dc7779b214 /front-end/src/views | |
parent | 4b8ae76132d2342f40cec703b3d5145ea075c451 (diff) |
move blog admin state to pinia store plugin
Diffstat (limited to 'front-end/src/views')
17 files changed, 251 insertions, 286 deletions
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{ |