Skip to content

Commit dd4b014

Browse files
committed
feat: add summary and chapter editing to share pages
1 parent 6016318 commit dd4b014

5 files changed

Lines changed: 641 additions & 19 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// @vitest-environment jsdom
2+
3+
import type { Video } from "@cap/web-domain";
4+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5+
import { act, type ComponentProps, createElement, type ReactNode } from "react";
6+
import { createRoot, type Root } from "react-dom/client";
7+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8+
import { editAiContent } from "@/actions/videos/edit-ai-content";
9+
import { Summary } from "@/app/s/[videoId]/_components/tabs/Summary";
10+
import { SummaryEditor } from "@/app/s/[videoId]/_components/tabs/SummaryEditor";
11+
12+
vi.mock("@/actions/videos/edit-ai-content", () => ({ editAiContent: vi.fn() }));
13+
vi.mock("@cap/ui", async () => ({
14+
Button: (await import("../../../../packages/ui/src/components/Button"))
15+
.Button,
16+
}));
17+
18+
let root: Root;
19+
let container: HTMLDivElement;
20+
let queryClient: QueryClient;
21+
const initialContent = {
22+
summary: "Original **summary**",
23+
chapters: [
24+
{ title: "Introduction", start: 0 },
25+
{ title: "Next steps", start: 60 },
26+
],
27+
};
28+
const videoId = "video-id" as Video.VideoId;
29+
const onClose = vi.fn();
30+
31+
beforeEach(() => {
32+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
33+
container = document.createElement("div");
34+
document.body.append(container);
35+
root = createRoot(container);
36+
queryClient = new QueryClient({
37+
defaultOptions: { queries: { retry: false } },
38+
});
39+
queryClient.setQueryData(["videoStatus", videoId], {
40+
...initialContent,
41+
aiGenerationStatus: "COMPLETE",
42+
name: "Video name",
43+
});
44+
vi.mocked(editAiContent).mockReset();
45+
});
46+
afterEach(async () => {
47+
await act(async () => root.unmount());
48+
queryClient.clear();
49+
container.remove();
50+
vi.unstubAllGlobals();
51+
});
52+
const render = async (
53+
element: ReactNode = createElement(SummaryEditor, {
54+
videoId,
55+
initialContent,
56+
duration: 120,
57+
onClose,
58+
}),
59+
) => {
60+
await act(async () =>
61+
root.render(
62+
createElement(QueryClientProvider, { client: queryClient }, element),
63+
),
64+
);
65+
};
66+
const button = (text: string) => {
67+
const found = Array.from(container.querySelectorAll("button")).find(
68+
(node) => node.textContent?.trim() === text,
69+
);
70+
if (!found) throw new Error(`Button missing: ${text}`);
71+
return found;
72+
};
73+
const change = async (
74+
element: HTMLInputElement | HTMLTextAreaElement,
75+
value: string,
76+
) => {
77+
await act(async () => {
78+
const setter = Object.getOwnPropertyDescriptor(
79+
element instanceof HTMLTextAreaElement
80+
? HTMLTextAreaElement.prototype
81+
: HTMLInputElement.prototype,
82+
"value",
83+
)?.set;
84+
setter?.call(element, value);
85+
element.dispatchEvent(new Event("input", { bubbles: true }));
86+
});
87+
};
88+
const summary = () => {
89+
const element = container.querySelector("textarea");
90+
if (!element) throw new Error("Summary editor missing");
91+
return element;
92+
};
93+
94+
describe("summary and chapter editor", () => {
95+
it("starts clean with MySQL chapter key ordering", async () => {
96+
await render(
97+
createElement(SummaryEditor, {
98+
videoId,
99+
initialContent: {
100+
...initialContent,
101+
chapters: [{ start: 0, title: "Intro" }],
102+
},
103+
duration: 120,
104+
onClose,
105+
}),
106+
);
107+
expect(button("Save changes").disabled).toBe(true);
108+
});
109+
it("focuses the summary and cancels without a write", async () => {
110+
await render();
111+
expect(document.activeElement).toBe(summary());
112+
expect(button("Save changes").disabled).toBe(true);
113+
await change(summary(), "Draft");
114+
await act(async () => button("Cancel").click());
115+
expect(onClose).toHaveBeenCalledWith(false);
116+
expect(editAiContent).not.toHaveBeenCalled();
117+
});
118+
it("saves once, updates the shared cache, and preserves unrelated status", async () => {
119+
let finish: (value: Awaited<ReturnType<typeof editAiContent>>) => void =
120+
() => {};
121+
vi.mocked(editAiContent).mockReturnValue(
122+
new Promise((resolve) => {
123+
finish = resolve;
124+
}),
125+
);
126+
await render();
127+
await change(summary(), "Edited");
128+
await act(async () => {
129+
button("Save changes").click();
130+
});
131+
expect(button("Saving…").disabled).toBe(true);
132+
expect(button("Cancel").disabled).toBe(true);
133+
expect(summary().disabled).toBe(true);
134+
await act(async () => {
135+
finish({ success: true, data: { ...initialContent, summary: "Edited" } });
136+
});
137+
expect(editAiContent).toHaveBeenCalledTimes(1);
138+
expect(queryClient.getQueryData(["videoStatus", videoId])).toMatchObject({
139+
summary: "Edited",
140+
name: "Video name",
141+
aiGenerationStatus: "COMPLETE",
142+
});
143+
expect(onClose).toHaveBeenCalledWith(true);
144+
});
145+
it("retains a failed draft and allows retry", async () => {
146+
vi.mocked(editAiContent).mockResolvedValue({
147+
success: false,
148+
message: "Conflict: content changed",
149+
});
150+
await render();
151+
await change(summary(), "Unsaved draft");
152+
await act(async () => button("Save changes").click());
153+
expect(summary().value).toBe("Unsaved draft");
154+
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
155+
"Conflict",
156+
);
157+
expect(button("Save changes").disabled).toBe(false);
158+
expect(onClose).not.toHaveBeenCalled();
159+
});
160+
it("adds and removes chapters and rejects out-of-range timestamps", async () => {
161+
await render();
162+
await act(async () => button("Add chapter").click());
163+
expect(container.querySelectorAll("input")).toHaveLength(6);
164+
expect(button("Save changes").disabled).toBe(true);
165+
const inputs = Array.from(container.querySelectorAll("input"));
166+
const time = inputs[4];
167+
const title = inputs[5];
168+
if (!time || !title) throw new Error("New chapter missing");
169+
await change(title, "Conclusion");
170+
await change(time, "02:00");
171+
expect(container.textContent).toContain("before the video ends");
172+
await change(time, "01:30");
173+
expect(button("Save changes").disabled).toBe(false);
174+
await act(async () =>
175+
container
176+
.querySelector<HTMLButtonElement>('[aria-label="Remove chapter 3"]')
177+
?.click(),
178+
);
179+
expect(container.querySelectorAll("input")).toHaveLength(4);
180+
});
181+
it("keeps a draft when incoming content changes", async () => {
182+
await render();
183+
await change(summary(), "My draft");
184+
await render(
185+
createElement(SummaryEditor, {
186+
videoId,
187+
initialContent: { ...initialContent, summary: "New remote content" },
188+
duration: 120,
189+
onClose,
190+
}),
191+
);
192+
expect(summary().value).toBe("My draft");
193+
vi.mocked(editAiContent).mockResolvedValue({
194+
success: false,
195+
message: "Conflict",
196+
});
197+
await act(async () => button("Save changes").click());
198+
expect(editAiContent).toHaveBeenCalledWith(
199+
videoId,
200+
expect.objectContaining({ expected: initialContent }),
201+
);
202+
});
203+
it("does not change fractional chapter times when editing only text", async () => {
204+
const content = {
205+
...initialContent,
206+
chapters: [{ title: "Intro", start: 1.123456 }],
207+
};
208+
await render(
209+
createElement(SummaryEditor, {
210+
videoId,
211+
initialContent: content,
212+
duration: 120,
213+
onClose,
214+
}),
215+
);
216+
await change(summary(), "Edited summary");
217+
vi.mocked(editAiContent).mockResolvedValue({
218+
success: false,
219+
message: "Try again",
220+
});
221+
await act(async () => button("Save changes").click());
222+
expect(editAiContent).toHaveBeenCalledWith(
223+
videoId,
224+
expect.objectContaining({
225+
value: { ...content, summary: "Edited summary" },
226+
}),
227+
);
228+
});
229+
});
230+
231+
describe("summary permissions", () => {
232+
const props: ComponentProps<typeof Summary> = {
233+
videoId,
234+
ownerIsPro: true,
235+
isOwner: true,
236+
initialAiData: { ...initialContent, aiGenerationStatus: "COMPLETE" },
237+
};
238+
it.each([
239+
{ isOwner: false },
240+
{ ownerIsPro: false },
241+
{
242+
initialAiData: {
243+
...initialContent,
244+
aiGenerationStatus: "PROCESSING" as const,
245+
},
246+
},
247+
])("hides editing when unavailable", async (overrides) => {
248+
await render(createElement(Summary, { ...props, ...overrides }));
249+
expect(
250+
container.querySelector('[aria-label="Edit summary and chapters"]'),
251+
).toBeNull();
252+
});
253+
it("allows restoring a deliberately empty summary and chapters", async () => {
254+
await render(
255+
createElement(Summary, {
256+
...props,
257+
initialAiData: {
258+
summary: "",
259+
chapters: [],
260+
aiGenerationStatus: "COMPLETE",
261+
},
262+
}),
263+
);
264+
await act(async () => button("Edit").click());
265+
expect(summary().value).toBe("");
266+
});
267+
it("uses keyboard-accessible chapter seek buttons", async () => {
268+
const onSeek = vi.fn();
269+
await render(createElement(Summary, { ...props, onSeek }));
270+
const chapter = Array.from(container.querySelectorAll("button")).find(
271+
(node) => node.textContent?.includes("Next steps"),
272+
);
273+
await act(async () => chapter?.click());
274+
expect(onSeek).toHaveBeenCalledWith(60);
275+
});
276+
});

apps/web/app/s/[videoId]/_components/Sidebar.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"
99
import { useCurrentUser } from "@/app/Layout/AuthContext";
1010
import type { VideoData } from "../types";
1111
import { Activity } from "./tabs/Activity";
12+
import type { SummaryEditingState } from "./tabs/SummaryEditor";
1213

1314
// Activity is the default tab, so it stays in the entry chunk; the other tabs
1415
// (and their deps — react-markdown for Summary, the 1000-line transcript view)
@@ -143,6 +144,13 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>(
143144
? "transcript"
144145
: "activity";
145146

147+
const [summaryEditingState, setSummaryEditingState] =
148+
useState<SummaryEditingState>("clean");
149+
const canLeaveSummary = () =>
150+
summaryEditingState !== "saving" &&
151+
(summaryEditingState !== "dirty" ||
152+
window.confirm("Discard your unsaved summary and chapter changes?"));
153+
146154
const [activeTab, setActiveTab] = useState<TabType>(defaultTab);
147155
const [[page, direction], setPage] = useState([0, 0]);
148156

@@ -174,6 +182,7 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>(
174182
];
175183

176184
const paginate = (tabId: TabType) => {
185+
if (tabId === activeTab || !canLeaveSummary()) return;
177186
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab);
178187
const newIndex = tabs.findIndex((tab) => tab.id === tabId);
179188
const direction = newIndex > currentIndex ? 1 : -1;
@@ -216,6 +225,10 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>(
216225
<Summary
217226
videoId={data.id}
218227
ownerIsPro={data.owner.isPro}
228+
isOwner={isOwner}
229+
transcriptionStatus={data.transcriptionStatus}
230+
duration={data.duration}
231+
onEditingStateChange={setSummaryEditingState}
219232
onSeek={onSeek}
220233
isSummaryDisabled={videoSettings?.disableSummary}
221234
initialAiData={aiData || undefined}
@@ -284,7 +297,9 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>(
284297
{onCollapse && (
285298
<button
286299
type="button"
287-
onClick={onCollapse}
300+
onClick={() => {
301+
if (canLeaveSummary()) onCollapse();
302+
}}
288303
aria-label="Hide comments"
289304
title="Hide comments"
290305
className="hidden shrink-0 items-center justify-center px-3 text-gray-9 transition-colors hover:bg-gray-1 hover:text-gray-12 lg:flex"

apps/web/app/s/[videoId]/_components/SummaryChapters.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,6 @@ const SummaryChapters = ({
4646
{hasSummary && (
4747
<>
4848
<h3 className="text-lg font-medium">Summary</h3>
49-
<div className="mb-2">
50-
<span className="text-xs font-semibold text-gray-8">
51-
Generated by Cap AI
52-
</span>
53-
</div>
5449
<div className="text-sm prose prose-sm prose-gray max-w-none prose-p:my-2 prose-ul:my-2 prose-li:my-0 prose-strong:text-gray-12">
5550
<ReactMarkdown>{aiData.summary}</ReactMarkdown>
5651
</div>
@@ -62,16 +57,17 @@ const SummaryChapters = ({
6257
<h3 className="mb-2 text-lg font-medium">Chapters</h3>
6358
<div className="divide-y">
6459
{aiData.chapters?.map((chapter) => (
65-
<div
60+
<button
61+
type="button"
6662
key={chapter.start}
67-
className="flex items-center p-2 rounded transition-colors cursor-pointer hover:bg-gray-100"
63+
className="flex items-center w-full p-2 text-left rounded transition-colors hover:bg-gray-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-9"
6864
onClick={() => handleSeek(chapter.start)}
6965
>
7066
<span className="w-16 text-xs text-gray-500">
7167
{formatTimeMinutes(chapter.start)}
7268
</span>
7369
<span className="ml-2 text-sm">{chapter.title}</span>
74-
</div>
70+
</button>
7571
))}
7672
</div>
7773
</div>

0 commit comments

Comments
 (0)