diff options
author | vnugent <public@vaughnnugent.com> | 2023-07-12 01:28:23 -0400 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2023-07-12 01:28:23 -0400 |
commit | f64955c69d91e578e580b409ba31ac4b3477da96 (patch) | |
tree | 16f01392ddf1abfea13d7d1ede3bfb0459fe8f0d /front-end/src/views/Blog |
Initial commit
Diffstat (limited to 'front-end/src/views/Blog')
21 files changed, 2742 insertions, 0 deletions
diff --git a/front-end/src/views/Blog/blog-api/index.ts b/front-end/src/views/Blog/blog-api/index.ts new file mode 100644 index 0000000..678883b --- /dev/null +++ b/front-end/src/views/Blog/blog-api/index.ts @@ -0,0 +1,22 @@ +// 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 new file mode 100644 index 0000000..87c0595 --- /dev/null +++ b/front-end/src/views/Blog/ckeditor/Editor.vue @@ -0,0 +1,155 @@ +<template> + <div class="pt-6"> + <div class="flex justify-end w-full gap-2 my-2"> + <div class="w-fit"> + <Popover class="relative"> + <PopoverButton class="btn"> + Add + <fa-icon class="ml-2" icon="photo-film" /> + </PopoverButton> + <PopoverPanel class="absolute right-0 z-10 top-10"> + <div class="md-pannel"> + <div class=""> + Search for content by its id or file name. + </div> + <ContentSearch :blog="$props.blog"/> + </div> + </PopoverPanel> + </Popover> + </div> + <div class="w-fit"> + <Popover class="relative"> + <PopoverButton class="btn" @click="recoverMd"> + Markdown + <fa-icon class="ml-2" :icon="['fab','markdown']" /> + </PopoverButton> + <PopoverPanel class="absolute right-0 z-10 top-10"> + <div class="md-pannel"> + <div class=""> + Paste your markdown here to convert it to html. + </div> + <div class="my-4"> + <textarea class="w-full h-40 p-2 bg-transparent border" v-model="mdBuffer"></textarea> + </div> + <div class="flex justify-end"> + <button class="btn primary" @click="convertMarkdown">Convert</button> + </div> + </div> + </PopoverPanel> + </Popover> + </div> + <div class="w-fit"> + <button class="btn" @click="recoverFromCrash"> + Recover + <fa-icon class="ml-2" icon="rotate-left" /> + </button> + </div> + </div> + <div id="ck-editor-frame" ref="editorFrame"> + <div class="w-full text-center"> + <h5>Loading editor...</h5> + <fa-icon class="text-2xl" icon="spinner" spin /> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { debounce } from 'lodash'; +import { ref } from 'vue'; +import { useSessionStorage } from '@vueuse/core'; +import { tryOnMounted } from '@vueuse/shared'; +import { apiCall } from '@vnuge/vnlib.browser'; +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue' +import { BlogState } from '../blog-api' +import { Converter } from 'showdown' + +//Import the editor config +import { config } from './build.ts' +import ContentSearch from '../components/ContentSearch.vue'; + +const emit = defineEmits(['change', 'load']) + +defineProps<{ + blog: BlogState +}>() + +let editor = {} +//Init new shodown converter +const showdownConverter = new Converter() +const mdBuffer = ref('') +const editorFrame = ref(null) +const crashBuffer = useSessionStorage('post-crash', '') + +const recoverFromCrash = () => { + //Set editor content from crash buffer + editor.setData(crashBuffer.value); +} + +const onChange = (content:string) =>{ + //Save the content to the crash buffer + crashBuffer.value = content; + emit('change', content) +} + +const convertMarkdown = () => { + + const html = showdownConverter.makeHtml(mdBuffer.value); + + //Set initial data + editor.setData(html) + + //manually trigger change event + onChange(html) + + //Clear the buffer + mdBuffer.value = '' +} + +const recoverMd = () => { + const current = editor.getData(); + const md = showdownConverter.makeMd(current); + mdBuffer.value = md; +} + +tryOnMounted(() => + //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>) + } + + if (!window['CKEDITOR']) { + toaster.general.error({ + title: 'Script Error', + text: 'The CKEditor script failed to load, check script permissions.' + }) + return; + } + + //CKEditor 5 superbuild in global scope + const { ClassicEditor } = window['CKEDITOR'] + + //Init editor when loading is complete + editor = await ClassicEditor.create(editorFrame.value, config); + + //Update the local copy when the editor data changes + editor.model.document.on('change:data', debounce(() => onChange(editor.getData())), 500) + + //Call initial load hook + emit('load', editor); + }) +) + +</script> + +<style lang="scss"> + +.md-pannel{ + @apply p-6 min-w-[32rem] bg-white shadow-md dark:bg-dark-700 border dark:border-dark-300; +} + +</style>
\ No newline at end of file diff --git a/front-end/src/views/Blog/ckeditor/build.ts b/front-end/src/views/Blog/ckeditor/build.ts new file mode 100644 index 0000000..83b46a7 --- /dev/null +++ b/front-end/src/views/Blog/ckeditor/build.ts @@ -0,0 +1,125 @@ +export const config = { + // https://ckeditor.com/docs/ckeditor5/latest/features/toolbar/toolbar.html#extended-toolbar-configuration-format + toolbar: { + items: [ + 'findAndReplace', 'selectAll', '|', + 'heading', '|', + 'bold', 'italic', 'strikethrough', 'underline', 'code', 'subscript', 'superscript', 'removeFormat', '|', + 'bulletedList', 'numberedList', 'todoList', '|', + 'outdent', 'indent', 'alignment', '|', + 'undo', 'redo', + '-', + 'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'highlight', '|', + 'link', 'insertImage', 'blockQuote', 'insertTable', 'mediaEmbed', 'codeBlock', 'htmlEmbed', '|', + 'specialCharacters', 'horizontalLine', 'pageBreak', '|', + 'exportPDF', 'exportWord', 'sourceEditing' + ], + shouldNotGroupWhenFull: true + }, + // Changing the language of the interface requires loading the language file using the <script> tag. + // language: 'es', + list: { + properties: { + styles: true, + startIndex: true, + reversed: true + } + }, + // https://ckeditor.com/docs/ckeditor5/latest/features/headings.html#configuration + heading: { + options: [ + { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' }, + { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' }, + { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' }, + { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' }, + { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' }, + { model: 'heading5', view: 'h5', title: 'Heading 5', class: 'ck-heading_heading5' }, + { model: 'heading6', view: 'h6', title: 'Heading 6', class: 'ck-heading_heading6' } + ] + }, + // https://ckeditor.com/docs/ckeditor5/latest/features/editor-placeholder.html#using-the-editor-configuration + placeholder: 'Welcome to CKEditor 5!', + // https://ckeditor.com/docs/ckeditor5/latest/features/font.html#configuring-the-font-family-feature + fontFamily: { + options: [ + 'default', + 'Arial, Helvetica, sans-serif', + 'Courier New, Courier, monospace', + 'Georgia, serif', + 'Lucida Sans Unicode, Lucida Grande, sans-serif', + 'Tahoma, Geneva, sans-serif', + 'Times New Roman, Times, serif', + 'Trebuchet MS, Helvetica, sans-serif', + 'Verdana, Geneva, sans-serif' + ], + supportAllValues: true + }, + // https://ckeditor.com/docs/ckeditor5/latest/features/font.html#configuring-the-font-size-feature + fontSize: { + options: [10, 12, 14, 'default', 18, 20, 22], + supportAllValues: true + }, + // Be careful with the setting below. It instructs CKEditor to accept ALL HTML markup. + // https://ckeditor.com/docs/ckeditor5/latest/features/general-html-support.html#enabling-all-html-features + htmlSupport: { + allow: [ + { + name: /.*/, + attributes: true, + classes: true, + styles: true + } + ] + }, + // Be careful with enabling previews + // https://ckeditor.com/docs/ckeditor5/latest/features/html-embed.html#content-previews + htmlEmbed: { + showPreviews: true + }, + // https://ckeditor.com/docs/ckeditor5/latest/features/link.html#custom-link-attributes-decorators + link: { + decorators: { + addTargetToExternalLinks: true, + defaultProtocol: 'https://', + toggleDownloadable: { + mode: 'manual', + label: 'Downloadable', + attributes: { + download: 'file' + } + } + } + }, + // https://ckeditor.com/docs/ckeditor5/latest/features/mentions.html#configuration + mention: { + }, + // The "super-build" contains more premium features that require additional configuration, disable them below. + // Do not turn them on unless you read the documentation and know how to configure them and setup the editor. + removePlugins: [ + // These two are commercial, but you can try them out without registering to a trial. + // 'ExportPdf', + // 'ExportWord', + 'CKBox', + 'CKFinder', + 'EasyImage', + // This sample uses the Base64UploadAdapter to handle image uploads as it requires no configuration. + // https://ckeditor.com/docs/ckeditor5/latest/features/images/image-upload/base64-upload-adapter.html + // Storing images as Base64 is usually a very bad idea. + // Replace it on production website with other solutions: + // https://ckeditor.com/docs/ckeditor5/latest/features/images/image-upload/image-upload.html + // 'Base64UploadAdapter', + 'RealTimeCollaborativeComments', + 'RealTimeCollaborativeTrackChanges', + 'RealTimeCollaborativeRevisionHistory', + 'PresenceList', + 'Comments', + 'TrackChanges', + 'TrackChangesData', + 'RevisionHistory', + 'Pagination', + 'WProofreader', + // Careful, with the Mathtype plugin CKEditor will not load when loading this sample + // from a local file system (file://) - load this site via HTTP server if you enable MathType + 'MathType' + ], +}
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/Channels.vue b/front-end/src/views/Blog/components/Channels.vue new file mode 100644 index 0000000..ad88e50 --- /dev/null +++ b/front-end/src/views/Blog/components/Channels.vue @@ -0,0 +1,99 @@ +<template> + <div id="channel-editor"> + <EditorTable title="Manage channels" :show-edit="showEdit" :pagination="pagination" @open-new="openNew"> + <template v-slot:table> + <ChannelTable + :channels="items" + @open-edit="openEdit" + /> + </template> + <template #editor> + <ChannelEdit + :blog="$props.blog" + @close="closeEdit" + @on-submit="onSubmit" + @on-delete="onDelete" + /> + </template> + </EditorTable> + </div> +</template> + +<script setup lang="ts"> +import { computed } from 'vue'; +import { BlogState } from '../blog-api'; +import { isEmpty, filter as _filter } from 'lodash'; +import { apiCall } from '@vnuge/vnlib.browser'; +import { BlogChannel, ChannelFeed, useFilteredPages } from '@vnuge/cmnext-admin'; +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 { updateChannel, addChannel, deleteChannel, getQuery } = props.blog.channels; +const { channelEdit } = getQuery() + +//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 closeEdit = (update?:boolean) => { + channelEdit.value = '' + //reload channels + if(update){ + emit('reload') + } + //Reset page to top + window.scrollTo(0, 0) +} + +const openNew = () => { + channelEdit.value = 'new' + //Reset page to top + window.scrollTo(0, 0) +} + +const onSubmit = async ({channel, feed} : { channel:BlogChannel, feed? : ChannelFeed}) => { + + //Check for new channel, or updating old channel + if(channelEdit.value === 'new'){ + //Exec create call + await apiCall(async () => { + await addChannel(channel, feed); + //Close the edit panel + closeEdit(true); + }) + } + else if(!isEmpty(channelEdit.value)){ + //Exec update call + await apiCall(async () => { + await updateChannel(channel, feed); + //Close the edit panel + closeEdit(true); + }) + } + //Notify error state +} + +const onDelete = async (channel : BlogChannel) => { + //Exec delete call + await apiCall(async () => { + await deleteChannel(channel); + //Close the edit panel + closeEdit(true); + }) +} + +</script> + +<style lang="scss"> + +</style>
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/Channels/ChannelEdit.vue b/front-end/src/views/Blog/components/Channels/ChannelEdit.vue new file mode 100644 index 0000000..56376fe --- /dev/null +++ b/front-end/src/views/Blog/components/Channels/ChannelEdit.vue @@ -0,0 +1,157 @@ +<template> + <div class="flex flex-col w-full"> + <div class="my-4 ml-auto"> + <div class="button-group"> + <button class="btn primary" form="channel-edit-form">Save</button> + <button class="btn" @click="close">Cancel</button> + </div> + </div> + <div class="mx-auto"> + <h4 v-if="editMode" class="text-center">Edit Channel</h4> + <h4 v-else class="text-center">Create Channel</h4> + <p> + Your root directory and index file name must be unique within your S3 bucket. + </p> + </div> + <dynamic-form + id="channel-edit-form" + class="mx-auto" + :form="channelSchema" + :validator="channelVal.v$" + @submit="onSubmit" + /> + <div class="relative"> + <div class="absolute top-0 right-10"> + <button class="btn xs no-border red" @click="disableFeed" v-if="feedEnabled"> + Disable + </button> + </div> + </div> + <div class="max-w-xl mx-auto mt-6"> + <h4 v-if="editMode" class="text-center">Edit Feed</h4> + <h4 v-else class="text-center">Create Feed</h4> + <p> + Optionally define the rss feed for this channel. If you do not configure the feed, posts + to this channel will not be published to an rss feed, you may configure this feed at any time. + </p> + </div> + + <!-- Feed edit form --> + <dynamic-form + id="feed-edit-form" + class="mx-auto mt-4" + :form="feedSchema" + :validator="feedVal.v$" + @submit="onSubmit" + /> + + <!-- Feed properties --> + <FeedFields :properties="feedProps" /> + + <div class="mt-6"> + <div class="mx-auto w-fit"> + <button class="btn red" @click="onDelete" v-if="editMode"> + Delete Permenantly + </button> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { computed } from 'vue'; +import { BlogState } from '../../blog-api'; +import { forEach, isEmpty, cloneDeep, isNil } from 'lodash'; +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'; + +const emit = defineEmits(['close', 'onSubmit', 'onDelete']) + +const props = defineProps<{ + blog: BlogState +}>() + +//Disallow empty channels +const channel = computed(() => props.blog.channels.editChannel.value || {} as BlogChannel) +const editMode = computed(() => !isNil(channel.value.id)) + +const { getChannelValidator, channelSchema, feedSchema, getFeedValidator } = getChannelForm(editMode); +const { reveal } = useConfirm(); + +//Get the feed properties +const feedProps = useXmlProperties(computed(() => channel.value.feed)); + +//Must have reactive buffers for the channel and feed +const channelBuffer = reactiveComputed(() => cloneDeep(channel.value) as BlogChannel) +const feedBuffer = reactiveComputed(() => cloneDeep(channel.value.feed || {}) as ChannelFeed) + +//Get validators for channel and feed +const channelVal = getChannelValidator(channelBuffer); +const feedVal = getFeedValidator(feedBuffer); + +const feedEnabled = computed(() => !isEmpty(feedBuffer.url)) + +const disableFeed = () => { + //Clear the feed + forEach(feedBuffer, (_value, key) => feedBuffer[key] = null) + //Reset the feed validator + feedVal.reset(); +} + +const onSubmit = async () => { + //validate + if(!await channelVal.validate()){ + return; + } + + //Feed may not be defined, if it is validate it + if(feedEnabled.value){ + + if(!await feedVal.validate()){ + return; + } + + //set/overwite feed properties + const feed = { + ...feedBuffer, + properties:feedProps.getCurrentProperties() + } + + //Invoke submitted with feed + emit('onSubmit', { channel: channelBuffer, feed }) + } + else{ + //Invoke submitted without feed + emit('onSubmit', { channel: channelBuffer, feed: null}) + } + +} + +const onDelete = async () => { + //Show confirm + const { isCanceled } = await reveal({ + title: 'Delete Channel?', + text: 'Are you sure you want to delete this channel? This action cannot be undone.', + }) + if(isCanceled){ + return; + } + + if(!confirm('Are you sure you want to delete this channel forever?')){ + return; + } + + emit('onDelete', channelBuffer) +} + +const close = () => emit('close') + +</script> + +<style lang="scss"> + + +</style>
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/Channels/ChannelTable.vue b/front-end/src/views/Blog/components/Channels/ChannelTable.vue new file mode 100644 index 0000000..cdf15e0 --- /dev/null +++ b/front-end/src/views/Blog/components/Channels/ChannelTable.vue @@ -0,0 +1,48 @@ +<template> + <thead> + <tr> + <th>Name</th> + <th>Path</th> + <th>Index</th> + <th>Feed?</th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="channel in channels" :key="channel.id" class="table-row"> + <td> + {{ channel.name }} + </td> + <td> + {{ channel.path }} + </td> + <td> + {{ channel.index }} + </td> + <td> + {{ feedEnabled(channel) }} + </td> + <td class="w-12"> + <button class="btn xs no-border" @click="openEdit(channel)"> + <fa-icon icon="pencil" /> + </button> + </td> + </tr> + </tbody> +</template> + +<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 feedEnabled = (channel: BlogChannel) => channel.feed ? 'Enabled' : 'Disabled' +const openEdit = (channel: BlogChannel) => emit('open-edit', channel) + +</script> diff --git a/front-end/src/views/Blog/components/Content.vue b/front-end/src/views/Blog/components/Content.vue new file mode 100644 index 0000000..00f8602 --- /dev/null +++ b/front-end/src/views/Blog/components/Content.vue @@ -0,0 +1,133 @@ +<template> + <div id="content-editor" class=""> + <EditorTable title="Manage content" :show-edit="showEdit" :pagination="pagination" @open-new="openNew"> + <template v-slot:table> + <ContentTable + :content="items" + @open-edit="openEdit" + @copy-link="copyLink" + /> + </template> + <template #editor> + <ContentEditor + :blog="$props.blog" + @submit="onSubmit" + @close="closeEdit" + @delete="onDelete" + /> + </template> + </EditorTable> + </div> +</template> + +<script setup lang="ts"> +import { computed } from 'vue'; +import { BlogState } from '../blog-api'; +import { isEmpty } from 'lodash'; +import { apiCall } from '@vnuge/vnlib.browser'; +import EditorTable from './EditorTable.vue'; +import ContentEditor from './Content/ContentEditor.vue'; +import ContentTable from './Content/ContentTable.vue'; +import { useClipboard } from '@vueuse/core'; +import { ContentMeta, useFilteredPages } from '@vnuge/cmnext-admin'; + +const emit = defineEmits(['reload']) + +const props = defineProps<{ + blog: BlogState +}>() + + +//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 showEdit = computed(() => !isEmpty(selectedId.value)); + +const openEdit = async (item: ContentMeta) => selectedId.value = item.id + +const closeEdit = (update?: boolean) => { + selectedId.value = '' + //reload channels + if (update) { + emit('reload') + } + //Reset page to top + window.scrollTo(0, 0) +} + +const openNew = () => { + selectedId.value = 'new' + //Reset page to top + window.scrollTo(0, 0) +} + +interface OnSubmitValue{ + item: ContentMeta, + file: File | undefined +} + +//Allow copying of the public url to clipboard +const { copy } = useClipboard() +const copyLink = async (item : ContentMeta) =>{ + apiCall(async ({toaster}) =>{ + const url = await getPublicUrl(item); + await copy(url); + toaster.general.info({ title: 'Copied link to clipboard' }) + }); +} + +const onSubmit = async (value : OnSubmitValue) => { + + //Check for new channel, or updating old channel + if (selectedId.value === 'new') { + //Exec create call + await apiCall(async () => { + + if(!value.file?.name){ + throw Error('No file selected') + } + + //endpoint returns the content + await uploadContent(value.file, value.item.name!); + + //Close the edit panel + closeEdit(true); + }) + } + else if (!isEmpty(selectedId.value)) { + //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); + } + else{ + await updateContentName(value.item, value.item.name!); + } + //Close the edit panel + closeEdit(true); + }) + } + //Notify error state +} + +const onDelete = async (item: ContentMeta) => { + //Exec delete call + await apiCall(async () => { + await deleteContent(item); + //Close the edit panel + closeEdit(true); + }) +} + + +</script>
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/Content/ContentEditor.vue b/front-end/src/views/Blog/components/Content/ContentEditor.vue new file mode 100644 index 0000000..4de7f8a --- /dev/null +++ b/front-end/src/views/Blog/components/Content/ContentEditor.vue @@ -0,0 +1,225 @@ +<template> + <div id="content-editor" class="flex flex-col w-full"> + <div class="my-4 ml-auto"> + <div class="button-group"> + <!-- Submit the post form --> + <button :disabled="waiting" class="btn primary" form="content-upload-form"> + <fa-icon icon="spinner" v-if="waiting" class="animate-spin" /> + <span v-else>Save</span> + </button> + <button class="btn" @click="onClose">Cancel</button> + </div> + </div> + <div class="mx-auto sm:min-w-[20rem]"> + <h4 class="text-center">Edit Content</h4> + <p> + Add or edit content file + </p> + <div class="mt-3 text-sm"> + Publishing to: + </div> + <div class="p-2 px-3 bg-gray-200 border border-gray-300 rounded-md dark:bg-transparent"> + {{ selectedChannelName }} + </div> + </div> + <div id="content-edit-body" class="min-h-[24rem] my-10"> + <form id="content-upload-form" class="flex" @submit.prevent="onSubmit"> + <fieldset class="mx-auto flex flex-col gap-10 w-[32rem]"> + <div class="flex flex-col"> + <div class="p-3 py-0.5"> + <label class="">File name</label> + <input + type="text" + class="w-full input primary" + placeholder="Title" + v-model="v$.name.$model" + :class="{'invalid':v$.name.$invalid && v$.name.$dirty}" + /> + </div> + <div v-if="editFile?.id" class="mt-3"> + <div class="p-3 py-0.5"> + <label>Content Id</label> + <input type="text" class="w-full input primary" :value="editFile.id" readonly /> + </div> + </div> + + <div v-if="uploadedFile.name" class="border border-gray-300 p-4 w-[24rem] mx-auto rounded-sm relative mt-5"> + <div class="absolute top-0 text-right -right-12"> + <button class="rounded-sm btn sm red" @click.prevent="removeNewFile"> + <fa-icon :icon="['fas', 'trash']" /> + </button> + </div> + <div class=""> + Name: + <span class="border-b border-coolGray-400"> + {{ getFileName(uploadedFile) }} + </span> + </div> + <div class="mt-3"> + Size: {{ getFileSize(uploadedFile) }} + </div> + <div class="mt-3"> + Content-Type: {{ getContentType(uploadedFile) }} + </div> + </div> + <div v-else-if="editFile?.id" > + <div class="border border-gray-300 p-4 min-w-[24rem] mx-auto rounded-sm relative mt-5"> + <div class=""> + Name: {{ getFileName(editFile) }} + </div> + <div class="mt-3"> + Size: {{ getSizeinKb(editFile?.length) }} + </div> + <div class="mt-3"> + File Path: {{ editFile.path }} + </div> + <div class="mt-3"> + Content-Type: {{ editFile.content_type }} + </div> + </div> + </div> + <div v-if="!uploadedFile.name" class="m-auto mt-5 w-fit"> + <button class="btn" @click.prevent="open()"> + {{ editFile?.id ? 'Overwrite file' : 'Select File' }} + </button> + </div> + </div> + </fieldset> + </form> + </div> + <div class="mt-4"> + <div class="mx-auto w-fit"> + <button class="btn red" @click="onDelete">Delete Forever</button> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { computed, ref } from 'vue'; +import { reactiveComputed, useFileDialog } from '@vueuse/core'; +import { ContentMeta } from '@vnuge/cmnext-admin'; +import { useConfirm, useVuelidateWrapper, useFormToaster, useWait } from '@vnuge/vnlib.browser'; +import { defaultTo, first, isEmpty, round, truncate } from 'lodash'; +import { required, helpers, maxLength } from '@vuelidate/validators' +import useVuelidate from '@vuelidate/core'; +import { BlogState } from '../../blog-api'; + +const emit = defineEmits(['close', 'submit', 'delete']); +const props = defineProps<{ + blog: BlogState +}>(); + +const { reveal } = useConfirm(); +const { waiting } = useWait(); + +const { content, channels } = props.blog; + +const selectedId = computed(() => content.selectedId.value); +const selectedContent = computed<ContentMeta>(() => defaultTo(content.selectedItem.value, {} as ContentMeta)); +const metaBuffer = reactiveComputed<ContentMeta>(() => ({ ...selectedContent.value})); +const selectedChannelName = computed(() => defaultTo(channels.selectedItem.value?.name, 'No channel selected')); + +const v$ = useVuelidate({ + name: { + required, + maxLen:maxLength(50), + reg: helpers.withMessage('The file name contains invalid characters', helpers.regex(/^[a-zA-Z0-9 \-\.]*$/)) + }, +}, metaBuffer) + +const { validate } = useVuelidateWrapper(v$); + +const file = ref<File | undefined>(); +const { files, open, reset, onChange } = useFileDialog({ accept: '*' }) +//update the file buffer when a user selects a file to upload +onChange(() => { + file.value = first(files.value) + v$.value.name.$model = file.value?.name; +}) + +const editFile = computed<ContentMeta | undefined>(() => selectedContent.value); +const uploadedFile = computed<File>(() => defaultTo(file.value, {} as File)); + +const getFileName = (file : File | ContentMeta) => truncate(file.name, { length: 20 }); +const getFileSize = (file : File) => { + const size = round(file.size > 1024 ? file.size / 1024 : file.size, 2); + return `${size} ${file.size > 1024 ? 'KB' : 'B'}`; +} +const getContentType = (file : File) => file.type; +const getSizeinKb = (value : number | undefined) => { + value = defaultTo(value, 0); + const size = round(value > 1024 ? value / 1024 : value, 2); + return `${size} ${value > 1024 ? 'KB' : 'B'}`; +} + +const onSubmit = async () => { + + const { error } = useFormToaster() + const hasFile = !isEmpty(file.value?.name); + + //Validate the form + if(!await validate()){ + return; + } + + //Check if in edit mode + if(selectedId.value === 'new'){ + //New file upload + if(!hasFile){ + error({ title: 'No file selected' }) + return; + } + } + //Edit mode + else{ + //If a new file has been attached, then we should prompt for an overwrite + if(hasFile){ + //Confirm overwrite + const { isCanceled } = await reveal({ + title: 'Overwrite file?', + text: 'Are you sure you want to overwrite the file? This action cannot be undone.', + }) + if (isCanceled) { + return; + } + } + } + emit('submit', { item: metaBuffer, file: file.value }); +} + +const onClose = () => emit('close'); + +const onDelete = async () => { + //Show confirm + const { isCanceled } = await reveal({ + title: 'Delete File?', + text: 'Are you sure you want to delete this file? This action cannot be undone.', + }) + if (isCanceled) { + return; + } + + if (!confirm('Are you sure you want to delete this file forever?')) { + return; + } + + //Emit the delete event with the original post + emit('delete', metaBuffer) +} + +const removeNewFile = () =>{ + file.value = undefined; + v$.value.name.$model = editFile.value?.name ?? ''; + reset(); +} + +</script> + +<style lang="scss"> +#content-upload-form{ + input.primary.invalid{ + @apply border-red-500; + } +} +</style>
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/Content/ContentTable.vue b/front-end/src/views/Blog/components/Content/ContentTable.vue new file mode 100644 index 0000000..c47a063 --- /dev/null +++ b/front-end/src/views/Blog/components/Content/ContentTable.vue @@ -0,0 +1,75 @@ +<template> + <thead> + <tr> + <th>File Name</th> + <th>Id</th> + <th>Date</th> + <th>Content Type</th> + <th>Length</th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="item in content" :key="item.id" class="table-row"> + <td> + {{ getItemName(item) }} + </td> + <td> + {{ getItemId(item) }} + </td> + <td> + {{ getDateString(item.date) }} + </td> + <td> + {{ item.content_type }} + </td> + <td> + {{ getItemLength(item) }} + </td> + <td class="w-24"> + <fieldset :disabled="waiting"> + <button class="btn xs no-border" @click="copyLink(item)"> + <fa-icon icon="link" /> + </button> + <button class="btn xs no-border" @click="copy(item.id)"> + <fa-icon icon="copy" /> + </button> + <button class="btn xs no-border" @click="openEdit(item)"> + <fa-icon icon="pencil" /> + </button> + </fieldset> + </td> + </tr> + </tbody> +</template> + +<script setup lang="ts"> +import { toRefs } from 'vue'; +import { filter as _filter, truncate } from 'lodash'; +import { useClipboard } from '@vueuse/core'; +import { useWait } from '@vnuge/vnlib.browser'; +import { ContentMeta } from '@vnuge/cmnext-admin'; + +const emit = defineEmits(['open-edit', 'copy-link']) + +const props = defineProps<{ + content: ContentMeta[] +}>() + +const { content } = toRefs(props) + +const { waiting } = useWait() +const { copy } = useClipboard() + +const getDateString = (time?: number) => new Date((time || 0) * 1000).toLocaleString(); +const getItemLength = (item: ContentMeta) : string =>{ + const length = item.length || 0; + return length > 1024 ? `${(length / 1024).toFixed(2)} KB` : `${length} B` +} +const getItemId = (item: ContentMeta) => truncate(item.id || '', { length: 20 }) +const getItemName = (item : ContentMeta) => truncate(item.name || '', { length: 30 }) + +const openEdit = async (item: ContentMeta) => emit('open-edit', item) +const copyLink = (item : ContentMeta) => emit('copy-link', 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 new file mode 100644 index 0000000..37fd438 --- /dev/null +++ b/front-end/src/views/Blog/components/ContentSearch.vue @@ -0,0 +1,115 @@ +<template> + <div id="content-search" class="my-4"> + <div class=""> + <div class=""> + <input class="w-full input primary" placeholder="Search..." v-model="search" /> + </div> + </div> + <div class="search-results"> + <div v-if="searchResults.length == 0" class="result"> + No results found. + </div> + <div v-else v-for="result in searchResults" :key="result.id" @click.prevent="onSelected(result)" class="result"> + <div class="flex-auto result name"> + {{ result.shortName }} + </div> + <div class="result id"> + {{ result.shortId }} + </div> + <div class="rseult controls"> + <div v-if="waiting"> + <fa-icon icon="spinner" spin /> + </div> + <div v-else-if="result.copied.value" class="text-sm text-amber-500"> + copied + </div> + <div v-else class=""> + <button class="btn secondary sm borderless" @click="result.copyLink()"> + <fa-icon icon="link" /> + </button> + </div> + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { useClipboard } from '@vueuse/core'; +import { apiCall, useWait } from '@vnuge/vnlib.browser'; +import { computed, Ref, ref } from 'vue'; +import { map, slice, truncate } from 'lodash'; +import { ContentMeta } from '@vnuge/cmnext-admin'; +import { BlogState } from '../blog-api'; + +const emit = defineEmits(['selected']) + +const props = defineProps<{ + blog: BlogState, +}>() + +const { createReactiveSearch, getPublicUrl } = props.blog.content +const { waiting } = useWait() + +const search = ref('') +const searcher = createReactiveSearch(search); + +interface ContentResult extends ContentMeta { + readonly shortId: string, + readonly shortName: string, + readonly copied: Ref<boolean>, + copyLink(): void +} + +const searchResults = computed<ContentResult[]>(() => { + const current = slice(searcher.value, 0, 5); + + //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); + await copy(link); + }) + } + + //Formats the result for display + return map(current, content => { + //scoped clipboard for copy link + const { copied, copy } = useClipboard(); + return { + ...content, + //truncate the id and name for display + shortId: truncate(content.id, { length: 15 }), + shortName: truncate(content.name, { length: 24 }), + copyLink: () => copyLink(content, copy), + copied + } + }) +}) + +const onSelected = (result: ContentResult) => { + emit('selected', result) +} + +</script> + +<style lang="scss"> + + .search-results{ + @apply mt-3; + } + + .result{ + @apply flex flex-row items-center justify-between; + @apply p-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-600; + + .id{ + @apply text-sm; + } + + .controls{ + @apply min-w-[4rem] text-center; + } + } + +</style>
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/EditorTable.vue b/front-end/src/views/Blog/components/EditorTable.vue new file mode 100644 index 0000000..4ec5a33 --- /dev/null +++ b/front-end/src/views/Blog/components/EditorTable.vue @@ -0,0 +1,96 @@ +<template> + <slot class="flex flex-row"> + <div class="flex-1 px-4 mt-3"> + <div v-if="!showEdit" class=""> + <div class="flex justify-between p-4 pt-0"> + <div class="w-[20rem]"> + <h4>{{ $props.title }}</h4> + </div> + <div class="h-full"> + <div :class="{'opacity-100':waiting}" class="opacity-0"> + <fa-icon icon="spinner" class="animate-spin" /> + </div> + </div> + <div class="mt-auto"> + <div class="flex justify-center"> + <nav aria-label="Pagination"> + <ul class="inline-flex items-center space-x-1 text-sm rounded-md"> + <li> + <button :disabled="isFirstPage" class="page-button" @click="prev"> + <fa-icon icon="chevron-left" /> + </button> + </li> + <li> + <span class="inline-flex items-center px-4 py-2 space-x-1"> + Page + <b class="mx-1"> + {{ currentPage }} + </b> + of + <b class="ml-1"> + {{ pageCount }} + </b> + </span> + </li> + <li> + <button :disabled="isLastPage" class="page-button" @click="next"> + <fa-icon icon="chevron-right" /> + </button> + </li> + </ul> + </nav> + </div> + </div> + + <div class="h-fit"> + <button class="rounded btn primary sm" @click="openNew"> + <fa-icon :icon="['fas', 'plus']" class="mr-2" /> + New + </button> + </div> + </div> + <table class="edit-table"> + <slot name="table" /> + </table> + </div> + <div v-else class=""> + <slot name="editor" /> + </div> + </div> + </slot> +</template> + +<script setup lang="ts"> +import { toRefs } from 'vue'; +import { useWait } from '@vnuge/vnlib.browser'; +import { UseOffsetPaginationReturn } from '@vueuse/core'; + +const emit = defineEmits(['open-new']) +const props = defineProps<{ + title: string, + showEdit: boolean, + pagination: UseOffsetPaginationReturn +}>() + +const { showEdit } = toRefs(props) + +const { waiting } = useWait() + +//Get pagination +const { pageCount, next, prev, isLastPage, isFirstPage, currentPage } = props.pagination + +const openNew = () => { + emit('open-new') +} + +</script> + +<style lang="scss"> + +button.page-button{ + @apply inline-flex items-center px-2 py-1.5 space-x-2 font-medium; + @apply text-gray-500 bg-white border border-gray-300 rounded-full hover:bg-gray-50; + @apply dark:border-dark-300 dark:bg-transparent dark:text-gray-300 hover:dark:bg-dark-700; +} + +</style>
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/FeedFields.vue b/front-end/src/views/Blog/components/FeedFields.vue new file mode 100644 index 0000000..90e1454 --- /dev/null +++ b/front-end/src/views/Blog/components/FeedFields.vue @@ -0,0 +1,140 @@ +<template> + <div id="feed-custom-fields"> + <div class="my-3 text-center"> + <h4>Feed custom fields</h4> + </div> + + <div v-if="cleanXml" class="w-full max-w-2xl mx-auto"> + <pre class="xml"> +{{ cleanXml }} + </pre> + </div> + + + <div class="my-2 ml-auto w-fit"> + <div v-if="!editMode" class="button-group"> + <button class="btn" @click="edit">Edit</button> + </div> + <div v-else class="button-group"> + <button class="btn primary" @click="save" >Update</button> + <button class="btn" @click="cancel">Cancel</button> + </div> + </div> + + + <div v-if="editMode" class="flex flex-col"> + <div v-if="$props.blog" class="mb-2"> + <EpAdder :blog="$props.blog" @submit="onAddEnclosure" /> + </div> + + <div class=""> + <JsonEditorVue :ask-to-format="true" class="json" v-model="jsonFeedData"/> + </div> + </div> + + </div> +</template> + +<script setup lang="ts"> +import { computed, ref } from 'vue'; +import { FeedProperty, UseXmlProperties } from '@vnuge/cmnext-admin'; +import { BlogState } from '../blog-api'; +import JsonEditorVue from 'json-editor-vue' +import EpAdder from './podcast-helpers/EpisodeAdder.vue'; + +const props = defineProps<{ + properties: UseXmlProperties, + blog?: BlogState +}>() + +const { getXml, saveJson, getModel, addProperties } = props.properties + +const jsonFeedData = ref() +const editMode = ref(false) +const xmlData = ref<string | undefined>(getXml()) + +const cleanXml = computed(() => { + + const formatXml = (xml : string) => { // tab = optional indent value, default is tab (\t) + var formatted = '', indent = ''; + xml.split(/>\s*</).forEach(function (node) { + if (node.match(/^\/\w/)){ + indent = indent.substring(1); // decrease indent by one 'tab' + } + formatted += indent + '<' + node + '>\r\n'; + if (node.match(/^<?\w[^>]*[^\/]$/)){ + indent += '\t'; // increase indent + } + }); + + return formatted.substring(1, formatted.length - 3); + } + return formatXml(xmlData.value || '') +}) + +const edit = () => { + jsonFeedData.value = getModel() + editMode.value = true +} + +const save = () : void => { + //Only close editor if the json is valid + if(saveJson(jsonFeedData.value)){ + editMode.value = false + //update xml + xmlData.value = getXml() + } +} + +const cancel = () : void => { + editMode.value = false + xmlData.value = getXml() +} + +const onAddEnclosure = (props: FeedProperty[]) =>{ + addProperties(props); + //update xml + xmlData.value = getXml() + //update json editor + jsonFeedData.value = getModel() +} + +</script> + +<style lang="scss"> + +#feed-custom-fields{ + + @apply w-full max-w-[80%] mx-auto py-4 my-5; + + .json > .jse-main{ + @apply w-full min-h-[40rem] rounded bg-transparent mx-auto; + } + + .feed-fields{ + @apply mx-auto gap-4 flex flex-row justify-center my-6; + + input.primary{ + @apply w-full; + } + + textarea.primary{ + @apply w-full h-full tracking-wider font-mono p-3 text-sm; + } + + textarea.invalid{ + @apply border-red-500; + } + } + + .xml{ + @apply tracking-wider font-mono p-3 text-sm border dark:border-dark-500 rounded whitespace-pre-wrap; + } + + .xml.invalid{ + @apply border-red-500; + } + +} + +</style>
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/Posts.vue b/front-end/src/views/Blog/components/Posts.vue new file mode 100644 index 0000000..5ebeeac --- /dev/null +++ b/front-end/src/views/Blog/components/Posts.vue @@ -0,0 +1,113 @@ +<template> + <div id="post-editor" class=""> + <EditorTable title="Manage posts" :show-edit="showEdit" :pagination="pagination" @open-new="openNew"> + <template v-slot:table> + <PostTable + :posts="items" + @open-edit="openEdit" + /> + </template> + <template #editor> + <PostEditor + :blog="$props.blog" + @submit="onSubmit" + @close="closeEdit" + @delete="onDelete" + /> + </template> + </EditorTable> + </div> +</template> + +<script setup lang="ts"> +import { computed } from 'vue'; +import { isEmpty } from 'lodash'; +import { PostMeta, useFilteredPages } from '@vnuge/cmnext-admin'; +import { apiCall, debugLog } from '@vnuge/vnlib.browser'; +import EditorTable from './EditorTable.vue'; +import PostEditor from './Posts/PostEdit.vue'; +import PostTable from './Posts/PostTable.vue'; +import { BlogState } from '../blog-api'; + +const emit = defineEmits(['reload']) + +const props = defineProps<{ + blog: BlogState +}>() + +const { selectedId, publishPost, updatePost, deletePost } = props.blog.posts; +const { updatePostContent } = props.blog.content; + +const showEdit = computed(() => !isEmpty(selectedId.value)); + +//Init paginated items for the table and use filtered items +const { pagination, items } = useFilteredPages(props.blog.posts, 15) + +//Open with the post id +const openEdit = async (post: PostMeta) => selectedId.value = post.id; + +const closeEdit = (update?: boolean) => { + selectedId.value = '' + //reload channels + if (update) { + emit('reload') + } + //Reset page to top + window.scrollTo(0, 0) +} + +const openNew = () => { + //Reset the edit post + selectedId.value = 'new' + //Reset page to top + window.scrollTo(0, 0) +} + +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') { + //Exec create call + await apiCall(async () => { + + //endpoint returns the content + const newMeta = await publishPost(post); + + //Publish the content + await updatePostContent(newMeta, content) + + //Close the edit panel + closeEdit(true); + }) + } + else if (!isEmpty(selectedId.value)) { + //Exec update call + await apiCall(async () => { + await updatePost(post); + + //Publish the content + await updatePostContent(post, content) + + //Close the edit panel + closeEdit(true); + }) + } + //Notify error state +} + +const onDelete = async (post: PostMeta) => { + //Exec delete call + await apiCall(async () => { + await deletePost(post); + //Close the edit panel + closeEdit(true); + }) +} + +</script> + +<style lang="scss"> + +</style>
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/Posts/PostEdit.vue b/front-end/src/views/Blog/components/Posts/PostEdit.vue new file mode 100644 index 0000000..4f7b52b --- /dev/null +++ b/front-end/src/views/Blog/components/Posts/PostEdit.vue @@ -0,0 +1,155 @@ +<template> + <div id="new-post-editor" class="flex flex-col w-full"> + <div class="my-4 ml-auto"> + <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> + </div> + </div> + <div class="mx-auto"> + <h4 class="text-center">Edit Post</h4> + <p> + Add or edit a post to publish to your blog. + </p> + </div> + <div class="relative"> + <div class="absolute top-2 right-10"> + <button class="btn no-border" @click="setMeAsAuthor">@Me</button> + </div> + </div> + <dynamic-form + id="post-edit-form" + class="mx-auto" + :form="schema" + :disabled="false" + :validator="v$" + @submit="onSubmit" + /> + + <div id="post-content-editor" class="px-6" :class="{'invalid':v$.content.$invalid}"> + <Editor @change="onContentChanged" :blog="$props.blog" @load="onEditorLoad" /> + </div> + + <FeedFields :properties="postProperties" :blog="$props.blog" /> + + <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 v-if="!isNew" class="btn red" @click="onDelete">Delete Forever</button> + </div> + </div> + </div> +</template> +<script setup lang="ts"> +import { computed } from 'vue'; +import { BlogState } from '../../blog-api'; +import { reactiveComputed } from '@vueuse/core'; +import { isNil, isString, split } from 'lodash'; +import { PostMeta, useXmlProperties } from '@vnuge/cmnext-admin'; +import { apiCall, useConfirm, useUser } from '@vnuge/vnlib.browser'; +import { getPostForm } from '../../form-helpers'; +import Editor from '../../ckeditor/Editor.vue'; +import FeedFields from '../FeedFields.vue'; + +const emit = defineEmits(['close', 'submit', 'delete']); +const props = defineProps<{ + blog: BlogState +}>() + +const { reveal } = useConfirm(); +const { getProfile } = useUser(); +const { schema, getValidator } = getPostForm(); + +const { posts, content } = props.blog; + +const isNew = computed(() => isNil(posts.selectedItem.value)); + +/* 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, + content: '' + } as PostMeta +}); + +const { v$, validate } = getValidator(postBuffer); + +//Wrap the post properties in an xml feed editor +const postProperties = useXmlProperties(posts.selectedItem); + +const onSubmit = async () =>{ + if(!await validate()){ + return; + } + + //get all properties + const p = postProperties.getCurrentProperties(); + + const post = { + ...postBuffer, + properties: p, + content: undefined + } + + //Remove the content from the post object + delete post.content; + + //Convert the tags string to an array of strings + post.tags = isString(post.tags) ? split(post.tags, ',') : post.tags; + + emit('submit', { post, content: v$.value.content.$model}); +} + +const onClose = () => emit('close'); + +const onContentChanged = (content: string) => { + //Set the validator content string + v$.value.content.$model = content; +} + +const onDelete = async () => { + //Show confirm + const { isCanceled } = await reveal({ + title: 'Delete Post?', + text: 'Are you sure you want to delete this post? This action cannot be undone.', + }) + if (isCanceled) { + return; + } + + if (!confirm('Are you sure you want to delete this post forever?')) { + return; + } + + //Emit the delete event with the original post + emit('delete', posts.selectedItem.value) +} + +const setMeAsAuthor = () => { + apiCall(async () => { + const { first, last } = await getProfile<{first?:string, last?:string, email:string}>(); + v$.value.author.$model = `${first} ${last}` + }) +} + +const onEditorLoad = async (editor : any) =>{ + + //Get the initial content + const postContent = await content.getSelectedPostContent(); + + //Set the initial content + if(!isNil(postContent)){ + onContentChanged(postContent); + editor.setData(postContent); + } +} + +</script> + +<style lang="scss"> + +</style>
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/Posts/PostTable.vue b/front-end/src/views/Blog/components/Posts/PostTable.vue new file mode 100644 index 0000000..e5e45f2 --- /dev/null +++ b/front-end/src/views/Blog/components/Posts/PostTable.vue @@ -0,0 +1,62 @@ +<template> + <thead> + <tr> + <th>Title</th> + <th>Id</th> + <th>Date</th> + <th>Author</th> + <th>Summary</th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="post in posts" :key="post.id" class="table-row"> + <td> + {{ post.title }} + </td> + <td> + {{ getPostId(post) }} + </td> + <td> + {{ getDateString(post.date) }} + </td> + <td> + {{ post.author }} + </td> + <td> + {{ getSummaryString(post.summary) }} + </td> + <td class="w-20"> + <button class="btn xs no-border" @click="copy(post.id)"> + <fa-icon icon="copy" /> + </button> + <button class="btn xs no-border" @click="openEdit(post)"> + <fa-icon icon="pencil" /> + </button> + </td> + </tr> + </tbody> +</template> + +<script setup lang="ts"> +import { toRefs } from 'vue'; +import { filter as _filter, truncate } from 'lodash'; +import { useClipboard } from '@vueuse/core'; +import { PostMeta } from '@vnuge/cmnext-admin'; + +const emit = defineEmits(['reload', 'open-edit']) + +const props = defineProps<{ + posts: PostMeta[], +}>() + +const { posts } = toRefs(props) + +const { copy } = useClipboard() + +const openEdit = async (post: PostMeta) => emit('open-edit', post) + +const getDateString = (time?: number) => new Date((time || 0) * 1000).toLocaleString(); +const getSummaryString = (summary?: string) => truncate(summary || '', { length: 40 }) +const getPostId = (post: PostMeta) => truncate(post.id || '', { length: 20 }) +</script>
\ No newline at end of file diff --git a/front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue b/front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue new file mode 100644 index 0000000..79b21cf --- /dev/null +++ b/front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue @@ -0,0 +1,164 @@ +<template> + <div id="podcast-upload-form"> + + <div class="ml-auto w-fit"> + <div class=""> + <button class="btn sm" @click="setIsOpen(true)">Add enclosure</button> + </div> + </div> + + <Dialog id="enclosure-dialog" :open="isOpen" @close="setIsOpen" class="relative z-50"> + <div class="fixed inset-0 bg-black/30" aria-hidden="true" /> + + <div class="fixed inset-0 flex justify-center pt-[8rem]"> + <DialogPanel class="dialog"> + <div class=""> + <DialogTitle>Set feed enclosure</DialogTitle> + <DialogDescription> + You may set a podcast episode or other rss enclosure from + content stored in the cms. + </DialogDescription> + <div class="my-3 ml-auto w-fit"> + <Popover class="relative"> + <PopoverButton class="btn"> + Add media + <fa-icon class="ml-2" icon="photo-film" /> + </PopoverButton> + <PopoverPanel class="absolute right-0 z-10 top-10"> + <div class="md-pannel"> + <div class=""> + Search for content by its id or file name. + </div> + <ContentSearch :blog="$props.blog" @selected="onContentSelected"/> + </div> + </PopoverPanel> + </Popover> + </div> + <dynamic-form + class="" + id="enclosure-form" + :form="schema" + :validator="v$" + @submit="onFormSubmit" + @cancel="onCancel" + /> + <div class="mt-4 ml-auto w-fit"> + <div class="button-group"> + <button class="btn sm primary" @click="onFormSubmit">Submit</button> + <button class="btn sm" @click="onCancel">Cancel</button> + </div> + </div> + </div> + </DialogPanel> + </div> + </Dialog> + </div> +</template> +<script setup lang="ts"> + +import { ref, reactive } from 'vue'; +import { BlogState } from '../../blog-api'; +import { PodcastEntity, getPodcastForm } from './podcast-form' +import { + Dialog, + DialogPanel, + DialogTitle, + DialogDescription, + PopoverButton, + PopoverPanel, + Popover, +} from '@headlessui/vue' +import ContentSearch from '../ContentSearch.vue' +import { apiCall, debugLog } from '@vnuge/vnlib.browser'; +import { ContentMeta } from '@vnuge/cmnext-admin'; + +const emit = defineEmits(['submit']) + +const props = defineProps<{ + blog: BlogState, +}>() + +const isOpen = ref(false) +const { getPublicUrl } = props.blog.content; +const { schema, setEnclosureContent, getValidator, exportProperties } = getPodcastForm() + +const buffer = reactive<PodcastEntity>({} as PodcastEntity) + +const { v$, validate } = getValidator(buffer) + +const setIsOpen = (value: boolean) => isOpen.value = value + +const onFormSubmit = async () =>{ + //Validate the form + if(! await validate()){ + return + } + + //get the enclosure properties to add to the xml + const props = exportProperties(buffer) + debugLog(props); + emit('submit', props) + setIsOpen(false) +} + +const onCancel = () =>{ + setIsOpen(false) +} + +const onContentSelected = (content: ContentMeta) =>{ + apiCall(async () =>{ + //Get the content link from the server + const url = await getPublicUrl(content) + + //set the form content + setEnclosureContent(buffer, content, `/${url}`) + }) +} + +</script> + +<style lang="scss"> + +#enclosure-dialog{ + + .dialog{ + @apply w-full max-w-3xl px-8 pb-8 pt-4 mx-auto mb-auto border rounded shadow-md; + @apply bg-white dark:bg-dark-700 dark:text-gray-300 dark:border-dark-500; + } + + .dynamic-form.input-group{ + @apply grid grid-cols-2 gap-4; + } + + .dynamic-form.input-container{ + @apply flex flex-col; + } + + .dynamic-form.field-description{ + @apply text-sm text-gray-500 dark:text-gray-400 px-2; + } + + .dynamic-form.input-label{ + @apply text-sm font-semibold text-gray-700 dark:text-gray-100 ml-1 mb-1; + } + + .dynamic-form.dynamic-input.input{ + @apply py-1.5 bg-transparent; + + &:disabled{ + @apply bg-gray-100 dark:bg-transparent dark:border-transparent; + } + + &:focus{ + @apply border-primary-500; + } + } + + .dirty.dynamic-form.input-container{ + &.data-invalid .dynamic-form.dynamic-input.input{ + @apply border-red-500; + } + } +} + +</style>
\ No newline at end of file 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 new file mode 100644 index 0000000..ab8ad8a --- /dev/null +++ b/front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts @@ -0,0 +1,174 @@ +// 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 { helpers, required, maxLength, alphaNum, numeric } from "@vuelidate/validators" +import useVuelidate from "@vuelidate/core" +import { MaybeRef } from '@vueuse/core'; +import { useVuelidateWrapper } from '@vnuge/vnlib.browser'; +import { ContentMeta, FeedProperty } from '@vnuge/cmnext-admin'; + +export interface EnclosureEntity{ + fileId: string; + contentUrl: string; + contentLength: number; + contentType: string; +} + +export interface PodcastEntity extends EnclosureEntity{ + episodeType: string; + duration: number; +} + +export const getPodcastForm = (editMode?: Ref<boolean>) => { + const schema = computed(() => { + return { + fields: [ + { + id: 'episode-type', + type: 'text', + label: 'Episode Type', + name: 'episodeType', + placeholder: '', + description: 'The itunes episode type, typically "full" or "trailer"', + }, + { + id: 'episode-duration', + type: 'text', + label: 'Duration', + name: 'duration', + placeholder: '', + description: 'The duration in seconds for the episode', + }, + { + id: 'ep-content-id', + type: 'text', + label: 'File Id', + name: 'fileId', + placeholder: '', + description: 'The file id of the episode already in the channel', + disabled: true, + }, + { + id: 'content-url', + type: 'text', + label: 'Content url', + name: 'contentUrl', + placeholder: '', + description: 'This the relative url to the episode content file', + disabled: true, + }, + { + id: 'content-length', + type: 'text', + label: 'Content length', + name: 'contentLength', + placeholder: '', + description: 'This the length in bytes of the episode content file', + disabled: true, + }, + { + id: 'content-type', + type: 'text', + label: 'The MIME content type', + name: 'contentType', + placeholder: '', + description: 'The MIME content type for the episode content file', + disabled: true, + } + ] + } + }); + + + const alphaNumSlash = helpers.regex(/^[a-zA-Z0-9\/]*$/); + + const rules = { + fileId: { + required:helpers.withMessage('The file id is required', required), + maxLength: helpers.withMessage('The file id must be less than 64 characters', maxLength(64)), + alphaNumeric: helpers.withMessage('The file id must be alpha numeric', alphaNum) + }, + episodeType: { + required: helpers.withMessage('The episode type is required', required), + maxLength: helpers.withMessage('The episode type must be less than 64 characters', maxLength(64)), + alphaNumeric: helpers.withMessage('The episode type must be alpha numeric', alphaNum) + }, + duration: { + required: helpers.withMessage('The duration is required', required), + numeric: helpers.withMessage('The duration must be a number', numeric) + }, + contentUrl: { + required: helpers.withMessage('The content url is required', required), + maxLength: helpers.withMessage('The content url must be less than 256 characters', maxLength(256)) + }, + contentLength: { + required: helpers.withMessage('The content length is required', required), + numeric: helpers.withMessage('The content length must be a number', numeric) + }, + contentType: { + required: helpers.withMessage('The content type is required', required), + maxLength: helpers.withMessage('The content type must be less than 64 characters', maxLength(64)), + alphaNumeric: helpers.withMessage('The content type must be in MIME format', alphaNumSlash) + } + } + + const getValidator = <T extends PodcastEntity>(buffer: MaybeRef<T>) => { + const v$ = useVuelidate(rules, buffer, { $lazy: true, $autoDirty: true }); + const { validate } = useVuelidateWrapper(v$); + + return { v$, validate, reset: v$.value.$reset }; + } + + const setEnclosureContent = (enclosure: EnclosureEntity, content: ContentMeta, url: string) => { + enclosure.fileId = content.id; + enclosure.contentLength = content.length + enclosure.contentType = content.content_type; + enclosure.contentUrl = url; + } + + const exportProperties = (podcast: PodcastEntity) : FeedProperty[] => { + return [ + { + name: 'episodeType', + namespace: 'itunes', + value: podcast.episodeType + }, + { + name: 'duration', + namespace: 'itunes', + value: podcast.duration?.toString() + }, + //Setup the enclosure + { + name:"enclosure", + attributes:{ + url: podcast.contentUrl, + length: podcast.contentLength?.toString(), + type: podcast.contentType + }, + } + ] + } + + return { + schema, + rules, + getValidator, + setEnclosureContent, + exportProperties + }; +} + diff --git a/front-end/src/views/Blog/form-helpers/channels.ts b/front-end/src/views/Blog/form-helpers/channels.ts new file mode 100644 index 0000000..cd33a20 --- /dev/null +++ b/front-end/src/views/Blog/form-helpers/channels.ts @@ -0,0 +1,227 @@ +// 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 { MaybeRef, computed, watch, Ref } from 'vue' +import { helpers, required, maxLength, numeric } from "@vuelidate/validators" +import { useVuelidateWrapper } from '@vnuge/vnlib.browser'; +import { BlogChannel, ChannelFeed } from '@vnuge/cmnext-admin'; +import useVuelidate from "@vuelidate/core" + +export const getChannelForm = (editMode?: Ref<boolean>) => { + const channelSchema = computed(() => { + return { + fields: [ + { + id: 'channel-name', + type: 'text', + label: 'Channel Name', + name: 'name', + placeholder: 'Enter the name of the channel', + description: 'A simple human readable name for the channel' + }, + { + id: 'channel-path', + type: 'text', + label: 'Root Path', + name: 'path', + placeholder: 'Enter the root path to the channel', + description: editMode?.value ? 'You may not edit the channel directory' : 'The path in your bucket to the working directory for the channel', + disabled: editMode?.value + }, + { + id: 'channel-index', + type: 'text', + label: 'Index File', + name: 'index', + placeholder: 'Enter the index file for the channel', + description: editMode?.value ? + 'You may not edit the index file path' + : 'The name or path of the post index file, stored under the root directory of the channel', + disabled: editMode?.value + }, + { + id: 'channel-content-dir', + type: 'text', + label: 'Content Directory', + name: 'content', + placeholder: 'Enter the content directory for the channel', + description: editMode?.value ? + 'You may not edit the content directory path' + : 'The name or path of the content directory, stored under the root directory of the channel', + disabled: editMode?.value + }, + { + id: 'index-file-example', + type: 'text', + label: 'Index Path', + name: 'example', + placeholder: 'Your index file path', + description: 'This is the location within your bucket where the index file will be stored', + disabled: true, + } + ] + } + }); + + const feedSchema = { + fields: [ + { + id: 'channel-feed-url', + type: 'text', + label: 'Publish Url', + name: 'url', + placeholder: 'Enter the feed url for the channel', + description: 'The rss syndication url for your blog channel, the http url your blog resides at.' + }, + { + id: 'channel-feed-path', + type: 'text', + label: 'Feed File', + name: 'path', + placeholder: 'feed.xml', + description: 'The path to the feed xml file within the channel directory' + }, + { + id: 'channel-feed-image', + type: 'text', + label: 'Image Url', + name: 'image', + placeholder: 'Enter the url for the default feed image', + description: 'The full http url to the default feed image' + }, + { + id: 'channel-feed-author', + type: 'text', + label: 'Feed Author', + name: 'author', + placeholder: 'Your name', + description: 'The author name for the feed' + }, + { + id: 'channel-feed-contact', + type: 'text', + label: 'Feed Contact', + name: 'contact', + placeholder: 'Your contact email address', + description: 'The webmaster contact email address' + }, + { + id: 'channel-feed-max-items', + type: 'number', + label: 'Feed Max Items', + name: 'maxItems', + placeholder: 'Enter the feed max items for the channel', + description: 'The maximum number of posts to publish in the feed' + }, + { + id: 'channel-feed-description', + type: 'textarea', + label: 'Feed Description', + name: 'description', + placeholder: 'Enter the feed description for the channel', + } + ] + } + + const alphaNumSpace = helpers.regex(/^[a-zA-Z0-9 ]*$/); + const httpUrl = helpers.regex(/^(http|https):\/\/[^ "]+$/); + + const channelRules = { + name: { + required: helpers.withMessage('Channel name is required', required), + maxlength: helpers.withMessage('Channel name must be less than 50 characters', maxLength(50)), + alphaNumSpace: helpers.withMessage('Channel name must be alphanumeric', alphaNumSpace), + }, + path: { + required: helpers.withMessage('Channel path is required', required), + maxlength: helpers.withMessage('Channel path must be less than 50 characters', maxLength(50)), + }, + index: { + required: helpers.withMessage('Channel index is required', required), + maxlength: helpers.withMessage('Channel index must be less than 50 characters', maxLength(50)), + }, + content: { + required: helpers.withMessage('Channel content directory is required', required), + maxlength: helpers.withMessage('Channel content directory must be less than 50 characters', maxLength(50)), + }, + example: {} + } + + const feedRules = { + url: { + required: helpers.withMessage('Channel feed url is required', required), + maxlength: helpers.withMessage('Channel feed url must be less than 100 characters', maxLength(100)), + url: helpers.withMessage('Channel feed url must be a valid url', httpUrl), + }, + path: { + required: helpers.withMessage('Channel feed path is required', required), + maxlength: helpers.withMessage('Channel feed path must be less than 50 characters', maxLength(50)), + }, + image: { + maxlength: helpers.withMessage('Channel feed image must be less than 200 characters', maxLength(200)), + }, + contact: { + maxlength: helpers.withMessage('Channel feed contact must be less than 50 characters', maxLength(50)), + }, + description: { + alphaNumSpace: helpers.withMessage('Channel feed description must be alphanumeric', alphaNumSpace), + maxlength: helpers.withMessage('Channel feed description must be less than 50 characters', maxLength(200)), + }, + maxItems: { + numeric: helpers.withMessage('Channel feed max items must be a number', numeric), + }, + author: { + alphaNumSpace: helpers.withMessage('Channel feed author must be alphanumeric', alphaNumSpace), + maxlength: helpers.withMessage('Channel feed author must be less than 50 characters', maxLength(50)), + } + } + + const getChannelValidator = <T extends BlogChannel>(buffer: MaybeRef<T | undefined>) => { + + const v$ = useVuelidate(channelRules, buffer, { $lazy: true, $autoDirty: true }); + + const updateExample = () => { + if (!v$.value.path.$model || !v$.value.index.$model) { + v$.value.example.$model = ''; + return; + } + //Update the example path + v$.value.example.$model = `${v$.value.path.$model}/${v$.value.index.$model}`; + } + + watch(v$, updateExample); + + updateExample(); + + const { validate } = useVuelidateWrapper(v$); + return { v$, validate, reset: v$.value.$reset }; + } + + const getFeedValidator = <T extends ChannelFeed>(buffer: MaybeRef<T | undefined>) => { + const v$ = useVuelidate(feedRules, buffer, { $lazy: true, $autoDirty: true }); + const { validate } = useVuelidateWrapper(v$); + return { v$, validate, reset: v$.value.$reset }; + } + + return { + channelSchema, + feedSchema, + channelRules, + feedRules, + getChannelValidator, + getFeedValidator + }; +} + diff --git a/front-end/src/views/Blog/form-helpers/index.ts b/front-end/src/views/Blog/form-helpers/index.ts new file mode 100644 index 0000000..d3df0f7 --- /dev/null +++ b/front-end/src/views/Blog/form-helpers/index.ts @@ -0,0 +1,17 @@ +// 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/>. + +export * from './channels' +export * from './posts'
\ No newline at end of file diff --git a/front-end/src/views/Blog/form-helpers/posts.ts b/front-end/src/views/Blog/form-helpers/posts.ts new file mode 100644 index 0000000..da805e7 --- /dev/null +++ b/front-end/src/views/Blog/form-helpers/posts.ts @@ -0,0 +1,116 @@ +// 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 { MaybeRef, computed } from "vue"; +import { useVuelidateWrapper } from "@vnuge/vnlib.browser" +import { PostMeta } from '@vnuge/cmnext-admin' +import { helpers, required, maxLength } from "@vuelidate/validators" +import useVuelidate from "@vuelidate/core" + +export const getPostForm = () => { + + const schema = computed(() => { + return { + fields: [ + { + id: 'post-title', + type: 'text', + label: 'Post Title', + name: 'title', + placeholder: 'Enter the title of the post', + description: 'A simple human readable title for the post' + }, + { + id: 'post-author', + type: 'text', + label: 'Post Author', + name: 'author', + placeholder: 'Enter the author of the post', + description: 'The author of the post' + }, + { + id: 'post-tags', + type: 'text', + label: 'Post Tags', + name: 'tags', + placeholder: 'Enter the tags for the post', + description: 'A comma separated list of tags for the post' + }, + { + id: 'post-image', + type: 'text', + label: 'Post Image', + name: 'image', + placeholder: 'Enter the image url for the post', + description: 'The full http url to the post image' + }, + { + id: 'post-summary', + type: 'textarea', + label: 'Post Summary', + name: 'summary', + placeholder: 'Enter the summary of the post', + description: 'A short summary of the post, also the description for the rss feed' + }, + { + id: 'existing-post-id', + type: 'text', + label: 'Post Id', + name: 'id', + placeholder: '', + description: 'The id of the post, this cannot be changed', + disabled: true, + } + ] + } + }); + + const alphaNumSpace = helpers.regex(/^[a-zA-Z0-9 ]*$/); + const httpUrl = helpers.regex(/^(http|https):\/\/[^ "]+$/); + + const rules = { + title: { + required: helpers.withMessage('Post title is required', required), + maxlength: helpers.withMessage('Post title must be less than 50 characters', maxLength(50)), + alphaNumSpace: helpers.withMessage('Post title must be alphanumeric', alphaNumSpace), + }, + summary: { + required: helpers.withMessage('Post summary is required', required), + maxlength: helpers.withMessage('Post summary must be less than 50 characters', maxLength(200)), + }, + author: { + required: helpers.withMessage('Post author is required', required), + maxlength: helpers.withMessage('Post author must be less than 50 characters', maxLength(50)), + }, + tags: {}, + image: { + maxlength: helpers.withMessage('Post image must be less than 200 characters', maxLength(200)), + httpUrl: helpers.withMessage('Post image must be a valid http url', httpUrl), + }, + content: { + required: helpers.withMessage('Post content is required', required), + maxLength: maxLength(50000), + }, + id: {} + } + + const getValidator = <T extends PostMeta>(buffer: MaybeRef<T | undefined>) => { + const v$ = useVuelidate(rules, buffer, { $lazy: true, $autoDirty: true }); + const { validate } = useVuelidateWrapper(v$); + return { v$, validate, reset: v$.value.$reset }; + } + + return { schema, rules, getValidator }; +}
\ No newline at end of file diff --git a/front-end/src/views/Blog/index.vue b/front-end/src/views/Blog/index.vue new file mode 100644 index 0000000..6bfcb6e --- /dev/null +++ b/front-end/src/views/Blog/index.vue @@ -0,0 +1,324 @@ +<template> + <div class="container mx-auto mt-10 mb-[10rem]"> + <div id="blog-admin-template" class=""> + + <TabGroup vertical :selected-index="tabId" @change="onTabChange"> + <div class="menu"> + <TabList> + <div class="inline-flex items-center justify-center w-16 h-16"> + <span class="username-box"> + {{ firstLetter }} + </span> + </div> + + <div class="border-t border-gray-100 dark:border-dark-500"> + <div class="px-2"> + + <Tab v-slot="{ selected }" as="div" class="py-4"> + <div class="t group menu-item" :class="{'active':selected}"> + + <fa-icon icon="bullhorn" size="lg" /> + + <span class="opacity-0 tooltip group-hover:opacity-100"> + Channel + </span> + </div> + </Tab> + + <ul class="flex flex-col pt-4 space-y-1 border-t border-gray-100 dark:border-dark-500"> + <Tab v-slot="{ selected }" as="li"> + <div class="group menu-item" :class="{'active':selected}"> + + <fa-icon icon="comment" size="xl" /> + + <span class="opacity-0 tooltip group-hover:opacity-100"> + Posts + </span> + </div> + </Tab> + <Tab v-slot="{ selected }" as="li"> + <div class="group menu-item" :class="{'active':selected}"> + + <fa-icon icon="folder-open" size="lg" /> + + <span class="opacity-0 tooltip group-hover:opacity-100"> + Content + </span> + </div> + </Tab> + + </ul> + </div> + </div> + </TabList> + </div> + + <TabPanels class="tab-container"> + <div class="flex flex-row h-12 px-4 pb-2"> + + <div class="inline-flex flex-row gap-3"> + <div class="my-auto"> + <fa-icon icon="bullhorn" /> + </div> + + <select id="channel-select" class="" v-model="channel"> + <option value="">Select Channel</option> + <option v-for="c in channels.items.value" :value="c.id"> + {{ c.name }} + </option> + </select> + </div> + + <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"/> + </div> + + <div class="flex flex-row py-2 mr-auto"> + <Switch v-model="lastModified" + :class="lastModified ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-500'" + class="relative inline-flex items-center w-10 h-5 my-auto duration-75 rounded-full"> + <span class="sr-only">Last modified</span> + <span :class="lastModified ? 'translate-x-6' : 'translate-x-1'" + class="inline-block w-3 h-3 transition transform bg-white rounded-full" /> + </Switch> + <div class="my-auto ml-3"> + Last Modifed + </div> + + </div> + </div> + + <TabPanel> + <Channels :blog="blogState" /> + </TabPanel> + + <TabPanel> + <Posts :blog="blogState" /> + </TabPanel> + + <TabPanel> + <Content :blog="blogState" /> + </TabPanel> + + </TabPanels> + </TabGroup> + </div> + </div> +</template> + +<script setup lang="ts"> +import { computed } from 'vue'; +import { useScriptTag } from '@vueuse/core'; +import { useRouteQuery } from '@vueuse/router'; +import { TabGroup, TabList, Tab, TabPanels, TabPanel, Switch } from '@headlessui/vue' +import { first } from 'lodash'; +import { usePageGuard, useUser, useTitle } from '@vnuge/vnlib.browser'; +import { createBlogContext, useComputedChannels, useComputedPosts, useComputedContent, SortType } from '@vnuge/cmnext-admin'; +import { BlogState } from './blog-api'; +import Channels from './components/Channels.vue'; +import Posts from './components/Posts.vue'; +import Content from './components/Content.vue'; + +//Protect page +usePageGuard(); +useTitle('CMNext Admin') + +//Load scripts +const ckEditorTag = useScriptTag("https://cdn.ckeditor.com/ckeditor5/35.4.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() + +//Load user profile and forget if not set +if(!userName.value){ + getProfile() +} + +const firstLetter = computed(() => first(userName.value)) + +const tabIdQ = useRouteQuery<string>('tabid', '', { mode: 'push' }) + +const context = createBlogContext({ + 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, + set: (value:boolean) => { + sort.value = value ? SortType.ModifiedTime : SortType.CreatedTime + } +}) + +const onTabChange = (id:number) => tabIdQ.value = id.toString(10) + +</script> + +<style lang="scss"> + +#blog-admin-template{ + @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; + + .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; + } + + .menu-item{ + @apply relative flex justify-center rounded px-2 py-2 cursor-pointer; + @apply text-gray-500 hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-dark-700 dark:hover:text-gray-300; + + &.active{ + @apply text-primary-600; + } + + .tooltip{ + @apply absolute start-full -translate-y-1/2 top-1/2 ms-4 rounded px-2 py-1.5 text-xs font-medium; + @apply text-white bg-gray-900 dark:bg-dark-600; + } + } + + .menu{ + @apply flex flex-col justify-between w-16 border-e; + @apply bg-white dark:bg-dark-800 dark:border-dark-500; + } + + #channel-select{ + @apply w-full p-1 px-2 border rounded-sm sm:text-sm min-w-[13rem]; + @apply border-gray-300 text-gray-700 bg-white; + @apply dark:bg-dark-800 dark:border-dark-500 focus:dark:border-dark-400 hover:dark:border-dark-400 dark:text-inherit; + + option{ + @apply text-base; + } + } + + .tab-container{ + @apply flex-1 py-4 rounded-r-sm dark:bg-dark-800 bg-white text-gray-700 dark:text-inherit; + } + + // Rules for dynamic forms in edit panes + .dynamic-form.form{ + @apply w-full mt-4 md:px-12; + + .dynamic-form.input-group{ + @apply grid grid-flow-row grid-cols-2; + } + + .dynamic-form.input-group{ + @apply gap-x-16; + + .dynamic-form.input-container{ + + .dynamic-form.dynamic-input{ + @apply border rounded-sm p-2 bg-transparent w-full dark:border-dark-600; + @apply dark:bg-dark-800 focus:border-primary-500; + + &.input-textarea{ + @apply h-40 outline-none; + } + + &::placeholder{ + @apply dark:text-gray-500; + } + + &:disabled{ + @apply text-rose-400 border-transparent; + } + } + + &.dirty.data-invalid .dynamic-form.dynamic-input{ + @apply border-red-500 focus:border-red-500; + } + + .dynamic-form.field-description{ + @apply pt-1 p-2 pb-4 text-sm; + } + + } + + .dynamic-form.input-label{ + @apply col-span-2 text-right m-auto mr-2; + } + } + } + + table.edit-table { + @apply w-full divide-y-2 divide-gray-200 bg-white text-sm dark:divide-dark-500 dark:bg-dark-800; + + thead{ + @apply text-left text-lg; + } + + tbody{ + @apply divide-y divide-gray-200 dark:divide-dark-500; + } + + thead th, + tr td{ + @apply whitespace-nowrap px-4 py-2 font-medium; + } + } + + .ck.ck-editor{ + @apply border dark:border-coolGray-600; + } + + .ck-editor .ck-content, + .ck-editor .ck-source-editing-area{ + @apply min-h-[32rem] resize-y dark:bg-dark-800; + + a { + @apply text-blue-500; + } + + p{ + @apply my-2; + } + + pre{ + @apply p-2 dark:text-gray-200; + } + + h1, h2{ + @apply border-b pb-3 mb-4; + } + } + + .ck-source-editing-area textarea{ + @apply dark:bg-transparent; + } + + .ck.ck-toolbar, + .ck.ck-reset + { + @apply dark:bg-dark-800 dark:text-gray-300; + + .ck-button, + .ck-dropdown + { + @apply dark:text-gray-300; + + &:hover, + &.ck-on + { + @apply dark:bg-dark-600; + } + } + } +} +</style>
\ No newline at end of file |