From ec1f3ebc562824c846d23d997eba587679820cd2 Mon Sep 17 00:00:00 2001 From: Thomas Haaf Date: Wed, 12 Jun 2024 14:57:38 +0200 Subject: [PATCH 01/21] fix deprecated faker call --- server/src/service/testdata-generator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/service/testdata-generator.ts b/server/src/service/testdata-generator.ts index 872f6c8..e8b4777 100644 --- a/server/src/service/testdata-generator.ts +++ b/server/src/service/testdata-generator.ts @@ -149,7 +149,7 @@ export async function createRandomAttachment(post: PostEntity, seed?: number): P const data = await generateRandomPng(faker.datatype.number()); const file: FileEntity = await FileEntity.fromData(await data.arrayBuffer()); await AppDataSource.manager.createQueryBuilder().insert().into(FileEntity).values(file).onConflict('("sha256") DO NOTHING').execute(); - const attachment: AttachmentEntity = { post, file, filename: faker.random.word() + ".png" }; + const attachment: AttachmentEntity = { post, file, filename: faker.lorem.word() + ".png" }; await AppDataSource.manager.getRepository(AttachmentEntity).insert(attachment); return attachment; } catch (e) { From e7b69058741f39e6a58aa6caa61d486eee60dbc6 Mon Sep 17 00:00:00 2001 From: Thomas Haaf Date: Thu, 13 Jun 2024 11:32:27 +0200 Subject: [PATCH 02/21] fixed missing images in edit mode of post --- client/src/views/PostFormView.vue | 123 +++++++++++++------------- server/src/routes/posts.ts | 138 ++++++++++++++++++------------ 2 files changed, 143 insertions(+), 118 deletions(-) diff --git a/client/src/views/PostFormView.vue b/client/src/views/PostFormView.vue index 8e8dfa7..745fbf5 100644 --- a/client/src/views/PostFormView.vue +++ b/client/src/views/PostFormView.vue @@ -11,48 +11,27 @@
- +
- +
- +
- +
{{ t("posts.form.message.hint") }}
@@ -75,15 +54,12 @@

{{ (form?.title?.length ?? 0) > 0 ? form?.title : t("posts.form.preview.title") }}

-
+
- +
@@ -92,40 +68,35 @@

{{ tc("posts.form.imageupload", Object.keys(files).length) }} - ({{ convertToHumanReadableFileSize(totalBytesInFiles) }}) + ({{ + convertToHumanReadableFileSize(totalBytesInFiles) }})

- -
+ +
Dateien fallen lassen Neue Dateien hierher ziehen oder hier klicken um Dateien auszuwählen
+ + + + + +
@@ -244,6 +215,7 @@ } @keyframes shake { + 10%, 90% { transform: scale(0.9) translate3d(-1px, 0, 0); @@ -284,12 +256,14 @@ import { debounce } from "@client/debounce.js"; import { PostEndpoints } from "@client/util/api-client.js"; import { faUpload } from "@fortawesome/free-solid-svg-icons"; import { t, tc } from "@client/plugins/i18n.js"; -import type { DraftResponseDto, NewPostRequestDto, Post, Tag, SupportedInsertPositionType } from "@fumix/fu-blog-common"; +import type { DraftResponseDto, NewPostRequestDto, Post, Tag, SupportedInsertPositionType, Attachment } from "@fumix/fu-blog-common"; import { bytesToBase64URL, convertToHumanReadableFileSize } from "@fumix/fu-blog-common"; import { computed, onMounted, reactive, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import VueTagsInput from "@sipec/vue3-tags-input"; + + const tag = ref(""); const tags = ref<{ text: string; tiClasses?: string[] }[]>([]); // vue-tags-input internal format const md = ref(null); @@ -316,6 +290,7 @@ const props = defineProps({ }, }); + const totalBytesInFiles = computed(() => Object.values(files) .map((it) => it.size) @@ -340,6 +315,12 @@ onMounted(async () => { form.draft = resJson.draft; tags.value = resJson.tags ? resJson.tags?.map((tag) => ({ text: tag.name })) : []; postHasError.value = false; + + if (resJson.attachments) { + // ADD already existing attachments to files + processAttachments(resJson.attachments); + } + } catch (e) { postHasError.value = true; } @@ -351,6 +332,24 @@ onMounted(async () => { }, 1000); }); + +// Function to process attachments and populate the files object +const processAttachments = (attachments: Attachment[]) => { + attachments.forEach((attachment) => { + const blob = new Uint8Array((attachment.file.binaryData as any).data); + const file = new File([blob], attachment.filename, { type: attachment.file.mimeType }); + files[attachment.file.sha256] = file; + }); +} + +// Create a computed property to generate URLs for the files +const filesWithUrls = computed(() => { + return Object.entries(files).reduce((acc, [sha256, file]) => { + acc[sha256] = file; + return acc; + }, {} as { [sha256: string]: File }); +}); + const pasteImageFileToMarkdown = (markdown: string, insertPosition: SupportedInsertPositionType = "afterCursor") => { form.markdown = insertIntoTextarea(markdown, markdownArea.value as unknown as HTMLTextAreaElement, insertPosition); }; diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index 3c8f3e0..c3a42fe 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -89,7 +89,7 @@ router.get("/:id(\\d+$)", async (req: Request, res: Response, next) => { where: { id: +req.params.id, }, - relations: ["createdBy", "updatedBy", "tags"], + relations: ["createdBy", "updatedBy", "tags", "attachments"], }) .then((result) => { if (result === null) { @@ -151,10 +151,12 @@ router.post( }; const insertResult = await manager.getRepository(PostEntity).insert(post); + await manager.createQueryBuilder(PostEntity, "tags").relation("tags").of(post).add(tags); const attachmentEntities: AttachmentEntity[] = await Promise.all( extractUploadFiles(req).map((it) => convertAttachment(post, it)), ); + if (attachmentEntities.length > 0) { await manager .createQueryBuilder() @@ -172,6 +174,8 @@ router.post( } else { return { postId: post.id, attachments: [] }; } + + }) .catch((err) => { throw new InternalServerError(true, "Could not create post " + err); @@ -182,55 +186,6 @@ router.post( }, ); -async function getPersistedTagsForPost(post: PostEntity, bodyJson: PostRequestDto): Promise { - if (!bodyJson.stringTags || bodyJson.stringTags.length <= 0) { - return Promise.resolve([]); - } - - const tagsToUseInPost: TagEntity[] = []; - const alreadySavedTags = - bodyJson.stringTags?.length > 0 - ? await AppDataSource.manager - .getRepository(TagEntity) - .createQueryBuilder("tagEntity") - .select() - .where("tagEntity.name IN(:...names)", { names: bodyJson.stringTags }) - .getMany() - : await Promise.all([]); - tagsToUseInPost.push(...alreadySavedTags); - - const unsavedTags = bodyJson.stringTags - ?.filter((tag: string) => { - return !alreadySavedTags.some((tagEntity: TagEntity) => { - return tagEntity.name === tag; - }); - }) - .map( - (tagToSave: string) => - { - name: tagToSave, - }, - ); - - const newlySavedTags = await AppDataSource.manager.getRepository(TagEntity).save(unsavedTags); - tagsToUseInPost.push(...newlySavedTags); - - return tagsToUseInPost.filter((value) => value !== null && value !== undefined); -} - -// autocompletion suggestions (fuzzy search) for tags -router.get("/tags/:search", async (req: Request, res: Response, next) => { - await AppDataSource.manager - .getRepository(TagEntity) - .createQueryBuilder() - .select() - .where("SIMILARITY(name, :search) > 0.3", { search: req.params.search }) - .orderBy("SIMILARITY(name, '" + req.params.id + "')", "DESC") - .limit(5) - .getMany() - .then((result) => res.status(200).json({ data: result })) - .catch(next); -}); // EDIT EXISTING POST router.post("/:id(\\d+$)", authMiddleware, multipleFilesUpload, async (req: Request, res: Response, next) => { @@ -271,13 +226,31 @@ router.post("/:id(\\d+$)", authMiddleware, multipleFilesUpload, async (req: Requ draft: body.draft, // tags: tagsToUseInPost, }) - .then((updateResult) => { - // TODO: Optimize, so unchanged attachments are not deleted and re-added + .then(async (updateResult) => { + manager.getRepository(AttachmentEntity).delete({ post: { id: post.id } }); - // manager.getRepository(AttachmentEntity).insert(extractUploadFiles(req).map((it) => convertAttachment(post, it))); - // tagsToUseInPost.forEach((tag) => { - // manager.getRepository(PostEntity).createQueryBuilder().relation(PostEntity, "tags").add(tag); - // }); + + const attachmentEntities: AttachmentEntity[] = await Promise.all( + extractUploadFiles(req).map((it) => convertAttachment(post, it)), + ); + + if (attachmentEntities.length > 0) { + await manager + .createQueryBuilder() + .insert() + .into(FileEntity) + .values(attachmentEntities.map((it) => it.file)) + .onConflict('("sha256") DO NOTHING') + .execute(); + return await manager + .getRepository(AttachmentEntity) + .insert(attachmentEntities) + .then((it) => { + return { postId: post.id, attachments: attachmentEntities }; + }); + } else { + return { postId: post.id, attachments: [] }; + } }) .catch(next); // many to many cant be updated so we have to save again @@ -295,6 +268,58 @@ router.post("/:id(\\d+$)", authMiddleware, multipleFilesUpload, async (req: Requ .catch(next); }); +async function getPersistedTagsForPost(post: PostEntity, bodyJson: PostRequestDto): Promise { + if (!bodyJson.stringTags || bodyJson.stringTags.length <= 0) { + return Promise.resolve([]); + } + + const tagsToUseInPost: TagEntity[] = []; + const alreadySavedTags = + bodyJson.stringTags?.length > 0 + ? await AppDataSource.manager + .getRepository(TagEntity) + .createQueryBuilder("tagEntity") + .select() + .where("tagEntity.name IN(:...names)", { names: bodyJson.stringTags }) + .getMany() + : await Promise.all([]); + tagsToUseInPost.push(...alreadySavedTags); + + const unsavedTags = bodyJson.stringTags + ?.filter((tag: string) => { + return !alreadySavedTags.some((tagEntity: TagEntity) => { + return tagEntity.name === tag; + }); + }) + .map( + (tagToSave: string) => + { + name: tagToSave, + }, + ); + + const newlySavedTags = await AppDataSource.manager.getRepository(TagEntity).save(unsavedTags); + tagsToUseInPost.push(...newlySavedTags); + + return tagsToUseInPost.filter((value) => value !== null && value !== undefined); +} + +// autocompletion suggestions (fuzzy search) for tags +router.get("/tags/:search", async (req: Request, res: Response, next) => { + await AppDataSource.manager + .getRepository(TagEntity) + .createQueryBuilder() + .select() + .where("SIMILARITY(name, :search) > 0.3", { search: req.params.search }) + .orderBy("SIMILARITY(name, '" + req.params.id + "')", "DESC") + .limit(5) + .getMany() + .then((result) => res.status(200).json({ data: result })) + .catch(next); +}); + + + // DELETE POST router.get("/delete/:id(\\d+$)", async (req: Request, res: Response, next) => { await AppDataSource.manager.transaction(async (manager) => { @@ -324,6 +349,7 @@ router.get("/:id(\\d+)/og-image", async (req: Request, res: Response, next) => { .findOne({ where: { id: +req.params.id } }) .then((post) => { if (post && post.id) { + console.log("Generating share image for post", post.id); res.status(200).write(generateShareImage(post.title, post.createdAt)); res.end(); } else { From 7f6b738f9cc784fda2eaed89ede14c9d4c0529f0 Mon Sep 17 00:00:00 2001 From: Thomas Haaf Date: Thu, 13 Jun 2024 11:34:06 +0200 Subject: [PATCH 03/21] minor cleanup --- client/src/views/PostFormView.vue | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/client/src/views/PostFormView.vue b/client/src/views/PostFormView.vue index 745fbf5..292c4cd 100644 --- a/client/src/views/PostFormView.vue +++ b/client/src/views/PostFormView.vue @@ -86,17 +86,6 @@ @delete="delete files[hash]"> - - - From a43f944958d8179047fc8bcd2556ec9b198c4eb0 Mon Sep 17 00:00:00 2001 From: Thomas Haaf Date: Thu, 13 Jun 2024 12:03:07 +0200 Subject: [PATCH 04/21] fix image deletion remove img markdown from textarea after deletion of image minor cleanup --- client/src/views/PostFormView.vue | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/src/views/PostFormView.vue b/client/src/views/PostFormView.vue index 292c4cd..caf34d3 100644 --- a/client/src/views/PostFormView.vue +++ b/client/src/views/PostFormView.vue @@ -83,7 +83,7 @@ + @delete="removeImageFileFromMarkdown(files[hash]); delete files[hash]"> @@ -343,6 +343,15 @@ const pasteImageFileToMarkdown = (markdown: string, insertPosition: SupportedIns form.markdown = insertIntoTextarea(markdown, markdownArea.value as unknown as HTMLTextAreaElement, insertPosition); }; +const removeImageFileFromMarkdown = (file: File) => { + const markDownBeforeRemove = form.markdown; + const strToRemove = `![${file.name}](${Object.keys(files).find((key) => files[key] === file)})`; + // Giving the textarea time to update the value, otherwise the last image deletion will not update the preview! + setTimeout(() => { + form.markdown = markDownBeforeRemove.replace(strToRemove, ""); + }, 0); +} + const dropMarkdown = (evt: DragEvent) => { const items = evt.dataTransfer?.items; const textArea = evt.target as HTMLTextAreaElement; From ba328d38ffb8d25d3ee38fc635b2522c93ba6b37 Mon Sep 17 00:00:00 2001 From: Thomas Haaf Date: Thu, 13 Jun 2024 13:00:33 +0200 Subject: [PATCH 05/21] fix lint issues --- client/src/views/PostFormView.vue | 99 +++++++++++++++++++++---------- server/src/routes/posts.ts | 16 ++--- 2 files changed, 73 insertions(+), 42 deletions(-) diff --git a/client/src/views/PostFormView.vue b/client/src/views/PostFormView.vue index caf34d3..f95749c 100644 --- a/client/src/views/PostFormView.vue +++ b/client/src/views/PostFormView.vue @@ -11,27 +11,48 @@
- +
- +
- +
- +
{{ t("posts.form.message.hint") }}
@@ -54,12 +75,15 @@

{{ (form?.title?.length ?? 0) > 0 ? form?.title : t("posts.form.preview.title") }}

-
+
- +
@@ -68,22 +92,41 @@

{{ tc("posts.form.imageupload", Object.keys(files).length) }} - ({{ - convertToHumanReadableFileSize(totalBytesInFiles) }}) + ({{ convertToHumanReadableFileSize(totalBytesInFiles) }})

- -
+ +
Dateien fallen lassen Neue Dateien hierher ziehen oder hier klicken um Dateien auszuwählen
- +
@@ -204,7 +247,6 @@ } @keyframes shake { - 10%, 90% { transform: scale(0.9) translate3d(-1px, 0, 0); @@ -251,8 +293,6 @@ import { computed, onMounted, reactive, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import VueTagsInput from "@sipec/vue3-tags-input"; - - const tag = ref(""); const tags = ref<{ text: string; tiClasses?: string[] }[]>([]); // vue-tags-input internal format const md = ref(null); @@ -279,7 +319,6 @@ const props = defineProps({ }, }); - const totalBytesInFiles = computed(() => Object.values(files) .map((it) => it.size) @@ -309,7 +348,6 @@ onMounted(async () => { // ADD already existing attachments to files processAttachments(resJson.attachments); } - } catch (e) { postHasError.value = true; } @@ -321,7 +359,6 @@ onMounted(async () => { }, 1000); }); - // Function to process attachments and populate the files object const processAttachments = (attachments: Attachment[]) => { attachments.forEach((attachment) => { @@ -329,7 +366,7 @@ const processAttachments = (attachments: Attachment[]) => { const file = new File([blob], attachment.filename, { type: attachment.file.mimeType }); files[attachment.file.sha256] = file; }); -} +}; // Create a computed property to generate URLs for the files const filesWithUrls = computed(() => { @@ -350,7 +387,7 @@ const removeImageFileFromMarkdown = (file: File) => { setTimeout(() => { form.markdown = markDownBeforeRemove.replace(strToRemove, ""); }, 0); -} +}; const dropMarkdown = (evt: DragEvent) => { const items = evt.dataTransfer?.items; diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index c3a42fe..ad6607a 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -174,8 +174,6 @@ router.post( } else { return { postId: post.id, attachments: [] }; } - - }) .catch((err) => { throw new InternalServerError(true, "Could not create post " + err); @@ -186,7 +184,6 @@ router.post( }, ); - // EDIT EXISTING POST router.post("/:id(\\d+$)", authMiddleware, multipleFilesUpload, async (req: Request, res: Response, next) => { const postId = +req.params.id; @@ -227,7 +224,6 @@ router.post("/:id(\\d+$)", authMiddleware, multipleFilesUpload, async (req: Requ // tags: tagsToUseInPost, }) .then(async (updateResult) => { - manager.getRepository(AttachmentEntity).delete({ post: { id: post.id } }); const attachmentEntities: AttachmentEntity[] = await Promise.all( @@ -277,11 +273,11 @@ async function getPersistedTagsForPost(post: PostEntity, bodyJson: PostRequestDt const alreadySavedTags = bodyJson.stringTags?.length > 0 ? await AppDataSource.manager - .getRepository(TagEntity) - .createQueryBuilder("tagEntity") - .select() - .where("tagEntity.name IN(:...names)", { names: bodyJson.stringTags }) - .getMany() + .getRepository(TagEntity) + .createQueryBuilder("tagEntity") + .select() + .where("tagEntity.name IN(:...names)", { names: bodyJson.stringTags }) + .getMany() : await Promise.all([]); tagsToUseInPost.push(...alreadySavedTags); @@ -318,8 +314,6 @@ router.get("/tags/:search", async (req: Request, res: Response, next) => { .catch(next); }); - - // DELETE POST router.get("/delete/:id(\\d+$)", async (req: Request, res: Response, next) => { await AppDataSource.manager.transaction(async (manager) => { From a68ba3c3e33af14ca2f4d98b054ff2dffcd21cc4 Mon Sep 17 00:00:00 2001 From: Thomas Haaf Date: Thu, 13 Jun 2024 15:28:34 +0200 Subject: [PATCH 06/21] fix image upload --- client/src/components/ImagePreview.vue | 93 +++++++++++++---- client/src/views/PostFormView.vue | 134 ++++++++++++++++--------- 2 files changed, 159 insertions(+), 68 deletions(-) diff --git a/client/src/components/ImagePreview.vue b/client/src/components/ImagePreview.vue index 51ddbbc..d0b4ad0 100644 --- a/client/src/components/ImagePreview.vue +++ b/client/src/components/ImagePreview.vue @@ -1,40 +1,88 @@ diff --git a/client/src/views/PostFormView.vue b/client/src/views/PostFormView.vue index f95749c..7f76fda 100644 --- a/client/src/views/PostFormView.vue +++ b/client/src/views/PostFormView.vue @@ -62,6 +62,54 @@
+
+

+ {{ tc("posts.form.imageupload", Object.keys(files).length) }} + + ({{ convertToHumanReadableFileSize(totalBytesInFiles) }}) + +

+ + + +
+
+ + + + +
+
+ +
+
+ Dateien fallen lassen + Neue Dateien hierher ziehen oder hier klicken um Dateien auszuwählen +
+
+ @@ -103,7 +103,7 @@ diff --git a/client/src/i18n/de.json b/client/src/i18n/de.json index 2338ebb..9e9ef67 100644 --- a/client/src/i18n/de.json +++ b/client/src/i18n/de.json @@ -23,7 +23,10 @@ "save": "Speichern", "cancel": "Abbrechen", "edit": "Bearbeiten", + "insert": "Einfügen", "delete": "Löschen", + "delete_image": "Bild löschen", + "remove": "Bildrefernz aus Text entfernen", "read": "Lesen", "create_post": "Post erstellen", "roles": "Rollen", diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index 0a73092..5ea9216 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -23,7 +23,9 @@ "save": "Save", "cancel": "Cancel", "edit": "Edit", - "delete": "Delete", + "insert": "Insert", + "delete_image": "Delete image", + "remove": "Remove image reference from text", "read": "Read", "create_post": "Create Post", "roles": "Roles", @@ -66,7 +68,7 @@ "not-available": { "title": "Post not available", "message": "This post is not available or has been deleted. You can find all posts in the overview, or use the search function to find a specific post." - } + } } }, "admin": { @@ -102,4 +104,4 @@ }, "user_not_found": "User not found." } -} +} \ No newline at end of file diff --git a/client/src/views/PostFormView.vue b/client/src/views/PostFormView.vue index 05413b4..0254175 100644 --- a/client/src/views/PostFormView.vue +++ b/client/src/views/PostFormView.vue @@ -50,7 +50,6 @@ ref="markdownArea" style="height: 40vh; min-height: 200px" aria-describedby="markdownHelp" - v-on:drop="dropMarkdown($event)" required > @@ -90,6 +89,7 @@ removeImageFileFromMarkdown(files[hash]); delete files[hash]; " + @softdelete="removeImageFileFromMarkdown(files[hash])" > @@ -417,32 +417,6 @@ const removeImageFileFromMarkdown = (file: File) => { }, 0); }; -const dropMarkdown = (evt: DragEvent) => { - const items = evt.dataTransfer?.items; - const textArea = evt.target as HTMLTextAreaElement; - if (items && textArea) { - for (const item of items) { - // evt.preventDefault(); - // We cannot use preventDefault(), because we will be unable to get the cursor position to drop to. - // instead we have to pase everything and remove the base64 string afterwards - if (item.kind === "string") { - if (item.type === "text/markdown") { - item.getAsString((markdown_img_link) => { - form.markdown = insertIntoTextarea(markdown_img_link, textArea, "beforeCursor"); - }); - } else { - // Remove base64 string from drop events default behaviour - item.getAsString((str) => { - setTimeout(() => { - form.markdown = form.markdown.replace(str, ""); - }, 0); - }); - } - } - } - } -}; - const openFileDialog = (): void => { document.getElementById("file")?.click(); }; From 0f7b8591cf51d9161bedf3b237f0c92be24fe452 Mon Sep 17 00:00:00 2001 From: Thomas Haaf Date: Fri, 14 Jun 2024 17:34:10 +0200 Subject: [PATCH 11/21] cleanup drag/drop images --- client/src/components/ImagePreview.vue | 46 +++++++++++++++++++++++--- client/src/i18n/de.json | 5 +-- client/src/i18n/en.json | 3 +- client/src/views/PostFormView.vue | 9 ++++- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/client/src/components/ImagePreview.vue b/client/src/components/ImagePreview.vue index d7eff20..99d2a92 100644 --- a/client/src/components/ImagePreview.vue +++ b/client/src/components/ImagePreview.vue @@ -1,5 +1,6 @@