consolidate most of post edit/create

This commit is contained in:
Michael Freno
2025-12-20 23:18:05 -05:00
parent 8e7a93e762
commit 268841fb4d
6 changed files with 444 additions and 663 deletions

View File

@@ -628,6 +628,7 @@ a.hover-underline-animation:hover::after {
ul,
ol {
padding: 0 1rem;
margin-left: 2rem;
}
h1,
@@ -654,7 +655,7 @@ a.hover-underline-animation:hover::after {
hr {
border: none;
border-top: 2px solid rgba(#0d0d0d, 0.1);
border-top: 2px solid var(--color-text);
margin: 2rem 0;
}

View File

@@ -0,0 +1,404 @@
import { Show, createSignal, createEffect, onCleanup } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { api } from "~/lib/api";
import { debounce } from "es-toolkit";
import Dropzone from "~/components/blog/Dropzone";
import TextEditor from "~/components/blog/TextEditor";
import TagMaker from "~/components/blog/TagMaker";
import AddAttachmentSection from "~/components/blog/AddAttachmentSection";
import XCircle from "~/components/icons/XCircle";
import AddImageToS3 from "~/lib/s3upload";
interface PostFormProps {
mode: "create" | "edit";
postId?: number;
initialData?: {
title: string;
subtitle: string;
body: string;
banner_photo: string;
published: boolean;
tags: string[];
};
userID: number;
}
export default function PostForm(props: PostFormProps) {
const navigate = useNavigate();
const [title, setTitle] = createSignal(props.initialData?.title || "");
const [subtitle, setSubtitle] = createSignal(
props.initialData?.subtitle || ""
);
const [body, setBody] = createSignal(props.initialData?.body || "");
const [bannerPhoto, setBannerPhoto] = createSignal(
props.initialData?.banner_photo || ""
);
const [bannerImageFile, setBannerImageFile] = createSignal<File>();
const [bannerImageHolder, setBannerImageHolder] = createSignal<
string | ArrayBuffer | null
>(null);
const [requestedDeleteImage, setRequestedDeleteImage] = createSignal(false);
const [published, setPublished] = createSignal(
props.initialData?.published || false
);
const [tags, setTags] = createSignal<string[]>(props.initialData?.tags || []);
const [tagInputValue, setTagInputValue] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
const [showAutoSaveMessage, setShowAutoSaveMessage] = createSignal(false);
const [isInitialLoad, setIsInitialLoad] = createSignal(props.mode === "edit");
const [initialBody, setInitialBody] = createSignal<string | undefined>(
props.initialData?.body
);
const [hasSaved, setHasSaved] = createSignal(props.mode === "edit");
// Mark initial load as complete after data is loaded (for edit mode)
createEffect(() => {
if (props.mode === "edit" && props.initialData) {
setIsInitialLoad(false);
}
});
const autoSave = async () => {
const titleVal = title();
if (titleVal) {
try {
let bannerImageKey = "";
const bannerFile = bannerImageFile();
if (bannerFile) {
bannerImageKey = (await AddImageToS3(
bannerFile,
titleVal,
"blog"
)) as string;
}
if (props.mode === "edit") {
await api.database.updatePost.mutate({
id: props.postId!,
title: titleVal.replaceAll(" ", "_"),
subtitle: subtitle() || null,
body: body() || null,
banner_photo:
bannerImageKey !== ""
? bannerImageKey
: requestedDeleteImage()
? "_DELETE_IMAGE_"
: null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: props.userID
});
} else {
// Create mode: only save once
if (!hasSaved()) {
await api.database.createPost.mutate({
category: "blog",
title: titleVal.replaceAll(" ", "_"),
subtitle: subtitle() || null,
body: body() || null,
banner_photo: bannerImageKey !== "" ? bannerImageKey : null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: props.userID
});
setHasSaved(true);
}
}
showAutoSaveTrigger();
} catch (err) {
console.error("Autosave failed:", err);
}
}
};
const showAutoSaveTrigger = () => {
setShowAutoSaveMessage(true);
setTimeout(() => {
setShowAutoSaveMessage(false);
}, 5000);
};
// Debounced auto-save (1 second after last change)
const debouncedAutoSave = debounce(autoSave, 1000);
// Track changes to trigger auto-save
createEffect(() => {
const titleVal = title();
const subtitleVal = subtitle();
const bodyVal = body();
const tagsVal = tags();
const publishedVal = published();
// Only trigger auto-save if conditions are met
if (props.mode === "edit" && !isInitialLoad() && titleVal) {
debouncedAutoSave();
} else if (props.mode === "create" && titleVal) {
debouncedAutoSave();
}
});
onCleanup(() => {
debouncedAutoSave.cancel();
});
const handleBannerImageDrop = (acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const file = acceptedFiles[0];
if (props.mode === "edit") {
setRequestedDeleteImage(false);
}
setBannerImageFile(file);
const reader = new FileReader();
reader.onload = () => {
setBannerImageHolder(reader.result);
};
reader.readAsDataURL(file);
}
};
const removeBannerImage = () => {
setBannerImageFile(undefined);
setBannerImageHolder(null);
if (props.mode === "edit") {
setRequestedDeleteImage(true);
}
};
const tagHandler = (input: string) => {
const split = input.split(" ");
if (split.length > 1) {
const newSplit: string[] = [];
split.forEach((word) => {
if (word[0] === "#" && word.length > 1) {
setTags((prevTags) => [...prevTags, word]);
} else {
newSplit.push(word);
}
});
setTagInputValue(newSplit.join(" "));
} else {
setTagInputValue(input);
}
};
const deleteTag = (idx: number) => {
setTags((prevTags) => prevTags.filter((_, index) => index !== idx));
};
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!props.userID) {
setError(`You must be logged in to ${props.mode} posts`);
return;
}
if (!title()) {
setError("Title is required to publish");
return;
}
setLoading(true);
setError("");
try {
let bannerImageKey = "";
const bannerFile = bannerImageFile();
if (bannerFile) {
bannerImageKey = (await AddImageToS3(
bannerFile,
title(),
"blog"
)) as string;
}
if (props.mode === "edit") {
await api.database.updatePost.mutate({
id: props.postId!,
title: title().replaceAll(" ", "_"),
subtitle: subtitle() || null,
body: body() || null,
banner_photo:
bannerImageKey !== ""
? bannerImageKey
: requestedDeleteImage()
? "_DELETE_IMAGE_"
: null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: props.userID
});
} else {
await api.database.createPost.mutate({
category: "blog",
title: title().replaceAll(" ", "_"),
subtitle: subtitle() || null,
body: body() || null,
banner_photo: bannerImageKey !== "" ? bannerImageKey : null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: props.userID
});
}
navigate(`/blog/${encodeURIComponent(title().replaceAll(" ", "_"))}`);
} catch (err) {
console.error(`Error ${props.mode}ing post:`, err);
setError(`Failed to ${props.mode} post. Please try again.`);
} finally {
setLoading(false);
}
};
return (
<div class="bg-base text-text min-h-screen px-8 py-32">
<div class="text-center text-2xl tracking-wide">
{props.mode === "edit" ? "Edit a Blog" : "Create a Blog"}
</div>
<div class="flex h-full w-full justify-center">
<form onSubmit={handleSubmit} class="w-full md:w-3/4 lg:w-1/3 xl:w-1/2">
{/* Title */}
<div class="input-group mx-4">
<input
type="text"
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
name="title"
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Title</label>
</div>
{/* Subtitle */}
<div class="input-group mx-4">
<input
type="text"
value={subtitle()}
onInput={(e) => setSubtitle(e.currentTarget.value)}
name="subtitle"
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Subtitle</label>
</div>
{/* Banner */}
<div class="pt-8 text-center text-xl">Banner</div>
<div class="flex justify-center pb-8">
<Dropzone
onDrop={handleBannerImageDrop}
accept="image/jpg, image/jpeg, image/png"
fileHolder={bannerImageHolder()}
preSet={
props.mode === "edit" && !requestedDeleteImage()
? bannerPhoto() || null
: null
}
/>
<button
type="button"
class="z-50 -ml-6 h-fit rounded-full"
onClick={removeBannerImage}
>
<XCircle
height={36}
width={36}
stroke={"currentColor"}
strokeWidth={1}
/>
</button>
</div>
{/* Attachments */}
<AddAttachmentSection
type="blog"
postId={props.postId}
postTitle={title()}
existingAttachments={
props.mode === "edit" && props.initialData
? (props.initialData as any)?.attachments
: undefined
}
/>
{/* Text Editor */}
<div class="-mx-6 md:-mx-36">
<TextEditor updateContent={setBody} preSet={initialBody()} />
</div>
{/* Tags */}
<TagMaker
tagInputValue={tagInputValue()}
tagHandler={tagHandler}
tags={tags()}
deleteTag={deleteTag}
/>
{/* Auto-save message */}
<div
class={`${
showAutoSaveMessage() ? "" : "user-select opacity-0"
} text-green flex min-h-4 justify-center text-center italic transition-opacity duration-500 ease-in-out`}
>
{showAutoSaveMessage() ? "Auto save success!" : ""}
</div>
{/* Publish checkbox */}
<div class="flex justify-end pt-4 pb-2">
<input
type="checkbox"
class="my-auto"
name="publish"
checked={published()}
onChange={(e) => setPublished(e.currentTarget.checked)}
/>
<div class="my-auto px-2 text-sm font-normal">Published</div>
</div>
{/* Error message */}
<Show when={error()}>
<div class="text-red text-sm">{error()}</div>
</Show>
{/* Submit button */}
<div class="flex justify-end">
<button
type="submit"
disabled={loading()}
class={`${
loading()
? "bg-surface2 cursor-not-allowed"
: published()
? "bg-peach hover:brightness-125"
: "bg-green hover:brightness-125"
} text-crust flex w-36 justify-center rounded py-3 transition-all duration-300 ease-out active:scale-90`}
>
{loading()
? "Loading..."
: published()
? "Publish!"
: "Save as Draft"}
</button>
</div>
</form>
</div>
<Show when={props.mode === "edit"}>
<div class="mt-2 flex justify-center">
<a
href={`/blog/${encodeURIComponent(title().replaceAll(" ", "_"))}`}
class="border-blue bg-blue hover:bg-blue rounded border px-4 py-2 shadow-md transition-all duration-300 ease-in-out hover:brightness-125 active:scale-90"
>
Go to Post
</a>
</div>
</Show>
</div>
);
}

View File

@@ -117,6 +117,11 @@ export default function TextEditor(props: TextEditorProps) {
IframeEmbed
],
content: props.preSet || `<p><em><b>Hello!</b> World</em></p>`,
editorProps: {
attributes: {
class: "focus:outline-none"
}
},
onUpdate: ({ editor }) => {
untrack(() => {
props.updateContent(editor.getHTML());
@@ -497,7 +502,7 @@ export default function TextEditor(props: TextEditorProps) {
<div
ref={editorRef}
class="prose prose-sm prose-invert sm:prose-base md:prose-xl lg:prose-xl xl:prose-2xl [&_hr]:border-surface2 mx-auto min-h-[400px] min-w-full focus:outline-none [&_hr]:my-8 [&_hr]:border-t-2"
class="prose prose-sm prose-invert sm:prose-base md:prose-xl lg:prose-xl xl:prose-2xl mx-auto h-[80dvh] min-w-full overflow-scroll focus:outline-none"
/>
</div>
);

View File

@@ -8,6 +8,7 @@ import XCircle from "~/components/icons/XCircle";
import Dropzone from "~/components/blog/Dropzone";
import AddImageToS3 from "~/lib/s3upload";
import { validatePassword, isValidEmail } from "~/lib/validation";
import { TerminalSplash } from "~/components/TerminalSplash";
type UserProfile = {
id: string;
@@ -463,14 +464,7 @@ export default function AccountPage() {
<div class="bg-base mx-8 min-h-screen md:mx-24 lg:mx-36">
<div class="pt-24">
<Show
when={!loading() && user()}
fallback={
<div class="mt-[35vh] flex w-full justify-center">
<div class="text-text text-xl">Loading...</div>
</div>
}
>
<Show when={!loading() && user()} fallback={<TerminalSplash />}>
{(currentUser) => (
<>
<div class="text-text mb-8 text-center text-3xl font-bold">

View File

@@ -1,16 +1,9 @@
import { Show, createSignal, createEffect, onCleanup } from "solid-js";
import { useNavigate, query } from "@solidjs/router";
import { Show } from "solid-js";
import { query } from "@solidjs/router";
import { Title, Meta } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web";
import { api } from "~/lib/api";
import { debounce } from "es-toolkit";
import Dropzone from "~/components/blog/Dropzone";
import TextEditor from "~/components/blog/TextEditor";
import TagMaker from "~/components/blog/TagMaker";
import AddAttachmentSection from "~/components/blog/AddAttachmentSection";
import XCircle from "~/components/icons/XCircle";
import AddImageToS3 from "~/lib/s3upload";
import PostForm from "~/components/blog/PostForm";
const getAuthState = query(async () => {
"use server";
@@ -23,175 +16,8 @@ const getAuthState = query(async () => {
}, "auth-state");
export default function CreatePost() {
const navigate = useNavigate();
const authState = createAsync(() => getAuthState());
const [title, setTitle] = createSignal("");
const [subtitle, setSubtitle] = createSignal("");
const [body, setBody] = createSignal("");
const [bannerPhoto, setBannerPhoto] = createSignal<string>("");
const [bannerImageFile, setBannerImageFile] = createSignal<File>();
const [bannerImageHolder, setBannerImageHolder] = createSignal<
string | ArrayBuffer | null
>(null);
const [published, setPublished] = createSignal(false);
const [tags, setTags] = createSignal<string[]>([]);
const [tagInputValue, setTagInputValue] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
const [showAutoSaveMessage, setShowAutoSaveMessage] = createSignal(false);
const [hasSaved, setHasSaved] = createSignal(false);
const autoSave = async () => {
const titleVal = title();
const bodyVal = body();
if (titleVal && bodyVal !== "") {
try {
let bannerImageKey = "";
const bannerFile = bannerImageFile();
if (bannerFile) {
bannerImageKey = (await AddImageToS3(
bannerFile,
titleVal,
"blog"
)) as string;
}
const method = hasSaved() ? "PATCH" : "POST";
if (method === "POST") {
await api.database.createPost.mutate({
category: "blog",
title: titleVal.replaceAll(" ", "_"),
subtitle: subtitle() || null,
body: bodyVal || null,
banner_photo: bannerImageKey !== "" ? bannerImageKey : null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: authState()!.userID
});
}
setHasSaved(true);
showAutoSaveTrigger();
} catch (err) {
console.error("Autosave failed:", err);
}
}
};
const showAutoSaveTrigger = () => {
setShowAutoSaveMessage(true);
setTimeout(() => {
setShowAutoSaveMessage(false);
}, 5000);
};
// Debounced auto-save (1 second after last change)
const debouncedAutoSave = debounce(autoSave, 1000);
// Track changes to trigger auto-save
createEffect(() => {
// Track all relevant fields
const titleVal = title();
const subtitleVal = subtitle();
const bodyVal = body();
const tagsVal = tags();
const publishedVal = published();
// Only trigger auto-save if we have at least title and body
if (titleVal && bodyVal) {
debouncedAutoSave();
}
});
onCleanup(() => {
debouncedAutoSave.cancel();
});
const handleBannerImageDrop = (acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const file = acceptedFiles[0];
setBannerImageFile(file);
const reader = new FileReader();
reader.onload = () => {
setBannerImageHolder(reader.result);
};
reader.readAsDataURL(file);
}
};
const removeBannerImage = () => {
setBannerImageFile(undefined);
setBannerImageHolder(null);
};
const tagHandler = (input: string) => {
const split = input.split(" ");
if (split.length > 1) {
const newSplit: string[] = [];
split.forEach((word) => {
if (word[0] === "#" && word.length > 1) {
setTags((prevTags) => [...prevTags, word]);
} else {
newSplit.push(word);
}
});
setTagInputValue(newSplit.join(" "));
} else {
setTagInputValue(input);
}
};
const deleteTag = (idx: number) => {
setTags((prevTags) => prevTags.filter((_, index) => index !== idx));
};
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!authState()?.userID) {
setError("You must be logged in to create a post");
return;
}
setLoading(true);
setError("");
try {
let bannerImageKey = "";
const bannerFile = bannerImageFile();
if (bannerFile) {
bannerImageKey = (await AddImageToS3(
bannerFile,
title(),
"blog"
)) as string;
}
const result = await api.database.createPost.mutate({
category: "blog",
title: title().replaceAll(" ", "_"),
subtitle: subtitle() || null,
body: body() || null,
banner_photo: bannerImageKey !== "" ? bannerImageKey : null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: authState()!.userID
});
if (result.data) {
navigate(`/blog/${encodeURIComponent(title().replaceAll(" ", "_"))}`);
}
} catch (err) {
console.error("Error creating post:", err);
setError("Failed to create post. Please try again.");
} finally {
setLoading(false);
}
};
return (
<>
<Title>Create Blog Post | Michael Freno</Title>
@@ -211,130 +37,9 @@ export default function CreatePost() {
</div>
}
>
<div class="bg-base text-text min-h-screen px-8 py-32">
<div class="text-center text-2xl tracking-wide">Create a Blog</div>
<div class="flex h-full w-full justify-center">
<form
onSubmit={handleSubmit}
class="w-full md:w-3/4 lg:w-1/3 xl:w-1/2"
>
{/* Title */}
<div class="input-group mx-4">
<input
type="text"
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
required
name="title"
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Title</label>
</div>
{/* Subtitle */}
<div class="input-group mx-4">
<input
type="text"
value={subtitle()}
onInput={(e) => setSubtitle(e.currentTarget.value)}
name="subtitle"
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Subtitle</label>
</div>
{/* Banner */}
<div class="pt-8 text-center text-xl">Banner</div>
<div class="flex justify-center pb-8">
<Dropzone
onDrop={handleBannerImageDrop}
accept="image/jpg, image/jpeg, image/png"
fileHolder={bannerImageHolder()}
preSet={null}
/>
<button
type="button"
class="z-50 -ml-6 h-fit rounded-full"
onClick={removeBannerImage}
>
<XCircle
height={36}
width={36}
stroke={"currentColor"}
strokeWidth={1}
/>
</button>
</div>
{/* Attachments */}
<AddAttachmentSection type="blog" postTitle={title()} />
{/* Text Editor */}
<div class="md:-mx-36">
<TextEditor updateContent={setBody} preSet={undefined} />
</div>
{/* Tags */}
<TagMaker
tagInputValue={tagInputValue()}
tagHandler={tagHandler}
tags={tags()}
deleteTag={deleteTag}
/>
{/* Auto-save message */}
<div
class={`${
showAutoSaveMessage() ? "" : "user-select opacity-0"
} text-green flex min-h-[16px] justify-center text-center italic transition-opacity duration-500 ease-in-out`}
>
{showAutoSaveMessage() ? "Auto save success!" : ""}
</div>
{/* Publish checkbox */}
<div class="flex justify-end pt-4 pb-2">
<input
type="checkbox"
class="my-auto"
name="publish"
checked={published()}
onChange={(e) => setPublished(e.currentTarget.checked)}
/>
<div class="my-auto px-2 text-sm font-normal">Publish</div>
</div>
{/* Error message */}
<Show when={error()}>
<div class="text-red text-sm">{error()}</div>
</Show>
{/* Submit button */}
<div class="flex justify-end">
<button
type="submit"
disabled={loading()}
class={`${
loading()
? "bg-surface2 cursor-not-allowed"
: published()
? "bg-peach hover:brightness-125"
: "bg-green hover:brightness-125"
} text-crust flex w-36 justify-center rounded py-3 transition-all duration-300 ease-out active:scale-90`}
>
{loading()
? "Loading..."
: published()
? "Publish!"
: "Save as Draft"}
</button>
</div>
</form>
</div>
</div>
<Show when={authState()?.userID}>
<PostForm mode="create" userID={authState()!.userID} />
</Show>
</Show>
</>
);

View File

@@ -1,16 +1,9 @@
import { Show, createSignal, createEffect, onCleanup } from "solid-js";
import { useParams, useNavigate, query } from "@solidjs/router";
import { Show } from "solid-js";
import { useParams, query } from "@solidjs/router";
import { Title, Meta } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web";
import { api } from "~/lib/api";
import { debounce } from "es-toolkit";
import Dropzone from "~/components/blog/Dropzone";
import TextEditor from "~/components/blog/TextEditor";
import TagMaker from "~/components/blog/TagMaker";
import AddAttachmentSection from "~/components/blog/AddAttachmentSection";
import XCircle from "~/components/icons/XCircle";
import AddImageToS3 from "~/lib/s3upload";
import PostForm from "~/components/blog/PostForm";
const getPostForEdit = query(async (id: string) => {
"use server";
@@ -41,210 +34,24 @@ const getPostForEdit = query(async (id: string) => {
export default function EditPost() {
const params = useParams();
const navigate = useNavigate();
const data = createAsync(() => getPostForEdit(params.id));
const [title, setTitle] = createSignal("");
const [subtitle, setSubtitle] = createSignal("");
const [body, setBody] = createSignal("");
const [bannerPhoto, setBannerPhoto] = createSignal("");
const [bannerImageFile, setBannerImageFile] = createSignal<File>();
const [bannerImageHolder, setBannerImageHolder] = createSignal<
string | ArrayBuffer | null
>(null);
const [requestedDeleteImage, setRequestedDeleteImage] = createSignal(false);
const [published, setPublished] = createSignal(false);
const [tags, setTags] = createSignal<string[]>([]);
const [tagInputValue, setTagInputValue] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
const [showAutoSaveMessage, setShowAutoSaveMessage] = createSignal(false);
const [isInitialLoad, setIsInitialLoad] = createSignal(true);
const [initialBody, setInitialBody] = createSignal<string | undefined>(
undefined
);
const postData = () => {
const d = data();
if (!d?.post) return null;
// Populate form when data loads
createEffect(() => {
const postData = data();
if (postData?.post) {
const p = postData.post as any;
setTitle(p.title?.replaceAll("_", " ") || "");
setSubtitle(p.subtitle || "");
setBody(p.body);
const p = d.post as any;
const tagValues = d.tags ? (d.tags as any[]).map((t) => t.value) : [];
// Set initial body only once for the editor
if (initialBody() === undefined) {
setInitialBody(p.body);
}
setBannerPhoto(p.banner_photo || "");
setPublished(p.published || false);
if (postData.tags) {
const tagValues = (postData.tags as any[]).map((t) => t.value);
setTags(tagValues);
}
// Mark initial load as complete after data is loaded
setIsInitialLoad(false);
}
});
const autoSave = async () => {
const titleVal = title();
const postData = data();
if (titleVal && postData?.post) {
try {
let bannerImageKey = "";
const bannerFile = bannerImageFile();
if (bannerFile) {
bannerImageKey = (await AddImageToS3(
bannerFile,
titleVal,
"blog"
)) as string;
}
await api.database.updatePost.mutate({
id: parseInt(params.id),
title: titleVal.replaceAll(" ", "_"),
subtitle: subtitle() || null,
body: body() || null,
banner_photo:
bannerImageKey !== ""
? bannerImageKey
: requestedDeleteImage()
? "_DELETE_IMAGE_"
: null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: data()!.userID
});
showAutoSaveTrigger();
} catch (err) {
console.error("Autosave failed:", err);
}
}
};
const showAutoSaveTrigger = () => {
setShowAutoSaveMessage(true);
setTimeout(() => {
setShowAutoSaveMessage(false);
}, 5000);
};
// Debounced auto-save (1 second after last change)
const debouncedAutoSave = debounce(autoSave, 1000);
// Track changes to trigger auto-save (but not on initial load)
createEffect(() => {
// Track all relevant fields
const titleVal = title();
const subtitleVal = subtitle();
const bodyVal = body();
const tagsVal = tags();
const publishedVal = published();
// Only trigger auto-save if not initial load and we have title
if (!isInitialLoad() && titleVal) {
debouncedAutoSave();
}
});
onCleanup(() => {
debouncedAutoSave.cancel();
});
const handleBannerImageDrop = (acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const file = acceptedFiles[0];
setRequestedDeleteImage(false);
setBannerImageFile(file);
const reader = new FileReader();
reader.onload = () => {
setBannerImageHolder(reader.result);
};
reader.readAsDataURL(file);
}
};
const removeBannerImage = () => {
setBannerImageFile(undefined);
setBannerImageHolder(null);
setRequestedDeleteImage(true);
};
const tagHandler = (input: string) => {
const split = input.split(" ");
if (split.length > 1) {
const newSplit: string[] = [];
split.forEach((word) => {
if (word[0] === "#" && word.length > 1) {
setTags((prevTags) => [...prevTags, word]);
} else {
newSplit.push(word);
}
});
setTagInputValue(newSplit.join(" "));
} else {
setTagInputValue(input);
}
};
const deleteTag = (idx: number) => {
setTags((prevTags) => prevTags.filter((_, index) => index !== idx));
};
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!data()?.userID) {
setError("You must be logged in to edit posts");
return;
}
setLoading(true);
setError("");
try {
let bannerImageKey = "";
const bannerFile = bannerImageFile();
if (bannerFile) {
bannerImageKey = (await AddImageToS3(
bannerFile,
title(),
"blog"
)) as string;
}
await api.database.updatePost.mutate({
id: parseInt(params.id),
title: title().replaceAll(" ", "_"),
subtitle: subtitle() || null,
body: body() || null,
banner_photo:
bannerImageKey !== ""
? bannerImageKey
: requestedDeleteImage()
? "_DELETE_IMAGE_"
: null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: data()!.userID
});
navigate(`/blog/${encodeURIComponent(title().replaceAll(" ", "_"))}`);
} catch (err) {
console.error("Error updating post:", err);
setError("Failed to update post. Please try again.");
} finally {
setLoading(false);
}
return {
title: p.title?.replaceAll("_", " ") || "",
subtitle: p.subtitle || "",
body: p.body || "",
banner_photo: p.banner_photo || "",
published: p.published || false,
tags: tagValues,
attachments: p.attachments
};
};
return (
@@ -267,154 +74,19 @@ export default function EditPost() {
}
>
<Show
when={data()}
when={data() && postData()}
fallback={
<div class="w-full pt-[30vh] text-center">
<div class="text-text text-xl">Loading post...</div>
</div>
}
>
<div class="bg-base text-text min-h-screen px-8 py-32">
<div class="text-center text-2xl tracking-wide">Edit a Blog</div>
<div class="flex h-full w-full justify-center">
<form
onSubmit={handleSubmit}
class="w-full md:w-3/4 lg:w-1/3 xl:w-1/2"
>
{/* Title */}
<div class="input-group mx-4">
<input
type="text"
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
required
name="title"
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Title</label>
</div>
{/* Subtitle */}
<div class="input-group mx-4">
<input
type="text"
value={subtitle()}
onInput={(e) => setSubtitle(e.currentTarget.value)}
name="subtitle"
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Subtitle</label>
</div>
{/* Banner */}
<div class="pt-8 text-center text-xl">Banner</div>
<div class="flex justify-center pb-8">
<Dropzone
onDrop={handleBannerImageDrop}
accept="image/jpg, image/jpeg, image/png"
fileHolder={bannerImageHolder()}
preSet={
requestedDeleteImage() ? null : bannerPhoto() || null
}
/>
<button
type="button"
class="z-50 -ml-6 h-fit rounded-full"
onClick={removeBannerImage}
>
<XCircle
height={36}
width={36}
stroke={"currentColor"}
strokeWidth={1}
/>
</button>
</div>
{/* Attachments */}
<AddAttachmentSection
type="blog"
postId={parseInt(params.id)}
postTitle={title()}
existingAttachments={
(data()?.post as any)?.attachments || undefined
}
/>
{/* Text Editor */}
<div class="-mx-6 md:-mx-36">
<TextEditor updateContent={setBody} preSet={initialBody()} />
</div>
{/* Tags */}
<TagMaker
tagInputValue={tagInputValue()}
tagHandler={tagHandler}
tags={tags()}
deleteTag={deleteTag}
/>
{/* Auto-save message */}
<div
class={`${
showAutoSaveMessage() ? "" : "user-select opacity-0"
} text-green flex min-h-[16px] justify-center text-center italic transition-opacity duration-500 ease-in-out`}
>
{showAutoSaveMessage() ? "Auto save success!" : ""}
</div>
{/* Publish checkbox */}
<div class="flex justify-end pt-4 pb-2">
<input
type="checkbox"
class="my-auto"
name="publish"
checked={published()}
onChange={(e) => setPublished(e.currentTarget.checked)}
/>
<div class="my-auto px-2 text-sm font-normal">Published</div>
</div>
{/* Error message */}
<Show when={error()}>
<div class="text-red text-sm">{error()}</div>
</Show>
{/* Submit button */}
<div class="flex justify-end">
<button
type="submit"
disabled={loading()}
class={`${
loading()
? "bg-surface2 cursor-not-allowed"
: published()
? "bg-peach hover:brightness-125"
: "bg-green hover:brightness-125"
} text-crust flex w-36 justify-center rounded py-3 transition-all duration-300 ease-out active:scale-90`}
>
{loading()
? "Loading..."
: published()
? "Publish!"
: "Save as Draft"}
</button>
</div>
</form>
</div>
<div class="mt-2 flex justify-center">
<a
href={`/blog/${encodeURIComponent(title().replaceAll(" ", "_"))}`}
class="border-blue bg-blue hover:bg-blue rounded border px-4 py-2 shadow-md transition-all duration-300 ease-in-out hover:brightness-125 active:scale-90"
>
Go to Post
</a>
</div>
</div>
<PostForm
mode="edit"
postId={parseInt(params.id)}
initialData={postData()!}
userID={data()!.userID}
/>
</Show>
</Show>
</>