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
@@ -1,88 +0,0 @@
import type { ReactNode } from "react";
export function MarkdownPreview({ markdown }: { markdown: string }) {
const blocks = parseBlocks(markdown);
if (blocks.length === 0) {
return <p className="text-sm text-muted-foreground">No preview yet.</p>;
}
return (
<div className="grid gap-3 text-sm leading-6">
{blocks.map((block, index) => {
if (block.type === "heading") {
return (
<h2 key={index} className="text-lg font-semibold">
{block.text}
</h2>
);
}
if (block.type === "list") {
return (
<ul key={index} className="list-disc space-y-1 pl-5">
{block.items.map((item, itemIndex) => (
<li key={itemIndex}>{item}</li>
))}
</ul>
);
}
return <p key={index}>{block.text}</p>;
})}
</div>
);
}
type MarkdownBlock =
| { type: "heading"; text: ReactNode }
| { type: "list"; items: ReactNode[] }
| { type: "paragraph"; text: ReactNode };
function parseBlocks(markdown: string): MarkdownBlock[] {
const blocks: MarkdownBlock[] = [];
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
let paragraph: string[] = [];
let listItems: string[] = [];
function flushParagraph() {
if (paragraph.length === 0) return;
blocks.push({ type: "paragraph", text: paragraph.join(" ") });
paragraph = [];
}
function flushList() {
if (listItems.length === 0) return;
blocks.push({ type: "list", items: listItems.map((item) => item) });
listItems = [];
}
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
flushParagraph();
flushList();
continue;
}
if (line.startsWith("# ")) {
flushParagraph();
flushList();
blocks.push({ type: "heading", text: line.slice(2).trim() });
continue;
}
if (line.startsWith("- ")) {
flushParagraph();
listItems.push(line.slice(2).trim());
continue;
}
flushList();
paragraph.push(line);
}
flushParagraph();
flushList();
return blocks;
}
+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>
@@ -0,0 +1,25 @@
"use client";
import { RichTextContent } from "@/components/rich-text";
import { toggleNoteChecklistItem } from "../server/checklist-actions";
type Props = {
noteId: string;
body: string;
clampLines?: number;
className?: string;
};
export function NoteRichTextBody({ noteId, body, clampLines, className }: Props) {
return (
<RichTextContent
html={body}
className={className}
clampLines={clampLines}
interactiveChecklists
onToggleChecklist={async (taskIndex, checked) => {
await toggleNoteChecklistItem({ id: noteId, taskIndex, checked });
}}
/>
);
}
+22 -32
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { Plus, Pin } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import type { NoteDto } from "../server/queries";
import { NoteRichTextBody } from "./note-rich-text-body";
function relTime(date: Date | string): string {
const d = new Date(date);
@@ -20,7 +21,7 @@ export function NotesIndex({ notes }: { notes: NoteDto[] }) {
const others = notes.filter((n) => !n.pinned);
return (
<div className="mx-auto grid w-full max-w-5xl gap-5">
<div className="mx-auto grid w-full max-w-5xl gap-5 min-w-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="serif text-[22px] tracking-tight">Notes</h2>
@@ -50,7 +51,7 @@ export function NotesIndex({ notes }: { notes: NoteDto[] }) {
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{pinned.map((note) => (
<NoteCardLink key={note.id} note={note} />
<NoteCard key={note.id} note={note} />
))}
</div>
</div>
@@ -63,29 +64,25 @@ export function NotesIndex({ notes }: { notes: NoteDto[] }) {
style={{ borderColor: "var(--hair)" }}
>
{others.map((note, i) => (
<Link
<div
key={note.id}
href={`/notes/${note.id}`}
className="flex gap-3 px-[14px] py-3 hover:bg-[var(--shade)]"
className="grid gap-2 px-[14px] py-3 hover:bg-[var(--shade)] min-w-0 sm:grid-cols-[minmax(0,1fr)_auto]"
style={{
borderBottom: i === others.length - 1 ? "0" : "0.5px solid var(--hair)",
}}
>
<div className="flex-1 min-w-0">
<h4 className="serif text-[15px] font-medium text-[var(--ink)] m-0">
{note.title}
</h4>
<div
className="muted text-[12.5px] mt-0.5 truncate"
style={{ color: "var(--ink-mute)" }}
>
{note.body || "No body text."}
</div>
<div className="min-w-0">
<Link href={`/notes/${note.id}`} className="block min-w-0">
<h4 className="serif text-[15px] font-medium text-[var(--ink)] m-0">
{note.title}
</h4>
</Link>
<NoteRichTextBody noteId={note.id} body={note.body} clampLines={2} />
</div>
<div className="muted text-[11.5px] shrink-0 self-center">
<div className="muted text-[11.5px] shrink-0 self-start sm:self-center">
{relTime(note.updatedAt)}
</div>
</Link>
</div>
))}
{others.length === 0 && (
<div className="muted px-[14px] py-6 text-center text-[13px]">No notes match.</div>
@@ -98,24 +95,17 @@ export function NotesIndex({ notes }: { notes: NoteDto[] }) {
);
}
function NoteCardLink({ note }: { note: NoteDto }) {
function NoteCard({ note }: { note: NoteDto }) {
return (
<Link href={`/notes/${note.id}`} className="note-card">
{note.pinned && <Pin className="pin size-3" />}
<h4 className="text-[15.5px]">{note.title}</h4>
<p
style={{
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{note.body || "No body text."}
</p>
<div className="note-card min-w-0">
<Link href={`/notes/${note.id}`} className="block min-w-0">
{note.pinned && <Pin className="pin size-3" />}
<h4 className="text-[15.5px]">{note.title}</h4>
</Link>
<NoteRichTextBody noteId={note.id} body={note.body} clampLines={3} />
<div className="flex items-center gap-1.5 mt-1 text-[11.5px] muted">
<span>{relTime(note.updatedAt)}</span>
</div>
</Link>
</div>
);
}
+27 -18
View File
@@ -1,8 +1,18 @@
import { FileText, Pin } from "lucide-react";
import type { NoteShareData } from "../server/share-queries";
import { ShareEyebrow } from "@/components/share/share-eyebrow";
"use client";
export function NoteSharedView({ data }: { data: NoteShareData }) {
import { FileText, Pin } from "lucide-react";
import { RichTextContent } from "@/components/rich-text";
import { ShareEyebrow } from "@/components/share/share-eyebrow";
import type { NoteShareData } from "../server/share-queries";
import { toggleShareNoteChecklistItem } from "../server/checklist-actions";
type Props = {
data: NoteShareData;
canWrite?: boolean;
token?: string;
};
export function NoteSharedView({ data, canWrite = false, token }: Props) {
const updated = new Date(data.updatedAt).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
@@ -39,20 +49,19 @@ export function NoteSharedView({ data }: { data: NoteShareData }) {
<span>Updated {updated}</span>
</p>
{data.body && (
<div
className="serif"
style={{
fontSize: 16,
lineHeight: 1.7,
color: "var(--ink-2)",
whiteSpace: "pre-wrap",
textWrap: "pretty",
}}
>
{data.body}
</div>
)}
{data.body ? (
<RichTextContent
html={data.body}
interactiveChecklists={canWrite && !!token}
onToggleChecklist={
canWrite && token
? async (taskIndex, checked) => {
await toggleShareNoteChecklistItem(token, data.id, taskIndex, checked);
}
: undefined
}
/>
) : null}
</>
);
}
+27 -33
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { listWidgetNotes, searchNotes } from "./server/queries";
import { canShareNote, loadNoteForShare, type NoteShareData } from "./server/share-queries";
import { NoteSharedView } from "./components/shared-view";
import { NoteRichTextBody } from "./components/note-rich-text-body";
const notesWidgetConfigSchema = z.object({
filter: z.enum(["pinned", "all"]),
@@ -22,39 +23,30 @@ async function NotesWidget({ config }: { config: unknown; ctx: WidgetContext })
}
return (
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2 sm:grid-cols-2 min-w-0">
{notes.map((note) => (
<a key={note.id} href={`/notes/${note.id}`} className="note-card">
{parsed.filter === "pinned" && (
<svg
className="pin"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 17v5" />
<path d="M9 4h6l1 6 3 3H5l3-3 1-6z" />
</svg>
)}
<h4 className="text-[14px]">{note.title}</h4>
{note.body && (
<p
style={{
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{note.body}
</p>
)}
</a>
<div key={note.id} className="note-card min-w-0">
<a href={`/notes/${note.id}`} className="block min-w-0">
{parsed.filter === "pinned" && (
<svg
className="pin"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 17v5" />
<path d="M9 4h6l1 6 3 3H5l3-3 1-6z" />
</svg>
)}
<h4 className="text-[14px]">{note.title}</h4>
</a>
{note.body ? <NoteRichTextBody noteId={note.id} body={note.body} clampLines={3} /> : null}
</div>
))}
</div>
);
@@ -74,7 +66,9 @@ const manifest: ModuleManifest = {
resolveUrl: (id) => `/notes/${id}`,
canShareEntity: canShareNote,
loadForShare: loadNoteForShare,
renderSharedView: ({ data }) => <NoteSharedView data={data as NoteShareData} />,
renderSharedView: ({ data, capabilities, token }) => (
<NoteSharedView data={data as NoteShareData} canWrite={capabilities.write} token={token} />
),
renderActivity: (entry) => {
const title = entry.payload?.title as string | undefined;
if (entry.action === "create") return `Created note${title ? ` "${title}"` : ""}`;
@@ -0,0 +1,82 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { toggleTaskItemInHtml } from "@/components/rich-text/toggle-checklist";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { resolveShareToken } from "@/modules/_core/share";
import { logActivityForScope, logShareActivity } from "@/modules/_core/activity";
import { notes } from "../schema";
import { canAccessNote, getNoteForScope } from "./queries";
const toggleInput = z.object({
id: z.string().uuid(),
taskIndex: z.number().int().min(0),
checked: z.boolean(),
});
export async function toggleNoteChecklistItem(input: z.input<typeof toggleInput>) {
const parsed = toggleInput.parse(input);
const { household, user } = await getCurrentSession();
if (!(await canAccessNote(parsed.id, household.id))) throw new Error("Forbidden");
const note = await getNoteForScope(household.id, parsed.id);
const body = toggleTaskItemInHtml(note.body, parsed.taskIndex, parsed.checked);
await db
.update(notes)
.set({ body, updatedAt: new Date() })
.where(and(eq(notes.id, parsed.id), eq(notes.householdId, household.id)));
await logActivityForScope(
{ householdId: household.id, userId: user.id },
{
entityType: "notes.note",
entityId: parsed.id,
action: "update",
payload: { title: note.title, checklist: true },
},
);
revalidatePath("/notes");
revalidatePath(`/notes/${parsed.id}`);
}
export async function toggleShareNoteChecklistItem(
rawToken: string,
noteId: string,
taskIndex: number,
checked: boolean,
) {
z.string().min(1).parse(rawToken);
const parsed = toggleInput.parse({ id: noteId, taskIndex, checked });
const resolved = await resolveShareToken(rawToken);
if (!resolved || resolved.entityType !== "notes.note" || !resolved.capabilities.write) {
throw new Error("Invalid or read-only share token");
}
if (resolved.entityId !== parsed.id) throw new Error("Forbidden");
const [note] = await db
.select({ id: notes.id, title: notes.title, body: notes.body })
.from(notes)
.where(and(eq(notes.id, parsed.id), eq(notes.householdId, resolved.householdId)))
.limit(1);
if (!note) throw new Error("Note not found");
const body = toggleTaskItemInHtml(note.body, parsed.taskIndex, parsed.checked);
await db.update(notes).set({ body, updatedAt: new Date() }).where(eq(notes.id, parsed.id));
await logShareActivity({
householdId: resolved.householdId,
entityType: "notes.note",
entityId: parsed.id,
action: "share.toggle",
payload: { title: note.title, taskIndex: parsed.taskIndex, checked: parsed.checked },
});
revalidatePath(`/s/${rawToken}`);
}