feat(template): add form hook for rich text editor / create template page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user