migrated editing client

This commit is contained in:
Michael Freno
2025-12-19 12:54:03 -05:00
parent 9de72a6932
commit 0459c9536c
8 changed files with 1285 additions and 192 deletions

View File

@@ -1,10 +1,16 @@
import { Show, createSignal } from "solid-js";
import { useSearchParams, useNavigate, query } from "@solidjs/router";
import { Show, createSignal, onCleanup } from "solid-js";
import { useNavigate, query } from "@solidjs/router";
import { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web";
import { getPrivilegeLevel, getUserID } from "~/server/utils";
import { api } from "~/lib/api";
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 () => {
"use server";
@@ -17,19 +23,124 @@ const getAuthState = query(async () => {
}, "auth-state");
export default function CreatePost() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const authState = createAsync(() => getAuthState());
const [title, setTitle] = createSignal("");
const [subtitle, setSubtitle] = createSignal("");
const [body, setBody] = createSignal("");
const [bannerPhoto, setBannerPhoto] = 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);
let autosaveInterval: number | undefined;
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);
};
// Set up autosave interval (2 minutes)
autosaveInterval = setInterval(
() => {
autoSave();
},
2 * 60 * 1000
) as unknown as number;
onCleanup(() => {
if (autosaveInterval) {
clearInterval(autosaveInterval);
}
});
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();
@@ -43,20 +154,29 @@ export default function CreatePost() {
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(),
title: title().replaceAll(" ", "_"),
subtitle: subtitle() || null,
body: body() || null,
banner_photo: bannerPhoto() || null,
banner_photo: bannerImageKey !== "" ? bannerImageKey : null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: authState()!.userID
});
if (result.data) {
// Redirect to the new post
navigate(`/blog/${encodeURIComponent(title())}`);
navigate(`/blog/${encodeURIComponent(title().replaceAll(" ", "_"))}`);
}
} catch (err) {
console.error("Error creating post:", err);
@@ -81,108 +201,100 @@ export default function CreatePost() {
</div>
}
>
<div class="bg-base min-h-screen px-4 py-12">
<div class="mx-auto max-w-4xl">
<h1 class="mb-8 text-center text-4xl font-bold">
Create Blog Post
</h1>
<form onSubmit={handleSubmit} class="space-y-6">
<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>
<label for="title" class="mb-2 block text-sm font-medium">
Title *
</label>
<div class="input-group mx-4">
<input
id="title"
type="text"
required
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
placeholder="Enter post title"
required
name="title"
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Title</label>
</div>
{/* Subtitle */}
<div>
<label for="subtitle" class="mb-2 block text-sm font-medium">
Subtitle
</label>
<div class="input-group mx-4">
<input
id="subtitle"
type="text"
value={subtitle()}
onInput={(e) => setSubtitle(e.currentTarget.value)}
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
placeholder="Enter post subtitle"
name="subtitle"
placeholder=" "
class="underlinedInput w-full bg-transparent"
/>
<span class="bar"></span>
<label class="underlinedInputLabel">Subtitle</label>
</div>
{/* Body */}
<div>
<label for="body" class="mb-2 block text-sm font-medium">
Body (HTML)
</label>
<textarea
id="body"
rows={15}
value={body()}
onInput={(e) => setBody(e.currentTarget.value)}
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2 font-mono text-sm"
placeholder="Enter post content (HTML)"
{/* 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>
{/* Banner Photo URL */}
<div>
<label for="banner" class="mb-2 block text-sm font-medium">
Banner Photo URL
</label>
<input
id="banner"
type="text"
value={bannerPhoto()}
onInput={(e) => setBannerPhoto(e.currentTarget.value)}
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
placeholder="Enter banner photo URL"
/>
{/* Attachments */}
<AddAttachmentSection type="blog" postTitle={title()} />
{/* Text Editor */}
<div class="md:-mx-36">
<TextEditor updateContent={setBody} preSet={undefined} />
</div>
{/* Tags */}
<div>
<label for="tags" class="mb-2 block text-sm font-medium">
Tags (comma-separated)
</label>
<input
id="tags"
type="text"
value={tags().join(", ")}
onInput={(e) =>
setTags(
e.currentTarget.value
.split(",")
.map((t) => t.trim())
.filter(Boolean)
)
}
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
placeholder="tag1, tag2, tag3"
/>
<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>
{/* Published */}
<div class="flex items-center gap-2">
{/* Publish checkbox */}
<div class="flex justify-end pt-4 pb-2">
<input
id="published"
type="checkbox"
class="my-auto"
name="publish"
checked={published()}
onChange={(e) => setPublished(e.currentTarget.checked)}
class="h-4 w-4"
/>
<label for="published" class="text-sm font-medium">
Publish immediately
</label>
<div class="my-auto px-2 text-sm font-normal">Publish</div>
</div>
{/* Error message */}
@@ -191,25 +303,23 @@ export default function CreatePost() {
</Show>
{/* Submit button */}
<div class="flex gap-4">
<div class="flex justify-end">
<button
type="submit"
disabled={loading()}
class={`flex-1 rounded-md px-6 py-3 text-base transition-all ${
class={`${
loading()
? "bg-blue cursor-not-allowed brightness-50"
: "bg-blue hover:brightness-125 active:scale-95"
}`}
? "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() ? "Creating..." : "Create Post"}
</button>
<button
type="button"
onClick={() => navigate("/blog")}
class="border-surface2 rounded-md border px-6 py-3 transition-all hover:brightness-125"
>
Cancel
{loading()
? "Loading..."
: published()
? "Publish!"
: "Save as Draft"}
</button>
</div>
</form>