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

BIN
bun.lockb

Binary file not shown.

View File

@@ -16,6 +16,15 @@
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
"@tailwindcss/vite": "^4.0.7",
"@tiptap/core": "^3.14.0",
"@tiptap/extension-code-block-lowlight": "^3.14.0",
"@tiptap/extension-color": "^3.14.0",
"@tiptap/extension-image": "^3.14.0",
"@tiptap/extension-link": "^3.14.0",
"@tiptap/extension-list-item": "^3.14.0",
"@tiptap/extension-text-style": "^3.14.0",
"@tiptap/pm": "^3.14.0",
"@tiptap/starter-kit": "^3.14.0",
"@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2",
"@tursodatabase/api": "^1.9.2",
@@ -26,6 +35,7 @@
"jose": "^6.1.3",
"motion": "^12.23.26",
"solid-js": "^1.9.5",
"solid-tiptap": "^0.8.0",
"uuid": "^13.0.0",
"valibot": "^0.29.0",
"vinxi": "^0.5.7",

View File

@@ -0,0 +1,177 @@
import { createSignal, createEffect, For, Show } from "solid-js";
import Dropzone from "./Dropzone";
import XCircle from "~/components/icons/XCircle";
import AddImageToS3 from "~/lib/s3upload";
export interface AddAttachmentSectionProps {
type: "blog" | "project";
postId?: number;
postTitle: string;
existingAttachments?: string;
}
export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
const [images, setImages] = createSignal<File[]>([]);
const [imageHolder, setImageHolder] = createSignal<string[]>([]);
const [newImageHolder, setNewImageHolder] = createSignal<string[]>([]);
const [newImageHolderKeys, setNewImageHolderKeys] = createSignal<string[]>(
[]
);
createEffect(() => {
if (props.existingAttachments) {
const imgStringArr = props.existingAttachments.split(",");
setImageHolder(imgStringArr);
}
});
const handleImageDrop = async (acceptedFiles: File[]) => {
if (props.postTitle) {
for (const file of acceptedFiles) {
setImages((prev) => [...prev, file]);
try {
const key = await AddImageToS3(file, props.postTitle, props.type);
if (key) {
setNewImageHolderKeys((prev) => [...prev, key]);
const reader = new FileReader();
reader.onload = () => {
const str = reader.result;
if (str) {
setNewImageHolder((prev) => [...prev, str as string]);
}
};
reader.readAsDataURL(file);
}
} catch (err) {
console.error("Failed to upload image:", err);
}
}
}
};
const removeImage = async (index: number, key: string) => {
if (props.postId && props.existingAttachments) {
const imgStringArr = props.existingAttachments.split(",");
const newString = imgStringArr.filter((str) => str !== key).join(",");
try {
await fetch("/api/trpc/misc.deleteImage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key,
newAttachmentString: newString,
type: props.type,
id: props.postId
})
});
setImageHolder((prev) => prev.filter((_, i) => i !== index));
} catch (err) {
console.error("Failed to delete image:", err);
}
}
};
const removeNewImage = async (index: number, key: string) => {
setImages((prev) => prev.filter((_, i) => i !== index));
setNewImageHolder((prev) => prev.filter((_, i) => i !== index));
try {
await fetch("/api/trpc/misc.simpleDeleteImage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key })
});
} catch (err) {
console.error("Failed to delete image:", err);
}
};
const copyToClipboard = async (key: string) => {
try {
const bucketString = import.meta.env.VITE_AWS_BUCKET_STRING || "";
await navigator.clipboard.writeText(bucketString + key);
console.log("Text copied to clipboard");
} catch (err) {
console.error("Failed to copy text:", err);
}
};
return (
<Show
when={props.postTitle}
fallback={
<div class="text-subtext0 pb-4 text-center italic">
Add title to add attachments
</div>
}
>
<div class="text-center text-xl">Attachments</div>
<div class="flex justify-center">
<Dropzone
onDrop={handleImageDrop}
accept="image/jpg, image/jpeg, image/png"
fileHolder={null}
preSet={null}
/>
</div>
<div class="-mx-24 grid grid-cols-6 gap-4">
<For each={imageHolder()}>
{(key, index) => (
<div>
<button
type="button"
class="hover:bg-crust hover:bg-opacity-80 absolute ml-4 pb-[120px]"
onClick={() => removeImage(index(), key)}
>
<XCircle
height={24}
width={24}
stroke={"currentColor"}
strokeWidth={1}
/>
</button>
<img src={key} class="mx-4 my-auto h-36 w-36" alt="attachment" />
</div>
)}
</For>
<div class="border-surface2 mx-auto border-r" />
<For each={newImageHolder()}>
{(img, index) => (
<div>
<button
type="button"
class="hover:bg-crust hover:bg-opacity-80 absolute ml-4 pb-[120px]"
onClick={() =>
removeNewImage(index(), newImageHolderKeys()[index()])
}
>
<XCircle
height={24}
width={24}
stroke={"currentColor"}
strokeWidth={1}
/>
</button>
<button
type="button"
onClick={() =>
copyToClipboard(newImageHolderKeys()[index()] as string)
}
>
<img
src={img}
class="mx-4 my-auto h-36 w-36"
alt="new attachment"
/>
</button>
</div>
)}
</For>
</div>
</Show>
);
}

View File

@@ -0,0 +1,115 @@
import { Show } from "solid-js";
export interface DropzoneProps {
onDrop: (files: File[]) => void;
accept?: string;
fileHolder?: string | ArrayBuffer | null;
preSet?: string | null;
}
export default function Dropzone(props: DropzoneProps) {
let inputRef: HTMLInputElement | undefined;
const handleFileChange = (e: Event) => {
const target = e.target as HTMLInputElement;
const files = target.files;
if (files && files.length > 0) {
props.onDrop(Array.from(files));
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
props.onDrop(Array.from(files));
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
return (
<div
class="border-surface2 z-10 my-4 flex border border-dashed bg-transparent shadow-xl"
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => inputRef?.click()}
>
<label
for="upload"
class="flex h-48 w-48 cursor-pointer items-center justify-center"
>
<input
ref={inputRef}
type="file"
class="hidden"
accept={props.accept || "image/jpg, image/jpeg, image/png"}
onChange={handleFileChange}
multiple
/>
<Show
when={props.fileHolder !== null || props.preSet !== null}
fallback={
<>
<div class="flex flex-col items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-text h-8 w-8 fill-transparent"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span id="drop" class="text-md text-subtext0">
Upload Image
<br />
<span class="text-sm">Click or drag</span>
</span>
</div>
</>
}
>
<div>
<Show
when={!props.fileHolder && props.preSet === "userDefault"}
fallback={
<img
src={(props.fileHolder || props.preSet) as string}
class="h-36 w-36 rounded-full object-cover object-center"
alt="upload"
/>
}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={1.5}
stroke="currentColor"
class="mx-auto h-12 w-12"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
<span id="drop" class="text-md text-subtext0">
Upload Image
<br />
<span class="text-sm">Click or drag</span>
</span>
</Show>
</div>
</Show>
</label>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { For } from "solid-js";
import InfoIcon from "~/components/icons/InfoIcon";
import Xmark from "~/components/icons/Xmark";
export interface TagMakerProps {
tagInputValue: string;
tagHandler: (input: string) => void;
tags: string[];
deleteTag: (idx: number) => void;
}
export default function TagMaker(props: TagMakerProps) {
return (
<div class="flex w-full flex-col justify-center md:flex-row md:justify-between">
<div class="absolute -mt-12 mb-8 flex w-full justify-center md:mt-0 md:mb-0 md:w-full md:justify-normal">
<div class="tooltip md:-ml-8">
<div class="md:mt-12">
<InfoIcon height={24} width={24} strokeWidth={1} />
</div>
<div class="tooltip-text -ml-20 w-40">
<div class="px-1">start with # end with a space</div>
</div>
</div>
</div>
<div class="py-4 md:flex md:pt-0">
<div class="textarea-group">
<input
value={props.tagInputValue}
onInput={(e) => props.tagHandler(e.currentTarget.value)}
name="message"
placeholder=" "
class="underlinedInput w-full bg-transparent select-text"
/>
<span class="bar" />
<label class="underlinedInputLabel">Tags</label>
</div>
</div>
<div class="my-auto flex max-w-[420px] flex-wrap justify-center italic md:justify-start">
<For each={props.tags}>
{(tag, idx) => (
<div class="group bg-mauve relative m-1 h-fit w-fit max-w-[120px] rounded-xl px-2 py-1 text-sm">
<div class="overflow-hidden text-base overflow-ellipsis whitespace-nowrap">
{tag}
</div>
<button
type="button"
class="bg-mantle bg-opacity-50 absolute inset-0 flex items-center justify-center rounded-xl opacity-0 group-hover:opacity-100"
onClick={() => props.deleteTag(idx())}
>
<Xmark strokeWidth={1} color={"white"} height={24} width={24} />
</button>
</div>
)}
</For>
</div>
</div>
);
}

View File

@@ -0,0 +1,488 @@
import { Show } from "solid-js";
import { createTiptapEditor, useEditorHTML } from "solid-tiptap";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import Image from "@tiptap/extension-image";
import { Node } from "@tiptap/core";
import { createLowlight, common } from "lowlight";
import css from "highlight.js/lib/languages/css";
import js from "highlight.js/lib/languages/javascript";
import ts from "highlight.js/lib/languages/typescript";
import ocaml from "highlight.js/lib/languages/ocaml";
import rust from "highlight.js/lib/languages/rust";
// Create lowlight instance with common languages
const lowlight = createLowlight(common);
// Register additional languages
lowlight.register("css", css);
lowlight.register("js", js);
lowlight.register("ts", ts);
lowlight.register("ocaml", ocaml);
lowlight.register("rust", rust);
// IFrame extension
interface IframeOptions {
allowFullscreen: boolean;
HTMLAttributes: {
[key: string]: any;
};
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
iframe: {
setIframe: (options: { src: string }) => ReturnType;
};
}
}
const IframeEmbed = Node.create<IframeOptions>({
name: "iframe",
group: "block",
atom: true,
addOptions() {
return {
allowFullscreen: true,
HTMLAttributes: {
class: "iframe-wrapper"
}
};
},
addAttributes() {
return {
src: {
default: null
},
frameborder: {
default: 0
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen
}
};
},
parseHTML() {
return [
{
tag: "iframe"
}
];
},
renderHTML({ HTMLAttributes }) {
return ["div", this.options.HTMLAttributes, ["iframe", HTMLAttributes]];
},
addCommands() {
return {
setIframe:
(options: { src: string }) =>
({ tr, dispatch }) => {
const { selection } = tr;
const node = this.type.create(options);
if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node);
}
return true;
}
};
}
});
export interface TextEditorProps {
updateContent: (content: string) => void;
preSet?: string;
}
export default function TextEditor(props: TextEditorProps) {
let editorRef!: HTMLDivElement;
const editor = createTiptapEditor(() => ({
element: editorRef,
extensions: [
StarterKit,
CodeBlockLowlight.configure({ lowlight }),
Link.configure({
openOnClick: true
}),
Image,
IframeEmbed
],
content: props.preSet || `<p><em><b>Hello!</b> World</em></p>`,
onUpdate: ({ editor }) => {
props.updateContent(editor.getHTML());
}
}));
const setLink = () => {
const instance = editor();
if (!instance) return;
const previousUrl = instance.getAttributes("link").href;
const url = window.prompt("URL", previousUrl);
if (url === null) return;
if (url === "") {
instance.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
instance
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.run();
};
const addIframe = () => {
const instance = editor();
if (!instance) return;
const url = window.prompt("URL");
if (url) {
instance.commands.setIframe({ src: url });
}
};
const addImage = () => {
const instance = editor();
if (!instance) return;
const url = window.prompt("URL");
if (url) {
instance.chain().focus().setImage({ src: url }).run();
}
};
return (
<div class="border-surface2 text-text w-full rounded-md border px-4 py-2">
<Show when={editor()}>
{(instance) => (
<>
{/* Bubble Menu - appears when text is selected */}
<div
class="tiptap-bubble-menu"
style={{
display: "none" // Will be shown by Tiptap when text is selected
}}
>
<div class="bg-mantle text-text mt-4 w-fit rounded p-2 text-sm whitespace-nowrap shadow-lg">
<div class="flex flex-wrap gap-1">
<button
type="button"
onClick={() =>
instance()
.chain()
.focus()
.toggleHeading({ level: 1 })
.run()
}
class={`${
instance().isActive("heading", { level: 1 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1`}
>
H1
</button>
<button
type="button"
onClick={() =>
instance()
.chain()
.focus()
.toggleHeading({ level: 2 })
.run()
}
class={`${
instance().isActive("heading", { level: 2 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1`}
>
H2
</button>
<button
type="button"
onClick={() =>
instance()
.chain()
.focus()
.toggleHeading({ level: 3 })
.run()
}
class={`${
instance().isActive("heading", { level: 3 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1`}
>
H3
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleBold().run()
}
class={`${
instance().isActive("bold")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
>
<strong>B</strong>
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleItalic().run()
}
class={`${
instance().isActive("italic")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
>
<em>I</em>
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleStrike().run()
}
class={`${
instance().isActive("strike")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
>
<s>S</s>
</button>
<button
type="button"
onClick={setLink}
class={`${
instance().isActive("link")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
>
Link
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleCode().run()
}
class={`${
instance().isActive("code")
? "bg-crust"
: "hover:bg-crust"
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
>
Code
</button>
</div>
</div>
</div>
{/* Toolbar - always visible */}
<div class="border-surface2 mb-2 flex flex-wrap gap-1 border-b pb-2">
<button
type="button"
onClick={() =>
instance().chain().focus().toggleHeading({ level: 1 }).run()
}
class={`${
instance().isActive("heading", { level: 1 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Heading 1"
>
H1
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleHeading({ level: 2 }).run()
}
class={`${
instance().isActive("heading", { level: 2 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Heading 2"
>
H2
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleHeading({ level: 3 }).run()
}
class={`${
instance().isActive("heading", { level: 3 })
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Heading 3"
>
H3
</button>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={() => instance().chain().focus().toggleBold().run()}
class={`${
instance().isActive("bold")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Bold"
>
<strong>B</strong>
</button>
<button
type="button"
onClick={() => instance().chain().focus().toggleItalic().run()}
class={`${
instance().isActive("italic")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Italic"
>
<em>I</em>
</button>
<button
type="button"
onClick={() => instance().chain().focus().toggleStrike().run()}
class={`${
instance().isActive("strike")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Strikethrough"
>
<s>S</s>
</button>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleBulletList().run()
}
class={`${
instance().isActive("bulletList")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Bullet List"
>
List
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleOrderedList().run()
}
class={`${
instance().isActive("orderedList")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Ordered List"
>
1. List
</button>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleBlockquote().run()
}
class={`${
instance().isActive("blockquote")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Blockquote"
>
" Quote
</button>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={() =>
instance().chain().focus().toggleCodeBlock().run()
}
class={`${
instance().isActive("codeBlock")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Code Block"
>
{"</>"}
</button>
<button
type="button"
onClick={setLink}
class={`${
instance().isActive("link")
? "bg-surface2"
: "hover:bg-surface1"
} rounded px-2 py-1 text-xs`}
title="Add Link"
>
🔗 Link
</button>
<button
type="button"
onClick={addImage}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Add Image"
>
🖼 Image
</button>
<button
type="button"
onClick={addIframe}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Add Iframe"
>
📺 Iframe
</button>
<div class="border-surface2 mx-1 border-l"></div>
<button
type="button"
onClick={() =>
instance().chain().focus().setHorizontalRule().run()
}
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
title="Horizontal Rule"
>
─ HR
</button>
</div>
</>
)}
</Show>
<div
ref={editorRef}
class="prose prose-sm prose-invert sm:prose-base md:prose-xl lg:prose-xl xl:prose-2xl mx-auto min-h-[400px] min-w-full focus:outline-none"
/>
</div>
);
}

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>

View File

@@ -1,4 +1,4 @@
import { Show, createSignal, createEffect } from "solid-js";
import { Show, createSignal, createEffect, onCleanup } from "solid-js";
import { useParams, useNavigate, query } from "@solidjs/router";
import { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
@@ -6,8 +6,13 @@ import { getRequestEvent } from "solid-js/web";
import { getPrivilegeLevel, getUserID } from "~/server/utils";
import { api } from "~/lib/api";
import { ConnectionFactory } from "~/server/utils";
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";
// Server function to fetch post for editing
const getPostForEdit = query(async (id: string) => {
"use server";
@@ -44,17 +49,26 @@ export default function EditPost() {
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);
let autosaveInterval: number | undefined;
// Populate form when data loads
createEffect(() => {
const postData = data();
if (postData?.post) {
const p = postData.post as any;
setTitle(p.title || "");
setTitle(p.title?.replaceAll("_", " ") || "");
setSubtitle(p.subtitle || "");
setBody(p.body || "");
setBannerPhoto(p.banner_photo || "");
@@ -67,6 +81,106 @@ export default function EditPost() {
}
});
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);
};
// 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];
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();
@@ -79,19 +193,33 @@ export default function EditPost() {
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(),
title: title().replaceAll(" ", "_"),
subtitle: subtitle() || null,
body: body() || null,
banner_photo: bannerPhoto() || null,
banner_photo:
bannerImageKey !== ""
? bannerImageKey
: requestedDeleteImage()
? "_DELETE_IMAGE_"
: null,
published: published(),
tags: tags().length > 0 ? tags() : null,
author_id: data()!.userID
});
// Redirect to the post
navigate(`/blog/${encodeURIComponent(title())}`);
navigate(`/blog/${encodeURIComponent(title().replaceAll(" ", "_"))}`);
} catch (err) {
console.error("Error updating post:", err);
setError("Failed to update post. Please try again.");
@@ -108,7 +236,7 @@ export default function EditPost() {
when={data()?.privilegeLevel === "admin"}
fallback={
<div class="w-full pt-[30vh] text-center">
<div class="text-2xl">Unauthorized</div>
<div class="text-text text-2xl">Unauthorized</div>
<div class="text-subtext0 mt-4">
You must be an admin to edit posts.
</div>
@@ -119,110 +247,113 @@ export default function EditPost() {
when={data()}
fallback={
<div class="w-full pt-[30vh] text-center">
<div class="text-xl">Loading post...</div>
<div class="text-text text-xl">Loading post...</div>
</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">Edit 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">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>
<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={
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>
{/* 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"
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={body()} />
</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">
Published
</label>
<div class="my-auto px-2 text-sm font-normal">Published</div>
</div>
{/* Error message */}
@@ -231,31 +362,35 @@ export default function EditPost() {
</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() ? "Saving..." : "Save Changes"}
</button>
<button
type="button"
onClick={() =>
navigate(`/blog/${encodeURIComponent(title())}`)
}
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>
</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>