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:
@@ -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 - Tacos - 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>
|
||||
|
||||
Reference in New Issue
Block a user