feat(memo): add and delete sections via RPC
Add memo.addSection and memo.deleteSection RPC methods with full-stack implementation: contract types, Zod schemas, DB queries, RPC handlers, UI buttons, and tests. - memo.addSection: creates a new empty section, supports positional insert after a given section via afterSectionId - memo.deleteSection: removes a section and cascades cleanup of its annotations, citations, and section reviews - Outline sidebar: per-section delete button, + Add Section button - Article area: + Add Section button after last section - 9 new tests (15 total memo tests, 48 project-wide)
This commit is contained in:
@@ -1914,6 +1914,8 @@ function Memo({
|
||||
>({});
|
||||
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [addingSection, setAddingSection] = useState(false);
|
||||
const [deletingSectionId, setDeletingSectionId] = useState<string | null>(null);
|
||||
const saveTimers = useRef<Record<string, number>>({});
|
||||
const sections = sectionsDraft;
|
||||
const activeIndex = Math.max(
|
||||
@@ -2123,6 +2125,64 @@ function Memo({
|
||||
else setSaveError(result.error.message);
|
||||
}
|
||||
|
||||
async function addSection(afterSectionId?: string) {
|
||||
if (!company) return;
|
||||
setAddingSection(true);
|
||||
try {
|
||||
const index = afterSectionId
|
||||
? sections.findIndex((s) => s.id === afterSectionId)
|
||||
: sections.length - 1;
|
||||
const title = `New Section`;
|
||||
const result = await rpc.call("memo.addSection", {
|
||||
companyId: company.id,
|
||||
title,
|
||||
afterSectionId,
|
||||
});
|
||||
if (result.ok) {
|
||||
const newSection = result.data.section;
|
||||
setSectionsDraft((prev) => {
|
||||
const next = [...prev];
|
||||
next.splice(index + 1, 0, newSection);
|
||||
return next;
|
||||
});
|
||||
setActiveSectionId(newSection.id);
|
||||
setSaveStateBySection((prev) => ({ ...prev, [newSection.id]: "saved" }));
|
||||
addToast({ type: "success", title: "Section added", desc: `"${title}" added to memo` });
|
||||
} else {
|
||||
addToast({ type: "error", title: "Could not add section", desc: result.error.message });
|
||||
}
|
||||
} finally {
|
||||
setAddingSection(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSection(sectionId: string) {
|
||||
if (!company) return;
|
||||
if (sections.length <= 1) {
|
||||
addToast({ type: "warning", title: "Cannot delete", desc: "A memo must have at least one section." });
|
||||
return;
|
||||
}
|
||||
setDeletingSectionId(sectionId);
|
||||
try {
|
||||
const result = await rpc.call("memo.deleteSection", {
|
||||
companyId: company.id,
|
||||
sectionId,
|
||||
});
|
||||
if (result.ok) {
|
||||
setSectionsDraft((prev) => prev.filter((s) => s.id !== sectionId));
|
||||
if (activeSectionId === sectionId) {
|
||||
const remaining = sections.filter((s) => s.id !== sectionId);
|
||||
setActiveSectionId(remaining[0]?.id ?? "");
|
||||
}
|
||||
addToast({ type: "success", title: "Section deleted" });
|
||||
} else {
|
||||
addToast({ type: "error", title: "Could not delete section", desc: result.error.message });
|
||||
}
|
||||
} finally {
|
||||
setDeletingSectionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const activeCitations = memo.citations.filter(
|
||||
(c) => c.sectionId === activeSectionId,
|
||||
);
|
||||
@@ -2176,22 +2236,43 @@ function Memo({
|
||||
</div>
|
||||
{!outlineCollapsed &&
|
||||
sections.map((s, i) => (
|
||||
<button
|
||||
className={navButton(s.id === activeSectionId)}
|
||||
<div
|
||||
className={navItem(s.id === activeSectionId)}
|
||||
key={s.id}
|
||||
onClick={() => activateSection(s.id, true)}
|
||||
>
|
||||
<span className="mr-2 font-[var(--font-mono)] text-[10px] text-[var(--muted)]">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{s.title}
|
||||
{s.primaryAgent && (
|
||||
<span className="ml-auto font-[var(--font-mono)] text-[9px] text-[var(--muted)]">
|
||||
[{s.primaryAgent}]
|
||||
<button
|
||||
className="flex-1 text-left bg-transparent border-0 p-0 text-inherit cursor-pointer"
|
||||
onClick={() => activateSection(s.id, true)}
|
||||
>
|
||||
<span className="mr-2 font-[var(--font-mono)] text-[10px] text-[var(--muted)]">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{s.title}
|
||||
{s.primaryAgent && (
|
||||
<span className="ml-auto font-[var(--font-mono)] text-[9px] text-[var(--muted)]">
|
||||
[{s.primaryAgent}]
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={cx(ui.iconBtn, "shrink-0 text-[var(--muted)] hover:text-[var(--red)]", deletingSectionId === s.id && "opacity-50")}
|
||||
aria-label={`Delete section ${s.title}`}
|
||||
disabled={deletingSectionId === s.id || sections.length <= 1}
|
||||
onClick={() => void deleteSection(s.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!outlineCollapsed && (
|
||||
<button
|
||||
className={cx(ui.btn, ui.btnSm, "mt-2 w-full", addingSection && "opacity-50")}
|
||||
disabled={addingSection}
|
||||
onClick={() => void addSection()}
|
||||
>
|
||||
{addingSection ? "Adding..." : "+ Add Section"}
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
<article className="mx-auto min-w-0 max-w-[720px] overflow-y-auto px-8 py-8">
|
||||
<div className="sticky top-0 z-10 mb-5 flex items-center gap-2 bg-[var(--bg)] py-2">
|
||||
@@ -2309,6 +2390,13 @@ function Memo({
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
<button
|
||||
className={cx(ui.btn, ui.btnSm, "mt-4 mb-8 w-full", addingSection && "opacity-50")}
|
||||
disabled={addingSection}
|
||||
onClick={() => void addSection(sections[sections.length - 1]?.id)}
|
||||
>
|
||||
{addingSection ? "Adding..." : "+ Add Section"}
|
||||
</button>
|
||||
</article>
|
||||
{rightPanelCollapsed ? (
|
||||
<button
|
||||
@@ -4062,6 +4150,14 @@ function navButton(active: boolean) {
|
||||
);
|
||||
}
|
||||
|
||||
function navItem(active: boolean) {
|
||||
return cx(
|
||||
"relative flex items-center gap-1 px-3 py-1.5 text-[12.5px] text-[var(--muted)] hover:text-[var(--fg)]",
|
||||
active &&
|
||||
"font-semibold text-[var(--fg)] before:absolute before:left-0 before:top-1 before:bottom-1 before:w-[3px] before:bg-[var(--accent)]",
|
||||
);
|
||||
}
|
||||
|
||||
function tagClass(tone?: string) {
|
||||
return cx(
|
||||
ui.tag,
|
||||
|
||||
Reference in New Issue
Block a user