- backend: POST /notes/:id/summarize — calls extraction-service, stores summary artifact - backend: GET /notes/export — JSON + Markdown format support - backend: extraction-client.ts for extraction-service integration - backend: 4 new integration tests (summarize, export JSON, export MD, invalid format) - web: summarizeNote + exportNotes client functions - web: Summarize button on note detail page - web: Export Notes button on workspaces page - web: exclude e2e/ from vitest config - Total: 80 backend, 14 web, 23 mobile = 117 tests
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { useParams } from "next/navigation";
|
|
import { AppShell } from "@/components/AppShell";
|
|
import { NoteEditor } from "@/components/NoteEditor";
|
|
import { MetadataPanel } from "@/components/MetadataPanel";
|
|
import { LinkedNotesPanel } from "@/components/LinkedNotesPanel";
|
|
import { TaskReviewPanel } from "@/components/TaskReviewPanel";
|
|
import { ArtifactPanel } from "@/components/ArtifactPanel";
|
|
import { AgentTimeline } from "@/components/AgentTimeline";
|
|
import { LinkNoteModal } from "@/components/LinkNoteModal";
|
|
import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, summarizeNote, updateNoteDetail } from "@/lib/notes-client";
|
|
import type { NoteDetail } from "@/lib/types";
|
|
|
|
export default function NoteDetailPage() {
|
|
const params = useParams<{ noteId: string }>();
|
|
const noteId = params.noteId;
|
|
const [note, setNote] = useState<NoteDetail | null>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isCreatingTask, setIsCreatingTask] = useState(false);
|
|
const [isCreatingArtifact, setIsCreatingArtifact] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showLinkNote, setShowLinkNote] = useState(false);
|
|
|
|
useEffect(() => {
|
|
void (async () => {
|
|
try {
|
|
setNote(await getNoteDetail(noteId));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unable to load note");
|
|
}
|
|
})();
|
|
}, [noteId]);
|
|
|
|
async function handleSave(updates: { title: string; body: string }) {
|
|
if (!note) {
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
await updateNoteDetail(note.id, note.workspaceId, updates);
|
|
const refreshed = await getNoteDetail(note.id);
|
|
setNote(refreshed);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unable to save note");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleCreateArtifact(input: {
|
|
title: string;
|
|
artifactType: "file" | "summary" | "extraction" | "citation" | "export";
|
|
description?: string;
|
|
blobPath?: string;
|
|
}) {
|
|
if (!note) {
|
|
return;
|
|
}
|
|
|
|
setIsCreatingArtifact(true);
|
|
|
|
try {
|
|
await createNoteArtifact({
|
|
id: crypto.randomUUID(),
|
|
workspaceId: note.workspaceId,
|
|
noteId: note.id,
|
|
artifactType: input.artifactType,
|
|
title: input.title,
|
|
description: input.description,
|
|
blobPath: input.blobPath,
|
|
});
|
|
const refreshed = await getNoteDetail(note.id);
|
|
setNote(refreshed);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unable to create artifact");
|
|
} finally {
|
|
setIsCreatingArtifact(false);
|
|
}
|
|
}
|
|
|
|
async function handleCreateTask(input: { title: string; description?: string }) {
|
|
if (!note) {
|
|
return;
|
|
}
|
|
|
|
setIsCreatingTask(true);
|
|
|
|
try {
|
|
await createNoteTask({
|
|
id: crypto.randomUUID(),
|
|
workspaceId: note.workspaceId,
|
|
noteId: note.id,
|
|
title: input.title,
|
|
description: input.description,
|
|
source: "manual",
|
|
});
|
|
const refreshed = await getNoteDetail(note.id);
|
|
setNote(refreshed);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unable to create task");
|
|
} finally {
|
|
setIsCreatingTask(false);
|
|
}
|
|
}
|
|
|
|
async function handleSummarize() {
|
|
if (!note) return;
|
|
try {
|
|
await summarizeNote(note.id, note.workspaceId);
|
|
setNote(await getNoteDetail(note.id, note.workspaceId));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unable to summarize note");
|
|
}
|
|
}
|
|
|
|
async function handleArchive() {
|
|
if (!note) return;
|
|
try {
|
|
await archiveNote(note.id, note.workspaceId);
|
|
setNote(await getNoteDetail(note.id, note.workspaceId));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unable to archive note");
|
|
}
|
|
}
|
|
|
|
async function handleRestore() {
|
|
if (!note) return;
|
|
try {
|
|
await restoreNote(note.id, note.workspaceId);
|
|
setNote(await getNoteDetail(note.id, note.workspaceId));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Unable to restore note");
|
|
}
|
|
}
|
|
|
|
if (!note) {
|
|
return (
|
|
<AppShell
|
|
title="Note detail"
|
|
description="Editable note surface with metadata, linked context, tasks, artifacts, and review history."
|
|
actions={<div className="badge">Loading</div>}
|
|
>
|
|
<section className="surface-card" style={{ padding: "var(--ml-space-6)", color: "var(--ml-text-secondary)" }}>
|
|
{error ?? "Loading note…"}
|
|
</section>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppShell
|
|
title={note.title}
|
|
description="Editable note surface with metadata, linked context, tasks, artifacts, and review history."
|
|
actions={
|
|
<div style={{ display: "flex", gap: "var(--ml-space-2)", alignItems: "center" }}>
|
|
{isSaving ? (
|
|
<div className="badge">Saving</div>
|
|
) : (
|
|
<Link href="/reviews" className="badge">
|
|
{`Review: ${note.metadata.reviewState}`}
|
|
</Link>
|
|
)}
|
|
<button className="btn btn-secondary" onClick={handleSummarize}>
|
|
Summarize
|
|
</button>
|
|
<button className="btn btn-secondary" onClick={() => setShowLinkNote(true)}>
|
|
Link Note
|
|
</button>
|
|
{note.status === "archived" ? (
|
|
<button className="btn btn-primary" onClick={handleRestore}>Restore</button>
|
|
) : (
|
|
<button className="btn btn-secondary" onClick={handleArchive}>Archive</button>
|
|
)}
|
|
</div>
|
|
}
|
|
>
|
|
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.7fr) minmax(320px, 1fr)", gap: "var(--ml-space-4)" }}>
|
|
<NoteEditor note={note} onSave={handleSave} isSaving={isSaving} />
|
|
|
|
<aside style={{ display: "grid", gap: "var(--ml-space-4)" }}>
|
|
{error ? <div className="surface-card" style={{ padding: "var(--ml-space-4)", color: "var(--ml-text-secondary)" }}>{error}</div> : null}
|
|
<MetadataPanel note={note} />
|
|
<LinkedNotesPanel linkedNotes={note.linkedNotes} />
|
|
<TaskReviewPanel tasks={note.tasks} onCreate={handleCreateTask} isCreating={isCreatingTask} />
|
|
<ArtifactPanel artifacts={note.artifacts} onCreate={handleCreateArtifact} isCreating={isCreatingArtifact} />
|
|
<AgentTimeline items={note.timeline} />
|
|
</aside>
|
|
</div>
|
|
{showLinkNote && (
|
|
<LinkNoteModal
|
|
noteId={note.id}
|
|
workspaceId={note.workspaceId}
|
|
existingLinkedIds={note.linkedNotes.map((ln) => ln.id)}
|
|
onLinked={() => {
|
|
setShowLinkNote(false);
|
|
void getNoteDetail(note.id, note.workspaceId).then(setNote);
|
|
}}
|
|
onClose={() => setShowLinkNote(false)}
|
|
/>
|
|
)}
|
|
</AppShell>
|
|
);
|
|
}
|