Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autosave rework #97

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions client/src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"back": "Zurück",
"close": "Schließen",
"save": "Speichern",
"publish": "Veröffentlichen",
"savedraft": "Als Entwurf speichern",
"cancel": "Abbrechen",
"edit": "Bearbeiten",
"delete": "Löschen",
Expand All @@ -29,7 +31,9 @@
"roles": "Rollen",
"login": "Anmelden",
"githubLinkText": "Die Blog-Software auf GitHub",
"startpage": "Startseite"
"startpage": "Startseite",
"restore": "Wiederherstellen",
"discard": "Verwerfen"
}
},
"nav": {
Expand Down Expand Up @@ -59,7 +63,8 @@
"tags": "Schlagworte",
"enter": "Geben Sie Schlagworte ein..."
},
"imageupload": "keine angehängten Bilder | ein angehängtes Bild | {count} angehängte Bilder"
"imageupload": "keine angehängten Bilder | ein angehängtes Bild | {count} angehängte Bilder",
"restore": "Unveröffentlichte Version gefunden"
},
"confirm": {
"title": "Löschen bestätigen",
Expand All @@ -69,7 +74,7 @@
"not-available": {
"title": "Post nicht vorhanden",
"message": "Der Post ist nicht verfügbar oder wurde gelöscht. Sie können mit dem Button zur Startseite zurück, oder nutzen sie die Suchfunktion um einen bestimmten Post zu finden."
}
}
}
},
"admin": {
Expand Down
11 changes: 8 additions & 3 deletions client/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"back": "Back",
"close": "Close",
"save": "Save",
"publish": "Publish",
"savedraft": "Save draft",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
Expand All @@ -29,7 +31,9 @@
"roles": "Roles",
"login": "Login",
"githubLinkText": "The blog software on GitHub",
"startpage": "Startpage"
"startpage": "Startpage",
"restore": "Restore",
"discard": "Discard"
}
},
"nav": {
Expand All @@ -56,7 +60,8 @@
"title": "Preview"
},
"imageupload": "Upload image",
"tags": "Tags"
"tags": "Tags",
"restore": "Unpublished version found"
},
"confirm": {
"title": "Confirm deletion",
Expand All @@ -66,7 +71,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": {
Expand Down
21 changes: 12 additions & 9 deletions client/src/util/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { loadIdToken, updateIdToken } from "@client/util/storage.js";
import type {
AiSummaryData,
DataUrl,
DraftResponseDto,
SavePostResponseDto,
EditPostRequestDto,
JsonMimeType,
NewPostRequestDto,
Expand All @@ -19,7 +19,7 @@ async function callServer<
ResponseType = ResponseMimeType extends JsonMimeType ? any : ResponseMimeType extends SupportedImageMimeType ? ArrayBuffer : any,
>(
url: ApiUrl,
method: "GET" | "POST",
method: "GET" | "POST" | "DELETE" | "PUT",
responseType: ResponseMimeType,
payload: ApiRequestJsonPayload<RequestType> | null = null,
authenticated = true,
Expand Down Expand Up @@ -113,26 +113,29 @@ export class OpenAiEndpoints {
}

export class PostEndpoints {
static async createPostWithoutFiles(json: NewPostRequestDto): Promise<DraftResponseDto> {
return callServer<NewPostRequestDto, JsonMimeType, DraftResponseDto>("/api/posts/new", "POST", "application/json", { json: json });
static async createPostWithoutFiles(json: NewPostRequestDto): Promise<SavePostResponseDto> {
return callServer<NewPostRequestDto, JsonMimeType, SavePostResponseDto>("/api/posts/new", "POST", "application/json", { json: json });
}
static async createPost(json: NewPostRequestDto, files: File[]): Promise<DraftResponseDto> {
return callServer<NewPostRequestDto, JsonMimeType, DraftResponseDto>(
static async createPost(json: NewPostRequestDto, files: File[]): Promise<SavePostResponseDto> {
return callServer<NewPostRequestDto, JsonMimeType, SavePostResponseDto>(
"/api/posts/new",
"POST",
"application/json",
new ApiRequestJsonPayloadWithFiles(json, files),
);
}
static async editPost(json: EditPostRequestDto, files: File[]): Promise<DraftResponseDto> {
return callServer<EditPostRequestDto, JsonMimeType, DraftResponseDto>(
static async editPost(json: EditPostRequestDto, files: File[]): Promise<SavePostResponseDto> {
return callServer<EditPostRequestDto, JsonMimeType, SavePostResponseDto>(
`/api/posts/${json.id}`,
"POST",
"application/json",
new ApiRequestJsonPayloadWithFiles(json, files),
);
}
static async deletePost(id: number): Promise<{ affected: number }> {
return callServer<void, JsonMimeType, { affected: number }>(`/api/posts/delete/${id}`, "POST", "application/json");
return callServer<void, JsonMimeType, { affected: number }>(`/api/posts/${id}`, "DELETE", "application/json");
}
static async deleteAutosave(id: number): Promise<{ affected: number }> {
return callServer<void, JsonMimeType, { affected: number }>(`/api/posts/autosave/${id}`, "DELETE", "application/json");
}
}
114 changes: 97 additions & 17 deletions client/src/views/PostFormView.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
<template>
<div v-if="!postHasError" class="container">
<div v-if="hasAutosave" class="row mb-2 collapse show" id="collapseTarget">
<div class="col w-50">
<div class="alert alert-warning">
{{ t("posts.form.restore") }}
<button
type="button"
class="btn btn-sm btn-primary mx-3"
data-bs-toggle="collapse"
data-bs-target="#collapseTarget"
aria-expanded="true"
aria-controls="collapseTarget"
@click="restore"
>
{{ t("app.base.restore") }}
</button>
<button
type="button"
class="btn btn-sm btn-danger"
data-bs-toggle="collapse"
data-bs-target="#collapseTarget"
aria-expanded="true"
aria-controls="collapseTarget"
@click="discard"
>
{{ t("app.base.discard") }}
</button>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col w-50">
<div class="card flex-md-row mb-4 box-shadow h-md-250">
<div class="card-body">
<form @submit="submitForm($event)">
<form @submit.prevent="submitForm" @change="handleAutoSave">
<div class="form-floating mb-3">
<input v-model="form.title" type="text" class="form-control" id="title" placeholder="Titel" required />
<label for="title">{{ t("posts.form.title") }}</label>
Expand Down Expand Up @@ -57,13 +86,11 @@
<div id="markdownHelp" class="form-text">{{ t("posts.form.message.hint") }}</div>
</div>

<div class="form-check form-switch">
<input v-model="form.draft" class="form-check-input" type="checkbox" id="draft" />
<label class="form-check-label" for="draft">{{ t("posts.form.draft") }}</label>
</div>

<button type="submit" class="btn btn-sm btn-primary float-end">{{ t("app.base.save") }}</button>
<button type="button" class="btn btn-sm btn-secondary float-end mx-3" @click="router.go(-1)">
<button type="submit" class="btn btn-sm btn-primary float-end">{{ t("app.base.publish") }}</button>
<button type="button" class="btn btn-sm btn-info float-end mx-3" @click="saveDraft">
{{ t("app.base.savedraft") }}
</button>
<button type="button" class="btn btn-sm btn-secondary float-end" @click="router.go(-1)">
{{ t("app.base.cancel") }}
</button>
</form>
Expand Down Expand Up @@ -284,8 +311,15 @@ 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 { bytesToBase64URL, convertToHumanReadableFileSize } from "@fumix/fu-blog-common";
import type {
SavePostResponseDto,
NewPostRequestDto,
Post,
Tag,
SupportedInsertPositionType,
EditPostRequestDto,
} from "@fumix/fu-blog-common";
import { bytesToBase64URL, convertToHumanReadableFileSize, isNeitherNullNorUndefined } 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";
Expand All @@ -300,13 +334,16 @@ const router = useRouter();
const markdownArea = ref(null);
const postHasError = ref<boolean>(false);
const tagList = ref<Tag[]>([]);
const foundAutosave = ref<Post | null>(null);
const hasAutosave = ref<boolean>(false);

const form = reactive<NewPostRequestDto>({
title: "",
description: "",
markdown: "",
draft: false,
stringTags: [],
autosave: false,
});

const props = defineProps({
Expand Down Expand Up @@ -340,9 +377,15 @@ onMounted(async () => {
form.draft = resJson.draft;
tags.value = resJson.tags ? resJson.tags?.map((tag) => ({ text: tag.name })) : [];
postHasError.value = false;
let findAutosaveResponse = await fetch(`/api/posts/autosave/${props.postId}`);
foundAutosave.value = (await findAutosaveResponse.json())?.data as Post;
hasAutosave.value = foundAutosave.value !== null;
} catch (e) {
postHasError.value = true;
}
} else {
// foundAutosave.value = ref<Post>((await (await fetch(`/api/posts/autosave`)).json())?.data as Post);
// console.log("found autosave " + JSON.stringify(foundAutosave.value));
}

debounce(() => {
Expand Down Expand Up @@ -404,6 +447,26 @@ const handleFileChange = (e: Event): void => {
}
};

const discard = async () => {
const postToDiscard = foundAutosave.value as Post;
if (postToDiscard?.id) {
await PostEndpoints.deleteAutosave(postToDiscard.id).catch((reason) => console.log("failed to delete autosave", reason));
}
};

const restore = () => {
if (foundAutosave.value) {
// restore autosaved post to the form
const toRestore = foundAutosave.value as Post;
form.draft = toRestore.draft;
form.title = toRestore.title;
form.stringTags = toRestore.tags ? toRestore.tags?.map((tag) => tag.name).filter(isNeitherNullNorUndefined) : [];
tags.value = toRestore.tags ? toRestore.tags?.map((tag) => ({ text: tag.name })) : [];
form.markdown = toRestore.markdown;
form.description = toRestore.description;
}
};

const handleTagsChanged = (newTags: any[]) => {
tags.value = newTags;
};
Expand Down Expand Up @@ -449,6 +512,11 @@ const setDescription = (description: string) => {
form.description = description;
};

const handleAutoSave = () =>
debounce(() => {
send(props.postId, true);
}, 1000);

const setKeyvisual = (base64Str: string) => {
fetch(base64Str)
.then((res) => res.blob())
Expand All @@ -472,9 +540,13 @@ const addFile = (file: File) => {
.catch((it) => console.error("Failed to calculate SHA-256 hash!"));
};

const submitForm = (e: Event) => {
e.preventDefault();
send(props.postId);
const submitForm = () => {
send(props.postId, false);
};

const saveDraft = () => {
form.draft = true;
send(props.postId, false);
};

const insertIntoTextarea = (
Expand All @@ -491,16 +563,24 @@ const insertIntoTextarea = (
return before + insertedText + after;
};

const send = async (id: number | undefined) => {
const successAction = (r: DraftResponseDto) => {
router.push(`/posts/post/${r.postId}`);
const send = async (id: number | undefined, shouldAutosave: boolean) => {
const successAction = (r: SavePostResponseDto) => {
if (shouldAutosave) {
// TODO: display draft saved banner
console.log("auto-saving...");
} else {
router.push(`/posts/post/${r.postId}`);
}
};
if (!id) {
form.autosave = shouldAutosave;
await PostEndpoints.createPost(form, Object.values(files))
.then(successAction)
.catch((reason) => console.log("Create post request failed", reason));
} else {
await PostEndpoints.editPost(Object.assign(form, { id }), Object.values(files))
let editRequest = Object.assign(form, { id }) as EditPostRequestDto;
editRequest.autosave = shouldAutosave;
await PostEndpoints.editPost(editRequest, Object.values(files))
.then(successAction)
.catch((reason) => console.log("Edit post request failed", reason));
}
Expand Down
10 changes: 4 additions & 6 deletions client/src/views/PostView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import { useSeoMeta } from "@unhead/vue";
import { onMounted, type PropType, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { PostEndpoints } from "@client/util/api-client";

const { t } = useI18n();
const loading = ref(true);
Expand Down Expand Up @@ -131,13 +132,10 @@ onMounted(async () => {
});

const deletePost = async (post: Post) => {
try {
const res = await fetch(`/api/posts/delete/${post.id}`);
await res.json();
router.push("/posts");
} catch (e) {
console.log("ERROR: ", e);
if (post.id) {
const res = await PostEndpoints.deletePost(post.id).catch((reason) => console.log("failed to delete autosave", reason));
}
router.push("/posts");
};

const showConfirm = (post: Post | null) => {
Expand Down
Loading