From d97f22764a2d8419a3d44c8b4ff89b78fbe7e07b Mon Sep 17 00:00:00 2001 From: Riyadh Date: Mon, 18 May 2026 11:15:22 +0000 Subject: [PATCH] #591 meetings: reuse the real notes Composer (not a custom textarea) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The yellow side tab on /executive-meetings previously opened a bespoke textarea panel I added in #590. The user pointed out (screenshot) that it should be the exact same composer card they use on /notes — yellow background, color picker, label menu, checklist toggle, "حفظ" Save, paper-plane Send. Changes: - Extracted Composer + its inline helpers (ChecklistEditor, KindToggle, ColorPicker, LabelMenu, newItemId) out of pages/notes.tsx into a new shared module at components/notes/composer.tsx. - Extracted SendNoteDialog out of pages/notes.tsx into a new shared module at components/notes/send-note-dialog.tsx. - pages/notes.tsx now imports both modules; behavior of the notes page is unchanged. - pages/executive-meetings.tsx: deleted InlineQuickNotePanel. The schedule view now renders inside the yellow side-tab toggle, plus for the paper-plane Send flow. A new quickNoteTrigger counter is bumped each time the tab opens so the shared Composer auto-expands and focuses its textarea. A small X in the corner dismisses the panel; Save just collapses the composer (matches /notes UX), Send saves then opens the recipient picker. - Notes created from the meetings panel land as unfiled notes in the user's notes (folder "all"), visible immediately in /notes. Notes: - No locale-key cleanup needed: the old quickNote.placeholder/send keys lived only as t() fallbacks, not in en.json/ar.json. - pnpm --filter @workspace/tx-os exec tsc --noEmit: clean. - HMR errors visible in the browser console were from the intermediate syntax-error state during extraction; cleared after the final edit. --- .../tx-os/src/components/notes/composer.tsx | 537 ++++++++++++++ .../src/components/notes/send-note-dialog.tsx | 195 +++++ .../tx-os/src/pages/executive-meetings.tsx | 160 ++-- artifacts/tx-os/src/pages/notes.tsx | 686 +----------------- 4 files changed, 789 insertions(+), 789 deletions(-) create mode 100644 artifacts/tx-os/src/components/notes/composer.tsx create mode 100644 artifacts/tx-os/src/components/notes/send-note-dialog.tsx diff --git a/artifacts/tx-os/src/components/notes/composer.tsx b/artifacts/tx-os/src/components/notes/composer.tsx new file mode 100644 index 00000000..d73f9484 --- /dev/null +++ b/artifacts/tx-os/src/components/notes/composer.tsx @@ -0,0 +1,537 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, ListTodo, Palette, Plus, Send, Tag, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useVisualViewport } from "@/hooks/use-visual-viewport"; +import { + type ChecklistItem, + type NoteKind, + type NoteLabel, + NOTE_COLORS, + colorAccent, + colorBg, + useCreateLabel, + useCreateNote, +} from "@/lib/notes-api"; +import { type FolderSelection } from "@/components/notes/folders-rail"; + +export function newItemId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `it_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`; +} + +export function ChecklistEditor({ + items, + onChange, + testIdPrefix, +}: { + items: ChecklistItem[]; + onChange: (next: ChecklistItem[]) => void; + testIdPrefix: string; +}) { + const { t } = useTranslation(); + const inputRefs = useRef>(new Map()); + const focusItemIdRef = useRef(null); + + useEffect(() => { + const id = focusItemIdRef.current; + if (!id) return; + const el = inputRefs.current.get(id); + if (el) { + el.focus(); + focusItemIdRef.current = null; + } + }, [items]); + + const setItem = (id: string, patch: Partial) => { + onChange(items.map((it) => (it.id === id ? { ...it, ...patch } : it))); + }; + const removeItem = (id: string) => { + onChange(items.filter((it) => it.id !== id)); + }; + const appendItem = () => { + const item: ChecklistItem = { id: newItemId(), text: "", done: false }; + focusItemIdRef.current = item.id; + onChange([...items, item]); + }; + + return ( +
+ {items.length === 0 && ( +
+ {t("notes.checklist.emptyHint", "No items yet — add one below.")} +
+ )} + {items.map((it) => ( +
+ setItem(it.id, { done: e.target.checked })} + className="h-4 w-4 rounded border-black/30 cursor-pointer accent-amber-500" + aria-label={t("notes.checklist.itemPlaceholder", "List item")} + data-testid={`${testIdPrefix}-check-${it.id}`} + /> + { + if (el) inputRefs.current.set(it.id, el); + else inputRefs.current.delete(it.id); + }} + value={it.text} + onChange={(e) => setItem(it.id, { text: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + appendItem(); + } else if ( + e.key === "Backspace" && + it.text.length === 0 && + items.length > 1 + ) { + e.preventDefault(); + removeItem(it.id); + } + }} + placeholder={t("notes.checklist.itemPlaceholder", "List item")} + className={`h-8 border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 ${ + it.done ? "line-through text-muted-foreground" : "" + }`} + data-testid={`${testIdPrefix}-text-${it.id}`} + /> + +
+ ))} + +
+ ); +} + +// Re-export colorAccent so callers (e.g. ChecklistView in notes.tsx) +// can access it via this module without bypassing it through notes-api. +export { colorAccent }; + +export function KindToggle({ + kind, + onChange, + testId, +}: { + kind: NoteKind; + onChange: (k: NoteKind) => void; + testId?: string; +}) { + const { t } = useTranslation(); + const next: NoteKind = kind === "text" ? "checklist" : "text"; + return ( + + ); +} + +export function ColorPicker({ + value, + onChange, +}: { + value: string; + onChange: (c: string) => void; +}) { + return ( + + + + + +
+ {NOTE_COLORS.map((c) => ( +
+
+
+ ); +} + +export function LabelMenu({ + labels, + selected, + onChange, +}: { + labels: NoteLabel[]; + selected: number[]; + onChange: (ids: number[]) => void; +}) { + const { t } = useTranslation(); + const create = useCreateLabel(); + const [name, setName] = useState(""); + + const toggle = (id: number) => { + if (selected.includes(id)) onChange(selected.filter((x) => x !== id)); + else onChange([...selected, id]); + }; + + return ( + + + + + +
+ {labels.length === 0 && ( +
+ {t("notes.noLabelsYet", "No labels yet")} +
+ )} + {labels.map((l) => ( + + ))} +
+
+ setName(e.target.value)} + placeholder={t("notes.newLabel", "New label")} + className="h-8 text-sm" + onKeyDown={(e) => { + if (e.key === "Enter" && name.trim()) { + create.mutate(name.trim(), { onSuccess: () => setName("") }); + } + }} + /> + +
+
+
+ ); +} + +export function Composer({ + labels, + folderSelection, + onSend, + hideSend = false, + openTrigger = 0, +}: { + labels: NoteLabel[]; + folderSelection: FolderSelection; + onSend: (id: number) => void; + // shared-folder editors create notes that get stamped to + // the folder owner; the owner-only `/notes/:id/send` route would 403 + // for them, so the composer hides its Send button in that context. + hideSend?: boolean; + // Numeric trigger bumped by the page when an external action (e.g. + // the meetings quick-note tab) wants the composer expanded and + // focused. Each new value opens + focuses; the count itself is + // irrelevant — only the change matters. + openTrigger?: number; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [content, setContent] = useState(""); + const [color, setColor] = useState("yellow"); + const [labelIds, setLabelIds] = useState([]); + const [kind, setKind] = useState("text"); + const [items, setItems] = useState([]); + const create = useCreateNote(); + // Resolve the folder the new note should land in based on the active + // selection. "all" → null (unfiled / global), "unfiled" → null, + // "folder" → that folder id. Without this the composer always created + // notes in Unfiled, so a note made from inside a folder vanished from + // the current view. + const targetFolderId = + folderSelection.kind === "folder" ? folderSelection.id : null; + const ref = useRef(null); + const contentRef = useRef(null); + const contentFocused = useRef(false); + const { keyboardInset } = useVisualViewport(); + // Same keyboard-aware scroll-into-view as the thread reply box: only + // fires while a software keyboard is actually open and the composer + // textarea is focused. Desktop focus is unaffected. + useEffect(() => { + if (keyboardInset <= 80) return; + if (!contentFocused.current) return; + contentRef.current?.scrollIntoView({ block: "center" }); + }, [keyboardInset]); + + useEffect(() => { + if (!open) return; + const onClick = (e: MouseEvent) => { + const target = e.target as Node | null; + if (ref.current?.contains(target)) return; + // Ignore clicks inside Radix popover/portal content (the color and + // label menus render in a portal outside the composer's DOM tree). + // Without this guard, picking a swatch would trigger save() on the + // mousedown — before the swatch's onClick updated `color` — so the + // new color was never persisted. + const el = target instanceof Element ? target : null; + if ( + el?.closest( + "[data-radix-popper-content-wrapper],[data-radix-portal],[role='dialog']", + ) + ) { + return; + } + save(); + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, content, color, labelIds, kind, items]); + + const reset = () => { + setContent(""); + setColor("yellow"); + setLabelIds([]); + setKind("text"); + setItems([]); + setOpen(false); + }; + + // External open trigger. React both to subsequent bumps AND to the + // initial mount when the trigger is already > 0 — this covers the + // case where the page resets folder selection and the Composer + // mounts fresh with a non-zero trigger. + const lastOpenTrigger = useRef(0); + useEffect(() => { + if (openTrigger === 0) return; + if (openTrigger === lastOpenTrigger.current) return; + lastOpenTrigger.current = openTrigger; + setOpen(true); + setKind("text"); + requestAnimationFrame(() => { + const el = contentRef.current; + if (!el) return; + el.focus(); + el.scrollIntoView({ block: "center", behavior: "smooth" }); + }); + }, [openTrigger]); + + // Build the create-note payload from current composer state and a + // boolean indicating whether the composer is empty (no body / + // checklist items). Shared by save() and sendNew() so the + // "save before send" path uses the exact same data the bare Save + // button would have submitted. + const buildPayload = () => { + const cleanItems = + kind === "checklist" + ? items + .map((it) => ({ ...it, text: it.text.trim() })) + .filter((it) => it.text.length > 0) + : []; + const isEmpty = + kind === "checklist" ? cleanItems.length === 0 : !content.trim(); + return { + isEmpty, + payload: { + content: kind === "checklist" ? "" : content.trim(), + color, + labelIds, + kind, + items: kind === "checklist" ? cleanItems : null, + folderId: targetFolderId, + }, + }; + }; + + const save = () => { + const { isEmpty, payload } = buildPayload(); + if (isEmpty) { + reset(); + return; + } + create.mutate(payload, { onSuccess: reset }); + }; + + // Send button inside the composer. The plan agreed with the user is + // "save first, then open the send dialog for the new note". On + // success we reset the composer (mirroring save()) and hand the + // freshly created note's id up to the page so it can mount + // . On failure we leave the composer state intact + // so the user can retry — same as the existing save error behavior. + const sendNew = () => { + const { isEmpty, payload } = buildPayload(); + if (isEmpty || create.isPending) return; + create.mutate(payload, { + onSuccess: (created) => { + reset(); + onSend(created.id); + }, + }); + }; + + const sendDisabled = + create.isPending || + (kind === "checklist" + ? items.every((it) => !it.text.trim()) + : !content.trim()); + + return ( +
+ {open ? ( + <> + {kind === "checklist" ? ( +
+ +
+ ) : ( +