From f2df912e0b0b143b6794e5ce8fa4453939580a0c Mon Sep 17 00:00:00 2001 From: sandrawillow001-afk Date: Mon, 28 Sep 2026 03:18:45 +0000 Subject: [PATCH] feat(frontend): add rich text editor for project descriptions (#795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain description input on the project creation form with a markdown-backed rich text editor so contributors can add formatting, links, lists, code, and images while keeping descriptions portable and sanitized. - Add RichTextEditor with a formatting toolbar, keyboard shortcuts, live sanitized preview, and URL/paste/drag-drop image insertion - Wire the editor into the localized and non-localized project forms via react-hook-form Controller - Fix an unclosed SyntaxHighlighter tag in MarkdownContent that broke the markdown renderer - Document the component and add unit tests Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- docs/frontend/rich-text-editor.md | 78 +++ .../[locale]/dashboard/projects/new/page.tsx | 21 +- frontend/app/dashboard/projects/new/page.tsx | 21 +- .../components/markdown/MarkdownContent.tsx | 2 +- .../components/markdown/RichTextEditor.tsx | 444 ++++++++++++++++++ .../__tests__/RichTextEditor.test.tsx | 114 +++++ 6 files changed, 669 insertions(+), 11 deletions(-) create mode 100644 docs/frontend/rich-text-editor.md create mode 100644 frontend/components/markdown/RichTextEditor.tsx create mode 100644 frontend/components/markdown/__tests__/RichTextEditor.test.tsx diff --git a/docs/frontend/rich-text-editor.md b/docs/frontend/rich-text-editor.md new file mode 100644 index 00000000..81c2b2d2 --- /dev/null +++ b/docs/frontend/rich-text-editor.md @@ -0,0 +1,78 @@ +# Rich Text Editor (#795) + +`frontend/components/markdown/RichTextEditor.tsx` replaces the plain `Input` +previously used for project descriptions. It gives contributors a formatting +toolbar, image support, and a live preview while keeping **markdown** as the +stored format. + +## Why markdown + +- Descriptions are persisted on-chain as part of `workDescription`, so a portable, + plain-text format avoids HTML escaping problems and payload bloat. +- Rendering reuses `MarkdownContent`, which already sanitizes output with + `rehype-sanitize` and `remark-gfm` — no new dependencies were added. + +## Usage + +```tsx +import { Controller } from 'react-hook-form'; +import { RichTextEditor } from '@/components/markdown/RichTextEditor'; + + ( + + )} +/>; +``` + +### Props + +| Prop | Type | Description | +|------|------|-------------| +| `value` | `string` | Markdown source (required) | +| `onChange` | `(value: string) => void` | Called on every edit (required) | +| `id` / `name` | `string` | Wired to the textarea for labels and forms | +| `placeholder` | `string` | Placeholder text | +| `disabled` | `boolean` | Disables the toolbar and textarea | +| `className` | `string` | Extra container classes | +| `maxLength` | `number` | Shows a counter and truncates input | +| `allowImages` | `boolean` | Toggle image insertion (default `true`) | +| `onBlur` | `() => void` | Forwarded to the textarea (react-hook-form support) | +| `aria-invalid` / `aria-describedby` | — | Forwarded for validation messaging | + +## supported formatting + +Bold, italic, strikethrough, `##`/`###` headings, bullet and numbered lists, +block quotes, fenced code blocks, and links. Keyboard shortcuts match common +editors: `Cmd/Ctrl+B`, `Cmd/Ctrl+I`, `Cmd/Ctrl+K`. + +## Images + +Images can be inserted three ways: + +1. **Toolbar** — choose *Insert image* and provide a URL plus alt text. +2. **Paste** — paste an image from the clipboard (≤ 5MB). +3. **Drag & drop** — drop an image file onto the textarea (≤ 5MB). + +Pasted/dropped files are inlined as data URLs. For production uploads, prefer +the server-side `POST /api/v1/uploads` endpoint and reference the returned URL. + +## Preview + +The *Preview* toggle renders the markdown through `MarkdownContent` with +sanitization enabled, so raw HTML and unsafe links are stripped before display. + +## Tests + +`frontend/components/markdown/__tests__/RichTextEditor.test.tsx` covers +formatting, list prefixes, image insertion/validation, preview rendering, the +character limit, and the disabled state. diff --git a/frontend/app/[locale]/dashboard/projects/new/page.tsx b/frontend/app/[locale]/dashboard/projects/new/page.tsx index 27f8754f..c60e3993 100644 --- a/frontend/app/[locale]/dashboard/projects/new/page.tsx +++ b/frontend/app/[locale]/dashboard/projects/new/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -22,6 +22,7 @@ import { toast } from 'sonner'; import { useAgenticPay } from '@/lib/hooks/useAgenticPay'; import { useAccount } from 'wagmi'; import { ConfirmModal } from '@/components/transaction/ConfirmModal'; +import { RichTextEditor } from '@/components/markdown/RichTextEditor'; import { parseEther } from 'viem'; type PendingTransaction = { @@ -136,6 +137,7 @@ export default function CreateProjectPage() { const { register, + control, handleSubmit, formState: { errors }, watch, @@ -248,10 +250,19 @@ export default function CreateProjectPage() {
- ( + + )} />
diff --git a/frontend/app/dashboard/projects/new/page.tsx b/frontend/app/dashboard/projects/new/page.tsx index 27f8754f..c60e3993 100644 --- a/frontend/app/dashboard/projects/new/page.tsx +++ b/frontend/app/dashboard/projects/new/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -22,6 +22,7 @@ import { toast } from 'sonner'; import { useAgenticPay } from '@/lib/hooks/useAgenticPay'; import { useAccount } from 'wagmi'; import { ConfirmModal } from '@/components/transaction/ConfirmModal'; +import { RichTextEditor } from '@/components/markdown/RichTextEditor'; import { parseEther } from 'viem'; type PendingTransaction = { @@ -136,6 +137,7 @@ export default function CreateProjectPage() { const { register, + control, handleSubmit, formState: { errors }, watch, @@ -248,10 +250,19 @@ export default function CreateProjectPage() {
- ( + + )} />
diff --git a/frontend/components/markdown/MarkdownContent.tsx b/frontend/components/markdown/MarkdownContent.tsx index ab0db372..f24051b6 100644 --- a/frontend/components/markdown/MarkdownContent.tsx +++ b/frontend/components/markdown/MarkdownContent.tsx @@ -46,7 +46,7 @@ export function MarkdownContent({ customStyle={{ margin: 0, borderRadius: '0.375rem', fontSize: '0.8125rem' }} > {code} - SyntaxHighlighter> + ); } return ( diff --git a/frontend/components/markdown/RichTextEditor.tsx b/frontend/components/markdown/RichTextEditor.tsx new file mode 100644 index 00000000..48beedda --- /dev/null +++ b/frontend/components/markdown/RichTextEditor.tsx @@ -0,0 +1,444 @@ +'use client'; + +/** + * RichTextEditor — Issue #795 + * + * A dependency-free, markdown-backed rich text editor used for project + * descriptions. It renders a formatting toolbar, a textarea, and an optional + * live preview that reuses {@link MarkdownContent} for sanitized rendering. + * + * Markdown is stored as the source of truth so descriptions stay portable and + * safe to persist/transport; the toolbar just inserts the corresponding syntax. + */ + +import { + useCallback, + useRef, + useState, + type ChangeEvent, + type ClipboardEvent, + type DragEvent, + type ReactNode, +} from 'react'; +import { + Bold, + Code, + Eye, + Heading2, + Heading3, + Image as ImageIcon, + Italic, + Link as LinkIcon, + List, + ListOrdered, + Pencil, + Quote, + Strikethrough, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { MarkdownContent } from '@/components/markdown/MarkdownContent'; +import { cn } from '@/lib/utils'; + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB + +type ToolbarAction = + | 'bold' + | 'italic' + | 'strikethrough' + | 'heading2' + | 'heading3' + | 'bulletList' + | 'numberedList' + | 'quote' + | 'code' + | 'link'; + +// Static toolbar definition lives at module scope so it never captures the +// editor's ref during render (see the `react-hooks/refs` rule). +const TOOLBAR_ITEMS: Array<{ action: ToolbarAction; label: string; icon: ReactNode }> = [ + { action: 'bold', label: 'Bold', icon: }, + { action: 'italic', label: 'Italic', icon: }, + { action: 'strikethrough', label: 'Strikethrough', icon: }, + { action: 'heading2', label: 'Heading 2', icon: }, + { action: 'heading3', label: 'Heading 3', icon: }, + { action: 'bulletList', label: 'Bullet list', icon: }, + { action: 'numberedList', label: 'Numbered list', icon: }, + { action: 'quote', label: 'Quote', icon: }, + { action: 'code', label: 'Code block', icon: }, + { action: 'link', label: 'Link', icon: }, +]; + +export interface RichTextEditorProps { + /** Current markdown source. */ + value: string; + /** Called with the updated markdown source on every edit. */ + onChange: (value: string) => void; + /** Field id, wired to the underlying textarea for