aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--back-end/src/Content.Publishing.Blog.Admin.csproj2
-rw-r--r--back-end/src/Endpoints/ContentEndpoint.cs62
-rw-r--r--back-end/src/Storage/ManagedStorage.cs14
-rw-r--r--ci/config/config.json2
-rw-r--r--front-end/package-lock.json72
-rw-r--r--front-end/package.json4
-rw-r--r--front-end/src/views/Blog/ckeditor/Editor.vue9
-rw-r--r--front-end/src/views/Blog/ckeditor/build.ts264
-rw-r--r--front-end/src/views/Blog/ckeditor/uploadAdapter.ts91
-rw-r--r--front-end/src/views/Blog/components/Channels.vue2
-rw-r--r--front-end/src/views/Blog/components/Content.vue22
-rw-r--r--front-end/src/views/Blog/components/Content/ContentEditor.vue19
-rw-r--r--front-end/src/views/Blog/components/Content/ContentTable.vue10
-rw-r--r--front-end/src/views/Blog/components/FeedFields.vue4
-rw-r--r--front-end/src/views/Blog/components/Posts.vue29
-rw-r--r--front-end/src/views/Blog/components/Posts/PostEdit.vue21
-rw-r--r--front-end/src/views/Blog/components/Posts/PostTable.vue19
-rw-r--r--lib/admin/src/content/computedContent.ts14
-rw-r--r--lib/admin/src/content/useContent.ts40
-rw-r--r--lib/admin/src/posts/computedPosts.ts12
-rw-r--r--lib/admin/src/types.ts22
21 files changed, 518 insertions, 216 deletions
diff --git a/back-end/src/Content.Publishing.Blog.Admin.csproj b/back-end/src/Content.Publishing.Blog.Admin.csproj
index 97a575f..8c923f0 100644
--- a/back-end/src/Content.Publishing.Blog.Admin.csproj
+++ b/back-end/src/Content.Publishing.Blog.Admin.csproj
@@ -34,7 +34,7 @@
<ItemGroup>
<PackageReference Include="FluentFTP" Version="48.0.3" />
- <PackageReference Include="Minio" Version="6.0.0" />
+ <PackageReference Include="Minio" Version="6.0.1" />
<PackageReference Include="VNLib.Plugins.Extensions.Loading" Version="0.1.0-ci0042" />
<PackageReference Include="VNLib.Plugins.Extensions.Validation" Version="0.1.0-ci0042" />
</ItemGroup>
diff --git a/back-end/src/Endpoints/ContentEndpoint.cs b/back-end/src/Endpoints/ContentEndpoint.cs
index 2427cf0..92aee3b 100644
--- a/back-end/src/Endpoints/ContentEndpoint.cs
+++ b/back-end/src/Endpoints/ContentEndpoint.cs
@@ -22,6 +22,7 @@
using System;
using System.IO;
using System.Net;
+using System.Linq;
using System.Threading.Tasks;
using FluentValidation;
@@ -45,6 +46,7 @@ namespace Content.Publishing.Blog.Admin.Endpoints
internal sealed class ContentEndpoint : ProtectedWebEndpoint
{
private static readonly IValidator<ContentMeta> MetaValidator = ContentMeta.GetValidator();
+ private static readonly IValidator<string[]> MultiDeleteValidator = GetMultiDeleteValidator();
private readonly ContentManager _content;
private readonly IChannelContextManager _blogContextManager;
@@ -312,12 +314,6 @@ namespace Content.Publishing.Blog.Admin.Endpoints
return VfReturnType.BadRequest;
}
- //get the content id
- if (!entity.QueryArgs.TryGetNonEmptyValue("id", out string? contentId))
- {
- return VfReturnType.BadRequest;
- }
-
//Get channel
IChannelContext? channel = await _blogContextManager.GetChannelAsync(channelId, entity.EventCancellation);
if (channel == null)
@@ -325,11 +321,59 @@ namespace Content.Publishing.Blog.Admin.Endpoints
return VfReturnType.NotFound;
}
- //Try to delete the content
- bool deleted = await _content.DeleteContentAsync(channel, contentId, entity.EventCancellation);
+ //get the single content id
+ if (entity.QueryArgs.TryGetNonEmptyValue("id", out string? contentId))
+ {
+ //Try to delete the content
+ bool deleted = await _content.DeleteContentAsync(channel, contentId, entity.EventCancellation);
+
+ return deleted ? VirtualOk(entity) : VfReturnType.NotFound;
+ }
+
+ //Check for bulk delete
+ if (entity.QueryArgs.TryGetNonEmptyValue("ids", out string? multiIds))
+ {
+ ValErrWebMessage webm = new();
- return deleted ? VirtualOk(entity) : VfReturnType.NotFound;
+ string[] allIds = multiIds.Split(',');
+
+ //validate the ids
+ if (!MultiDeleteValidator.Validate(allIds, webm))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
+ }
+
+ //Delete all async at the same time, then filter out the nulls
+ Task<string>[] deleted = allIds.Select(async id =>
+ {
+ return await _content.DeleteContentAsync(channel, id, entity.EventCancellation) ? id : null;
+
+ }).Where(id => id != null).ToArray()!;
+
+ //Get the deleted ids
+ string[] deletedIds = await Task.WhenAll(deleted);
+
+ webm.Result = deletedIds;
+ webm.Success = true;
+
+ return VirtualOk(entity, webm);
+ }
+
+ return VfReturnType.BadRequest;
+
}
+
+ static IValidator<string[]> GetMultiDeleteValidator()
+ {
+ InlineValidator<string[]> val = new();
+
+ val.RuleForEach(p => p)
+ .NotEmpty()
+ .Length(0, 64)
+ .AlphaNumericOnly();
+
+ return val;
+ }
}
}
diff --git a/back-end/src/Storage/ManagedStorage.cs b/back-end/src/Storage/ManagedStorage.cs
index 19e208c..66e9a4a 100644
--- a/back-end/src/Storage/ManagedStorage.cs
+++ b/back-end/src/Storage/ManagedStorage.cs
@@ -30,13 +30,23 @@ using VNLib.Plugins.Extensions.Loading;
namespace Content.Publishing.Blog.Admin.Storage
{
+ [ConfigurationName("storage", Required = false)]
internal sealed class ManagedStorage : ISimpleFilesystem
{
private readonly ISimpleFilesystem _backingStorage;
- public ManagedStorage(PluginBase plugin)
+ public ManagedStorage(PluginBase plugin) : this(plugin, null)
+ { }
+
+ public ManagedStorage(PluginBase plugin, IConfigScope? config)
{
- if (plugin.HasConfigForType<MinioClientManager>())
+ //try to get custom storage assembly
+ if (config != null && config.ContainsKey("custom_storage_assembly"))
+ {
+ string storageAssembly = config.GetRequiredProperty("custom_storage_assembly", p => p.GetString()!);
+ _backingStorage = plugin.CreateServiceExternal<ISimpleFilesystem>(storageAssembly);
+ }
+ else if (plugin.HasConfigForType<MinioClientManager>())
{
//Use minio storage
_backingStorage = plugin.GetOrCreateSingleton<MinioClientManager>();
diff --git a/ci/config/config.json b/ci/config/config.json
index e70899f..931c56b 100644
--- a/ci/config/config.json
+++ b/ci/config/config.json
@@ -7,7 +7,7 @@
//The defaut HTTP version to being requests with (does not support http/2 yet)
"default_version": "HTTP/1.1",
//The maxium size (in bytes) of response messges that will be compressed
- "compression_limit": 512000,
+ "compression_limit": 2048000,
//Minium response size (in bytes) to compress
"compression_minimum": 2048,
//The size of the buffer to use when parsing multipart/form data uploads
diff --git a/front-end/package-lock.json b/front-end/package-lock.json
index 1cfe9d2..30fd317 100644
--- a/front-end/package-lock.json
+++ b/front-end/package-lock.json
@@ -35,6 +35,8 @@
"vue3-otp-input": "^0.4.1"
},
"devDependencies": {
+ "@ckeditor/ckeditor5-core": "^40.0.0",
+ "@ckeditor/ckeditor5-upload": "^40.0.0",
"@types/lodash-es": "^4.14.194",
"@types/showdown": "^2.0.1",
"@vitejs/plugin-vue": "^4.1.0",
@@ -302,6 +304,61 @@
"vue": "^3.0.0"
}
},
+ "node_modules/@ckeditor/ckeditor5-core": {
+ "version": "40.0.0",
+ "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-core/-/ckeditor5-core-40.0.0.tgz",
+ "integrity": "sha512-8xoSDOc9/35jEikKtYbdYmBxPop7i/JYSkkZmJYbZ8XxkjQiIMAUYOJVdNntfuLGazU+THmutieEA/x3ISme4g==",
+ "dev": true,
+ "dependencies": {
+ "@ckeditor/ckeditor5-engine": "40.0.0",
+ "@ckeditor/ckeditor5-utils": "40.0.0",
+ "lodash-es": "4.17.21"
+ }
+ },
+ "node_modules/@ckeditor/ckeditor5-engine": {
+ "version": "40.0.0",
+ "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-engine/-/ckeditor5-engine-40.0.0.tgz",
+ "integrity": "sha512-zauOXFudE1B94RSziWWojdpqGprSo4rKwW3KLU6nfaz9Kq9RZkcP/TW5ADE0DxC2jWUMeItVE/3U8ES5fZ0hqQ==",
+ "dev": true,
+ "dependencies": {
+ "@ckeditor/ckeditor5-utils": "40.0.0",
+ "lodash-es": "4.17.21"
+ }
+ },
+ "node_modules/@ckeditor/ckeditor5-ui": {
+ "version": "40.0.0",
+ "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-ui/-/ckeditor5-ui-40.0.0.tgz",
+ "integrity": "sha512-wnfC7eSqdN6i+nHTN83+PCByWeCPDgdQAXvf3HHBNdsJna6khKC8Oy/1eU8F+vR84unJMrPoaCMIx0qRvK3hCA==",
+ "dev": true,
+ "dependencies": {
+ "@ckeditor/ckeditor5-core": "40.0.0",
+ "@ckeditor/ckeditor5-utils": "40.0.0",
+ "color-convert": "2.0.1",
+ "color-parse": "1.4.2",
+ "lodash-es": "4.17.21",
+ "vanilla-colorful": "0.7.2"
+ }
+ },
+ "node_modules/@ckeditor/ckeditor5-upload": {
+ "version": "40.0.0",
+ "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-upload/-/ckeditor5-upload-40.0.0.tgz",
+ "integrity": "sha512-LutDg8zjhJu1UKInAyvVHIk8HyroETi61KS2PQTyiTQv/DmRvjSK32Xl83KprTxAvqZsiDdXe+Nl1kdAO8S2ag==",
+ "dev": true,
+ "dependencies": {
+ "@ckeditor/ckeditor5-core": "40.0.0",
+ "@ckeditor/ckeditor5-ui": "40.0.0",
+ "@ckeditor/ckeditor5-utils": "40.0.0"
+ }
+ },
+ "node_modules/@ckeditor/ckeditor5-utils": {
+ "version": "40.0.0",
+ "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-utils/-/ckeditor5-utils-40.0.0.tgz",
+ "integrity": "sha512-52UwkeGxrZWhbwWWfixKceWhF1kuDeJNAM57wqfB7GS8CzElOpJ3AELeD/L/ZkUEBGL9asqribEH3CzgTjWKPA==",
+ "dev": true,
+ "dependencies": {
+ "lodash-es": "4.17.21"
+ }
+ },
"node_modules/@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
@@ -1842,6 +1899,15 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
+ "node_modules/color-parse": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.2.tgz",
+ "integrity": "sha512-RI7s49/8yqDj3fECFZjUI1Yi0z/Gq1py43oNJivAIIDSyJiOZLfYCRQEgn8HEVAj++PcRe8AnL2XF0fRJ3BTnA==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "^1.0.0"
+ }
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -4751,6 +4817,12 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
+ "node_modules/vanilla-colorful": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/vanilla-colorful/-/vanilla-colorful-0.7.2.tgz",
+ "integrity": "sha512-z2YZusTFC6KnLERx1cgoIRX2CjPRP0W75N+3CC6gbvdX5Ch47rZkEMGO2Xnf+IEmi3RiFLxS18gayMA27iU7Kg==",
+ "dev": true
+ },
"node_modules/vanilla-jsoneditor": {
"version": "0.18.12",
"resolved": "https://registry.npmjs.org/vanilla-jsoneditor/-/vanilla-jsoneditor-0.18.12.tgz",
diff --git a/front-end/package.json b/front-end/package.json
index 76422be..8bd688e 100644
--- a/front-end/package.json
+++ b/front-end/package.json
@@ -59,6 +59,8 @@
"vite-plugin-pages": "^0.31.0",
"vue-eslint-parser": "^9.3.0",
"vue-router": "^4.2.0",
- "vue-tsc": "^1.4.2"
+ "vue-tsc": "^1.4.2",
+ "@ckeditor/ckeditor5-upload": "^40.0.0",
+ "@ckeditor/ckeditor5-core": "^40.0.0"
}
}
diff --git a/front-end/src/views/Blog/ckeditor/Editor.vue b/front-end/src/views/Blog/ckeditor/Editor.vue
index 4fc534b..c3d637c 100644
--- a/front-end/src/views/Blog/ckeditor/Editor.vue
+++ b/front-end/src/views/Blog/ckeditor/Editor.vue
@@ -79,8 +79,9 @@ import { BlogState } from '../blog-api'
import { Converter } from 'showdown'
//Import the editor config
-import { config } from './build.ts'
+import { useCkConfig } from './build.ts'
import ContentSearch from '../components/ContentSearch.vue';
+import { useUploadAdapter } from './uploadAdapter';
const emit = defineEmits(['change', 'load', 'mode-change'])
@@ -153,6 +154,12 @@ tryOnMounted(() => defer(() =>
//CKEditor 5 superbuild in global scope
const { ClassicEditor } = window['CKEDITOR']
+ //Init the ck config
+ const config = useCkConfig([
+ //Add the upload adapter
+ useUploadAdapter(props.blog.content, apiCall, toaster.general)
+ ]);
+
//Init editor when loading is complete
editor = await ClassicEditor.create(editorFrame.value, config);
diff --git a/front-end/src/views/Blog/ckeditor/build.ts b/front-end/src/views/Blog/ckeditor/build.ts
index fe3e0b9..3150a55 100644
--- a/front-end/src/views/Blog/ckeditor/build.ts
+++ b/front-end/src/views/Blog/ckeditor/build.ts
@@ -1,132 +1,140 @@
-export const config = {
- // https://ckeditor.com/docs/ckeditor5/latest/features/toolbar/toolbar.html#extended-toolbar-configuration-format
- toolbar: {
- items: [
- 'findAndReplace', 'selectAll', '|',
- 'heading', '|',
- 'bold', 'italic', 'strikethrough', 'underline', 'code', 'subscript', 'superscript', 'removeFormat', '|',
- 'bulletedList', 'numberedList', 'todoList', '|',
- 'outdent', 'indent', 'alignment', '|',
- 'undo', 'redo',
- '-',
- 'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'highlight', '|',
- 'link', 'insertImage', 'blockQuote', 'insertTable', 'mediaEmbed', 'codeBlock', 'htmlEmbed', '|',
- 'specialCharacters', 'horizontalLine', 'pageBreak', '|',
- 'exportPDF', 'exportWord', 'sourceEditing'
- ],
- shouldNotGroupWhenFull: true
- },
- // Changing the language of the interface requires loading the language file using the <script> tag.
- // language: 'es',
- list: {
- properties: {
- styles: true,
- startIndex: true,
- reversed: true
- }
- },
- // https://ckeditor.com/docs/ckeditor5/latest/features/headings.html#configuration
- heading: {
- options: [
- { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
- { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
- { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
- { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
- { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' },
- { model: 'heading5', view: 'h5', title: 'Heading 5', class: 'ck-heading_heading5' },
- { model: 'heading6', view: 'h6', title: 'Heading 6', class: 'ck-heading_heading6' }
- ]
- },
- // https://ckeditor.com/docs/ckeditor5/latest/features/editor-placeholder.html#using-the-editor-configuration
- placeholder: 'Edit CMNext post content using CKEditor 5 ',
- // https://ckeditor.com/docs/ckeditor5/latest/features/font.html#configuring-the-font-family-feature
- fontFamily: {
- options: [
- 'default',
- 'Arial, Helvetica, sans-serif',
- 'Courier New, Courier, monospace',
- 'Georgia, serif',
- 'Lucida Sans Unicode, Lucida Grande, sans-serif',
- 'Tahoma, Geneva, sans-serif',
- 'Times New Roman, Times, serif',
- 'Trebuchet MS, Helvetica, sans-serif',
- 'Verdana, Geneva, sans-serif'
- ],
- supportAllValues: true
- },
- // https://ckeditor.com/docs/ckeditor5/latest/features/font.html#configuring-the-font-size-feature
- fontSize: {
- options: [10, 12, 14, 'default', 18, 20, 22],
- supportAllValues: true
- },
- // Be careful with the setting below. It instructs CKEditor to accept ALL HTML markup.
- // https://ckeditor.com/docs/ckeditor5/latest/features/general-html-support.html#enabling-all-html-features
- htmlSupport: {
- allow: [
- {
- name: /.*/,
- attributes: true,
- classes: true,
- styles: true
+import type { Editor, EditorConfig, PluginConstructor } from '@ckeditor/ckeditor5-core';
+
+export const useCkConfig = (manualPlugins: PluginConstructor<Editor>[]): EditorConfig => {
+
+ return {
+ //Add the upload adapter plugin
+ extraPlugins: manualPlugins,
+
+ // https://ckeditor.com/docs/ckeditor5/latest/features/toolbar/toolbar.html#extended-toolbar-configuration-format
+ toolbar: {
+ items: [
+ 'findAndReplace', 'selectAll', '|',
+ 'heading', '|',
+ 'bold', 'italic', 'strikethrough', 'underline', 'code', 'subscript', 'superscript', 'removeFormat', '|',
+ 'bulletedList', 'numberedList', 'todoList', '|',
+ 'outdent', 'indent', 'alignment', '|',
+ 'undo', 'redo',
+ '-',
+ 'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'highlight', '|',
+ 'link', 'insertImage', 'blockQuote', 'insertTable', 'mediaEmbed', 'codeBlock', 'htmlEmbed', '|',
+ 'specialCharacters', 'horizontalLine', 'pageBreak', '|',
+ 'exportPDF', 'exportWord', 'sourceEditing'
+ ],
+ shouldNotGroupWhenFull: true
+ },
+ // Changing the language of the interface requires loading the language file using the <script> tag.
+ // language: 'es',
+ list: {
+ properties: {
+ styles: true,
+ startIndex: true,
+ reversed: true
}
- ]
- },
- // Be careful with enabling previews
- // https://ckeditor.com/docs/ckeditor5/latest/features/html-embed.html#content-previews
- htmlEmbed: {
- showPreviews: true
- },
- // https://ckeditor.com/docs/ckeditor5/latest/features/link.html#custom-link-attributes-decorators
- link: {
- decorators: {
- addTargetToExternalLinks: true,
- defaultProtocol: 'https://',
- toggleDownloadable: {
- mode: 'manual',
- label: 'Downloadable',
- attributes: {
- download: 'file'
+ },
+ // https://ckeditor.com/docs/ckeditor5/latest/features/headings.html#configuration
+ heading: {
+ options: [
+ { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
+ { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'h1' },
+ { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'h2' },
+ { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'h3' },
+ { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'h4' },
+ { model: 'heading5', view: 'h5', title: 'Heading 5', class: 'h5' },
+ { model: 'heading6', view: 'h6', title: 'Heading 6', class: 'h6' }
+ ]
+ },
+ // https://ckeditor.com/docs/ckeditor5/latest/features/editor-placeholder.html#using-the-editor-configuration
+ placeholder: 'Edit CMNext post content using CKEditor 5 ',
+ // https://ckeditor.com/docs/ckeditor5/latest/features/font.html#configuring-the-font-family-feature
+ fontFamily: {
+ options: [
+ 'default',
+ 'Arial, Helvetica, sans-serif',
+ 'Courier New, Courier, monospace',
+ 'Georgia, serif',
+ 'Lucida Sans Unicode, Lucida Grande, sans-serif',
+ 'Tahoma, Geneva, sans-serif',
+ 'Times New Roman, Times, serif',
+ 'Trebuchet MS, Helvetica, sans-serif',
+ 'Verdana, Geneva, sans-serif'
+ ],
+ supportAllValues: true
+ },
+ // https://ckeditor.com/docs/ckeditor5/latest/features/font.html#configuring-the-font-size-feature
+ fontSize: {
+ options: [10, 12, 14, 'default', 18, 20, 22],
+ supportAllValues: true
+ },
+ // Be careful with the setting below. It instructs CKEditor to accept ALL HTML markup.
+ // https://ckeditor.com/docs/ckeditor5/latest/features/general-html-support.html#enabling-all-html-features
+ htmlSupport: {
+ allow: [
+ {
+ name: /.*/,
+ attributes: true,
+ classes: true,
+ styles: true
+ }
+ ]
+ },
+ // Be careful with enabling previews
+ // https://ckeditor.com/docs/ckeditor5/latest/features/html-embed.html#content-previews
+ htmlEmbed: {
+ showPreviews: true
+ },
+ // https://ckeditor.com/docs/ckeditor5/latest/features/link.html#custom-link-attributes-decorators
+ link: {
+ decorators: {
+ addTargetToExternalLinks: true,
+ defaultProtocol: 'https://',
+ toggleDownloadable: {
+ mode: 'manual',
+ label: 'Downloadable',
+ attributes: {
+ download: 'file'
+ }
}
}
- }
- },
- // https://ckeditor.com/docs/ckeditor5/latest/features/mentions.html#configuration
- mention: {
- },
- // The "super-build" contains more premium features that require additional configuration, disable them below.
- // Do not turn them on unless you read the documentation and know how to configure them and setup the editor.
- removePlugins: [
- // These two are commercial, but you can try them out without registering to a trial.
- // 'ExportPdf',
- // 'ExportWord',
- 'CKBox',
- 'CKFinder',
- 'EasyImage',
- // This sample uses the Base64UploadAdapter to handle image uploads as it requires no configuration.
- // https://ckeditor.com/docs/ckeditor5/latest/features/images/image-upload/base64-upload-adapter.html
- // Storing images as Base64 is usually a very bad idea.
- // Replace it on production website with other solutions:
- // https://ckeditor.com/docs/ckeditor5/latest/features/images/image-upload/image-upload.html
- // 'Base64UploadAdapter',
- 'RealTimeCollaborativeComments',
- 'RealTimeCollaborativeTrackChanges',
- 'RealTimeCollaborativeRevisionHistory',
- 'PresenceList',
- 'Comments',
- 'TrackChanges',
- 'TrackChangesData',
- 'RevisionHistory',
- 'Pagination',
- 'WProofreader',
- // Careful, with the Mathtype plugin CKEditor will not load when loading this sample
- // from a local file system (file://) - load this site via HTTP server if you enable MathType
- 'MathType',
- 'DocumentOutline',
- 'PasteFromOfficeEnhanced',
- 'Template',
- 'SlashCommand',
- 'AIAssistant',
- 'FormatPainter',
- 'TableOfContents'
- ],
+ },
+ // https://ckeditor.com/docs/ckeditor5/latest/features/mentions.html#configuration
+ mention: {
+ },
+ // The "super-build" contains more premium features that require additional configuration, disable them below.
+ // Do not turn them on unless you read the documentation and know how to configure them and setup the editor.
+ removePlugins: [
+ // These two are commercial, but you can try them out without registering to a trial.
+ // 'ExportPdf',
+ // 'ExportWord',
+ 'CKBox',
+ 'CKFinder',
+ 'EasyImage',
+ // This sample uses the Base64UploadAdapter to handle image uploads as it requires no configuration.
+ // https://ckeditor.com/docs/ckeditor5/latest/features/images/image-upload/base64-upload-adapter.html
+ // Storing images as Base64 is usually a very bad idea.
+ // Replace it on production website with other solutions:
+ // https://ckeditor.com/docs/ckeditor5/latest/features/images/image-upload/image-upload.html
+ 'Base64UploadAdapter',
+ 'RealTimeCollaborativeComments',
+ 'RealTimeCollaborativeTrackChanges',
+ 'RealTimeCollaborativeRevisionHistory',
+ 'PresenceList',
+ 'Comments',
+ 'TrackChanges',
+ 'TrackChangesData',
+ 'RevisionHistory',
+ 'Pagination',
+ 'WProofreader',
+ // Careful, with the Mathtype plugin CKEditor will not load when loading this sample
+ // from a local file system (file://) - load this site via HTTP server if you enable MathType
+ 'MathType',
+ 'DocumentOutline',
+ 'PasteFromOfficeEnhanced',
+ 'Template',
+ 'SlashCommand',
+ 'AIAssistant',
+ 'FormatPainter',
+ 'TableOfContents'
+ ],
+ }
} \ No newline at end of file
diff --git a/front-end/src/views/Blog/ckeditor/uploadAdapter.ts b/front-end/src/views/Blog/ckeditor/uploadAdapter.ts
new file mode 100644
index 0000000..1c22842
--- /dev/null
+++ b/front-end/src/views/Blog/ckeditor/uploadAdapter.ts
@@ -0,0 +1,91 @@
+// 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 { ComputedContent } from "@vnuge/cmnext-admin";
+import { IToaster } from "@vnuge/vnlib.browser";
+import { isNil } from "lodash-es";
+import type { AxiosRequestConfig } from "axios";
+import type { Editor } from "@ckeditor/ckeditor5-core";
+import type { UploadAdapter, UploadResponse, FileLoader } from '@ckeditor/ckeditor5-upload'
+
+export type ApiCall = (callback: (data: any) => Promise<any>) => Promise<any>;
+export type CKEditorPlugin = (editor: Editor) => void;
+
+/**
+ * Creates a CKEditor plugin that adds an upload adapter to the editor
+ * @param content The content api instance
+ * @param apiCall A callback function that wraps the api call
+ * @returns A CKEditor plugin initializer
+ */
+export const useUploadAdapter = (content: ComputedContent, apiCall: ApiCall, toaster?: IToaster): CKEditorPlugin =>{
+
+ const createUploadAdapter = (loader: FileLoader): UploadAdapter => {
+
+ const abortController = new AbortController();
+
+ /**
+ * Init request config local to a
+ */
+ const requestConfig = {
+ signal: abortController.signal,
+ onUploadProgress: (progressEvent: ProgressEvent) => {
+ loader.uploadTotal = progressEvent.total;
+ loader.uploaded = Math.round(progressEvent.loaded * 100);
+ }
+ } as unknown as AxiosRequestConfig;
+
+ const upload = async (): Promise<UploadResponse> => {
+ //Get the file
+ const file = await loader.file;
+
+ if(isNil(file)){
+ return{ default: '' }
+ }
+
+ //Exec server operations
+ const url = await apiCall(async () => {
+
+ //Upload the file
+ const meta = await content.uploadContent(file, file.name, requestConfig);
+
+ toaster?.info({
+ title: 'Upload Complete',
+ text: `Successfully uploaded file ${file.name} ID:${meta.id}`
+ })
+
+ //Get the public url
+ return await content.getPublicUrl(meta);
+
+ }) as string
+
+ //Reload content
+ content.refresh();
+
+ //Default url as the returned file url
+ return { default: url }
+ }
+
+ const abort = () => {
+ abortController.abort('Upload aborted');
+ }
+
+ return { upload, abort }
+ }
+
+ return function (editor: Editor): void {
+ //Add the upload adapter factory to the editor
+ editor.plugins.get('FileRepository').createUploadAdapter = createUploadAdapter;
+ };
+} \ No newline at end of file
diff --git a/front-end/src/views/Blog/components/Channels.vue b/front-end/src/views/Blog/components/Channels.vue
index 417d3f9..2a160b3 100644
--- a/front-end/src/views/Blog/components/Channels.vue
+++ b/front-end/src/views/Blog/components/Channels.vue
@@ -1,7 +1,7 @@
<template>
<div id="channel-editor">
<EditorTable title="Manage channels" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
- <template v-slot:table>
+ <template #table>
<ChannelTable
:channels="items"
@open-edit="openEdit"
diff --git a/front-end/src/views/Blog/components/Content.vue b/front-end/src/views/Blog/components/Content.vue
index ba78773..d8a9f24 100644
--- a/front-end/src/views/Blog/components/Content.vue
+++ b/front-end/src/views/Blog/components/Content.vue
@@ -1,11 +1,12 @@
<template>
<div id="content-editor" class="">
<EditorTable title="Manage content" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
- <template v-slot:table>
+ <template #table>
<ContentTable
:content="items"
@open-edit="openEdit"
@copy-link="copyLink"
+ @delete="onDelete"
/>
</template>
<template #editor>
@@ -40,7 +41,7 @@
import { computed, toRefs } from 'vue';
import { BlogState } from '../blog-api';
import { isEmpty } from 'lodash-es';
-import { apiCall } from '@vnuge/vnlib.browser';
+import { apiCall, useConfirm } from '@vnuge/vnlib.browser';
import { useClipboard } from '@vueuse/core';
import { ContentMeta, useFilteredPages } from '@vnuge/cmnext-admin';
import EditorTable from './EditorTable.vue';
@@ -67,6 +68,7 @@ const { selectedId,
//Setup content filter
const { items, pagination } = useFilteredPages(props.blog.content, 15)
+ const { reveal } = useConfirm()
const showEdit = computed(() => !isEmpty(selectedId.value));
const loadingProgress = computed(() => `${progress?.value}%`);
@@ -142,12 +144,28 @@ const onSubmit = async (value : OnSubmitValue) => {
}
const onDelete = async (item: ContentMeta) => {
+ //Show confirm
+ const { isCanceled } = await reveal({
+ title: 'Delete File?',
+ text: `Are you sure you want to delete ${item.name}? This action cannot be undone.`,
+ })
+ if (isCanceled) {
+ return;
+ }
+
+ if (!confirm(`Are you sure you want to delete ${item.name} forever?`)) {
+ return;
+ }
+
//Exec delete call
await apiCall(async () => {
await deleteContent(item);
//Close the edit panel
closeEdit(true);
})
+
+ //Refresh content after delete
+ props.blog.content.refresh();
}
diff --git a/front-end/src/views/Blog/components/Content/ContentEditor.vue b/front-end/src/views/Blog/components/Content/ContentEditor.vue
index 8da6b0a..756cec3 100644
--- a/front-end/src/views/Blog/components/Content/ContentEditor.vue
+++ b/front-end/src/views/Blog/components/Content/ContentEditor.vue
@@ -180,23 +180,8 @@ const onSubmit = async () => {
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)
-}
+//Emit delete event
+const onDelete = () => emit('delete', metaBuffer)
const removeNewFile = () =>{
file.value = undefined;
diff --git a/front-end/src/views/Blog/components/Content/ContentTable.vue b/front-end/src/views/Blog/components/Content/ContentTable.vue
index e5cbe58..3afe320 100644
--- a/front-end/src/views/Blog/components/Content/ContentTable.vue
+++ b/front-end/src/views/Blog/components/Content/ContentTable.vue
@@ -28,14 +28,17 @@
</td>
<td class="w-24">
<fieldset :disabled="waiting">
+ <button class="btn xs no-border" @click="openEdit(item)">
+ <fa-icon icon="pencil" />
+ </button>
<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 class="btn xs no-border red" @click="deleteItem(item)">
+ <fa-icon icon="trash" />
</button>
</fieldset>
</td>
@@ -50,7 +53,7 @@ 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 emit = defineEmits(['open-edit', 'copy-link', 'delete'])
const props = defineProps<{
content: ContentMeta[]
@@ -71,5 +74,6 @@ const getItemName = (item : ContentMeta) => truncate(item.name || '', { length:
const openEdit = async (item: ContentMeta) => emit('open-edit', item)
const copyLink = (item : ContentMeta) => emit('copy-link', item)
+const deleteItem = (item : ContentMeta) => emit('delete', item)
</script> \ 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
index 90e1454..0397c48 100644
--- a/front-end/src/views/Blog/components/FeedFields.vue
+++ b/front-end/src/views/Blog/components/FeedFields.vue
@@ -36,11 +36,11 @@
</template>
<script setup lang="ts">
-import { computed, ref } from 'vue';
+import { computed, defineAsyncComponent, 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 JsonEditorVue = defineAsyncComponent(() => import('json-editor-vue'))
const props = defineProps<{
properties: UseXmlProperties,
diff --git a/front-end/src/views/Blog/components/Posts.vue b/front-end/src/views/Blog/components/Posts.vue
index 0407a26..d52a0ac 100644
--- a/front-end/src/views/Blog/components/Posts.vue
+++ b/front-end/src/views/Blog/components/Posts.vue
@@ -1,10 +1,11 @@
<template>
<div id="post-editor" class="">
<EditorTable title="Manage posts" :show-edit="showEdit" :pagination="pagination" @open-new="openNew">
- <template v-slot:table>
+ <template #table>
<PostTable
:posts="items"
@open-edit="openEdit"
+ @delete="onDelete"
/>
</template>
<template #editor>
@@ -20,14 +21,15 @@
</template>
<script setup lang="ts">
-import { computed } from 'vue';
+import { computed, defineAsyncComponent } from 'vue';
import { isEmpty } from 'lodash-es';
import { PostMeta, useFilteredPages } from '@vnuge/cmnext-admin';
-import { apiCall, debugLog } from '@vnuge/vnlib.browser';
+import { apiCall, debugLog, useConfirm } from '@vnuge/vnlib.browser';
+import { BlogState } from '../blog-api';
import EditorTable from './EditorTable.vue';
-import PostEditor from './Posts/PostEdit.vue';
import PostTable from './Posts/PostTable.vue';
-import { BlogState } from '../blog-api';
+
+const PostEditor = defineAsyncComponent(() => import('./Posts/PostEdit.vue'))
const emit = defineEmits(['reload'])
@@ -37,6 +39,7 @@ const props = defineProps<{
const { selectedId, publishPost, updatePost, deletePost } = props.blog.posts;
const { updatePostContent } = props.blog.content;
+const { reveal } = useConfirm()
const showEdit = computed(() => !isEmpty(selectedId.value));
@@ -98,12 +101,28 @@ const onSubmit = async ({post, content } : { post:PostMeta, content:string }) =>
}
const onDelete = async (post: PostMeta) => {
+
+ //Show confirm
+ const { isCanceled } = await reveal({
+ title: 'Delete Post?',
+ text: `Are you sure you want to delete post '${post.title}?' This action cannot be undone.`,
+ })
+ if (isCanceled) {
+ return;
+ }
+
+ if (!confirm(`Are you sure you want to delete post '${post.id}' forever?`)) {
+ return;
+ }
+
//Exec delete call
await apiCall(async () => {
await deletePost(post);
//Close the edit panel
closeEdit(true);
})
+
+ props.blog.posts.refresh();
}
</script>
diff --git a/front-end/src/views/Blog/components/Posts/PostEdit.vue b/front-end/src/views/Blog/components/Posts/PostEdit.vue
index 6ea0bac..0268508 100644
--- a/front-end/src/views/Blog/components/Posts/PostEdit.vue
+++ b/front-end/src/views/Blog/components/Posts/PostEdit.vue
@@ -46,7 +46,7 @@ import { BlogState } from '../../blog-api';
import { reactiveComputed } from '@vueuse/core';
import { isNil, isString, split } from 'lodash-es';
import { PostMeta, useXmlProperties } from '@vnuge/cmnext-admin';
-import { apiCall, useConfirm, useUser } from '@vnuge/vnlib.browser';
+import { apiCall, useUser } from '@vnuge/vnlib.browser';
import { getPostForm } from '../../form-helpers';
import Editor from '../../ckeditor/Editor.vue';
import FeedFields from '../FeedFields.vue';
@@ -56,7 +56,6 @@ const props = defineProps<{
blog: BlogState
}>()
-const { reveal } = useConfirm();
const { getProfile } = useUser();
const { schema, getValidator } = getPostForm();
@@ -117,23 +116,7 @@ const onContentChanged = (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 onDelete = () => emit('delete', posts.selectedItem.value)
const setMeAsAuthor = () => {
apiCall(async () => {
diff --git a/front-end/src/views/Blog/components/Posts/PostTable.vue b/front-end/src/views/Blog/components/Posts/PostTable.vue
index 96c511c..c1583a6 100644
--- a/front-end/src/views/Blog/components/Posts/PostTable.vue
+++ b/front-end/src/views/Blog/components/Posts/PostTable.vue
@@ -11,7 +11,7 @@
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id" class="table-row">
- <td>
+ <td class="truncate max-w-[16rem]">
{{ post.title }}
</td>
<td>
@@ -20,18 +20,21 @@
<td>
{{ getDateString(post.date) }}
</td>
- <td>
+ <td class="truncate max-w-[10rem]">
{{ post.author }}
</td>
- <td>
- {{ getSummaryString(post.summary) }}
+ <td class="truncate max-w-[16rem]">
+ {{ post.summary }}
</td>
<td class="w-20">
+ <button class="btn xs no-border" @click="openEdit(post)">
+ <fa-icon icon="pencil" />
+ </button>
<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 class="btn xs no-border red" @click="onDelete(post)">
+ <fa-icon icon="trash" />
</button>
</td>
</tr>
@@ -45,7 +48,7 @@ import { useClipboard } from '@vueuse/core';
import { PostMeta } from '@vnuge/cmnext-admin';
import { useGeneralToaster } from '@vnuge/vnlib.browser';
-const emit = defineEmits(['reload', 'open-edit'])
+const emit = defineEmits(['reload', 'open-edit', 'delete'])
const props = defineProps<{
posts: PostMeta[],
@@ -59,8 +62,8 @@ const { info } = useGeneralToaster()
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 })
+const onDelete = (post: PostMeta) => emit('delete', post)
watch(copied, (c) => c ? info({'title':'Copied to clipboard'}) : null)
</script> \ No newline at end of file
diff --git a/lib/admin/src/content/computedContent.ts b/lib/admin/src/content/computedContent.ts
index 65c1537..75a25f8 100644
--- a/lib/admin/src/content/computedContent.ts
+++ b/lib/admin/src/content/computedContent.ts
@@ -13,7 +13,7 @@
// 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 { Ref, computed, ref } from "vue";
import { find, filter, includes, isEqual, isNil, toLower } from 'lodash-es';
import { apiCall } from "@vnuge/vnlib.browser"
import { ContentMeta, BlogEntity, ContentApi, ComputedBlogApi, BlogAdminContext } from "../types.js";
@@ -30,6 +30,10 @@ export interface ComputedContent extends ContentApi, ComputedBlogApi<ContentMeta
* @param filter The reactive filter used to filter the content
*/
createReactiveSearch(filter: Ref<string>): Ref<ContentMeta[] | undefined>;
+ /**
+ * triggers a refresh of the content
+ */
+ refresh(): void
}
/**
@@ -41,11 +45,12 @@ export const useComputedContent = (context: BlogAdminContext): ComputedContent =
//Get the content api from the context
const contentApi = useContent(context);
+ const trigger = ref(0);
const { content, post, channel } = context.getQuery();
//Watch for channel and selected id changes and get the content
- const items = watchAndCompute([channel, content, post], async () => {
+ const items = watchAndCompute([channel, content, post, trigger], async () => {
//Get all content if the channel is set, otherwise return empty array
return channel.value ? await apiCall(contentApi.getAllContent) ?? [] : [];
}, []);
@@ -70,6 +75,10 @@ export const useComputedContent = (context: BlogAdminContext): ComputedContent =
})
}
+ const refresh = () => {
+ trigger.value++;
+ }
+
return {
...contentApi,
items,
@@ -78,5 +87,6 @@ export const useComputedContent = (context: BlogAdminContext): ComputedContent =
createReactiveSearch,
selectedId: content,
getQuery: context.getQuery,
+ refresh
};
}
diff --git a/lib/admin/src/content/useContent.ts b/lib/admin/src/content/useContent.ts
index d23177c..47d27b8 100644
--- a/lib/admin/src/content/useContent.ts
+++ b/lib/admin/src/content/useContent.ts
@@ -13,8 +13,9 @@
// 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-es';
+import { includes, isArray, isEmpty, join, map } from 'lodash-es';
import { WebMessage } from "@vnuge/vnlib.browser"
+import { AxiosRequestConfig } from 'axios';
import { PostMeta, ContentMeta, ContentApi, BlogEntity, BlogAdminContext } from "../types.js";
@@ -56,14 +57,14 @@ export const useContent = (context : BlogAdminContext): ContentApi => {
* @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 _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);
+ return await _getContent(post.id);
}
const getAllContent = async (): Promise<ContentMeta[]> => {
@@ -72,15 +73,31 @@ export const useContent = (context : BlogAdminContext): ContentApi => {
return response.data;
}
- const deleteContent = async (content: ContentMeta): Promise<void> => {
+ const deleteContent = async (content: ContentMeta | ContentMeta[]): Promise<void> => {
const url = getUrl();
- await axios.delete(`${url}&id=${content.id}`);
+
+ if(isArray(content)){
+ const ids = join(map(content, x => x.id));
+ //bulk delete by setting multiple ids
+ const { data } = await axios.delete<WebMessage<string[]>>(`${url}&ids=${ids}`);
+
+ //Delete results returns a webmessage that contains the ids of the successfully deleted items
+ const deleted = data.getResultOrThrow();
+ if(deleted.length !== content.length){
+ throw { message: 'Some items failed to delete' }
+ }
+ }
+ else{
+ await axios.delete(`${url}&id=${content.id}`);
+ }
+
}
- const uploadContent = async (file: File, name: string): Promise<ContentMeta> => {
+ const uploadContent = async (file: File, name: string, config?:AxiosRequestConfig): Promise<ContentMeta> => {
const url = getUrl();
//Endpoint returns the new content meta for the uploaded content
const { data } = await axios.put<WebMessage<ContentMeta>>(url, file, {
+ ...config,
headers: {
'Content-Type': getContentType(file),
//Set the content name header as the supplied content name
@@ -103,10 +120,11 @@ export const useContent = (context : BlogAdminContext): ContentApi => {
return data.getResultOrThrow();
}
- const updateContent = async (content: ContentMeta, data: File): Promise<ContentMeta> => {
+ const updateContent = async (content: ContentMeta, data: File, config?: AxiosRequestConfig): Promise<ContentMeta> => {
const url = getUrl();
const response = await axios.put<ContentMeta>(`${url}&id=${content.id}`, data, {
+ ...config,
headers: {
'Content-Type': getContentType(data),
//Set the content name header as the supplied content name
@@ -136,6 +154,11 @@ export const useContent = (context : BlogAdminContext): ContentApi => {
return response.data.result;
}
+ const getContent = async (id: string): Promise<ContentMeta | undefined> => {
+ const index = await getAllContent();
+ return index.find(x => x.id === id);
+ }
+
return {
getPostContent,
getAllContent,
@@ -144,6 +167,7 @@ export const useContent = (context : BlogAdminContext): ContentApi => {
updateContentName,
updatePostContent,
updateContent,
- getPublicUrl
+ getPublicUrl,
+ getContent
};
}
diff --git a/lib/admin/src/posts/computedPosts.ts b/lib/admin/src/posts/computedPosts.ts
index 44171a5..640226f 100644
--- a/lib/admin/src/posts/computedPosts.ts
+++ b/lib/admin/src/posts/computedPosts.ts
@@ -13,7 +13,7 @@
// 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 { computed, ref } from "vue";
import { isEqual, find } from 'lodash-es';
import { apiCall } from "@vnuge/vnlib.browser";
import { PostMeta, ComputedPosts, BlogAdminContext } from "../types";
@@ -28,11 +28,12 @@ import { watchAndCompute } from "../helpers";
export const useComputedPosts = (context: BlogAdminContext): ComputedPosts => {
//Post api around the post url and channel
const postApi = usePostApi(context);
+ const trigger = ref(0);
const { channel, post } = context.getQuery();
//Get all posts
- const items = watchAndCompute([channel, post], async () => {
+ const items = watchAndCompute([channel, post, trigger], async () => {
return channel.value ? await apiCall(postApi.getPosts) ?? [] : [];
}, [])
@@ -40,11 +41,16 @@ export const useComputedPosts = (context: BlogAdminContext): ComputedPosts => {
return find(items.value, p => isEqual(p.id, post.value));
})
+ const refresh = () => {
+ trigger.value++;
+ }
+
return {
...postApi,
items,
selectedItem,
selectedId:post,
- getQuery: context.getQuery
+ getQuery: context.getQuery,
+ refresh
};
} \ No newline at end of file
diff --git a/lib/admin/src/types.ts b/lib/admin/src/types.ts
index f03c008..60b0064 100644
--- a/lib/admin/src/types.ts
+++ b/lib/admin/src/types.ts
@@ -14,7 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { UseOffsetPaginationReturn } from '@vueuse/core';
-import { AxiosInstance } from 'axios';
+import { AxiosInstance, AxiosRequestConfig } from 'axios';
import { Dictionary } from 'lodash';
import { Ref } from 'vue';
@@ -185,17 +185,29 @@ export interface ContentApi {
*/
getAllContent(): Promise<ContentMeta[]>;
/**
+ * Gets a single content meta object by its id
+ * @param id The id of the content meta object to get
+ * @returns A promise that resolves to the content meta object
+ */
+ getContent(id: string): Promise<ContentMeta | undefined>;
+ /**
* Deletes a content meta object from the server in the current channel
* @param content The content meta object to delete
+ * @returns A promise that resolves when the content has been deleted
*/
deleteContent(content: ContentMeta): Promise<void>;
/**
+ * Deletes an array of content meta objects from the server in the current channel
+ * @param content The content items 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>;
+ uploadContent(data: File, name: string, config?: AxiosRequestConfig): Promise<ContentMeta>;
/**
* Updates the content for a post in the current channel
* @param post The post to update the content for
@@ -221,7 +233,7 @@ export interface ContentApi {
* @param content The content meta object to update
* @param data The new content data file
*/
- updateContent(content: ContentMeta, data: File): Promise<ContentMeta>;
+ updateContent(content: ContentMeta, data: File, config?:AxiosRequestConfig): Promise<ContentMeta>;
}
/**
@@ -244,6 +256,10 @@ export interface ComputedBlogApi<T> extends CanPaginate<T>{
}
export interface ComputedPosts extends PostApi, ComputedBlogApi<PostMeta> {
+ /**
+ * Triggers a refresh of the posts
+ */
+ refresh(): void;
}
/**