aboutsummaryrefslogtreecommitdiff
path: root/front-end/src/views/Blog/components
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2023-07-12 01:28:23 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2023-07-12 01:28:23 -0400
commitf64955c69d91e578e580b409ba31ac4b3477da96 (patch)
tree16f01392ddf1abfea13d7d1ede3bfb0459fe8f0d /front-end/src/views/Blog/components
Initial commit
Diffstat (limited to 'front-end/src/views/Blog/components')
-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
14 files changed, 1756 insertions, 0 deletions
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
+ };
+}
+