consolidate most of post edit/create
This commit is contained in:
@@ -628,6 +628,7 @@ a.hover-underline-animation:hover::after {
|
|||||||
ul,
|
ul,
|
||||||
ol {
|
ol {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
margin-left: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -654,7 +655,7 @@ a.hover-underline-animation:hover::after {
|
|||||||
|
|
||||||
hr {
|
hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 2px solid rgba(#0d0d0d, 0.1);
|
border-top: 2px solid var(--color-text);
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
404
src/components/blog/PostForm.tsx
Normal file
404
src/components/blog/PostForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -117,6 +117,11 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
IframeEmbed
|
IframeEmbed
|
||||||
],
|
],
|
||||||
content: props.preSet || `<p><em><b>Hello!</b> World</em></p>`,
|
content: props.preSet || `<p><em><b>Hello!</b> World</em></p>`,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "focus:outline-none"
|
||||||
|
}
|
||||||
|
},
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
props.updateContent(editor.getHTML());
|
props.updateContent(editor.getHTML());
|
||||||
@@ -497,7 +502,7 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={editorRef}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import XCircle from "~/components/icons/XCircle";
|
|||||||
import Dropzone from "~/components/blog/Dropzone";
|
import Dropzone from "~/components/blog/Dropzone";
|
||||||
import AddImageToS3 from "~/lib/s3upload";
|
import AddImageToS3 from "~/lib/s3upload";
|
||||||
import { validatePassword, isValidEmail } from "~/lib/validation";
|
import { validatePassword, isValidEmail } from "~/lib/validation";
|
||||||
|
import { TerminalSplash } from "~/components/TerminalSplash";
|
||||||
|
|
||||||
type UserProfile = {
|
type UserProfile = {
|
||||||
id: string;
|
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="bg-base mx-8 min-h-screen md:mx-24 lg:mx-36">
|
||||||
<div class="pt-24">
|
<div class="pt-24">
|
||||||
<Show
|
<Show when={!loading() && user()} fallback={<TerminalSplash />}>
|
||||||
when={!loading() && user()}
|
|
||||||
fallback={
|
|
||||||
<div class="mt-[35vh] flex w-full justify-center">
|
|
||||||
<div class="text-text text-xl">Loading...</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(currentUser) => (
|
{(currentUser) => (
|
||||||
<>
|
<>
|
||||||
<div class="text-text mb-8 text-center text-3xl font-bold">
|
<div class="text-text mb-8 text-center text-3xl font-bold">
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import { Show, createSignal, createEffect, onCleanup } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
import { useNavigate, query } from "@solidjs/router";
|
import { query } from "@solidjs/router";
|
||||||
import { Title, Meta } from "@solidjs/meta";
|
import { Title, Meta } from "@solidjs/meta";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import { getRequestEvent } from "solid-js/web";
|
import { getRequestEvent } from "solid-js/web";
|
||||||
import { api } from "~/lib/api";
|
import PostForm from "~/components/blog/PostForm";
|
||||||
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";
|
|
||||||
|
|
||||||
const getAuthState = query(async () => {
|
const getAuthState = query(async () => {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -23,175 +16,8 @@ const getAuthState = query(async () => {
|
|||||||
}, "auth-state");
|
}, "auth-state");
|
||||||
|
|
||||||
export default function CreatePost() {
|
export default function CreatePost() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const authState = createAsync(() => getAuthState());
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>Create Blog Post | Michael Freno</Title>
|
<Title>Create Blog Post | Michael Freno</Title>
|
||||||
@@ -211,130 +37,9 @@ export default function CreatePost() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="bg-base text-text min-h-screen px-8 py-32">
|
<Show when={authState()?.userID}>
|
||||||
<div class="text-center text-2xl tracking-wide">Create a Blog</div>
|
<PostForm mode="create" userID={authState()!.userID} />
|
||||||
<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>
|
</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>
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import { Show, createSignal, createEffect, onCleanup } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
import { useParams, useNavigate, query } from "@solidjs/router";
|
import { useParams, query } from "@solidjs/router";
|
||||||
import { Title, Meta } from "@solidjs/meta";
|
import { Title, Meta } from "@solidjs/meta";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import { getRequestEvent } from "solid-js/web";
|
import { getRequestEvent } from "solid-js/web";
|
||||||
import { api } from "~/lib/api";
|
import PostForm from "~/components/blog/PostForm";
|
||||||
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";
|
|
||||||
|
|
||||||
const getPostForEdit = query(async (id: string) => {
|
const getPostForEdit = query(async (id: string) => {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -41,210 +34,24 @@ const getPostForEdit = query(async (id: string) => {
|
|||||||
|
|
||||||
export default function EditPost() {
|
export default function EditPost() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const data = createAsync(() => getPostForEdit(params.id));
|
const data = createAsync(() => getPostForEdit(params.id));
|
||||||
|
|
||||||
const [title, setTitle] = createSignal("");
|
const postData = () => {
|
||||||
const [subtitle, setSubtitle] = createSignal("");
|
const d = data();
|
||||||
const [body, setBody] = createSignal("");
|
if (!d?.post) return null;
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
// Populate form when data loads
|
const p = d.post as any;
|
||||||
createEffect(() => {
|
const tagValues = d.tags ? (d.tags as any[]).map((t) => t.value) : [];
|
||||||
const postData = data();
|
|
||||||
if (postData?.post) {
|
|
||||||
const p = postData.post as any;
|
|
||||||
setTitle(p.title?.replaceAll("_", " ") || "");
|
|
||||||
setSubtitle(p.subtitle || "");
|
|
||||||
setBody(p.body);
|
|
||||||
|
|
||||||
// Set initial body only once for the editor
|
return {
|
||||||
if (initialBody() === undefined) {
|
title: p.title?.replaceAll("_", " ") || "",
|
||||||
setInitialBody(p.body);
|
subtitle: p.subtitle || "",
|
||||||
}
|
body: p.body || "",
|
||||||
|
banner_photo: p.banner_photo || "",
|
||||||
setBannerPhoto(p.banner_photo || "");
|
published: p.published || false,
|
||||||
setPublished(p.published || false);
|
tags: tagValues,
|
||||||
|
attachments: p.attachments
|
||||||
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 (
|
return (
|
||||||
@@ -267,154 +74,19 @@ export default function EditPost() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={data()}
|
when={data() && postData()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="w-full pt-[30vh] text-center">
|
<div class="w-full pt-[30vh] text-center">
|
||||||
<div class="text-text text-xl">Loading post...</div>
|
<div class="text-text text-xl">Loading post...</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="bg-base text-text min-h-screen px-8 py-32">
|
<PostForm
|
||||||
<div class="text-center text-2xl tracking-wide">Edit a Blog</div>
|
mode="edit"
|
||||||
<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)}
|
postId={parseInt(params.id)}
|
||||||
postTitle={title()}
|
initialData={postData()!}
|
||||||
existingAttachments={
|
userID={data()!.userID}
|
||||||
(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>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user