mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
4627f75426
Adds a general-purpose External Annotations API that allows external programs (linters, AI tools, security scanners) to push annotations into a live Plannotator session via HTTP, with real-time delivery over SSE. ## What's included - **Shared core** (`packages/shared/external-annotation.ts`): types, in-memory store, input validation, SSE serialization - **Server handlers**: Bun + Pi implementations with full CRUD (GET/POST/PATCH/DELETE) + SSE streaming - **Client hook** (`useExternalAnnotations`): EventSource with polling fallback, optimistic updates - **Editor integration**: two-array state model (local + external), content-aware dedup, ID-based routing - **Persistence**: source field preserved through share URLs and crash-recovery drafts - **Docs**: new Integrations category with API overview page, updated API reference ## API surface All three servers (plan, review, annotate) expose: - `GET /api/external-annotations/stream` - SSE stream - `GET /api/external-annotations` - JSON snapshot (polling fallback) - `POST /api/external-annotations` - Add annotations (single or batch) - `PATCH /api/external-annotations?id=` - Update fields - `DELETE /api/external-annotations` - Remove by id, source, or clear all For provenance purposes, this commit was AI assisted.
87 lines
3.5 KiB
TypeScript
87 lines
3.5 KiB
TypeScript
import React from 'react';
|
|
import { Annotation, AnnotationType, Block } from '../types';
|
|
|
|
interface SidebarProps {
|
|
annotations: Annotation[];
|
|
blocks: Block[];
|
|
onSelect: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
selectedId: string | null;
|
|
}
|
|
|
|
export const AnnotationSidebar: React.FC<SidebarProps> = ({
|
|
annotations,
|
|
blocks,
|
|
onSelect,
|
|
onDelete,
|
|
selectedId
|
|
}) => {
|
|
const sortedAnnotations = [...annotations].sort((a, b) => {
|
|
const blockA = blocks.findIndex(blk => blk.id === a.blockId);
|
|
const blockB = blocks.findIndex(blk => blk.id === b.blockId);
|
|
if (blockA !== blockB) return blockA - blockB;
|
|
return a.startOffset - b.startOffset;
|
|
});
|
|
|
|
return (
|
|
<div className="w-[min(22rem,32vw)] min-w-[16rem] border-l border-border/50 bg-card/50 backdrop-blur-sm h-full flex flex-col transition-colors">
|
|
<div className="p-4 border-b border-border/50 flex items-center justify-between">
|
|
<h2 className="font-semibold text-foreground">Review Changes</h2>
|
|
<span className="text-xs bg-muted text-muted-foreground px-2 py-1 rounded-full">
|
|
{annotations.length}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
{sortedAnnotations.length === 0 ? (
|
|
<div className="text-center text-muted-foreground mt-10 text-sm">
|
|
<p>No annotations yet.</p>
|
|
<p className="mt-2 text-xs">Select text in the document to add comments or suggest changes.</p>
|
|
</div>
|
|
) : (
|
|
sortedAnnotations.map(ann => (
|
|
<div
|
|
key={ann.id}
|
|
onClick={() => onSelect(ann.id)}
|
|
className={`
|
|
group relative p-3 rounded-lg border transition-all cursor-pointer
|
|
${selectedId === ann.id
|
|
? 'bg-accent/20 border-accent shadow-sm'
|
|
: 'bg-card border-border hover:border-accent hover:shadow-sm'
|
|
}
|
|
`}
|
|
>
|
|
<div className="flex justify-between items-start mb-1">
|
|
<span className={`
|
|
text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded
|
|
${ann.type === AnnotationType.DELETION ? 'bg-destructive/20 text-destructive' :
|
|
'bg-accent/20 text-accent'}
|
|
`}>
|
|
{ann.type}
|
|
</span>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onDelete(ann.id); }}
|
|
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all p-1 rounded hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
title="Remove Annotation"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="text-sm text-muted-foreground mb-2 font-mono bg-muted p-2 rounded-lg text-xs truncate">
|
|
"{ann.originalText}"
|
|
</div>
|
|
|
|
{(ann.text && ann.type !== AnnotationType.DELETION) && (
|
|
<div className="text-sm text-foreground font-medium pl-3 border-l-2 border-primary">
|
|
{ann.text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|