diff options
Diffstat (limited to 'lib/admin/src')
-rw-r--r-- | lib/admin/src/channels/channels.ts | 60 | ||||
-rw-r--r-- | lib/admin/src/channels/computedChannels.ts | 65 | ||||
-rw-r--r-- | lib/admin/src/channels/index.ts | 17 | ||||
-rw-r--r-- | lib/admin/src/content/computedContent.ts | 82 | ||||
-rw-r--r-- | lib/admin/src/content/index.ts | 17 | ||||
-rw-r--r-- | lib/admin/src/content/useContent.ts | 149 | ||||
-rw-r--r-- | lib/admin/src/feedProperties/index.ts | 144 | ||||
-rw-r--r-- | lib/admin/src/helpers.ts | 45 | ||||
-rw-r--r-- | lib/admin/src/index.ts | 87 | ||||
-rw-r--r-- | lib/admin/src/ordering/index.ts | 61 | ||||
-rw-r--r-- | lib/admin/src/posts/computedPosts.ts | 50 | ||||
-rw-r--r-- | lib/admin/src/posts/index.ts | 17 | ||||
-rw-r--r-- | lib/admin/src/posts/usePost.ts | 70 | ||||
-rw-r--r-- | lib/admin/src/types.ts | 270 |
14 files changed, 1134 insertions, 0 deletions
diff --git a/lib/admin/src/channels/channels.ts b/lib/admin/src/channels/channels.ts new file mode 100644 index 0000000..3100963 --- /dev/null +++ b/lib/admin/src/channels/channels.ts @@ -0,0 +1,60 @@ +// 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 { useAxios } from '@vnuge/vnlib.browser'; +import { isEqual, toSafeInteger } from 'lodash'; +import { BlogChannel, ChannelFeed, ChannelApi, BlogAdminContext } from '../types.js' + +/** + * Gets the channel helper api to manage content channels + */ +export const useChannels = (context: BlogAdminContext): ChannelApi => { + const axios = useAxios(null); + + const getUrl = (): string => context.getChannelUrl(); + + const sanitizeNumbers = (channel: BlogChannel): BlogChannel => { + if (channel.feed) { + channel.feed.maxItems = isEqual(channel.feed.maxItems, '') ? undefined : toSafeInteger(channel.feed.maxItems); + } + return channel; + } + + const getChannels = async (): Promise<BlogChannel[]> => { + const { data } = await axios.get(getUrl()); + return data; + } + + const deleteChannel = async (channel: BlogChannel) => { + //Call delete with the channel id query + await axios.delete(`${getUrl()}?channel=${channel.id}`); + } + + const addChannel = async (channel: BlogChannel, feed?: ChannelFeed): Promise<BlogChannel> => { + //Clone the item to avoid modifying the original + const add = sanitizeNumbers({ ...channel, feed }); + //Call post with the channel data + return await axios.post(getUrl(), add); + } + + const updateChannel = async (channel: BlogChannel, feed?: ChannelFeed): Promise<BlogChannel> => { + //Manually assign the feed or null, and clone the item to avoid modifying the original + const update = sanitizeNumbers({ ...channel, feed }); + //Call put with the channel data + return await axios.patch(getUrl(), update); + } + + return { getChannels, deleteChannel, addChannel, updateChannel }; +} diff --git a/lib/admin/src/channels/computedChannels.ts b/lib/admin/src/channels/computedChannels.ts new file mode 100644 index 0000000..2fb64f2 --- /dev/null +++ b/lib/admin/src/channels/computedChannels.ts @@ -0,0 +1,65 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +import { apiCall } from '@vnuge/vnlib.browser'; +import { Ref, computed, ref, watch } from 'vue' +import { find, isEmpty, isEqual } from 'lodash'; +import { BlogChannel, ChannelApi, ComputedBlogApi, BlogAdminContext } from '../types.js' +import { useChannels } from './channels.js'; + +export interface ComputedChannels extends ChannelApi, ComputedBlogApi<BlogChannel> { + readonly editChannel: Readonly<Ref<BlogChannel | undefined>>; +} + +/** + * Create a computed channels object to manage channels + * @param channelUrl the path to the channel api + * @returns The computed channels object + */ +export const useComputedChannels = (context: BlogAdminContext): ComputedChannels => { + + const channels = useChannels(context) + const { channel, channelEdit } = context.getQuery() + + const items = ref<BlogChannel[]>([]); + + const loadChannels = async () => { + items.value = await apiCall(channels.getChannels) ?? []; + } + + const selectedItem = computed<BlogChannel | undefined>(() => { + return find(items.value, c => isEqual(c.id, channel.value)); + }); + + const editChannel = computed<BlogChannel | undefined>(() => { + return find(items.value, c => isEqual(c.id, channelEdit.value)); + }) + + //Initial load + loadChannels(); + + //Load channels when the edit id changes to empty + watch(channelEdit, (newId) => isEmpty(newId) ? loadChannels() : null); + + return { + ...channels, + items, + selectedItem, + editChannel, + selectedId:channel, + getQuery: context.getQuery, + } +} + diff --git a/lib/admin/src/channels/index.ts b/lib/admin/src/channels/index.ts new file mode 100644 index 0000000..d45eb72 --- /dev/null +++ b/lib/admin/src/channels/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 './computedChannels'
\ No newline at end of file diff --git a/lib/admin/src/content/computedContent.ts b/lib/admin/src/content/computedContent.ts new file mode 100644 index 0000000..c4ca575 --- /dev/null +++ b/lib/admin/src/content/computedContent.ts @@ -0,0 +1,82 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +import { Ref, computed } from "vue"; +import { find, filter, includes, isEqual, isNil, toLower } from "lodash"; +import { apiCall } from "@vnuge/vnlib.browser" +import { ContentMeta, BlogEntity, ContentApi, ComputedBlogApi, BlogAdminContext } from "../types.js"; +import { watchAndCompute } from "../helpers.js"; +import { useContent } from "./useContent.js"; + +export interface ComputedContent extends ContentApi, ComputedBlogApi<ContentMeta> { + /** + * Gets the raw content for a the currently selected post + */ + getSelectedPostContent(): Promise<string | undefined>; + /** + * Filter post items by the given reactive filter + * @param filter The reactive filter used to filter the content + */ + createReactiveSearch(filter: Ref<string>): Ref<ContentMeta[] | undefined>; +} + +/** + * Gets a computed object with the content and selected content + * @param context The blog admin context + * @returns A computed object with the content and selected content + */ +export const useComputedContent = (context: BlogAdminContext): ComputedContent => { + + //Get the content api from the context + const contentApi = useContent(context); + + const { content, post, channel } = context.getQuery(); + + //Watch for channel and selected id changes and get the content + const items = watchAndCompute([channel, content, post], async () => { + //Get all content if the channel is set, otherwise return empty array + return channel.value ? await apiCall(contentApi.getAllContent) ?? [] : []; + }, []); + + const selectedItem = computed<ContentMeta | undefined>(() => { + if (!isNil(channel.value) && content.value && content.value !== 'new') { + return find(items.value, c => isEqual(c.id, content.value)); + } + return {} as ContentMeta; + }) + + const getSelectedPostContent = async (): Promise<string | undefined> => { + if (!isNil(channel.value) && post.value && post.value !== 'new') { + return await apiCall(() => contentApi.getPostContent({ id: post.value } as BlogEntity)); + } + return ''; + } + + const createReactiveSearch = (sec: Ref<string>): Ref<ContentMeta[] | undefined> => { + return computed(() => { + return filter(items.value, c => includes(toLower(c.name), toLower(sec.value)) || includes(toLower(c.id), toLower(sec.value))); + }) + } + + return { + ...contentApi, + items, + selectedItem, + getSelectedPostContent, + createReactiveSearch, + selectedId: content, + getQuery: context.getQuery, + }; +} diff --git a/lib/admin/src/content/index.ts b/lib/admin/src/content/index.ts new file mode 100644 index 0000000..802c002 --- /dev/null +++ b/lib/admin/src/content/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 './useContent' +export * from './computedContent'
\ No newline at end of file diff --git a/lib/admin/src/content/useContent.ts b/lib/admin/src/content/useContent.ts new file mode 100644 index 0000000..1493565 --- /dev/null +++ b/lib/admin/src/content/useContent.ts @@ -0,0 +1,149 @@ +// 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 { includes, isEmpty } from "lodash"; +import { WebMessage, useAxios } from "@vnuge/vnlib.browser" +import { PostMeta, ContentMeta, ContentApi, BlogEntity, BlogAdminContext } from "../types.js"; + + +/** + * Configures a content api for a given content endpoint and channel + * @param contentUrl The content endpoint url + * @param channel The channel to get the content for + * @returns A content api object + */ +export const useContent = (context : BlogAdminContext): ContentApi => { + const axios = useAxios(null); + + const { channel } = context.getQuery(); + + const getUrl = (): string => { + const url = context.getContentUrl(); + //Return the url with the channel id query + return `${url}?channel=${channel.value}`; + } + + const getContentType = (file: File): string => { + if (isEmpty(file.type)) { + return 'application/octet-stream' + } + if (includes(file.type, 'javascript')) { + return 'application/javascript' + } + if (includes(file.type, 'json')) { + return 'application/json' + } + if (includes(file.type, 'xml')) { + return 'application/xml' + } + return file.type; + } + + /** + * Gets the raw content item from the server and returns a string of the content + * @param cotentId The id of the content to get the raw value of + * @returns A promise that resolves to the raw content string + */ + const getContent = async (cotentId: string): Promise<string> => { + const url = getUrl(); + const response = await axios.get(`${url}&id=${cotentId}`); + return await response.data; + } + + const getPostContent = async (post: BlogEntity): Promise<string> => { + return await getContent(post.id); + } + + const getAllContent = async (): Promise<ContentMeta[]> => { + const url = getUrl(); + const response = await axios.get<ContentMeta[]>(url); + return response.data; + } + + const deleteContent = async (content: ContentMeta): Promise<void> => { + const url = getUrl(); + await axios.delete(`${url}&id=${content.id}`); + } + + const uploadContent = async (file: File, name: string): Promise<ContentMeta> => { + const url = getUrl(); + //Endpoint returns the new content meta for the uploaded content + const { data } = await axios.put<WebMessage<ContentMeta>>(url, file, { + headers: { + 'Content-Type': getContentType(file), + //Set the content name header as the supplied content name + 'X-Content-Name': name + } + }); + return data.getResultOrThrow(); + } + + const updatePostContent = async (post: PostMeta, content: string): Promise<ContentMeta> => { + const url = getUrl(); + + const { data } = await axios.put<WebMessage<ContentMeta>>(`${url}&id=${post.id}`, content, { + headers: { + 'Content-Type': 'text/html', + //Set the content name header as the post id + 'X-Content-Name': `Content for post ${post.id}` + } + }); + return data.getResultOrThrow(); + } + + const updateContent = async (content: ContentMeta, data: File): Promise<ContentMeta> => { + const url = getUrl(); + + const response = await axios.put<ContentMeta>(`${url}&id=${content.id}`, data, { + headers: { + 'Content-Type': getContentType(data), + //Set the content name header as the supplied content name + 'X-Content-Name': content.name + } + }); + return response.data; + } + + const updateContentName = async (content: ContentMeta, name: string): Promise<ContentMeta> => { + const url = getUrl(); + + //Create a new object with the same properties as the content meta, but with the new name + const ct = { ...content, name: name } + const { data } = await axios.patch<WebMessage<ContentMeta>>(url, ct); + return data.getResultOrThrow(); + } + + const getPublicUrl = async (content: ContentMeta): Promise<string> => { + //Get the public url from the server + const response = await axios.get(`${getUrl()}&id=${content.id}&getlink=true`); + + //Response is a web-message + if (response.data?.success !== true) { + throw { response } + } + return response.data.result; + } + + return { + getPostContent, + getAllContent, + deleteContent, + uploadContent, + updateContentName, + updatePostContent, + updateContent, + getPublicUrl + }; +} diff --git a/lib/admin/src/feedProperties/index.ts b/lib/admin/src/feedProperties/index.ts new file mode 100644 index 0000000..733acde --- /dev/null +++ b/lib/admin/src/feedProperties/index.ts @@ -0,0 +1,144 @@ +// 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 { cloneDeep, filter, forEach, isEmpty, join, map } from 'lodash'; +import { watch, Ref, ref } from 'vue'; +import { FeedProperty, XmlPropertyContainer } from '../types'; + +/** + * An interface for working with xml properties from an xml feed + */ +export interface UseXmlProperties { + + /** + * Correctly formats and exports the current properties + */ + getCurrentProperties(): FeedProperty[] | undefined; + + /** + * Gets the current properties as xml + */ + getXml(): string | undefined; + + /** + * Saves properties values from a json string + * @param json The property json to parse + * @returns True if the json was parsed and saved, false otherwise + */ + saveJson: (json: string | undefined) => boolean; + + /** + * Gets a copy of the current properties + */ + getModel(): FeedProperty[] | undefined; + + /** + * Manually adds an array of properties to the current properties + */ + addProperties: (properties: FeedProperty[]) => void; +} + +/** + * Creates a new instance of the useXmlProperties api from the given feed + * @param feed The feed to read and watch for changes from + * @returns An api for working with xml properties + */ +export const useXmlProperties = <T extends XmlPropertyContainer>(feed: Ref<T | undefined>): UseXmlProperties => { + + //The current properties + const currentProperties = ref<FeedProperty[]>(feed.value?.properties || []); + + //Watch for changes to the feed + watch(feed, (newFeed) => { currentProperties.value = newFeed?.properties || [] }, { immediate: true }); + + const getCurrentProperties = (): FeedProperty[] | undefined => { + //Get all properties that are not emtpy + return filter(currentProperties.value, p => !isEmpty(p.name)); + } + + const getPropertyXml = (properties: FeedProperty[]): string => { + let output = ''; + forEach(properties, prop => { + //Open tag (with namespace if present) + output += !isEmpty(prop.namespace) ? `<${prop.namespace}:${prop.name}` : `<${prop.name}` + + if (!isEmpty(prop.attributes)) { + forEach(prop.attributes, (value, key) => output += ` ${key}="${value}"`) + } + + //Recursive call for nested property, or add its value + output += !isEmpty(prop.properties) ? `>${getPropertyXml(prop.properties!)}` : `>${prop.value || ''}` + + //Close tag + output += !isEmpty(prop.namespace) ? `</${prop.namespace}:${prop.name}>` : `</${prop.name}>` + return output; + }) + return output; + } + + const getModel = (): FeedProperty[] | undefined => { + return cloneDeep(currentProperties.value); + } + + const getXml = (): string => { + if (currentProperties.value === undefined) { + return ''; + } + return join(map(currentProperties.value, p => getPropertyXml([p])), '\n'); + } + + const saveJson = (json: string | undefined): boolean => { + + if (isEmpty(json)) { + //Clear all properties if json is undefined + currentProperties.value = []; + return true + } + + try { + const parsed = JSON.parse(json!); + + const props = map(parsed, (prop) => ({ + name: prop.name, + value: prop.value, + namespace: prop.namespace, + attributes: prop.attributes, + properties: prop.properties + })) + + //Remove any empty properties + const nonEmpty = filter(props, p => !isEmpty(p.name)); + + //Set the properties + currentProperties.value = nonEmpty; + + return true; + } catch (err) { + return false; + } + } + + const addProperties = (properties: FeedProperty[]) => { + currentProperties.value = [...currentProperties.value, ...properties]; + } + + return { + getCurrentProperties, + getXml, + saveJson, + getModel, + addProperties + } +}
\ No newline at end of file diff --git a/lib/admin/src/helpers.ts b/lib/admin/src/helpers.ts new file mode 100644 index 0000000..9bb7197 --- /dev/null +++ b/lib/admin/src/helpers.ts @@ -0,0 +1,45 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +import { Ref, WatchSource, ref } from 'vue'; +import { throttle } from 'lodash'; +import { watchArray } from '@vueuse/core'; + + +/** + * Watches a collection of items and runs the callback function if any of the + * items change. + * @param watchValue A collection of watchable items to watch + * @param cb The async callback method to run when any of the items change + * @param initial The initial value to set the watched value to + * @returns A ref that is updated when any of the items change + */ +export const watchAndCompute = <T>(watchValue: WatchSource[], cb: () => Promise<T>, initial: T): Ref<T> => { + + const watched = ref<T>(); + watched.value = initial; + + //Function to execute the callback and set the watched value + const exec = async () => { + watched.value = await cb(); + } + + //Initial call + exec(); + + watchArray(watchValue, throttle(exec, 100)) + + return watched as Ref<T>; +}
\ No newline at end of file diff --git a/lib/admin/src/index.ts b/lib/admin/src/index.ts new file mode 100644 index 0000000..b5abe68 --- /dev/null +++ b/lib/admin/src/index.ts @@ -0,0 +1,87 @@ +// 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 apis and types +export * from './types'; +export * from './ordering' +export * from './feedProperties' +export * from './posts' +export * from './content' +export * from './channels' + +import { MaybeRef } from "vue"; +import { get } from '@vueuse/core' +import { useRouteQuery } from "@vueuse/router"; +import { QueryState, QueryType, SortType, BlogAdminContext } from "./types"; + +export interface BlogAdminConfig { + readonly postUrl: MaybeRef<string>; + readonly contentUrl: MaybeRef<string>; + readonly channelUrl: MaybeRef<string>; + readonly defaultPageSize?: number; +} + +const createQueryState = (): QueryState => { + //setup filter search query + const search = useRouteQuery<string>(QueryType.Filter, '', { mode: 'replace' }); + + //Get sort order query + const sort = useRouteQuery<SortType>(QueryType.Sort, SortType.CreatedTime, { mode: 'replace' }); + + //Selected channel id + const channel = useRouteQuery<string>(QueryType.Channel, '', { mode: 'replace' }); + + //Edits are in push mode because they are used to navigate to edit pages + + const channelEdit = useRouteQuery<string>(QueryType.ChannelEdit, '', { mode: 'push' }); + + const content = useRouteQuery<string>(QueryType.Content, '', { mode: 'push' }); + //Get the selected post id from the route + const post = useRouteQuery<string>(QueryType.Post, '', { mode: 'push' }); + + return { + post, + channel, + content, + channelEdit, + search, + sort + } +} + +/** + * Create a blog context object from the given configuration + * @param param0 The blog configuration object + * @returns A blog context object to pass to the blog admin components + */ +export const createBlogContext = ({ channelUrl, postUrl, contentUrl }: BlogAdminConfig): BlogAdminContext => { + + const queryState = createQueryState(); + + const getQuery = (): QueryState => queryState; + + const getPostUrl = (): string => get(postUrl) + + const getContentUrl = (): string => get(contentUrl) + + const getChannelUrl = (): string => get(channelUrl) + + return{ + getQuery, + getPostUrl, + getChannelUrl, + getContentUrl, + } +}
\ No newline at end of file diff --git a/lib/admin/src/ordering/index.ts b/lib/admin/src/ordering/index.ts new file mode 100644 index 0000000..12cbf3c --- /dev/null +++ b/lib/admin/src/ordering/index.ts @@ -0,0 +1,61 @@ +// 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 { MaybeRefOrGetter, computed } from 'vue'; +import { useOffsetPagination } from '@vueuse/core'; +import { filter, includes, isEmpty, orderBy, slice, toLower } from 'lodash'; +import { CanPaginate, NamedBlogEntity, SortedFilteredPaged } from '../types'; + +/** + * Allows filtering, sorting, and paginating a collection of blog items + * @param pageable The collection of items to paginate and filter + * @returns The filtered and sorted items, and the pagination state + */ +export const useFilteredPages = <T extends NamedBlogEntity>(pageable: CanPaginate<T>, pageSize: MaybeRefOrGetter<number>): SortedFilteredPaged<T> => { + + //Get filterable items, and the query state to filter by + const { sort, search } = pageable.getQuery(); + + const filtered = computed<T[]>(() => { + + //Sort the posts by the sort order and decending + const sorted = orderBy(pageable.items.value, sort.value, ['desc']) + + if (isEmpty(search.value)) { + return sorted + } + else { + //Search query as lower-case + const lower = toLower(search.value); + return filter(sorted, c => includes(toLower(c.title || c.name), lower) || includes(toLower(c.id), lower)) + } + }) + + //Get total after sort and filter + const total = computed(() => filtered.value.length); + + //Setup pagination based on sort/filter + const pagination = useOffsetPagination({ total, pageSize }); + + const final = computed<T[]>(() => { + const currentPageSize = pagination.currentPageSize.value; + //get the current page of items to display + const offset = currentPageSize * (pagination.currentPage.value - 1); + const limit = currentPageSize * pagination.currentPage.value; + return slice(filtered.value, offset, limit); + }) + + return { items: final, pagination } +}
\ No newline at end of file diff --git a/lib/admin/src/posts/computedPosts.ts b/lib/admin/src/posts/computedPosts.ts new file mode 100644 index 0000000..61be169 --- /dev/null +++ b/lib/admin/src/posts/computedPosts.ts @@ -0,0 +1,50 @@ +// 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 } from "vue"; +import { isEqual, find } from "lodash"; +import { apiCall } from "@vnuge/vnlib.browser"; +import { PostMeta, ComputedPosts, BlogAdminContext } from "../types"; +import { usePostApi } from "./usePost"; +import { watchAndCompute } from "../helpers"; + +/** + * Creates a computed post api for reactive blog apis + * @param context The blog admin context + * @returns The computed post api + */ +export const useComputedPosts = (context: BlogAdminContext): ComputedPosts => { + //Post api around the post url and channel + const postApi = usePostApi(context); + + const { channel, post } = context.getQuery(); + + //Get all posts + const items = watchAndCompute([channel, post], async () => { + return channel.value ? await apiCall(postApi.getPosts) ?? [] : []; + }, []) + + const selectedItem = computed<PostMeta | undefined>(() => { + return find(items.value, p => isEqual(p.id, post.value)); + }) + + return { + ...postApi, + items, + selectedItem, + selectedId:post, + getQuery: context.getQuery + }; +}
\ No newline at end of file diff --git a/lib/admin/src/posts/index.ts b/lib/admin/src/posts/index.ts new file mode 100644 index 0000000..0105265 --- /dev/null +++ b/lib/admin/src/posts/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 './usePost' +export * from './computedPosts'
\ No newline at end of file diff --git a/lib/admin/src/posts/usePost.ts b/lib/admin/src/posts/usePost.ts new file mode 100644 index 0000000..56d0f17 --- /dev/null +++ b/lib/admin/src/posts/usePost.ts @@ -0,0 +1,70 @@ +// 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 { isArray, orderBy } from "lodash"; +import { WebMessage, useAxios } from "@vnuge/vnlib.browser" +import { PostMeta, PostApi, BlogAdminContext } from "../types"; + +/** + * Gets a reactive post api for the given channel + * @param context The blog admin context + * @returns The configured post api + */ +export const usePostApi = (context : BlogAdminContext): PostApi => { + const axios = useAxios(null); + + const { channel } = context.getQuery(); + + const getUrl = (): string => { + const url = context.getPostUrl(); + //Return the url with the channel id query + return `${url}?channel=${channel.value}`; + } + + const getPosts = async (): Promise<PostMeta[]> => { + const { data } = await axios.get(getUrl()); + return isArray(data) ? orderBy(data, 'date', 'desc') : []; + } + + const deletePost = (post: PostMeta): Promise<void> => { + //Call delete with the post id query + return axios.delete(`${getUrl()}&post=${post.id}`); + } + + const publishPost = async (post: PostMeta): Promise<PostMeta> => { + //Call post with the post data + const { data } = await axios.post<WebMessage<PostMeta>>(getUrl(), post); + return data.getResultOrThrow(); + } + + const updatePost = async (post: PostMeta): Promise<PostMeta> => { + //Call patch with the updated post content, must have an id set as an existing post + const { data } = await axios.patch<WebMessage<PostMeta>>(getUrl(), post); + return data.getResultOrThrow(); + } + + const getSinglePost = async (postId: string): Promise<PostMeta> => { + const { data } = await axios.get(`${getUrl()}&post=${postId}`); + return data; + } + + return { + getPosts, + deletePost, + publishPost, + updatePost, + getSinglePost + }; +}
\ No newline at end of file diff --git a/lib/admin/src/types.ts b/lib/admin/src/types.ts new file mode 100644 index 0000000..2d3a56c --- /dev/null +++ b/lib/admin/src/types.ts @@ -0,0 +1,270 @@ +// 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 { UseOffsetPaginationReturn } from '@vueuse/core'; +import { Dictionary } from 'lodash'; +import { Ref } from 'vue'; + +export enum QueryType { + Post = 'post', + Channel = 'channel', + Content = 'content', + ChannelEdit = 'ecid', + Filter = 'filter', + Sort = 'sort', + PageSize = 'size', +} + +export enum SortType { + CreatedTime = 'created', + ModifiedTime = 'date', +} + +/** + * A base blog entity that has a globally unique id and a date + */ +export interface BlogEntity{ + /** + * The globally unique id of the entity + */ + readonly id: string; + /** + * The date the entity was last modified + */ + readonly date: number; +} + +/** + * A blog entity that has a name and/or a title + */ +export interface NamedBlogEntity extends BlogEntity { + /** + * The name of the entity + */ + readonly name?: string; + /** + * The title of the entity or item + */ + readonly title?: string; +} + + +export interface FeedProperty { + name: string; + value?: string; + namespace?: string; + attributes?: Dictionary<string>; + properties?: FeedProperty[]; +} + +export interface XmlPropertyContainer { + properties?: FeedProperty[]; +} + +export interface ChannelFeed extends XmlPropertyContainer { + url: string; + path: string; + image: string; + copyright?: string; + maxItems?: number; + description?: string; + contact?: string; +} + +export interface BlogChannel extends BlogEntity { + name: string; + path: string; + index: string; + feed?: ChannelFeed; + content?: string; +} + +export interface ContentMeta extends NamedBlogEntity { + readonly content_type: string; + readonly length: number; + readonly path: string; +} + +/** + * Represents a blog post's meta data in the catalog + */ +export interface PostMeta extends NamedBlogEntity, XmlPropertyContainer { + readonly created ?: number; + summary?: string; + author?: string; + tags?: string[]; + image?: string; +} + +/** + * Represents the channel api and its operations + */ +export interface ChannelApi { + /** + * Gets all blog channels from the server + * @returns An array of blog channels + */ + getChannels: () => Promise<BlogChannel[]>; + /** + * Delets a blog channel from the catalog + * @param channel The channel to delete + */ + deleteChannel: (channel: BlogChannel) => Promise<void>; + /** + * Adds a channel to the catalog + * @param channel The channel to add + */ + addChannel: (channel: BlogChannel, feed?: ChannelFeed) => Promise<BlogChannel>; + /** + * Updates a channel in the catalog + * @param channel The channel to update + */ + updateChannel: (channel: BlogChannel, feed?: ChannelFeed) => Promise<BlogChannel>; +} + +export interface PostApi { + /** + * Gets all blog posts from the server + * @param channel The channel to get posts from + * @returns An array of blog posts + */ + getPosts: () => Promise<PostMeta[]>; + /** + * Deletes a post from the given channel by its id + * @param channel The channel the post belongs to + * @param post The post to delete + * @returns The response from the server + */ + deletePost: (post: PostMeta) => Promise<void>; + /** + * Publishes a new post to the given channel + * @param channel The blog channel to publish to + * @param post The post to publish + * @returns The response from the server + */ + publishPost: (post: PostMeta) => Promise<PostMeta>; + /** + * Updates a post in the given channel + * @param channel The channel the post belongs to + * @param post The post to update + * @returns The response from the server + */ + updatePost: (post: PostMeta) => Promise<PostMeta>; + /** + * Gets the post meta data for a single post by its id + * @param postId The id of the post to get + * @returns The post meta data + */ + getSinglePost: (postId: string) => Promise<PostMeta>; +} + +export interface ContentApi { + /** + * Gets the content for a post as text + * @param post The post to get the content for + * @returns A promise that resolves to the content string + */ + getPostContent(post: BlogEntity): Promise<string>; + /** + * Gets all content meta objects for the current channel + * @returns A promise that resolves to an array of content meta objects + */ + getAllContent(): Promise<ContentMeta[]>; + /** + * Deletes a content meta object from the server in the current channel + * @param content The content meta object to delete + */ + deleteContent(content: ContentMeta): Promise<void>; + /** + * Uploads a content file to the server in the current channel + * @param content The content file to upload + * @param name The name of the content file + * @returns A promise that resolves to the content meta object for the uploaded content + */ + uploadContent(data: File, name: string): Promise<ContentMeta>; + /** + * Updates the content for a post in the current channel + * @param post The post to update the content for + * @param content The post content to update + * @returns A promise that resolves to the content meta object for the updated content + */ + updatePostContent(post: PostMeta, content: string): Promise<ContentMeta>; + /** + * Updates the name of a content meta object in the current channel + * @param content The content meta object to update + * @param name The new name for the content + * @returns A promise that resolves when the content has been updated + */ + updateContentName(content: ContentMeta, name: string): Promise<ContentMeta>; + /** + * Gets the relative public url of the content meta object + * @param content The content meta object to get the public url for + * @returns The public url for the content + */ + getPublicUrl(content: ContentMeta): Promise<string>; + /** + * Allows you to overwrite the content for a content meta object + * @param content The content meta object to update + * @param data The new content data file + */ + updateContent(content: ContentMeta, data: File): Promise<ContentMeta>; +} + +/** + * Represents a collection of items that can be paginated, such as posts or content + */ +export interface CanPaginate<T> { + /** + * A reactive collection of items within the store + */ + readonly items: Readonly<Ref<T[]>>; + /** + * Gets the global query state + */ + getQuery(): Readonly<QueryState> +} + +export interface ComputedBlogApi<T> extends CanPaginate<T>{ + readonly selectedId: Ref<string>; + readonly selectedItem : Readonly<Ref<T | undefined>>; +} + +export interface ComputedPosts extends PostApi, ComputedBlogApi<PostMeta> { +} + +/** + * The current state of the query + */ +export interface QueryState { + readonly post: Ref<string>; + readonly channel: Ref<string>; + readonly content: Ref<string>; + readonly channelEdit: Ref<string>; + readonly search: Ref<string>; + readonly sort: Ref<SortType>; +} + + +export interface SortedFilteredPaged<T>{ + readonly items : Readonly<Ref<T[]>>; + readonly pagination: UseOffsetPaginationReturn; +} + +export interface BlogAdminContext { + getQuery(): QueryState; + getPostUrl(): string; + getContentUrl(): string; + getChannelUrl(): string; +}
\ No newline at end of file |