feat(template): add form hook for rich text editor / create template page

This commit is contained in:
Leons Aleksandrovs
2025-07-09 21:29:07 +03:00
parent 49bc7dc60a
commit 3376043428
7 changed files with 472 additions and 2 deletions
+23
View File
@@ -0,0 +1,23 @@
import { withForm } from "@/hooks/formHook";
const Template = withForm({
defaultValues: {
name: "",
template: "",
},
props: {},
render({ form }) {
return (
<div className="mt-4 flex flex-col gap-4">
<form.AppField
name="name"
children={(f) => <f.TextField label="Name" placeholder="Template name" />}
/>
<form.AppField name="template" children={(f) => <f.RichTextEdit />} />
</div>
);
},
});
export default Template;
@@ -0,0 +1,160 @@
import "../../editor.css";
import { useFieldContext } from "@/hooks/formHook";
import TextStyle from "@tiptap/extension-text-style";
import { EditorContent, useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Button } from "../ui/button";
import Link from "@tiptap/extension-link";
import {
BoldIcon,
CodeIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ItalicIcon,
ListIcon,
ListOrderedIcon,
PilcrowIcon,
QuoteIcon,
StrikethroughIcon,
} from "lucide-react";
const MenuBar = ({ editor }: { editor: Editor | null }) => {
if (!editor) {
return;
}
return (
<div className="control-group">
<div className="flex flex-wrap gap-2">
<Button
variant="ghost"
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "bg-accent" : ""}
>
<BoldIcon />
</Button>
<Button
variant="ghost"
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "bg-accent" : ""}
>
<ItalicIcon />
</Button>
<Button
variant="ghost"
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
className={editor.isActive("strike") ? "bg-accent" : ""}
>
<StrikethroughIcon />
</Button>
<Button
variant="ghost"
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
className={editor.isActive("code") ? "bg-accent" : ""}
>
<CodeIcon />
</Button>
<Button
variant="ghost"
onClick={() => editor.chain().focus().setParagraph().run()}
className={editor.isActive("paragraph") ? "bg-accent" : ""}
>
<PilcrowIcon />
</Button>
<Button
variant="ghost"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive("heading", { level: 1 }) ? "bg-accent" : ""}
>
<Heading1Icon />
</Button>
<Button
variant="ghost"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive("heading", { level: 2 }) ? "bg-accent" : ""}
>
<Heading2Icon />
</Button>
<Button
variant="ghost"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editor.isActive("heading", { level: 3 }) ? "bg-accent" : ""}
>
<Heading3Icon />
</Button>
<Button
variant="ghost"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "bg-accent" : ""}
>
<ListIcon />
</Button>
<Button
variant="ghost"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "bg-accent" : ""}
>
<ListOrderedIcon />
</Button>
<Button
variant="ghost"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "bg-accent" : ""}
>
<QuoteIcon />
</Button>
</div>
</div>
);
};
const extensions = [
TextStyle.configure(),
StarterKit.configure({
bulletList: {
keepMarks: true,
keepAttributes: true,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Link.configure({
defaultProtocol: "https",
}),
];
export default () => {
// Get field with predefined text type
const field = useFieldContext<string>();
// Configure editor
const editor = useEditor({
onUpdate: ({ editor }) => field.handleChange(editor.getHTML()),
onBlur: () => field.handleBlur(),
content: field.state.value,
extensions,
});
// Render custom field
return (
<div>
<div className="tiptap-container">
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
{!field.state.meta.isValid && (
<span className="text-xs text-danger mt-1">
{field.state.meta.errors.map((e) => e.message).join(", ")}
</span>
)}
</div>
);
};
+102
View File
@@ -0,0 +1,102 @@
:root {
--editor-accent: #1c81d9;
}
.tiptap-container {
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.05);
.control-group {
border-bottom: 1px solid var(--border);
padding: 0.5rem;
}
.tiptap {
outline: none;
padding: 1rem;
}
}
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* Links */
a {
color: var(--editor-accent);
text-decoration: underline;
}
/* List styles */
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
ul,
ol {
::marker {
color: var(--editor-accent);
}
padding: 0 1rem;
p {
/* Text in list */
margin-bottom: 0.5rem;
}
}
/* Heading styles */
h1,
h2,
h3 {
margin-top: 1.5rem;
margin-bottom: 1rem;
}
h1 {
font-size: 1.75rem;
font-weight: 700;
}
h2 {
font-weight: 600;
font-size: 1.5rem;
}
h3 {
font-weight: 500;
font-size: 1.25rem;
}
/* Code and preformatted text styles */
code {
border: 1px solid var(--border);
border-radius: 0.4rem;
background-color: var(--secondary);
font-size: 0.85rem;
padding: 0.25rem 0.3rem;
}
/* Quotes */
blockquote {
border-left: 4px solid var(--border);
margin: 1rem 0;
padding-left: 1rem;
}
/* Line breaks */
hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
}
+3 -1
View File
@@ -1,12 +1,14 @@
import RichTextEdit from "@/components/forms/RichTextEdit";
import TextField from "@/components/forms/TextField";
import { createFormHookContexts, createFormHook } from "@tanstack/react-form";
// export useFieldContext for use in your custom components
export const { fieldContext, formContext, useFieldContext } = createFormHookContexts();
export const { useAppForm } = createFormHook({
export const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
RichTextEdit,
},
formComponents: {},
fieldContext,
+44 -1
View File
@@ -1,16 +1,59 @@
import Template from "@/components/Template";
import { Button } from "@/components/ui/button";
import { useAppForm } from "@/hooks/formHook";
import Authorised from "@/layouts/Authorised";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import * as z from "zod/v4";
export const Route = createFileRoute("/templates/create")({
component: RouteComponent,
});
const TemplateSchema = z.object({
name: z.string().nonempty("Name is required"),
template: z.string().nonempty("Template is required"),
});
function RouteComponent() {
const loading = useState(false);
const createForm = useAppForm({
defaultValues: {
name: "",
template: "",
},
validators: {
onBlur: TemplateSchema,
},
});
return (
<Authorised>
<h1 className="text-2xl font-bold text-primary">Create new template</h1>
<div className="border rounded-md p-4 bg-orange-50 mt-4">
<p className="mb-2 text-orange-400 font-bold">NOTE!</p>
<p>
Places that you want AI to fill, need to be in this format{" "}
<span className="font-semibold">{"<what to fill>"}</span>. For example:
</p>
{/* TODO: create a create/edit component to which we pass initialData (will be easier for edit functionality) */}
<p className="mt-2">
Hello <span className="font-bold">{"<company name>"}</span> Team
</p>
<p>
My experiences{" "}
<span className="font-bold">{"<required experiences separated by comma>"}</span>
</p>
<p>
My experiences:{" "}
<span className="font-bold">{"<required experiences in unordered list>"}</span>
</p>
<p>etc...</p>
</div>
<Template form={createForm} />
<Button onClick={createForm.handleSubmit} disabled={loading[0]} className="mt-4">
Create
</Button>
</Authorised>
);
}