feat(notes): rich-text editor with tiptap (task 85)

Replace plain textarea with shared tiptap editor and sanitized html rendering.

Adds interactive checklists on read surfaces and mobile overflow fixes.
This commit is contained in:
ginnoir
2026-07-04 19:25:15 -05:00
parent 67f67525ff
commit a4be5d5061
23 changed files with 1942 additions and 206 deletions
+28 -20
View File
@@ -1,8 +1,10 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Pin, PinOff, Save, Trash2 } from "lucide-react";
import { useState, useTransition } from "react";
import { RichTextContent } from "@/components/rich-text";
import { DetailBackLink } from "@/components/detail-back-link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -10,7 +12,18 @@ import { Label } from "@/components/ui/label";
import { ShareButton } from "@/components/share-button";
import type { NoteDto } from "../server/queries";
import { createNote, deleteNote, setNotePinned, updateNote } from "../server/actions";
import { MarkdownPreview } from "./markdown-preview";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-80 rounded-[var(--r-md)] border-[0.5px] px-3 py-2 text-sm muted animate-pulse">
Loading editor
</div>
),
},
);
export function NoteEditor({ note }: { note?: NoteDto }) {
const router = useRouter();
@@ -60,7 +73,7 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
}
return (
<div className="mx-auto grid w-full max-w-6xl gap-4">
<div className="mx-auto grid w-full max-w-6xl gap-4 min-w-0">
<DetailBackLink href="/notes" label="Notes" />
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-2 min-w-0 flex-1">
@@ -73,26 +86,26 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
{currentNote ? (
<Button variant="outline" size="sm" onClick={togglePinned} disabled={isPending}>
{pinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />}
{pinned ? "Unpin" : "Pin"}
{pinned ? "Unpin note" : "Pin note"}
</Button>
) : null}
{currentNote ? <ShareButton entityType="notes.note" entityId={currentNote.id} /> : null}
{currentNote ? (
<Button variant="destructive" size="sm" onClick={removeNote} disabled={isPending}>
<Trash2 className="size-3.5" />
Delete
Delete note
</Button>
) : null}
<Button size="sm" onClick={saveNote} disabled={!title.trim() || isPending}>
<Save className="size-3.5" />
Save
Save note
</Button>
</div>
</header>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(280px,420px)]">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(280px,420px)] min-w-0">
<section
className="grid gap-4 rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)]"
className="grid gap-4 rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 shadow-[var(--shadow-1)] min-w-0"
style={{ borderColor: "var(--hair)" }}
>
<div className="space-y-1.5">
@@ -103,21 +116,15 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="space-y-1.5 min-w-0">
<Label htmlFor="note-body">Body</Label>
<textarea
<RichTextEditor
id="note-body"
aria-label="Body"
className="min-h-80 w-full rounded-[var(--r-md)] border-[0.5px] bg-transparent px-3 py-2 leading-[1.6] outline-none transition-colors placeholder:text-[var(--ink-faint)] focus-visible:border-[var(--ink)] focus-visible:ring-3 focus-visible:ring-[var(--ink)]/8"
style={{
fontFamily: "var(--serif)",
fontSize: "15px",
color: "var(--ink-2)",
borderColor: "var(--hair-2)",
}}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder="# Dinner ideas&#10;&#10;- Tacos&#10;- Soup"
onChange={setBody}
disabled={isPending}
placeholder="Start writing…"
/>
</div>
<div className="space-y-1.5">
@@ -132,11 +139,12 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
</section>
<aside
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 text-[var(--ink)] shadow-[var(--shadow-1)]"
className="rounded-[var(--r-lg)] border-[0.5px] bg-[var(--card)] p-4 text-[var(--ink)] shadow-[var(--shadow-1)] min-w-0"
style={{ borderColor: "var(--hair)" }}
aria-label="Preview"
>
<div className="eyebrow mb-3">Preview</div>
<MarkdownPreview markdown={body} />
<RichTextContent html={body} />
</aside>
</div>
</div>