diff options
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; } /** |