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
@@ -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}`);
}