aboutsummaryrefslogtreecommitdiff
path: root/lib/admin/src
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 /lib/admin/src
Initial commit
Diffstat (limited to 'lib/admin/src')
-rw-r--r--lib/admin/src/channels/channels.ts60
-rw-r--r--lib/admin/src/channels/computedChannels.ts65
-rw-r--r--lib/admin/src/channels/index.ts17
-rw-r--r--lib/admin/src/content/computedContent.ts82
-rw-r--r--lib/admin/src/content/index.ts17
-rw-r--r--lib/admin/src/content/useContent.ts149
-rw-r--r--lib/admin/src/feedProperties/index.ts144
-rw-r--r--lib/admin/src/helpers.ts45
-rw-r--r--lib/admin/src/index.ts87
-rw-r--r--lib/admin/src/ordering/index.ts61
-rw-r--r--lib/admin/src/posts/computedPosts.ts50
-rw-r--r--lib/admin/src/posts/index.ts17
-rw-r--r--lib/admin/src/posts/usePost.ts70
-rw-r--r--lib/admin/src/types.ts270
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