migrated editing client
This commit is contained in:
10
package.json
10
package.json
@@ -16,6 +16,15 @@
|
|||||||
"@solidjs/router": "^0.15.0",
|
"@solidjs/router": "^0.15.0",
|
||||||
"@solidjs/start": "^1.1.0",
|
"@solidjs/start": "^1.1.0",
|
||||||
"@tailwindcss/vite": "^4.0.7",
|
"@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/client": "^10.45.2",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^10.45.2",
|
||||||
"@tursodatabase/api": "^1.9.2",
|
"@tursodatabase/api": "^1.9.2",
|
||||||
@@ -26,6 +35,7 @@
|
|||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
"motion": "^12.23.26",
|
"motion": "^12.23.26",
|
||||||
"solid-js": "^1.9.5",
|
"solid-js": "^1.9.5",
|
||||||
|
"solid-tiptap": "^0.8.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"valibot": "^0.29.0",
|
"valibot": "^0.29.0",
|
||||||
"vinxi": "^0.5.7",
|
"vinxi": "^0.5.7",
|
||||||
|
|||||||
177
src/components/blog/AddAttachmentSection.tsx
Normal file
177
src/components/blog/AddAttachmentSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/components/blog/Dropzone.tsx
Normal file
115
src/components/blog/Dropzone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/blog/TagMaker.tsx
Normal file
58
src/components/blog/TagMaker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
488
src/components/blog/TextEditor.tsx
Normal file
488
src/components/blog/TextEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Show, createSignal } from "solid-js";
|
import { Show, createSignal, onCleanup } from "solid-js";
|
||||||
import { useSearchParams, useNavigate, query } from "@solidjs/router";
|
import { useNavigate, query } from "@solidjs/router";
|
||||||
import { Title } from "@solidjs/meta";
|
import { Title } 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 { getPrivilegeLevel, getUserID } from "~/server/utils";
|
import { getPrivilegeLevel, getUserID } from "~/server/utils";
|
||||||
import { api } from "~/lib/api";
|
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 () => {
|
const getAuthState = query(async () => {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -17,19 +23,124 @@ const getAuthState = query(async () => {
|
|||||||
}, "auth-state");
|
}, "auth-state");
|
||||||
|
|
||||||
export default function CreatePost() {
|
export default function CreatePost() {
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const authState = createAsync(() => getAuthState());
|
const authState = createAsync(() => getAuthState());
|
||||||
|
|
||||||
const [title, setTitle] = createSignal("");
|
const [title, setTitle] = createSignal("");
|
||||||
const [subtitle, setSubtitle] = createSignal("");
|
const [subtitle, setSubtitle] = createSignal("");
|
||||||
const [body, setBody] = 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 [published, setPublished] = createSignal(false);
|
||||||
const [tags, setTags] = createSignal<string[]>([]);
|
const [tags, setTags] = createSignal<string[]>([]);
|
||||||
|
const [tagInputValue, setTagInputValue] = createSignal("");
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
const [error, setError] = createSignal("");
|
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) => {
|
const handleSubmit = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -43,20 +154,29 @@ export default function CreatePost() {
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let bannerImageKey = "";
|
||||||
|
const bannerFile = bannerImageFile();
|
||||||
|
if (bannerFile) {
|
||||||
|
bannerImageKey = (await AddImageToS3(
|
||||||
|
bannerFile,
|
||||||
|
title(),
|
||||||
|
"blog"
|
||||||
|
)) as string;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await api.database.createPost.mutate({
|
const result = await api.database.createPost.mutate({
|
||||||
category: "blog",
|
category: "blog",
|
||||||
title: title(),
|
title: title().replaceAll(" ", "_"),
|
||||||
subtitle: subtitle() || null,
|
subtitle: subtitle() || null,
|
||||||
body: body() || null,
|
body: body() || null,
|
||||||
banner_photo: bannerPhoto() || null,
|
banner_photo: bannerImageKey !== "" ? bannerImageKey : null,
|
||||||
published: published(),
|
published: published(),
|
||||||
tags: tags().length > 0 ? tags() : null,
|
tags: tags().length > 0 ? tags() : null,
|
||||||
author_id: authState()!.userID
|
author_id: authState()!.userID
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
// Redirect to the new post
|
navigate(`/blog/${encodeURIComponent(title().replaceAll(" ", "_"))}`);
|
||||||
navigate(`/blog/${encodeURIComponent(title())}`);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error creating post:", err);
|
console.error("Error creating post:", err);
|
||||||
@@ -81,108 +201,100 @@ export default function CreatePost() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="bg-base min-h-screen px-4 py-12">
|
<div class="bg-base text-text min-h-screen px-8 py-32">
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="text-center text-2xl tracking-wide">Create a Blog</div>
|
||||||
<h1 class="mb-8 text-center text-4xl font-bold">
|
<div class="flex h-full w-full justify-center">
|
||||||
Create Blog Post
|
<form
|
||||||
</h1>
|
onSubmit={handleSubmit}
|
||||||
|
class="w-full md:w-3/4 lg:w-1/3 xl:w-1/2"
|
||||||
<form onSubmit={handleSubmit} class="space-y-6">
|
>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div>
|
<div class="input-group mx-4">
|
||||||
<label for="title" class="mb-2 block text-sm font-medium">
|
|
||||||
Title *
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="title"
|
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
value={title()}
|
value={title()}
|
||||||
onInput={(e) => setTitle(e.currentTarget.value)}
|
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||||
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
required
|
||||||
placeholder="Enter post title"
|
name="title"
|
||||||
|
placeholder=" "
|
||||||
|
class="underlinedInput w-full bg-transparent"
|
||||||
/>
|
/>
|
||||||
|
<span class="bar"></span>
|
||||||
|
<label class="underlinedInputLabel">Title</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle */}
|
||||||
<div>
|
<div class="input-group mx-4">
|
||||||
<label for="subtitle" class="mb-2 block text-sm font-medium">
|
|
||||||
Subtitle
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="subtitle"
|
|
||||||
type="text"
|
type="text"
|
||||||
value={subtitle()}
|
value={subtitle()}
|
||||||
onInput={(e) => setSubtitle(e.currentTarget.value)}
|
onInput={(e) => setSubtitle(e.currentTarget.value)}
|
||||||
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
name="subtitle"
|
||||||
placeholder="Enter post subtitle"
|
placeholder=" "
|
||||||
|
class="underlinedInput w-full bg-transparent"
|
||||||
/>
|
/>
|
||||||
|
<span class="bar"></span>
|
||||||
|
<label class="underlinedInputLabel">Subtitle</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Banner */}
|
||||||
<div>
|
<div class="pt-8 text-center text-xl">Banner</div>
|
||||||
<label for="body" class="mb-2 block text-sm font-medium">
|
<div class="flex justify-center pb-8">
|
||||||
Body (HTML)
|
<Dropzone
|
||||||
</label>
|
onDrop={handleBannerImageDrop}
|
||||||
<textarea
|
accept="image/jpg, image/jpeg, image/png"
|
||||||
id="body"
|
fileHolder={bannerImageHolder()}
|
||||||
rows={15}
|
preSet={null}
|
||||||
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)"
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Banner Photo URL */}
|
{/* Attachments */}
|
||||||
<div>
|
<AddAttachmentSection type="blog" postTitle={title()} />
|
||||||
<label for="banner" class="mb-2 block text-sm font-medium">
|
|
||||||
Banner Photo URL
|
{/* Text Editor */}
|
||||||
</label>
|
<div class="md:-mx-36">
|
||||||
<input
|
<TextEditor updateContent={setBody} preSet={undefined} />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div>
|
<TagMaker
|
||||||
<label for="tags" class="mb-2 block text-sm font-medium">
|
tagInputValue={tagInputValue()}
|
||||||
Tags (comma-separated)
|
tagHandler={tagHandler}
|
||||||
</label>
|
tags={tags()}
|
||||||
<input
|
deleteTag={deleteTag}
|
||||||
id="tags"
|
/>
|
||||||
type="text"
|
|
||||||
value={tags().join(", ")}
|
{/* Auto-save message */}
|
||||||
onInput={(e) =>
|
<div
|
||||||
setTags(
|
class={`${
|
||||||
e.currentTarget.value
|
showAutoSaveMessage() ? "" : "user-select opacity-0"
|
||||||
.split(",")
|
} text-green flex min-h-[16px] justify-center text-center italic transition-opacity duration-500 ease-in-out`}
|
||||||
.map((t) => t.trim())
|
>
|
||||||
.filter(Boolean)
|
{showAutoSaveMessage() ? "Auto save success!" : ""}
|
||||||
)
|
|
||||||
}
|
|
||||||
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
|
||||||
placeholder="tag1, tag2, tag3"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Published */}
|
{/* Publish checkbox */}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex justify-end pt-4 pb-2">
|
||||||
<input
|
<input
|
||||||
id="published"
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
class="my-auto"
|
||||||
|
name="publish"
|
||||||
checked={published()}
|
checked={published()}
|
||||||
onChange={(e) => setPublished(e.currentTarget.checked)}
|
onChange={(e) => setPublished(e.currentTarget.checked)}
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
/>
|
||||||
<label for="published" class="text-sm font-medium">
|
<div class="my-auto px-2 text-sm font-normal">Publish</div>
|
||||||
Publish immediately
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
@@ -191,25 +303,23 @@ export default function CreatePost() {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Submit button */}
|
||||||
<div class="flex gap-4">
|
<div class="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
class={`flex-1 rounded-md px-6 py-3 text-base transition-all ${
|
class={`${
|
||||||
loading()
|
loading()
|
||||||
? "bg-blue cursor-not-allowed brightness-50"
|
? "bg-surface2 cursor-not-allowed"
|
||||||
: "bg-blue hover:brightness-125 active:scale-95"
|
: 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"}
|
{loading()
|
||||||
</button>
|
? "Loading..."
|
||||||
|
: published()
|
||||||
<button
|
? "Publish!"
|
||||||
type="button"
|
: "Save as Draft"}
|
||||||
onClick={() => navigate("/blog")}
|
|
||||||
class="border-surface2 rounded-md border px-6 py-3 transition-all hover:brightness-125"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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 { useParams, useNavigate, query } from "@solidjs/router";
|
||||||
import { Title } from "@solidjs/meta";
|
import { Title } from "@solidjs/meta";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
@@ -6,8 +6,13 @@ import { getRequestEvent } from "solid-js/web";
|
|||||||
import { getPrivilegeLevel, getUserID } from "~/server/utils";
|
import { getPrivilegeLevel, getUserID } from "~/server/utils";
|
||||||
import { api } from "~/lib/api";
|
import { api } from "~/lib/api";
|
||||||
import { ConnectionFactory } from "~/server/utils";
|
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) => {
|
const getPostForEdit = query(async (id: string) => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
@@ -44,17 +49,26 @@ export default function EditPost() {
|
|||||||
const [subtitle, setSubtitle] = createSignal("");
|
const [subtitle, setSubtitle] = createSignal("");
|
||||||
const [body, setBody] = createSignal("");
|
const [body, setBody] = createSignal("");
|
||||||
const [bannerPhoto, setBannerPhoto] = 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 [published, setPublished] = createSignal(false);
|
||||||
const [tags, setTags] = createSignal<string[]>([]);
|
const [tags, setTags] = createSignal<string[]>([]);
|
||||||
|
const [tagInputValue, setTagInputValue] = createSignal("");
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
const [error, setError] = createSignal("");
|
const [error, setError] = createSignal("");
|
||||||
|
const [showAutoSaveMessage, setShowAutoSaveMessage] = createSignal(false);
|
||||||
|
|
||||||
|
let autosaveInterval: number | undefined;
|
||||||
|
|
||||||
// Populate form when data loads
|
// Populate form when data loads
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const postData = data();
|
const postData = data();
|
||||||
if (postData?.post) {
|
if (postData?.post) {
|
||||||
const p = postData.post as any;
|
const p = postData.post as any;
|
||||||
setTitle(p.title || "");
|
setTitle(p.title?.replaceAll("_", " ") || "");
|
||||||
setSubtitle(p.subtitle || "");
|
setSubtitle(p.subtitle || "");
|
||||||
setBody(p.body || "");
|
setBody(p.body || "");
|
||||||
setBannerPhoto(p.banner_photo || "");
|
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) => {
|
const handleSubmit = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -79,19 +193,33 @@ export default function EditPost() {
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let bannerImageKey = "";
|
||||||
|
const bannerFile = bannerImageFile();
|
||||||
|
if (bannerFile) {
|
||||||
|
bannerImageKey = (await AddImageToS3(
|
||||||
|
bannerFile,
|
||||||
|
title(),
|
||||||
|
"blog"
|
||||||
|
)) as string;
|
||||||
|
}
|
||||||
|
|
||||||
await api.database.updatePost.mutate({
|
await api.database.updatePost.mutate({
|
||||||
id: parseInt(params.id),
|
id: parseInt(params.id),
|
||||||
title: title(),
|
title: title().replaceAll(" ", "_"),
|
||||||
subtitle: subtitle() || null,
|
subtitle: subtitle() || null,
|
||||||
body: body() || null,
|
body: body() || null,
|
||||||
banner_photo: bannerPhoto() || null,
|
banner_photo:
|
||||||
|
bannerImageKey !== ""
|
||||||
|
? bannerImageKey
|
||||||
|
: requestedDeleteImage()
|
||||||
|
? "_DELETE_IMAGE_"
|
||||||
|
: null,
|
||||||
published: published(),
|
published: published(),
|
||||||
tags: tags().length > 0 ? tags() : null,
|
tags: tags().length > 0 ? tags() : null,
|
||||||
author_id: data()!.userID
|
author_id: data()!.userID
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to the post
|
navigate(`/blog/${encodeURIComponent(title().replaceAll(" ", "_"))}`);
|
||||||
navigate(`/blog/${encodeURIComponent(title())}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error updating post:", err);
|
console.error("Error updating post:", err);
|
||||||
setError("Failed to update post. Please try again.");
|
setError("Failed to update post. Please try again.");
|
||||||
@@ -108,7 +236,7 @@ export default function EditPost() {
|
|||||||
when={data()?.privilegeLevel === "admin"}
|
when={data()?.privilegeLevel === "admin"}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="w-full pt-[30vh] text-center">
|
<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">
|
<div class="text-subtext0 mt-4">
|
||||||
You must be an admin to edit posts.
|
You must be an admin to edit posts.
|
||||||
</div>
|
</div>
|
||||||
@@ -119,110 +247,113 @@ export default function EditPost() {
|
|||||||
when={data()}
|
when={data()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="w-full pt-[30vh] text-center">
|
<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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="bg-base min-h-screen px-4 py-12">
|
<div class="bg-base text-text min-h-screen px-8 py-32">
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="text-center text-2xl tracking-wide">Edit a Blog</div>
|
||||||
<h1 class="mb-8 text-center text-4xl font-bold">Edit Post</h1>
|
<div class="flex h-full w-full justify-center">
|
||||||
|
<form
|
||||||
<form onSubmit={handleSubmit} class="space-y-6">
|
onSubmit={handleSubmit}
|
||||||
|
class="w-full md:w-3/4 lg:w-1/3 xl:w-1/2"
|
||||||
|
>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div>
|
<div class="input-group mx-4">
|
||||||
<label for="title" class="mb-2 block text-sm font-medium">
|
|
||||||
Title *
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="title"
|
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
value={title()}
|
value={title()}
|
||||||
onInput={(e) => setTitle(e.currentTarget.value)}
|
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||||
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
required
|
||||||
placeholder="Enter post title"
|
name="title"
|
||||||
|
placeholder=" "
|
||||||
|
class="underlinedInput w-full bg-transparent"
|
||||||
/>
|
/>
|
||||||
|
<span class="bar"></span>
|
||||||
|
<label class="underlinedInputLabel">Title</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle */}
|
||||||
<div>
|
<div class="input-group mx-4">
|
||||||
<label for="subtitle" class="mb-2 block text-sm font-medium">
|
|
||||||
Subtitle
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="subtitle"
|
|
||||||
type="text"
|
type="text"
|
||||||
value={subtitle()}
|
value={subtitle()}
|
||||||
onInput={(e) => setSubtitle(e.currentTarget.value)}
|
onInput={(e) => setSubtitle(e.currentTarget.value)}
|
||||||
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
name="subtitle"
|
||||||
placeholder="Enter post subtitle"
|
placeholder=" "
|
||||||
|
class="underlinedInput w-full bg-transparent"
|
||||||
/>
|
/>
|
||||||
|
<span class="bar"></span>
|
||||||
|
<label class="underlinedInputLabel">Subtitle</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Banner */}
|
||||||
<div>
|
<div class="pt-8 text-center text-xl">Banner</div>
|
||||||
<label for="body" class="mb-2 block text-sm font-medium">
|
<div class="flex justify-center pb-8">
|
||||||
Body (HTML)
|
<Dropzone
|
||||||
</label>
|
onDrop={handleBannerImageDrop}
|
||||||
<textarea
|
accept="image/jpg, image/jpeg, image/png"
|
||||||
id="body"
|
fileHolder={bannerImageHolder()}
|
||||||
rows={15}
|
preSet={
|
||||||
value={body()}
|
requestedDeleteImage() ? null : bannerPhoto() || null
|
||||||
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)"
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Banner Photo URL */}
|
{/* Attachments */}
|
||||||
<div>
|
<AddAttachmentSection
|
||||||
<label for="banner" class="mb-2 block text-sm font-medium">
|
type="blog"
|
||||||
Banner Photo URL
|
postId={parseInt(params.id)}
|
||||||
</label>
|
postTitle={title()}
|
||||||
<input
|
existingAttachments={
|
||||||
id="banner"
|
(data()?.post as any)?.attachments || undefined
|
||||||
type="text"
|
}
|
||||||
value={bannerPhoto()}
|
/>
|
||||||
onInput={(e) => setBannerPhoto(e.currentTarget.value)}
|
|
||||||
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
{/* Text Editor */}
|
||||||
placeholder="Enter banner photo URL"
|
<div class="-mx-6 md:-mx-36">
|
||||||
/>
|
<TextEditor updateContent={setBody} preSet={body()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div>
|
<TagMaker
|
||||||
<label for="tags" class="mb-2 block text-sm font-medium">
|
tagInputValue={tagInputValue()}
|
||||||
Tags (comma-separated)
|
tagHandler={tagHandler}
|
||||||
</label>
|
tags={tags()}
|
||||||
<input
|
deleteTag={deleteTag}
|
||||||
id="tags"
|
/>
|
||||||
type="text"
|
|
||||||
value={tags().join(", ")}
|
{/* Auto-save message */}
|
||||||
onInput={(e) =>
|
<div
|
||||||
setTags(
|
class={`${
|
||||||
e.currentTarget.value
|
showAutoSaveMessage() ? "" : "user-select opacity-0"
|
||||||
.split(",")
|
} text-green flex min-h-[16px] justify-center text-center italic transition-opacity duration-500 ease-in-out`}
|
||||||
.map((t) => t.trim())
|
>
|
||||||
.filter(Boolean)
|
{showAutoSaveMessage() ? "Auto save success!" : ""}
|
||||||
)
|
|
||||||
}
|
|
||||||
class="border-surface2 bg-surface0 w-full rounded-md border px-4 py-2"
|
|
||||||
placeholder="tag1, tag2, tag3"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Published */}
|
{/* Publish checkbox */}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex justify-end pt-4 pb-2">
|
||||||
<input
|
<input
|
||||||
id="published"
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
class="my-auto"
|
||||||
|
name="publish"
|
||||||
checked={published()}
|
checked={published()}
|
||||||
onChange={(e) => setPublished(e.currentTarget.checked)}
|
onChange={(e) => setPublished(e.currentTarget.checked)}
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
/>
|
||||||
<label for="published" class="text-sm font-medium">
|
<div class="my-auto px-2 text-sm font-normal">Published</div>
|
||||||
Published
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
@@ -231,31 +362,35 @@ export default function EditPost() {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Submit button */}
|
||||||
<div class="flex gap-4">
|
<div class="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading()}
|
disabled={loading()}
|
||||||
class={`flex-1 rounded-md px-6 py-3 text-base transition-all ${
|
class={`${
|
||||||
loading()
|
loading()
|
||||||
? "bg-blue cursor-not-allowed brightness-50"
|
? "bg-surface2 cursor-not-allowed"
|
||||||
: "bg-blue hover:brightness-125 active:scale-95"
|
: 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"}
|
{loading()
|
||||||
</button>
|
? "Loading..."
|
||||||
|
: published()
|
||||||
<button
|
? "Publish!"
|
||||||
type="button"
|
: "Save as Draft"}
|
||||||
onClick={() =>
|
|
||||||
navigate(`/blog/${encodeURIComponent(title())}`)
|
|
||||||
}
|
|
||||||
class="border-surface2 rounded-md border px-6 py-3 transition-all hover:brightness-125"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
Reference in New Issue
Block a user