aboutsummaryrefslogtreecommitdiff
path: root/front-end/src/views/Blog
diff options
context:
space:
mode:
Diffstat (limited to 'front-end/src/views/Blog')
-rw-r--r--front-end/src/views/Blog/blog-api/index.ts22
-rw-r--r--front-end/src/views/Blog/ckeditor/Editor.vue155
-rw-r--r--front-end/src/views/Blog/ckeditor/build.ts125
-rw-r--r--front-end/src/views/Blog/components/Channels.vue99
-rw-r--r--front-end/src/views/Blog/components/Channels/ChannelEdit.vue157
-rw-r--r--front-end/src/views/Blog/components/Channels/ChannelTable.vue48
-rw-r--r--front-end/src/views/Blog/components/Content.vue133
-rw-r--r--front-end/src/views/Blog/components/Content/ContentEditor.vue225
-rw-r--r--front-end/src/views/Blog/components/Content/ContentTable.vue75
-rw-r--r--front-end/src/views/Blog/components/ContentSearch.vue115
-rw-r--r--front-end/src/views/Blog/components/EditorTable.vue96
-rw-r--r--front-end/src/views/Blog/components/FeedFields.vue140
-rw-r--r--front-end/src/views/Blog/components/Posts.vue113
-rw-r--r--front-end/src/views/Blog/components/Posts/PostEdit.vue155
-rw-r--r--front-end/src/views/Blog/components/Posts/PostTable.vue62
-rw-r--r--front-end/src/views/Blog/components/podcast-helpers/EpisodeAdder.vue164
-rw-r--r--front-end/src/views/Blog/components/podcast-helpers/podcast-form.ts174
-rw-r--r--front-end/src/views/Blog/form-helpers/channels.ts227
-rw-r--r--front-end/src/views/Blog/form-helpers/index.ts17
-rw-r--r--front-end/src/views/Blog/form-helpers/posts.ts116
-rw-r--r--front-end/src/views/Blog/index.vue324
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