#591 meetings: reuse the real notes Composer (not a custom textarea)
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 <Composer folderSelection={{kind:"all"}}> inside the yellow side-tab toggle, plus <SendNoteDialog> 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.
This commit is contained in:
@@ -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<Map<string, HTMLInputElement | null>>(new Map());
|
||||
const focusItemIdRef = useRef<string | null>(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<ChecklistItem>) => {
|
||||
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 (
|
||||
<div className="space-y-1.5" data-testid={`${testIdPrefix}-list`}>
|
||||
{items.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic px-1">
|
||||
{t("notes.checklist.emptyHint", "No items yet — add one below.")}
|
||||
</div>
|
||||
)}
|
||||
{items.map((it) => (
|
||||
<div
|
||||
key={it.id}
|
||||
className="flex items-center gap-2"
|
||||
data-testid={`${testIdPrefix}-item-${it.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={it.done}
|
||||
onChange={(e) => 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}`}
|
||||
/>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
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}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(it.id)}
|
||||
className="p-1 rounded text-muted-foreground hover:text-destructive"
|
||||
aria-label={t("notes.checklist.removeItem", "Remove item")}
|
||||
data-testid={`${testIdPrefix}-remove-${it.id}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={appendItem}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground px-1 py-1"
|
||||
data-testid={`${testIdPrefix}-add`}
|
||||
>
|
||||
<Plus size={12} />
|
||||
{t("notes.checklist.addItem", "Add item")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(next)}
|
||||
data-testid={testId}
|
||||
aria-pressed={kind === "checklist"}
|
||||
aria-label={t("notes.checklist.toggle", "To-do list")}
|
||||
title={t("notes.checklist.toggle", "To-do list")}
|
||||
className={`p-1 rounded-md hover:bg-black/5 ${
|
||||
kind === "checklist"
|
||||
? "text-amber-600 bg-amber-100/60"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<ListTodo size={15} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (c: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
|
||||
aria-label="Color"
|
||||
>
|
||||
<Palette size={15} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-2">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{NOTE_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onChange(c.id)}
|
||||
className={`w-7 h-7 rounded-full border border-black/10 ${c.bg} ${
|
||||
value === c.id ? "ring-2 ring-offset-1 " + c.ring : ""
|
||||
}`}
|
||||
aria-label={c.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
|
||||
aria-label="Labels"
|
||||
>
|
||||
<Tag size={15} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-2">
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{labels.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||
{t("notes.noLabelsYet", "No labels yet")}
|
||||
</div>
|
||||
)}
|
||||
{labels.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
onClick={() => toggle(l.id)}
|
||||
className="w-full flex items-center justify-between px-2 py-1 text-sm rounded hover:bg-slate-100"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag size={12} /> {l.name}
|
||||
</span>
|
||||
{selected.includes(l.id) && <Check size={14} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1 border-t pt-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => 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("") });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!name.trim()}
|
||||
onClick={() =>
|
||||
create.mutate(name.trim(), { onSuccess: () => setName("") })
|
||||
}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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<number[]>([]);
|
||||
const [kind, setKind] = useState<NoteKind>("text");
|
||||
const [items, setItems] = useState<ChecklistItem[]>([]);
|
||||
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<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLTextAreaElement | null>(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
|
||||
// <SendNoteDialog>. 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 (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="notes-composer"
|
||||
className={`max-w-xl mx-auto rounded-xl border border-black/10 shadow-sm p-3 transition ${colorBg(
|
||||
color,
|
||||
)}`}
|
||||
>
|
||||
{open ? (
|
||||
<>
|
||||
{kind === "checklist" ? (
|
||||
<div className="px-1">
|
||||
<ChecklistEditor
|
||||
items={items}
|
||||
onChange={setItems}
|
||||
testIdPrefix="notes-composer-checklist"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
autoFocus
|
||||
ref={contentRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onFocus={(e) => {
|
||||
contentFocused.current = true;
|
||||
if (keyboardInset > 80) {
|
||||
e.currentTarget.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
contentFocused.current = false;
|
||||
}}
|
||||
placeholder={t("notes.contentPlaceholder", "Take a note...")}
|
||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[80px] resize-none"
|
||||
data-testid="notes-composer-content"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<ColorPicker value={color} onChange={setColor} />
|
||||
<KindToggle
|
||||
kind={kind}
|
||||
onChange={setKind}
|
||||
testId="notes-composer-kind-toggle"
|
||||
/>
|
||||
<LabelMenu
|
||||
labels={labels}
|
||||
selected={labelIds}
|
||||
onChange={setLabelIds}
|
||||
/>
|
||||
<div className="ms-auto flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={save} data-testid="notes-composer-save">
|
||||
{t("notes.save", "Save")}
|
||||
</Button>
|
||||
{/* Send action inside the composer. Saves first
|
||||
(auto-save on send was explicitly OK'd by the user),
|
||||
then opens <SendNoteDialog> for the new note. Disabled
|
||||
while empty or while the create request is in flight
|
||||
to prevent duplicate notes from rapid clicks. */}
|
||||
{!hideSend && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={sendNew}
|
||||
disabled={sendDisabled}
|
||||
aria-label={t("notes.send", "Send")}
|
||||
data-testid="notes-composer-send"
|
||||
className="px-2"
|
||||
>
|
||||
<Send size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-full flex items-center gap-2 text-start text-muted-foreground py-1 px-1"
|
||||
data-testid="notes-composer-open"
|
||||
>
|
||||
<Plus
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{t("notes.takeNote", "Take a note...")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Search, Send } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
useSendNote,
|
||||
useUserDirectory,
|
||||
userDisplayName,
|
||||
} from "@/lib/notes-api";
|
||||
|
||||
export function SendNoteDialog({
|
||||
noteId,
|
||||
onClose,
|
||||
}: {
|
||||
noteId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { user } = useAuth();
|
||||
const { data: directory = [], isLoading } = useUserDirectory();
|
||||
const send = useSendNote();
|
||||
const [search, setSearch] = useState("");
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const me = user?.id;
|
||||
return directory.filter((u) => u.id !== me);
|
||||
}, [directory, user]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return candidates;
|
||||
return candidates.filter((u) =>
|
||||
[u.username, u.displayNameAr, u.displayNameEn]
|
||||
.filter(Boolean)
|
||||
.some((s) => (s as string).toLowerCase().includes(q)),
|
||||
);
|
||||
}, [candidates, search]);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const performSend = () => {
|
||||
send.mutate(
|
||||
{ id: noteId, recipientUserIds: Array.from(picked) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: t("notes.sendSuccess", "Note sent") });
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
setConfirmOpen(false);
|
||||
toast({
|
||||
title: t("notes.sendFailed", "Could not send note"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const requestSubmit = () => {
|
||||
if (picked.size === 0 || send.isPending) return;
|
||||
if (picked.size === 1) {
|
||||
performSend();
|
||||
return;
|
||||
}
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const confirmSubmit = () => {
|
||||
performSend();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md" data-testid="send-note-dialog">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle>{t("notes.sendNote", "Send note")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute top-1/2 -translate-y-1/2 text-muted-foreground start-2.5"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
|
||||
className="ps-9"
|
||||
data-testid="send-note-search"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto border rounded-lg divide-y">
|
||||
{isLoading && (
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
{t("common.loading", "Loading...")}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((u) => {
|
||||
const checked = picked.has(u.id);
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => toggle(u.id)}
|
||||
data-testid={`send-recipient-option-${u.id}`}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-slate-100 ${
|
||||
checked ? "bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{userDisplayName(u, lang)}</span>
|
||||
{checked && <Check size={14} className="text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{picked.size > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("notes.recipientsCount", { count: picked.size })}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={requestSubmit}
|
||||
disabled={picked.size === 0 || send.isPending}
|
||||
data-testid="send-note-submit"
|
||||
>
|
||||
<Send size={14} className="me-1" />
|
||||
{t("notes.send", "Send")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent data-testid="send-note-confirm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("notes.confirmSendTitle", "Send this note?")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("notes.confirmSendBody", { count: picked.size })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmSubmit}
|
||||
disabled={send.isPending}
|
||||
data-testid="send-note-confirm-submit"
|
||||
>
|
||||
{t("notes.confirmSendCta", "Send")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -113,6 +113,9 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { EditableCell } from "@/components/editable-cell";
|
||||
import { Composer } from "@/components/notes/composer";
|
||||
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
|
||||
import { useNoteLabels } from "@/lib/notes-api";
|
||||
// #486: reuse the same dialog the upcoming-alert pops, so postponing
|
||||
// from the schedule row quick-actions menu shares one implementation.
|
||||
import { PostponeDialog } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||
@@ -834,113 +837,6 @@ function MeetingsQuickNoteTab({
|
||||
);
|
||||
}
|
||||
|
||||
function InlineQuickNotePanel({
|
||||
lang,
|
||||
onClose,
|
||||
}: {
|
||||
lang: "ar" | "en";
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
const submit = async () => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/notes", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: trimmed, color: "yellow" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setContent("");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const placeholder = t(
|
||||
"executiveMeetings.quickNote.placeholder",
|
||||
lang === "ar" ? "اكتب ملاحظة سريعة…" : "Write a quick note…",
|
||||
);
|
||||
const sendLabel = t("executiveMeetings.quickNote.send", lang === "ar" ? "إرسال" : "Send");
|
||||
const cancelLabel = t("common.cancel", lang === "ar" ? "إلغاء" : "Cancel");
|
||||
return (
|
||||
<div
|
||||
data-testid="em-quick-note-panel"
|
||||
className="mb-3 sm:mb-4 rounded-lg border border-amber-300 bg-amber-50 shadow-sm print:hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-amber-200">
|
||||
<div className="text-sm font-semibold text-[#0B1E3F]">
|
||||
{t("executiveMeetings.quickNote.label", lang === "ar" ? "اكتب ملاحظة…" : "Take a note…")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={cancelLabel}
|
||||
data-testid="em-quick-note-close"
|
||||
className="rounded-md p-1 text-[#0B1E3F] hover:bg-amber-200/70"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
className="w-full resize-y rounded-md border border-amber-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-300"
|
||||
data-testid="em-quick-note-textarea"
|
||||
/>
|
||||
{error && (
|
||||
<div className="text-xs text-red-600" data-testid="em-quick-note-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose} type="button">
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || content.trim().length === 0}
|
||||
data-testid="em-quick-note-send"
|
||||
className="bg-[#0B1E3F] text-white hover:bg-[#0B1E3F]/90"
|
||||
>
|
||||
{sendLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecutiveMeetingsPageInner() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -955,6 +851,15 @@ function ExecutiveMeetingsPageInner() {
|
||||
// transient local state (not sessionStorage) — it's a quick capture,
|
||||
// not a view preference. Auto-closes when leaving the schedule tab.
|
||||
const [quickNoteOpen, setQuickNoteOpen] = useState<boolean>(false);
|
||||
// Bumped each time the yellow side tab opens the panel, so the
|
||||
// shared <Composer> expands and focuses its textarea on open (it
|
||||
// reacts to changes in this numeric trigger).
|
||||
const [quickNoteTrigger, setQuickNoteTrigger] = useState<number>(0);
|
||||
// When the user hits the paper-plane Send inside the composer we
|
||||
// save the note first, then mount <SendNoteDialog> for that id —
|
||||
// same flow as on /notes.
|
||||
const [sendForNoteId, setSendForNoteId] = useState<number | null>(null);
|
||||
const { data: quickNoteLabels = [] } = useNoteLabels();
|
||||
useEffect(() => {
|
||||
if (section !== "schedule") setQuickNoteOpen(false);
|
||||
}, [section]);
|
||||
@@ -1117,7 +1022,10 @@ function ExecutiveMeetingsPageInner() {
|
||||
{section === "schedule" && !isFullscreen && !bulkToolbarActive && !quickNoteOpen && (
|
||||
<MeetingsQuickNoteTab
|
||||
lang={lang}
|
||||
onClick={() => setQuickNoteOpen(true)}
|
||||
onClick={() => {
|
||||
setQuickNoteOpen(true);
|
||||
setQuickNoteTrigger((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isFullscreen && (
|
||||
@@ -1229,9 +1137,39 @@ function ExecutiveMeetingsPageInner() {
|
||||
}
|
||||
>
|
||||
{section === "schedule" && quickNoteOpen && (
|
||||
<InlineQuickNotePanel
|
||||
lang={lang}
|
||||
onClose={() => setQuickNoteOpen(false)}
|
||||
// Reuse the real notes <Composer> instead of a custom textarea
|
||||
// so the meetings quick-note matches /notes exactly (yellow
|
||||
// card, color picker, label menu, checklist toggle, Save +
|
||||
// paper-plane Send). folderSelection "all" → unfiled, so the
|
||||
// saved note shows up at the top of the Notes app.
|
||||
<div
|
||||
data-testid="em-quick-note-panel"
|
||||
className="relative mb-3 sm:mb-4 print:hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuickNoteOpen(false)}
|
||||
aria-label={t("common.cancel", lang === "ar" ? "إلغاء" : "Cancel")}
|
||||
data-testid="em-quick-note-close"
|
||||
className="absolute z-10 top-1 end-1 rounded-full p-1 text-[#0B1E3F]/70 hover:bg-black/5 hover:text-[#0B1E3F]"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<Composer
|
||||
labels={quickNoteLabels}
|
||||
folderSelection={{ kind: "all" }}
|
||||
onSend={(id) => {
|
||||
setSendForNoteId(id);
|
||||
setQuickNoteOpen(false);
|
||||
}}
|
||||
openTrigger={quickNoteTrigger}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{sendForNoteId !== null && (
|
||||
<SendNoteDialog
|
||||
noteId={sendForNoteId}
|
||||
onClose={() => setSendForNoteId(null)}
|
||||
/>
|
||||
)}
|
||||
{section === "schedule" && (
|
||||
|
||||
@@ -117,6 +117,14 @@ import {
|
||||
FoldersRail,
|
||||
type FolderSelection,
|
||||
} from "@/components/notes/folders-rail";
|
||||
import {
|
||||
ChecklistEditor,
|
||||
ColorPicker,
|
||||
Composer,
|
||||
KindToggle,
|
||||
LabelMenu,
|
||||
} from "@/components/notes/composer";
|
||||
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
|
||||
import {
|
||||
DndContext,
|
||||
MouseSensor,
|
||||
@@ -2508,172 +2516,6 @@ function StatusDot({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SendNoteDialog({
|
||||
noteId,
|
||||
onClose,
|
||||
}: {
|
||||
noteId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { user } = useAuth();
|
||||
const { data: directory = [], isLoading } = useUserDirectory();
|
||||
const send = useSendNote();
|
||||
const [search, setSearch] = useState("");
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const me = user?.id;
|
||||
return directory.filter((u) => u.id !== me);
|
||||
}, [directory, user]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return candidates;
|
||||
return candidates.filter((u) =>
|
||||
[u.username, u.displayNameAr, u.displayNameEn]
|
||||
.filter(Boolean)
|
||||
.some((s) => (s as string).toLowerCase().includes(q)),
|
||||
);
|
||||
}, [candidates, search]);
|
||||
|
||||
const toggle = (id: number) => {
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const performSend = () => {
|
||||
send.mutate(
|
||||
{ id: noteId, recipientUserIds: Array.from(picked) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: t("notes.sendSuccess", "Note sent") });
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
setConfirmOpen(false);
|
||||
toast({
|
||||
title: t("notes.sendFailed", "Could not send note"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const requestSubmit = () => {
|
||||
if (picked.size === 0 || send.isPending) return;
|
||||
if (picked.size === 1) {
|
||||
performSend();
|
||||
return;
|
||||
}
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const confirmSubmit = () => {
|
||||
performSend();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md" data-testid="send-note-dialog">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle>{t("notes.sendNote", "Send note")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute top-1/2 -translate-y-1/2 text-muted-foreground start-2.5"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
|
||||
className="ps-9"
|
||||
data-testid="send-note-search"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto border rounded-lg divide-y">
|
||||
{isLoading && (
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
{t("common.loading", "Loading...")}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((u) => {
|
||||
const checked = picked.has(u.id);
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => toggle(u.id)}
|
||||
data-testid={`send-recipient-option-${u.id}`}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-slate-100 ${
|
||||
checked ? "bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{userDisplayName(u, lang)}</span>
|
||||
{checked && <Check size={14} className="text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{picked.size > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("notes.recipientsCount", { count: picked.size })}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={requestSubmit}
|
||||
disabled={picked.size === 0 || send.isPending}
|
||||
data-testid="send-note-submit"
|
||||
>
|
||||
<Send size={14} className="me-1" />
|
||||
{t("notes.send", "Send")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent data-testid="send-note-confirm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("notes.confirmSendTitle", "Send this note?")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("notes.confirmSendBody", { count: picked.size })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmSubmit}
|
||||
disabled={send.isPending}
|
||||
data-testid="send-note-confirm-submit"
|
||||
>
|
||||
{t("notes.confirmSendCta", "Send")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchivedView({
|
||||
loadingMine,
|
||||
mine,
|
||||
@@ -3169,120 +3011,6 @@ function GroupedReplies({
|
||||
);
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
function ChecklistEditor({
|
||||
items,
|
||||
onChange,
|
||||
testIdPrefix,
|
||||
}: {
|
||||
items: ChecklistItem[];
|
||||
onChange: (next: ChecklistItem[]) => void;
|
||||
testIdPrefix: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inputRefs = useRef<Map<string, HTMLInputElement | null>>(new Map());
|
||||
const focusItemIdRef = useRef<string | null>(null);
|
||||
|
||||
// After append (Enter or Add), shift focus to the freshly inserted row.
|
||||
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<ChecklistItem>) => {
|
||||
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 (
|
||||
<div className="space-y-1.5" data-testid={`${testIdPrefix}-list`}>
|
||||
{items.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic px-1">
|
||||
{t("notes.checklist.emptyHint", "No items yet — add one below.")}
|
||||
</div>
|
||||
)}
|
||||
{items.map((it) => (
|
||||
<div
|
||||
key={it.id}
|
||||
className="flex items-center gap-2"
|
||||
data-testid={`${testIdPrefix}-item-${it.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={it.done}
|
||||
onChange={(e) => 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}`}
|
||||
/>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
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}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(it.id)}
|
||||
className="p-1 rounded text-muted-foreground hover:text-destructive"
|
||||
aria-label={t("notes.checklist.removeItem", "Remove item")}
|
||||
data-testid={`${testIdPrefix}-remove-${it.id}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={appendItem}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground px-1 py-1"
|
||||
data-testid={`${testIdPrefix}-add`}
|
||||
>
|
||||
<Plus size={12} />
|
||||
{t("notes.checklist.addItem", "Add item")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistView({
|
||||
items,
|
||||
onToggle,
|
||||
@@ -3338,404 +3066,6 @@ function ChecklistView({
|
||||
);
|
||||
}
|
||||
|
||||
function KindToggle({
|
||||
kind,
|
||||
onChange,
|
||||
testId,
|
||||
}: {
|
||||
kind: NoteKind;
|
||||
onChange: (k: NoteKind) => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const next: NoteKind = kind === "text" ? "checklist" : "text";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(next)}
|
||||
data-testid={testId}
|
||||
aria-pressed={kind === "checklist"}
|
||||
aria-label={t("notes.checklist.toggle", "To-do list")}
|
||||
title={t("notes.checklist.toggle", "To-do list")}
|
||||
className={`p-1 rounded-md hover:bg-black/5 ${
|
||||
kind === "checklist"
|
||||
? "text-amber-600 bg-amber-100/60"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<ListTodo size={15} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (c: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
|
||||
aria-label="Color"
|
||||
>
|
||||
<Palette size={15} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-2">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{NOTE_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onChange(c.id)}
|
||||
className={`w-7 h-7 rounded-full border border-black/10 ${c.bg} ${
|
||||
value === c.id ? "ring-2 ring-offset-1 " + c.ring : ""
|
||||
}`}
|
||||
aria-label={c.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
|
||||
aria-label="Labels"
|
||||
>
|
||||
<Tag size={15} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-2">
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{labels.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||
{t("notes.noLabelsYet", "No labels yet")}
|
||||
</div>
|
||||
)}
|
||||
{labels.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
onClick={() => toggle(l.id)}
|
||||
className="w-full flex items-center justify-between px-2 py-1 text-sm rounded hover:bg-slate-100"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag size={12} /> {l.name}
|
||||
</span>
|
||||
{selected.includes(l.id) && <Check size={14} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1 border-t pt-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => 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("") });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!name.trim()}
|
||||
onClick={() =>
|
||||
create.mutate(name.trim(), { onSuccess: () => setName("") })
|
||||
}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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 arriving via /notes?new=1) 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<number[]>([]);
|
||||
const [kind, setKind] = useState<NoteKind>("text");
|
||||
const [items, setItems] = useState<ChecklistItem[]>([]);
|
||||
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<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLTextAreaElement | null>(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 (e.g. /notes?new=1 from the meetings quick
|
||||
// note tab). 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 (e.g. user was in a shared folder and clicked the
|
||||
// quick-note tab).
|
||||
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
|
||||
// <SendNoteDialog>. 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 (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="notes-composer"
|
||||
className={`max-w-xl mx-auto rounded-xl border border-black/10 shadow-sm p-3 transition ${colorBg(
|
||||
color,
|
||||
)}`}
|
||||
>
|
||||
{open ? (
|
||||
<>
|
||||
{kind === "checklist" ? (
|
||||
<div className="px-1">
|
||||
<ChecklistEditor
|
||||
items={items}
|
||||
onChange={setItems}
|
||||
testIdPrefix="notes-composer-checklist"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
autoFocus
|
||||
ref={contentRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onFocus={(e) => {
|
||||
contentFocused.current = true;
|
||||
if (keyboardInset > 80) {
|
||||
e.currentTarget.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
contentFocused.current = false;
|
||||
}}
|
||||
placeholder={t("notes.contentPlaceholder", "Take a note...")}
|
||||
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[80px] resize-none"
|
||||
data-testid="notes-composer-content"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<ColorPicker value={color} onChange={setColor} />
|
||||
<KindToggle
|
||||
kind={kind}
|
||||
onChange={setKind}
|
||||
testId="notes-composer-kind-toggle"
|
||||
/>
|
||||
<LabelMenu
|
||||
labels={labels}
|
||||
selected={labelIds}
|
||||
onChange={setLabelIds}
|
||||
/>
|
||||
<div className="ms-auto flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={save} data-testid="notes-composer-save">
|
||||
{t("notes.save", "Save")}
|
||||
</Button>
|
||||
{/* Send action inside the composer. Saves first
|
||||
(auto-save on send was explicitly OK'd by the user),
|
||||
then opens <SendNoteDialog> for the new note. Disabled
|
||||
while empty or while the create request is in flight to
|
||||
prevent duplicate notes from rapid clicks. */}
|
||||
{!hideSend && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={sendNew}
|
||||
disabled={sendDisabled}
|
||||
aria-label={t("notes.send", "Send")}
|
||||
data-testid="notes-composer-send"
|
||||
className="px-2"
|
||||
>
|
||||
<Send size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-full flex items-center gap-2 text-start text-muted-foreground py-1 px-1"
|
||||
data-testid="notes-composer-open"
|
||||
>
|
||||
<Plus
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{t("notes.takeNote", "Take a note...")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditNoteDialog({
|
||||
note,
|
||||
labels,
|
||||
|
||||
Reference in New Issue
Block a user