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:
@@ -15,6 +15,8 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
|
||||
|
||||
- **87 — HTTP API v1** (commits `ea5d1d0`, `d4304b0`, `e8d13be`). Household bearer token + session auth; `/api/v1/` routes for calendar, lists, notes, garden, bangs; OpenAPI in `docs/api/openapi.yaml`; owner token UI in Settings → Data. Unit tests: `api-auth`, `api-v1-calendar`, `api-v1-auth`. ADR 0006. Run `pnpm db:migrate` for migration `0019_household_api_tokens`.
|
||||
|
||||
- **85 — Rich-text notes** (ADR 0004 accepted `67f6752`). TipTap editor in `src/components/rich-text/`; notes create/edit + index/widget/share surfaces; interactive checklists; DOMPurify sanitization; lazy plain-text→HTML migration; mobile overflow CSS; uploads `?scope=notes`. Unit tests: `rich-text.test.ts`. E2E: `tests/e2e/notes.spec.ts` (formatted note + 375px overflow).
|
||||
|
||||
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
|
||||
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
|
||||
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
|
||||
@@ -59,7 +61,7 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
|
||||
|
||||
1. ~~Bugs: tasks 80–84~~ — done
|
||||
2. ~~API foundation: task 87 (+ ADR 0006)~~ — done
|
||||
3. Shared rich-text + notes overhaul: task 85 (+ ADR 0004); closes notes mobile overflow
|
||||
3. ~~Shared rich-text + notes overhaul: task 85 (+ ADR 0004)~~ — done
|
||||
4. Journal: task 86 (+ ADR 0005), including journal API endpoints
|
||||
5. LLM agent chat: task 88
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@ Journal (86) and notes need modern formatting and emoji. Notes currently overflo
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] ADR 0004 accepted with library and storage format chosen.
|
||||
- [ ] Shared editor component in tree.
|
||||
- [ ] Notes create/edit use it.
|
||||
- [ ] Formatting renders on all notes surfaces.
|
||||
- [ ] Mobile overflow fixed; E2E green.
|
||||
- [x] ADR 0004 accepted with library and storage format chosen.
|
||||
- [x] Shared editor component in tree.
|
||||
- [x] Notes create/edit use it.
|
||||
- [x] Formatting renders on all notes surfaces.
|
||||
- [x] Mobile overflow fixed; E2E green.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -83,11 +83,25 @@
|
||||
"@fullcalendar/list": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@tiptap/extension-image": "^3.27.1",
|
||||
"@tiptap/extension-link": "^3.27.1",
|
||||
"@tiptap/extension-placeholder": "^3.27.1",
|
||||
"@tiptap/extension-table": "^3.27.1",
|
||||
"@tiptap/extension-table-cell": "^3.27.1",
|
||||
"@tiptap/extension-table-header": "^3.27.1",
|
||||
"@tiptap/extension-table-row": "^3.27.1",
|
||||
"@tiptap/extension-task-item": "^3.27.1",
|
||||
"@tiptap/extension-task-list": "^3.27.1",
|
||||
"@tiptap/extension-underline": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"isomorphic-dompurify": "^3.18.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"minio": "^8.0.7",
|
||||
"next": "^15.5.15",
|
||||
|
||||
Generated
+898
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "No file field in request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const scope = url.searchParams.get("scope") === "notes" ? "notes" : "garden";
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
|
||||
}
|
||||
@@ -45,7 +48,7 @@ export async function POST(request: Request) {
|
||||
.replace(/[^a-z0-9]/gi, "")
|
||||
.toLowerCase()
|
||||
.slice(0, 8);
|
||||
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`;
|
||||
const key = `${scope}/${session.household.id}/${randomUUID()}.${safeExt}`;
|
||||
|
||||
try {
|
||||
await ensureBucket();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { normalizeNoteBody } from "./normalize-body";
|
||||
import { sanitizeRichTextHtml } from "./sanitize";
|
||||
|
||||
export function richTextToPlainText(body: string, maxLength?: number): string {
|
||||
const normalized = normalizeNoteBody(body);
|
||||
const safe = sanitizeRichTextHtml(normalized);
|
||||
const text = safe
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<\/li>/gi, "\n")
|
||||
.replace(/<\/h[1-6]>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
if (maxLength === undefined) return text;
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trimEnd()}…`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { RichTextContent } from "./rich-text-content";
|
||||
export { RichTextEditor } from "./rich-text-editor";
|
||||
export { richTextToPlainText } from "./body-text";
|
||||
export { normalizeNoteBody } from "./normalize-body";
|
||||
export { sanitizeRichTextHtml } from "./sanitize";
|
||||
export { countTaskItems, toggleTaskItemInHtml } from "./toggle-checklist";
|
||||
@@ -0,0 +1,36 @@
|
||||
const HTML_LIKE = /^\s*</;
|
||||
|
||||
export function isLikelyHtml(body: string): boolean {
|
||||
return HTML_LIKE.test(body);
|
||||
}
|
||||
|
||||
export function normalizeNoteBody(body: string): string {
|
||||
if (!body.trim()) return "";
|
||||
if (isLikelyHtml(body)) return body;
|
||||
|
||||
const paragraphs = body
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (paragraphs.length === 0) return "";
|
||||
|
||||
return paragraphs
|
||||
.map((block) => {
|
||||
const lines = block
|
||||
.split("\n")
|
||||
.map((line) => escapeHtml(line.trim()))
|
||||
.filter(Boolean);
|
||||
return `<p>${lines.join("<br>")}</p>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useTransition } from "react";
|
||||
import { normalizeNoteBody } from "./normalize-body";
|
||||
import { sanitizeRichTextHtml } from "./sanitize";
|
||||
import "./rich-text.css";
|
||||
|
||||
type ToggleHandler = (taskIndex: number, checked: boolean) => Promise<void>;
|
||||
|
||||
type Props = {
|
||||
html: string;
|
||||
interactiveChecklists?: boolean;
|
||||
onToggleChecklist?: ToggleHandler;
|
||||
className?: string;
|
||||
clampLines?: number;
|
||||
};
|
||||
|
||||
export function RichTextContent({
|
||||
html,
|
||||
interactiveChecklists = false,
|
||||
onToggleChecklist,
|
||||
className,
|
||||
clampLines,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const safeHtml = useMemo(() => {
|
||||
const normalized = normalizeNoteBody(html);
|
||||
return sanitizeRichTextHtml(normalized);
|
||||
}, [html]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root || !interactiveChecklists || !onToggleChecklist) return;
|
||||
|
||||
const items = [...root.querySelectorAll<HTMLLIElement>('li[data-type="taskItem"]')];
|
||||
|
||||
for (const [index, item] of items.entries()) {
|
||||
const checked = item.getAttribute("data-checked") === "true";
|
||||
let label = item.querySelector("label");
|
||||
if (!label) {
|
||||
label = document.createElement("label");
|
||||
item.insertBefore(label, item.firstChild);
|
||||
}
|
||||
|
||||
let input = label.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
if (!input) {
|
||||
input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
label.prepend(input);
|
||||
}
|
||||
|
||||
input.checked = checked;
|
||||
input.disabled = isPending;
|
||||
input.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextChecked = !input!.checked;
|
||||
startTransition(async () => {
|
||||
await onToggleChecklist(index, nextChecked);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [safeHtml, interactiveChecklists, onToggleChecklist, isPending, router]);
|
||||
|
||||
if (!safeHtml) {
|
||||
return <p className="text-sm text-[var(--ink-mute)]">No body text.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={["rich-text-content", className].filter(Boolean).join(" ")}
|
||||
style={
|
||||
clampLines
|
||||
? {
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: clampLines,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { normalizeNoteBody } from "./normalize-body";
|
||||
import { RichTextToolbar } from "./rich-text-toolbar";
|
||||
import "./rich-text.css";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
"aria-label"?: string;
|
||||
};
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch("/api/uploads?scope=notes", { method: "POST", body: formData });
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(payload?.error ?? "Image upload failed");
|
||||
}
|
||||
const payload = (await response.json()) as { url: string };
|
||||
return payload.url;
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Start writing…",
|
||||
disabled = false,
|
||||
id,
|
||||
"aria-label": ariaLabel,
|
||||
}: Props) {
|
||||
const onChangeRef = useRef(onChange);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
editable: !disabled,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
||||
}),
|
||||
Image.configure({ inline: false, allowBase64: false }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
content: normalizeNoteBody(value),
|
||||
onUpdate: ({ editor: current }) => {
|
||||
onChangeRef.current(current.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
...(id ? { id } : {}),
|
||||
...(ariaLabel ? { "aria-label": ariaLabel } : {}),
|
||||
class: "rich-text-content",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const normalized = normalizeNoteBody(value);
|
||||
if (editor.getHTML() === normalized) return;
|
||||
editor.commands.setContent(normalized, { emitUpdate: false });
|
||||
}, [editor, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.setEditable(!disabled);
|
||||
}, [editor, disabled]);
|
||||
|
||||
async function handleImageUpload(file: File) {
|
||||
if (!editor) return;
|
||||
const url = await uploadImage(file);
|
||||
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rich-text-editor grid gap-2">
|
||||
<RichTextToolbar editor={editor} onImageUpload={handleImageUpload} disabled={disabled} />
|
||||
<div
|
||||
className="rounded-[var(--r-md)] border-[0.5px] bg-transparent px-3 py-2"
|
||||
style={{ borderColor: "var(--hair-2)" }}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
Heading2,
|
||||
ImagePlus,
|
||||
Italic,
|
||||
Link2,
|
||||
List,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Redo2,
|
||||
Smile,
|
||||
Strikethrough,
|
||||
Table2,
|
||||
Underline,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
|
||||
const EMOJIS = ["😀", "😊", "❤️", "👍", "🎉", "✅", "⭐", "🔥", "🍕", "🏠", "📅", "🛒"];
|
||||
|
||||
type Props = {
|
||||
editor: Editor | null;
|
||||
onImageUpload: (file: File) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function RichTextToolbar({ editor, onImageUpload, disabled }: Props) {
|
||||
if (!editor) return null;
|
||||
|
||||
const currentEditor = editor;
|
||||
|
||||
function setLink() {
|
||||
const previous = currentEditor.getAttributes("link").href as string | undefined;
|
||||
const url = window.prompt("Link URL", previous ?? "https://");
|
||||
if (url === null) return;
|
||||
if (url === "") {
|
||||
currentEditor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
currentEditor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||
}
|
||||
|
||||
function insertTable() {
|
||||
currentEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
}
|
||||
|
||||
function pickImage() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) void onImageUpload(file);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rich-text-toolbar">
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("bold")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleBold().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Bold"
|
||||
>
|
||||
<Bold className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("italic")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleItalic().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Italic"
|
||||
>
|
||||
<Italic className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("underline")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleUnderline().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Underline"
|
||||
>
|
||||
<Underline className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("strike")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleStrike().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Strikethrough"
|
||||
>
|
||||
<Strikethrough className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
||||
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("heading", { level: 2 })}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
disabled={disabled}
|
||||
aria-label="Heading"
|
||||
>
|
||||
<Heading2 className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("bulletList")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleBulletList().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Bullet list"
|
||||
>
|
||||
<List className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("orderedList")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleOrderedList().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Numbered list"
|
||||
>
|
||||
<ListOrdered className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("taskList")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleTaskList().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Checklist"
|
||||
>
|
||||
<ListChecks className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("blockquote")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleBlockquote().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Blockquote"
|
||||
>
|
||||
<Quote className="size-3.5" />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={currentEditor.isActive("codeBlock")}
|
||||
onPressedChange={() => currentEditor.chain().focus().toggleCodeBlock().run()}
|
||||
disabled={disabled}
|
||||
aria-label="Code block"
|
||||
>
|
||||
<Code className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
||||
|
||||
<Button type="button" variant="ghost" size="sm" onClick={setLink} disabled={disabled}>
|
||||
<Link2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={pickImage} disabled={disabled}>
|
||||
<ImagePlus className="size-3.5" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={insertTable} disabled={disabled}>
|
||||
<Table2 className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex h-8 items-center justify-center rounded-md px-2 text-sm hover:bg-[var(--shade)]"
|
||||
disabled={disabled}
|
||||
aria-label="Insert emoji"
|
||||
>
|
||||
<Smile className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="grid grid-cols-6 gap-1 p-2">
|
||||
{EMOJIS.map((emoji) => (
|
||||
<DropdownMenuItem
|
||||
key={emoji}
|
||||
className="justify-center px-2 text-lg"
|
||||
onClick={() => currentEditor.chain().focus().insertContent(emoji).run()}
|
||||
>
|
||||
{emoji}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => currentEditor.chain().focus().undo().run()}
|
||||
disabled={disabled || !currentEditor.can().undo()}
|
||||
>
|
||||
<Undo2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => currentEditor.chain().focus().redo().run()}
|
||||
disabled={disabled || !currentEditor.can().redo()}
|
||||
>
|
||||
<Redo2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
.rich-text-content {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
text-wrap: pretty;
|
||||
color: var(--ink-2);
|
||||
font-family: var(--serif);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.rich-text-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rich-text-content > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rich-text-content h1,
|
||||
.rich-text-content h2,
|
||||
.rich-text-content h3,
|
||||
.rich-text-content h4 {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin: 1.1em 0 0.45em;
|
||||
}
|
||||
|
||||
.rich-text-content h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.rich-text-content h2 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.rich-text-content h3 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.rich-text-content p {
|
||||
margin: 0.65em 0;
|
||||
}
|
||||
|
||||
.rich-text-content ul,
|
||||
.rich-text-content ol {
|
||||
margin: 0.65em 0;
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
|
||||
.rich-text-content blockquote {
|
||||
border-left: 3px solid var(--hair-2);
|
||||
color: var(--ink-mute);
|
||||
margin: 0.8em 0;
|
||||
padding-left: 0.9rem;
|
||||
}
|
||||
|
||||
.rich-text-content pre {
|
||||
background: var(--shade);
|
||||
border-radius: var(--r-md);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
margin: 0.8em 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.rich-text-content code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.rich-text-content a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.rich-text-content img {
|
||||
border-radius: var(--r-md);
|
||||
display: block;
|
||||
height: auto;
|
||||
margin: 0.8em 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.rich-text-content table {
|
||||
border-collapse: collapse;
|
||||
display: block;
|
||||
margin: 0.8em 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.rich-text-content th,
|
||||
.rich-text-content td {
|
||||
border: 0.5px solid var(--hair);
|
||||
min-width: 4rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rich-text-content ul[data-type="taskList"] {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.rich-text-content li[data-type="taskItem"] {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rich-text-content li[data-type="taskItem"] > label {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.rich-text-content li[data-type="taskItem"] > label input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.rich-text-content li[data-type="taskItem"] > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor .ProseMirror {
|
||||
min-height: 18rem;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.rich-text-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: var(--ink-faint);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rich-text-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
"i",
|
||||
"u",
|
||||
"s",
|
||||
"strike",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"pre",
|
||||
"code",
|
||||
"a",
|
||||
"img",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"span",
|
||||
"div",
|
||||
"label",
|
||||
"input",
|
||||
"hr",
|
||||
];
|
||||
|
||||
const ALLOWED_ATTR = [
|
||||
"href",
|
||||
"target",
|
||||
"rel",
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
"class",
|
||||
"data-type",
|
||||
"data-checked",
|
||||
"type",
|
||||
"checked",
|
||||
"disabled",
|
||||
"colspan",
|
||||
"rowspan",
|
||||
];
|
||||
|
||||
export function sanitizeRichTextHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOW_DATA_ATTR: true,
|
||||
ADD_ATTR: ["target"],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const TASK_ITEM_RE = /<li([^>]*data-type="taskItem"[^>]*)>/gi;
|
||||
|
||||
export function toggleTaskItemInHtml(html: string, taskIndex: number, checked: boolean): string {
|
||||
let index = 0;
|
||||
return html.replace(TASK_ITEM_RE, (match, attrs: string) => {
|
||||
const current = index;
|
||||
index += 1;
|
||||
if (current !== taskIndex) return match;
|
||||
|
||||
const checkedValue = checked ? "true" : "false";
|
||||
let nextAttrs = attrs;
|
||||
|
||||
if (/data-checked="(?:true|false)"/i.test(nextAttrs)) {
|
||||
nextAttrs = nextAttrs.replace(
|
||||
/data-checked="(?:true|false)"/i,
|
||||
`data-checked="${checkedValue}"`,
|
||||
);
|
||||
} else {
|
||||
nextAttrs = `${nextAttrs} data-checked="${checkedValue}"`;
|
||||
}
|
||||
|
||||
return `<li${nextAttrs}>`;
|
||||
});
|
||||
}
|
||||
|
||||
export function countTaskItems(html: string): number {
|
||||
return [...html.matchAll(TASK_ITEM_RE)].length;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
+27
-8
@@ -7,12 +7,7 @@ test("notes happy path", async ({ page }) => {
|
||||
const suffix = Date.now().toString();
|
||||
const title = `E2E Note ${suffix}`;
|
||||
const editedTitle = `E2E Updated ${suffix}`;
|
||||
const body = `# Heading ${suffix}
|
||||
|
||||
- Milk
|
||||
- Bread
|
||||
|
||||
<script>window.__famappInjected = true</script>`;
|
||||
const heading = `Heading ${suffix}`;
|
||||
const reminder = "2026-05-20T09:30";
|
||||
|
||||
await page.goto("/notes");
|
||||
@@ -20,13 +15,24 @@ test("notes happy path", async ({ page }) => {
|
||||
|
||||
await page.getByRole("link", { name: "New note" }).click();
|
||||
await page.getByLabel("Title").fill(title);
|
||||
await page.getByLabel("Body").fill(body);
|
||||
await page.getByLabel("Body").click();
|
||||
await page.getByRole("button", { name: "Heading" }).click();
|
||||
await page.getByLabel("Body").type(heading);
|
||||
await page.getByLabel("Body").press("Enter");
|
||||
await page.getByRole("button", { name: "Bullet list" }).click();
|
||||
await page.getByLabel("Body").type("Milk");
|
||||
await page.getByLabel("Body").press("Enter");
|
||||
await page.getByLabel("Body").type("Bread");
|
||||
await page.getByLabel("Body").press("Enter");
|
||||
await page.getByLabel("Body").type("<script>window.__famappInjected = true</script>");
|
||||
await page.getByLabel("Reminder").fill(reminder);
|
||||
await page.getByRole("button", { name: "Save note" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/notes\/[0-9a-f-]+$/);
|
||||
await expect(page.getByRole("heading", { name: new RegExp(title) })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: `Heading ${suffix}` })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("complementary").getByRole("heading", { name: heading }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("complementary").getByText("Milk")).toBeVisible();
|
||||
await expect(page.locator("script", { hasText: "window.__famappInjected" })).toHaveCount(0);
|
||||
await expect
|
||||
@@ -63,3 +69,16 @@ test("notes happy path", async ({ page }) => {
|
||||
await expect(page).toHaveURL(/\/notes$/);
|
||||
await expect(page.getByRole("link", { name: new RegExp(editedTitle) })).toBeHidden();
|
||||
});
|
||||
|
||||
test("notes mobile layout has no horizontal overflow", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/notes/new");
|
||||
await expect(page.getByLabel("Title")).toBeVisible();
|
||||
|
||||
const overflow = await page.evaluate(() => {
|
||||
const doc = document.documentElement;
|
||||
return doc.scrollWidth > doc.clientWidth;
|
||||
});
|
||||
|
||||
expect(overflow).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { normalizeNoteBody } from "../../src/components/rich-text/normalize-body";
|
||||
import { sanitizeRichTextHtml } from "../../src/components/rich-text/sanitize";
|
||||
import { toggleTaskItemInHtml } from "../../src/components/rich-text/toggle-checklist";
|
||||
|
||||
describe("normalizeNoteBody", () => {
|
||||
it("wraps plain text paragraphs in HTML", () => {
|
||||
const html = normalizeNoteBody("Hello\n\nWorld");
|
||||
assert.match(html, /<p>Hello<\/p>/);
|
||||
assert.match(html, /<p>World<\/p>/);
|
||||
});
|
||||
|
||||
it("preserves existing HTML", () => {
|
||||
const input = "<p><strong>Bold</strong></p>";
|
||||
assert.equal(normalizeNoteBody(input), input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRichTextHtml", () => {
|
||||
it("strips script tags", () => {
|
||||
const safe = sanitizeRichTextHtml('<p>Hi</p><script>alert("x")</script>');
|
||||
assert.doesNotMatch(safe, /<script/i);
|
||||
assert.match(safe, /Hi/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleTaskItemInHtml", () => {
|
||||
it("flips the requested checklist item", () => {
|
||||
const html =
|
||||
'<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>One</p></li><li data-type="taskItem" data-checked="false"><p>Two</p></li></ul>';
|
||||
const next = toggleTaskItemInHtml(html, 1, true);
|
||||
assert.match(next, /data-checked="false"/);
|
||||
assert.match(next, /data-checked="true"/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user