Skip to content

Commit f775378

Browse files
committed
feat: add collaborative review sessions + bot author + session status
Adds from PR backnotprop#316: - ReviewSession type with collaborative session fields - CollaborativeSessionButton component - useCollaborativeSession hook Extensions for Shuni integration: - AnnotationAuthor type (human/bot distinction) - Session status lifecycle (reviewing/approved/rejected/expired) - closedBy/closedAt fields on ReviewSession - author field on Annotation type uses AnnotationAuthor
1 parent e53e76c commit f775378

3 files changed

Lines changed: 467 additions & 1 deletion

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import React, { useState } from 'react';
2+
import type { Annotation, ImageAttachment } from '../types';
3+
import { useCollaborativeSession } from '../hooks/useCollaborativeSession';
4+
5+
interface CollaborativeSessionButtonProps {
6+
markdown: string;
7+
annotations: Annotation[];
8+
globalAttachments: ImageAttachment[];
9+
setAnnotations: React.Dispatch<React.SetStateAction<Annotation[]>>;
10+
setGlobalAttachments: React.Dispatch<React.SetStateAction<ImageAttachment[]>>;
11+
pasteApiUrl?: string;
12+
}
13+
14+
export const CollaborativeSessionButton: React.FC<CollaborativeSessionButtonProps> = ({
15+
markdown,
16+
annotations,
17+
globalAttachments,
18+
setAnnotations,
19+
setGlobalAttachments,
20+
pasteApiUrl,
21+
}) => {
22+
const [showShareDialog, setShowShareDialog] = useState(false);
23+
const [shareUrl, setShareUrl] = useState('');
24+
const [copySuccess, setCopySuccess] = useState(false);
25+
26+
const {
27+
isCollaborativeSession,
28+
reviewerCount,
29+
lastUpdatedAt,
30+
isLoading,
31+
error,
32+
createSession,
33+
submitAnnotations,
34+
refreshSession,
35+
} = useCollaborativeSession(markdown, setAnnotations, setGlobalAttachments, pasteApiUrl);
36+
37+
const handleCreateSession = async () => {
38+
const url = await createSession();
39+
if (url) {
40+
setShareUrl(url);
41+
setShowShareDialog(true);
42+
}
43+
};
44+
45+
const handleCopyUrl = async () => {
46+
await navigator.clipboard.writeText(shareUrl);
47+
setCopySuccess(true);
48+
setTimeout(() => setCopySuccess(false), 2000);
49+
};
50+
51+
const handleSubmit = async () => {
52+
const success = await submitAnnotations(annotations, globalAttachments);
53+
if (success) {
54+
alert('Annotations submitted successfully!');
55+
}
56+
};
57+
58+
const handleRefresh = async () => {
59+
await refreshSession();
60+
};
61+
62+
const formatLastUpdate = (timestamp: number) => {
63+
if (timestamp === 0) return 'Never';
64+
const diff = Date.now() - timestamp;
65+
const minutes = Math.floor(diff / 60000);
66+
if (minutes < 1) return 'Just now';
67+
if (minutes === 1) return '1 minute ago';
68+
if (minutes < 60) return `${minutes} minutes ago`;
69+
const hours = Math.floor(minutes / 60);
70+
if (hours === 1) return '1 hour ago';
71+
return `${hours} hours ago`;
72+
};
73+
74+
if (isCollaborativeSession) {
75+
return (
76+
<div className="flex items-center gap-2 px-3 py-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-700">
77+
<div className="flex-1 text-sm">
78+
<div className="font-medium text-purple-900 dark:text-purple-100">Collaborative Session</div>
79+
<div className="text-xs text-purple-700 dark:text-purple-300">
80+
{reviewerCount} reviewer{reviewerCount !== 1 ? 's' : ''} &bull; Updated {formatLastUpdate(lastUpdatedAt)}
81+
</div>
82+
</div>
83+
84+
<button
85+
onClick={handleRefresh}
86+
disabled={isLoading}
87+
className="px-3 py-1 text-sm bg-white dark:bg-gray-800 border border-purple-300 dark:border-purple-600 rounded hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors disabled:opacity-50"
88+
>
89+
{isLoading ? 'Refreshing...' : 'Refresh'}
90+
</button>
91+
92+
<button
93+
onClick={handleSubmit}
94+
disabled={isLoading}
95+
className="px-3 py-1 text-sm bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors disabled:opacity-50"
96+
>
97+
{isLoading ? 'Submitting...' : 'Submit'}
98+
</button>
99+
100+
{error && <div className="text-xs text-red-600 dark:text-red-400">{error}</div>}
101+
</div>
102+
);
103+
}
104+
105+
return (
106+
<>
107+
<button
108+
onClick={handleCreateSession}
109+
disabled={isLoading}
110+
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
111+
>
112+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113+
<path
114+
strokeLinecap="round"
115+
strokeLinejoin="round"
116+
strokeWidth={2}
117+
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
118+
/>
119+
</svg>
120+
{isLoading ? 'Creating...' : 'Start Collaborative Review'}
121+
</button>
122+
123+
{showShareDialog && (
124+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
125+
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
126+
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">Share Review Session</h3>
127+
128+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
129+
Share this URL with your team. Everyone can add annotations, and you can import all feedback at once.
130+
</p>
131+
132+
<div className="flex gap-2 mb-4">
133+
<input
134+
type="text"
135+
value={shareUrl}
136+
readOnly
137+
className="flex-1 px-3 py-2 border rounded text-sm font-mono bg-gray-50 dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100"
138+
/>
139+
<button
140+
onClick={handleCopyUrl}
141+
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
142+
>
143+
{copySuccess ? 'Copied!' : 'Copy'}
144+
</button>
145+
</div>
146+
147+
<button
148+
onClick={() => setShowShareDialog(false)}
149+
className="w-full px-4 py-2 border rounded hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100"
150+
>
151+
Close
152+
</button>
153+
</div>
154+
</div>
155+
)}
156+
157+
{error && <div className="text-sm text-red-600 dark:text-red-400 mt-2">{error}</div>}
158+
</>
159+
);
160+
};
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { useState, useCallback, useEffect } from 'react';
2+
import type { Annotation, ImageAttachment, ReviewSession } from '../types';
3+
4+
interface UseCollaborativeSessionResult {
5+
isCollaborativeSession: boolean;
6+
sessionId: string;
7+
sessionVersion: number;
8+
isLoading: boolean;
9+
error: string;
10+
createSession: () => Promise<string | null>;
11+
joinSession: (sessionId: string) => Promise<boolean>;
12+
submitAnnotations: (
13+
annotations: Annotation[],
14+
globalAttachments?: ImageAttachment[]
15+
) => Promise<boolean>;
16+
refreshSession: () => Promise<boolean>;
17+
reviewerCount: number;
18+
lastUpdatedAt: number;
19+
}
20+
21+
export function useCollaborativeSession(
22+
markdown: string,
23+
setAnnotations: React.Dispatch<React.SetStateAction<Annotation[]>>,
24+
setGlobalAttachments: React.Dispatch<React.SetStateAction<ImageAttachment[]>>,
25+
pasteApiUrl?: string
26+
): UseCollaborativeSessionResult {
27+
const [isCollaborativeSession, setIsCollaborativeSession] = useState(false);
28+
const [sessionId, setSessionId] = useState('');
29+
const [sessionVersion, setSessionVersion] = useState(0);
30+
const [isLoading, setIsLoading] = useState(false);
31+
const [error, setError] = useState('');
32+
const [reviewerCount, setReviewerCount] = useState(0);
33+
const [lastUpdatedAt, setLastUpdatedAt] = useState(0);
34+
35+
const apiBase = pasteApiUrl || 'https://plannotator-paste.plannotator.workers.dev';
36+
37+
const createSession = useCallback(async (): Promise<string | null> => {
38+
setIsLoading(true);
39+
setError('');
40+
41+
try {
42+
const response = await fetch(`${apiBase}/api/review-session`, {
43+
method: 'POST',
44+
headers: { 'Content-Type': 'application/json' },
45+
body: JSON.stringify({ plan: markdown }),
46+
signal: AbortSignal.timeout(10_000),
47+
});
48+
49+
if (!response.ok) {
50+
const errData = await response.json().catch(() => ({ error: 'Failed to create session' }));
51+
setError(errData.error || 'Failed to create session');
52+
return null;
53+
}
54+
55+
const { session, shareUrl } = await response.json();
56+
57+
setIsCollaborativeSession(true);
58+
setSessionId(session.id);
59+
setSessionVersion(session.version);
60+
setReviewerCount(session.reviewerCount);
61+
setLastUpdatedAt(session.lastUpdatedAt);
62+
63+
return shareUrl;
64+
} catch {
65+
setError('Network error while creating session');
66+
return null;
67+
} finally {
68+
setIsLoading(false);
69+
}
70+
}, [markdown, apiBase]);
71+
72+
const joinSession = useCallback(
73+
async (id: string): Promise<boolean> => {
74+
setIsLoading(true);
75+
setError('');
76+
77+
try {
78+
const response = await fetch(`${apiBase}/api/review-session/${id}`, {
79+
signal: AbortSignal.timeout(10_000),
80+
});
81+
82+
if (!response.ok) {
83+
setError('Session not found or expired');
84+
return false;
85+
}
86+
87+
const { session } = await response.json();
88+
89+
setIsCollaborativeSession(true);
90+
setSessionId(session.id);
91+
setSessionVersion(session.version);
92+
setReviewerCount(session.reviewerCount);
93+
setLastUpdatedAt(session.lastUpdatedAt);
94+
95+
setAnnotations(session.annotations);
96+
if (session.globalAttachments?.length) {
97+
setGlobalAttachments(session.globalAttachments);
98+
}
99+
100+
return true;
101+
} catch {
102+
setError('Failed to join session');
103+
return false;
104+
} finally {
105+
setIsLoading(false);
106+
}
107+
},
108+
[apiBase, setAnnotations, setGlobalAttachments]
109+
);
110+
111+
const submitAnnotations = useCallback(
112+
async (annotations: Annotation[], globalAttachments?: ImageAttachment[]): Promise<boolean> => {
113+
if (!isCollaborativeSession) return false;
114+
115+
setIsLoading(true);
116+
setError('');
117+
118+
try {
119+
const response = await fetch(`${apiBase}/api/review-session/${sessionId}/annotations`, {
120+
method: 'PATCH',
121+
headers: { 'Content-Type': 'application/json' },
122+
body: JSON.stringify({
123+
annotations,
124+
globalAttachments,
125+
expectedVersion: sessionVersion,
126+
}),
127+
signal: AbortSignal.timeout(10_000),
128+
});
129+
130+
if (!response.ok) {
131+
if (response.status === 409) {
132+
setError('Session was updated by another reviewer — refreshing...');
133+
await refreshSession();
134+
} else {
135+
const errData = await response.json().catch(() => ({ error: 'Failed to submit' }));
136+
setError(errData.error || 'Failed to submit annotations');
137+
}
138+
return false;
139+
}
140+
141+
const { session } = await response.json();
142+
143+
setSessionVersion(session.version);
144+
setReviewerCount(session.reviewerCount);
145+
setLastUpdatedAt(session.lastUpdatedAt);
146+
147+
return true;
148+
} catch {
149+
setError('Network error while submitting annotations');
150+
return false;
151+
} finally {
152+
setIsLoading(false);
153+
}
154+
},
155+
[isCollaborativeSession, sessionId, sessionVersion, apiBase]
156+
);
157+
158+
const refreshSession = useCallback(async (): Promise<boolean> => {
159+
if (!isCollaborativeSession) return false;
160+
161+
setIsLoading(true);
162+
setError('');
163+
164+
try {
165+
const response = await fetch(`${apiBase}/api/review-session/${sessionId}`, {
166+
signal: AbortSignal.timeout(10_000),
167+
});
168+
169+
if (!response.ok) {
170+
setError('Failed to refresh session');
171+
return false;
172+
}
173+
174+
const { session } = await response.json();
175+
176+
setSessionVersion(session.version);
177+
setReviewerCount(session.reviewerCount);
178+
setLastUpdatedAt(session.lastUpdatedAt);
179+
180+
setAnnotations((prev) => {
181+
const merged = [...prev];
182+
const existingSet = new Set(merged.map((a) => `${a.originalText}|${a.type}|${a.text || ''}`));
183+
184+
const newFromServer = session.annotations.filter((ann: Annotation) => {
185+
const key = `${ann.originalText}|${ann.type}|${ann.text || ''}`;
186+
return !existingSet.has(key);
187+
});
188+
189+
return [...merged, ...newFromServer];
190+
});
191+
192+
if (session.globalAttachments?.length) {
193+
setGlobalAttachments((prev) => {
194+
const existingPaths = new Set(prev.map((g) => g.path));
195+
const newAttachments = session.globalAttachments.filter((g: ImageAttachment) => !existingPaths.has(g.path));
196+
return [...prev, ...newAttachments];
197+
});
198+
}
199+
200+
return true;
201+
} catch {
202+
setError('Failed to refresh session');
203+
return false;
204+
} finally {
205+
setIsLoading(false);
206+
}
207+
}, [isCollaborativeSession, sessionId, apiBase, setAnnotations, setGlobalAttachments]);
208+
209+
// Check URL for /s/<id> pattern on mount
210+
useEffect(() => {
211+
const pathMatch = window.location.pathname.match(/^\/s\/([A-Za-z0-9]{6,16})$/);
212+
if (pathMatch) {
213+
const id = pathMatch[1];
214+
joinSession(id).then((success) => {
215+
if (success) {
216+
window.history.replaceState({}, '', '/');
217+
}
218+
});
219+
}
220+
}, [joinSession]);
221+
222+
return {
223+
isCollaborativeSession,
224+
sessionId,
225+
sessionVersion,
226+
isLoading,
227+
error,
228+
createSession,
229+
joinSession,
230+
submitAnnotations,
231+
refreshSession,
232+
reviewerCount,
233+
lastUpdatedAt,
234+
};
235+
}

0 commit comments

Comments
 (0)