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(); 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(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(true); const [initialBody, setInitialBody] = createSignal( props.initialData?.body ); const [hasSaved, setHasSaved] = createSignal(props.mode === "edit"); const [createdPostId, setCreatedPostId] = createSignal( props.postId ); // Mark initial load as complete after data is loaded (for edit mode) // Use setTimeout to ensure this runs after all signals are initialized createEffect(() => { if (props.mode === "edit" && props.initialData) { setTimeout(() => setIsInitialLoad(false), 0); } }); const showAutoSaveTrigger = () => { setShowAutoSaveMessage(true); setTimeout(() => { setShowAutoSaveMessage(false); }, 5000); }; // Helper to ensure post exists (create if needed) const ensurePostExists = async (): Promise => { const existingId = createdPostId() || props.postId; if (existingId) return existingId; // Create minimal post if it doesn't exist yet const result = await api.database.createPost.mutate({ category: "blog", title: title().replaceAll(" ", "_") || "Untitled", subtitle: null, body: "Hello, World!", banner_photo: null, published: false, tags: null, author_id: props.userID }); const newId = result.data as number; setCreatedPostId(newId); setHasSaved(true); return newId; }; // Individual autosave functions for each field const autoSaveTitle = async () => { const currentTitle = title(); if (!currentTitle || currentTitle === props.initialData?.title) return; try { const postId = await ensurePostExists(); await api.database.updatePost.mutate({ id: postId, title: currentTitle.replaceAll(" ", "_"), subtitle: subtitle() || null, body: body() || "Hello, World!", banner_photo: null, published: published(), tags: tags().length > 0 ? tags() : null, author_id: props.userID }); showAutoSaveTrigger(); } catch (err) { console.error("Title autosave failed:", err); } }; const autoSaveSubtitle = async () => { const currentSubtitle = subtitle(); if (currentSubtitle === props.initialData?.subtitle) return; if (!title()) return; // Need title to save try { const postId = await ensurePostExists(); await api.database.updatePost.mutate({ id: postId, title: title().replaceAll(" ", "_"), subtitle: currentSubtitle || null, body: body() || "Hello, World!", banner_photo: null, published: published(), tags: tags().length > 0 ? tags() : null, author_id: props.userID }); showAutoSaveTrigger(); } catch (err) { console.error("Subtitle autosave failed:", err); } }; const autoSaveBody = async () => { const currentBody = body(); if (currentBody === props.initialData?.body) return; if (!title()) return; try { const postId = await ensurePostExists(); await api.database.updatePost.mutate({ id: postId, title: title().replaceAll(" ", "_"), subtitle: subtitle() || null, body: currentBody || "Hello, World!", banner_photo: null, published: published(), tags: tags().length > 0 ? tags() : null, author_id: props.userID }); showAutoSaveTrigger(); } catch (err) { console.error("Body autosave failed:", err); } }; const autoSaveTags = async () => { const currentTags = tags(); const initialTags = props.initialData?.tags || []; if (JSON.stringify(currentTags) === JSON.stringify(initialTags)) return; if (!title()) return; try { const postId = await ensurePostExists(); await api.database.updatePost.mutate({ id: postId, title: title().replaceAll(" ", "_"), subtitle: subtitle() || null, body: body() || "Hello, World!", banner_photo: null, published: published(), tags: currentTags.length > 0 ? currentTags : null, author_id: props.userID }); showAutoSaveTrigger(); } catch (err) { console.error("Tags autosave failed:", err); } }; const autoSavePublished = async () => { const currentPublished = published(); if (currentPublished === props.initialData?.published) return; if (!title()) return; try { const postId = await ensurePostExists(); await api.database.updatePost.mutate({ id: postId, title: title().replaceAll(" ", "_"), subtitle: subtitle() || null, body: body() || "Hello, World!", banner_photo: null, published: currentPublished, tags: tags().length > 0 ? tags() : null, author_id: props.userID }); showAutoSaveTrigger(); } catch (err) { console.error("Published autosave failed:", err); } }; const autoSaveBanner = async () => { const bannerFile = bannerImageFile(); if (!bannerFile && !requestedDeleteImage()) return; if (!title()) return; try { let bannerImageKey = ""; if (bannerFile) { bannerImageKey = (await AddImageToS3( bannerFile, title(), "blog" )) as string; } const postId = await ensurePostExists(); await api.database.updatePost.mutate({ id: postId, title: title().replaceAll(" ", "_"), subtitle: subtitle() || null, body: body() || "Hello, World!", banner_photo: bannerImageKey !== "" ? bannerImageKey : requestedDeleteImage() ? "_DELETE_IMAGE_" : null, published: published(), tags: tags().length > 0 ? tags() : null, author_id: props.userID }); showAutoSaveTrigger(); } catch (err) { console.error("Banner autosave failed:", err); } }; // Debounced versions const debouncedAutoSaveTitle = debounce(autoSaveTitle, 2500); const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500); const debouncedAutoSaveBody = debounce(autoSaveBody, 2500); const debouncedAutoSaveTags = debounce(autoSaveTags, 2500); const debouncedAutoSavePublished = debounce(autoSavePublished, 1000); const debouncedAutoSaveBanner = debounce(autoSaveBanner, 2500); // Individual effects for each field createEffect(() => { const titleVal = title(); if (isInitialLoad()) return; if (titleVal && titleVal !== props.initialData?.title) { debouncedAutoSaveTitle(); } }); createEffect(() => { const subtitleVal = subtitle(); if (isInitialLoad()) return; if (subtitleVal !== props.initialData?.subtitle) { debouncedAutoSaveSubtitle(); } }); createEffect(() => { const bodyVal = body(); if (isInitialLoad()) return; if (bodyVal !== props.initialData?.body) { debouncedAutoSaveBody(); } }); createEffect(() => { const tagsVal = tags(); if (isInitialLoad()) return; const initialTags = props.initialData?.tags || []; if (JSON.stringify(tagsVal) !== JSON.stringify(initialTags)) { debouncedAutoSaveTags(); } }); createEffect(() => { const publishedVal = published(); if (isInitialLoad()) return; if (publishedVal !== props.initialData?.published) { debouncedAutoSavePublished(); } }); createEffect(() => { const bannerFile = bannerImageFile(); const deleteRequested = requestedDeleteImage(); if (isInitialLoad()) return; if (bannerFile || deleteRequested) { debouncedAutoSaveBanner(); } }); onCleanup(() => { debouncedAutoSaveTitle.cancel(); debouncedAutoSaveSubtitle.cancel(); debouncedAutoSaveBody.cancel(); debouncedAutoSaveTags.cancel(); debouncedAutoSavePublished.cancel(); debouncedAutoSaveBanner.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" || createdPostId()) { // Update existing post (either in edit mode or if autosave created it) await api.database.updatePost.mutate({ id: createdPostId() || props.postId!, title: title().replaceAll(" ", "_"), subtitle: subtitle() || null, body: body() || "Hello, World!", banner_photo: bannerImageKey !== "" ? bannerImageKey : requestedDeleteImage() ? "_DELETE_IMAGE_" : null, published: published(), tags: tags().length > 0 ? tags() : null, author_id: props.userID }); } else { // Create new post const result = await api.database.createPost.mutate({ category: "blog", title: title().replaceAll(" ", "_"), subtitle: subtitle() || null, body: body() || "Hello, World!", banner_photo: bannerImageKey !== "" ? bannerImageKey : null, published: published(), tags: tags().length > 0 ? tags() : null, author_id: props.userID }); setCreatedPostId(result.data as number); } 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 (
{props.mode === "edit" ? "Edit a Blog" : "Create a Blog"}
{/* Title */}
setTitle(e.currentTarget.value)} name="title" placeholder=" " class="underlinedInput w-full bg-transparent" />
{/* Subtitle */}
setSubtitle(e.currentTarget.value)} name="subtitle" placeholder=" " class="underlinedInput w-full bg-transparent" />
{/* Banner */}
Banner
{/* Attachments */}
{/* Text Editor */}
{/* Tags */} {/* Auto-save message */}
{showAutoSaveMessage() ? "Auto save success!" : ""}
{/* Publish checkbox */}
setPublished(e.currentTarget.checked)} />
Published
{/* Error message */}
{error()}
{/* Submit button */}
); }