diff --git a/bun.lockb b/bun.lockb index ec0da28..a4859bf 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 9b07843..78e918f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/blog/AddAttachmentSection.tsx b/src/components/blog/AddAttachmentSection.tsx new file mode 100644 index 0000000..0c7bcc1 --- /dev/null +++ b/src/components/blog/AddAttachmentSection.tsx @@ -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([]); + const [imageHolder, setImageHolder] = createSignal([]); + const [newImageHolder, setNewImageHolder] = createSignal([]); + const [newImageHolderKeys, setNewImageHolderKeys] = createSignal( + [] + ); + + 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 ( + + Add title to add attachments + + } + > +
Attachments
+
+ +
+
+ + {(key, index) => ( +
+ + attachment +
+ )} +
+
+ + {(img, index) => ( +
+ + +
+ )} +
+
+ + ); +} diff --git a/src/components/blog/Dropzone.tsx b/src/components/blog/Dropzone.tsx new file mode 100644 index 0000000..c3211ac --- /dev/null +++ b/src/components/blog/Dropzone.tsx @@ -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 ( +
inputRef?.click()} + > + +
+ ); +} diff --git a/src/components/blog/TagMaker.tsx b/src/components/blog/TagMaker.tsx new file mode 100644 index 0000000..829d20e --- /dev/null +++ b/src/components/blog/TagMaker.tsx @@ -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 ( +
+
+
+
+ +
+
+
start with # end with a space
+
+
+
+
+
+ props.tagHandler(e.currentTarget.value)} + name="message" + placeholder=" " + class="underlinedInput w-full bg-transparent select-text" + /> + + +
+
+
+ + {(tag, idx) => ( +
+
+ {tag} +
+ +
+ )} +
+
+
+ ); +} diff --git a/src/components/blog/TextEditor.tsx b/src/components/blog/TextEditor.tsx new file mode 100644 index 0000000..77df348 --- /dev/null +++ b/src/components/blog/TextEditor.tsx @@ -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 { + iframe: { + setIframe: (options: { src: string }) => ReturnType; + }; + } +} + +const IframeEmbed = Node.create({ + 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 || `

Hello! World

`, + 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 ( +
+ + {(instance) => ( + <> + {/* Bubble Menu - appears when text is selected */} +
+
+
+ + + + + + + + +
+
+
+ + {/* Toolbar - always visible */} +
+ + + +
+ + + +
+ + + +
+ + + + +
+ +
+ + )} +
+ +
+
+ ); +} diff --git a/src/routes/blog/create/index.tsx b/src/routes/blog/create/index.tsx index a4a8e69..9d1a9b9 100644 --- a/src/routes/blog/create/index.tsx +++ b/src/routes/blog/create/index.tsx @@ -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(""); + const [bannerImageFile, setBannerImageFile] = createSignal(); + const [bannerImageHolder, setBannerImageHolder] = createSignal< + string | ArrayBuffer | null + >(null); const [published, setPublished] = createSignal(false); const [tags, setTags] = createSignal([]); + 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() {
} > -
-
-

- Create Blog Post -

- -
+
+
Create a Blog
+
+ {/* Title */} -
- +
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" /> + +
{/* Subtitle */} -
- +
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" /> + +
- {/* Body */} -
- -