diff --git a/client/package.json b/client/package.json index 8176782..08b0b3a 100644 --- a/client/package.json +++ b/client/package.json @@ -23,6 +23,7 @@ "buffer": "^6.0.3", "luxon": "^3.3.0", "stream-browserify": "^3.0.0", + "throttle-debounce": "^5.0.0", "vue": "^3.2.45", "vue-i18n": "^9.2.2", "vue-router": "^4.1.5", @@ -35,6 +36,7 @@ "@types/bootstrap": "^5.2.6", "@types/luxon": "^3.2.0", "@types/node": "^18.15.1", + "@types/throttle-debounce": "^5.0.0", "@vitejs/plugin-vue": "^4.1.0", "@vue/eslint-config-prettier": "^7.1.0", "@vue/eslint-config-typescript": "^11.0.0", diff --git a/client/src/debounce.ts b/client/src/debounce.ts deleted file mode 100644 index 4f43efb..0000000 --- a/client/src/debounce.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ComputedRef, Ref } from "vue"; -import { computed, ref, watchEffect } from "vue"; - -export const debounce = (callback: () => T, timeout = 500): ComputedRef => { - let isFirstRun = false; // < don't use a ref, otherwise it will loop indefinitely - const current = ref(null as unknown as T) as Ref; - let lastTimeout: number | null = null; - - watchEffect(() => { - const newValue = callback(); - // First call: Set immediately - if (isFirstRun) { - current.value = newValue; - isFirstRun = false; - } else { - if (lastTimeout !== null) { - clearTimeout(lastTimeout); - } - lastTimeout = window.setTimeout(() => { - current.value = newValue; - }, Math.max(timeout, 1)); - } - }); - return computed(() => current.value); -}; diff --git a/client/src/types/throttle-debounce.d.ts b/client/src/types/throttle-debounce.d.ts new file mode 100644 index 0000000..5526786 --- /dev/null +++ b/client/src/types/throttle-debounce.d.ts @@ -0,0 +1 @@ +declare module "throttle-debounce"; diff --git a/client/src/views/PostFormView.vue b/client/src/views/PostFormView.vue index 4beb37b..b689d68 100644 --- a/client/src/views/PostFormView.vue +++ b/client/src/views/PostFormView.vue @@ -4,7 +4,7 @@
-
+
@@ -50,7 +50,7 @@
- +
@@ -266,7 +266,6 @@ import ImagePreview from "@client/components/ImagePreview.vue"; import LoadingSpinner from "@client/components/LoadingSpinner.vue"; import MarkDown from "@client/components/MarkDown.vue"; import PostNotAvailable from "@client/components/PostNotAvailable.vue"; -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 "@fumix/fu-blog-client/src/plugins/i18n.js"; @@ -274,6 +273,7 @@ import type { DraftResponseDto, NewPostRequestDto, Post, Tag } from "@fumix/fu-b import { bytesToBase64URL, convertToHumanReadableFileSize } from "@fumix/fu-blog-common"; import { computed, onMounted, reactive, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; +import { debounce } from "throttle-debounce"; import VueTagsInput from "@sipec/vue3-tags-input"; const tag = ref(""); @@ -285,6 +285,8 @@ const dropzoneHighlight = ref(false); const router = useRouter(); const markdownArea = ref(null); const postHasError = ref(false); +const autoSavedDraftId = ref(undefined); +const canBeAutosaved = ref(true); const tagList = ref([]); const form = reactive({ @@ -326,15 +328,17 @@ onMounted(async () => { form.draft = resJson.draft; tags.value = resJson.tags ? resJson.tags?.map((tag) => ({ text: tag.name })) : []; postHasError.value = false; + // avoid autosaving in edit mode, except if its still a draft + canBeAutosaved.value = resJson.draft; } catch (e) { postHasError.value = true; } } - debounce(() => { + debounce(1000, () => { loading.value = true; md.value = form.markdown; - }, 1000); + }); }); const pasteImageFileToMarkdown = (markdown: string) => { @@ -444,9 +448,14 @@ const addFile = (file: File) => { .catch((it) => console.error("Failed to calculate SHA-256 hash!")); }; -const submitForm = (e: Event) => { - e.preventDefault(); - send(props.postId); +const handleAutoSave = debounce(1000, () => { + if (canBeAutosaved.value) { + send(props.postId, true); + } +}); + +const submitForm = () => { + send(props.postId, false); }; const insertIntoTextarea = ( @@ -462,10 +471,19 @@ const insertIntoTextarea = ( return before + insertedText + after; }; -const send = async (id: number | undefined) => { +const send = async (id: number | undefined, autosave: boolean) => { + form.draft = autosave; const successAction = (r: DraftResponseDto) => { - router.push(`/posts/post/${r.postId}`); + if (!autosave) { + router.push(`/posts/post/${r.postId}`); + } else { + // no routing when autosaving + autoSavedDraftId.value = r.postId; + } }; + if (autoSavedDraftId.value) { + id = autoSavedDraftId.value; + } if (!id) { await PostEndpoints.createPost(form, Object.values(files)) .then(successAction) diff --git a/package-lock.json b/package-lock.json index acedfcd..c74ce56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fumix/fu-blog", - "version": "0.1.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fumix/fu-blog", - "version": "0.1.0", + "version": "0.2.1", "license": "Apache-2.0", "workspaces": [ "common", @@ -31,7 +31,7 @@ }, "client": { "name": "@fumix/fu-blog-client", - "version": "0.1.0", + "version": "0.2.1", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.3.0", "@fortawesome/free-brands-svg-icons": "^6.3.0", @@ -44,6 +44,7 @@ "buffer": "^6.0.3", "luxon": "^3.3.0", "stream-browserify": "^3.0.0", + "throttle-debounce": "^5.0.0", "vue": "^3.2.45", "vue-i18n": "^9.2.2", "vue-router": "^4.1.5", @@ -56,6 +57,7 @@ "@types/bootstrap": "^5.2.6", "@types/luxon": "^3.2.0", "@types/node": "^18.15.1", + "@types/throttle-debounce": "^5.0.0", "@vitejs/plugin-vue": "^4.1.0", "@vue/eslint-config-prettier": "^7.1.0", "@vue/eslint-config-typescript": "^11.0.0", @@ -106,7 +108,7 @@ }, "common": { "name": "@fumix/fu-blog-common", - "version": "0.1.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "dompurify": "^3.0.1", @@ -3155,6 +3157,12 @@ "@types/node": "*" } }, + "node_modules/@types/throttle-debounce": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.0.tgz", + "integrity": "sha512-Pb7k35iCGFcGPECoNE4DYp3Oyf2xcTd3FbFQxXUI9hEYKUl6YX+KLf7HrBmgVcD05nl50LIH6i+80js4iYmWbw==", + "dev": true + }, "node_modules/@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", @@ -13586,6 +13594,14 @@ "node": ">=0.8" } }, + "node_modules/throttle-debounce": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.0.tgz", + "integrity": "sha512-2iQTSgkkc1Zyk0MeVrt/3BvuOXYPl/R8Z0U2xxo9rjwNciaHDG3R+Lm6dh4EeUci49DanvBnuqI6jshoQQRGEg==", + "engines": { + "node": ">=12.22" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -14994,7 +15010,7 @@ }, "portal": { "name": "@fumix/fu-blog-portal", - "version": "0.1.0", + "version": "0.2.1", "dependencies": { "@fumix/fu-blog-server": "*", "ts-node": "^10.9.1", @@ -15007,7 +15023,7 @@ }, "server": { "name": "@fumix/fu-blog-server", - "version": "0.1.0", + "version": "0.2.1", "dependencies": { "@fumix/fu-blog-common": "*", "canvas": "^2.11.0", diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index 21c6707..ac048e2 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -24,13 +24,10 @@ router.get("/page/:page([0-9]+)/count/:count([0-9]+)/search/:search/operator/:op const skipEntries = page * itemsPerPage - itemsPerPage; let searchTerm = ""; if (req.params.search) { - const splitSearchParams: string[] = req.params.search.trim().split(" "); + const splitSearchParams: string[] = decodeURI(req.params.search).trim().split(" "); const operator = req.params.operator === "or" ? " | " : " & "; - searchTerm = splitSearchParams - .map((word) => escape(word)) - .filter(Boolean) - .join(operator); + searchTerm = splitSearchParams.filter(Boolean).join(operator); } // TODO : add createdBy and tags to search results