hale-commenting-system 2.1.1 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -0
- package/.editorconfig +17 -0
- package/.eslintrc.js +75 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +23 -0
- package/.github/workflows/ci.yaml +51 -0
- package/.prettierignore +1 -0
- package/.prettierrc +4 -0
- package/GITHUB_OAUTH_ENV_TEMPLATE.md +53 -0
- package/LICENSE +21 -0
- package/README.md +92 -21
- package/package.json +74 -50
- package/scripts/README.md +42 -0
- package/scripts/integrate.js +440 -0
- package/src/app/AppLayout/AppLayout.tsx +248 -0
- package/src/app/Comments/Comments.tsx +273 -0
- package/src/app/Dashboard/Dashboard.tsx +10 -0
- package/src/app/NotFound/NotFound.tsx +35 -0
- package/src/app/Settings/General/GeneralSettings.tsx +16 -0
- package/src/app/Settings/Profile/ProfileSettings.tsx +18 -0
- package/src/app/Support/Support.tsx +50 -0
- package/src/app/__snapshots__/app.test.tsx.snap +524 -0
- package/src/app/app.css +11 -0
- package/src/app/app.test.tsx +55 -0
- package/src/app/bgimages/Patternfly-Logo.svg +28 -0
- package/src/app/commenting-system/components/CommentOverlay.tsx +93 -0
- package/src/app/commenting-system/components/CommentPanel.tsx +534 -0
- package/src/app/commenting-system/components/CommentPin.tsx +60 -0
- package/src/app/commenting-system/components/DetailsTab.tsx +516 -0
- package/src/app/commenting-system/components/FloatingWidget.tsx +130 -0
- package/src/app/commenting-system/components/JiraTab.tsx +696 -0
- package/src/app/commenting-system/contexts/CommentContext.tsx +1033 -0
- package/src/app/commenting-system/contexts/GitHubAuthContext.tsx +84 -0
- package/{dist/index.d.ts → src/app/commenting-system/index.ts} +5 -4
- package/src/app/commenting-system/services/githubAdapter.ts +359 -0
- package/src/app/commenting-system/types/index.ts +27 -0
- package/src/app/commenting-system/utils/version.ts +19 -0
- package/src/app/index.tsx +22 -0
- package/src/app/routes.tsx +81 -0
- package/src/app/utils/useDocumentTitle.ts +13 -0
- package/src/favicon.png +0 -0
- package/src/index.html +18 -0
- package/src/index.tsx +25 -0
- package/src/test/setup.ts +33 -0
- package/src/typings.d.ts +12 -0
- package/stylePaths.js +14 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +19 -0
- package/webpack.common.js +139 -0
- package/webpack.dev.js +318 -0
- package/webpack.prod.js +38 -0
- package/bin/detect.d.ts +0 -10
- package/bin/detect.js +0 -134
- package/bin/generators.d.ts +0 -18
- package/bin/generators.js +0 -193
- package/bin/hale-commenting.js +0 -4
- package/bin/index.d.ts +0 -2
- package/bin/index.js +0 -61
- package/bin/onboarding.d.ts +0 -1
- package/bin/onboarding.js +0 -349
- package/bin/postinstall.d.ts +0 -2
- package/bin/postinstall.js +0 -65
- package/bin/validators.d.ts +0 -2
- package/bin/validators.js +0 -66
- package/dist/cli/detect.d.ts +0 -10
- package/dist/cli/detect.js +0 -134
- package/dist/cli/generators.d.ts +0 -18
- package/dist/cli/generators.js +0 -193
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -61
- package/dist/cli/onboarding.d.ts +0 -1
- package/dist/cli/onboarding.js +0 -349
- package/dist/cli/postinstall.d.ts +0 -2
- package/dist/cli/postinstall.js +0 -65
- package/dist/cli/validators.d.ts +0 -2
- package/dist/cli/validators.js +0 -66
- package/dist/components/CommentOverlay.d.ts +0 -2
- package/dist/components/CommentOverlay.js +0 -101
- package/dist/components/CommentPanel.d.ts +0 -6
- package/dist/components/CommentPanel.js +0 -334
- package/dist/components/CommentPin.d.ts +0 -11
- package/dist/components/CommentPin.js +0 -64
- package/dist/components/DetailsTab.d.ts +0 -2
- package/dist/components/DetailsTab.js +0 -380
- package/dist/components/FloatingWidget.d.ts +0 -8
- package/dist/components/FloatingWidget.js +0 -128
- package/dist/components/JiraTab.d.ts +0 -2
- package/dist/components/JiraTab.js +0 -507
- package/dist/contexts/CommentContext.d.ts +0 -30
- package/dist/contexts/CommentContext.js +0 -891
- package/dist/contexts/GitHubAuthContext.d.ts +0 -13
- package/dist/contexts/GitHubAuthContext.js +0 -96
- package/dist/index.js +0 -27
- package/dist/services/githubAdapter.d.ts +0 -56
- package/dist/services/githubAdapter.js +0 -321
- package/dist/types/index.d.ts +0 -25
- package/dist/types/index.js +0 -2
- package/dist/utils/version.d.ts +0 -1
- package/dist/utils/version.js +0 -23
- package/templates/webpack-middleware.js +0 -226
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { useLocation } from 'react-router-dom';
|
|
3
|
+
import { useComments } from '../contexts/CommentContext';
|
|
4
|
+
import { CommentPin } from './CommentPin';
|
|
5
|
+
import { getVersionFromPathOrQuery } from '../utils/version';
|
|
6
|
+
|
|
7
|
+
export const CommentOverlay: React.FunctionComponent = () => {
|
|
8
|
+
const location = useLocation();
|
|
9
|
+
const { commentsEnabled, addThread, selectedThreadId, setSelectedThreadId, syncFromGitHub, getThreadsForRoute } = useComments();
|
|
10
|
+
const detectedVersion = getVersionFromPathOrQuery(location.pathname, location.search);
|
|
11
|
+
const overlayRef = React.useRef<HTMLDivElement>(null);
|
|
12
|
+
|
|
13
|
+
// Show both open and closed threads as pins (GitHub-style: closed issues still exist)
|
|
14
|
+
const currentThreads = getThreadsForRoute(location.pathname, detectedVersion);
|
|
15
|
+
|
|
16
|
+
const handlePageClick = (e: MouseEvent) => {
|
|
17
|
+
if (!commentsEnabled) return;
|
|
18
|
+
|
|
19
|
+
// Check if clicking on a pin or any interactive element
|
|
20
|
+
const target = e.target as HTMLElement;
|
|
21
|
+
if (
|
|
22
|
+
target.closest('button') ||
|
|
23
|
+
target.closest('a') ||
|
|
24
|
+
target.closest('input') ||
|
|
25
|
+
target.closest('select') ||
|
|
26
|
+
target.closest('textarea') ||
|
|
27
|
+
target.closest('[role="button"]') ||
|
|
28
|
+
target.closest('[data-comment-controls]') ||
|
|
29
|
+
target.closest('[data-comment-pin]')
|
|
30
|
+
) {
|
|
31
|
+
return; // Don't create pin if clicking interactive elements
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Get the overlay container dimensions (accounts for drawer being open)
|
|
35
|
+
if (!overlayRef.current) return;
|
|
36
|
+
const rect = overlayRef.current.getBoundingClientRect();
|
|
37
|
+
|
|
38
|
+
// Calculate percentage based on the content area, not the full window
|
|
39
|
+
const xPercent = ((e.clientX - rect.left) / rect.width) * 100;
|
|
40
|
+
const yPercent = ((e.clientY - rect.top) / rect.height) * 100;
|
|
41
|
+
|
|
42
|
+
const threadId = addThread(xPercent, yPercent, location.pathname, detectedVersion);
|
|
43
|
+
setSelectedThreadId(threadId);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
React.useEffect(() => {
|
|
47
|
+
console.log('🔄 CommentOverlay useEffect triggered', { commentsEnabled, pathname: location.pathname, detectedVersion });
|
|
48
|
+
|
|
49
|
+
if (commentsEnabled) {
|
|
50
|
+
document.addEventListener('click', handlePageClick);
|
|
51
|
+
// Pull latest changes from GitHub when entering comment mode or switching routes
|
|
52
|
+
console.log('🔄 CommentOverlay calling syncFromGitHub...');
|
|
53
|
+
syncFromGitHub(location.pathname, detectedVersion).catch(() => undefined);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
document.removeEventListener('click', handlePageClick);
|
|
58
|
+
};
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
}, [commentsEnabled, location.pathname, detectedVersion]);
|
|
61
|
+
|
|
62
|
+
// Only show pins when commenting is enabled
|
|
63
|
+
if (!commentsEnabled) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
ref={overlayRef}
|
|
70
|
+
style={{
|
|
71
|
+
position: 'absolute',
|
|
72
|
+
top: 0,
|
|
73
|
+
left: 0,
|
|
74
|
+
width: '100%',
|
|
75
|
+
height: '100%',
|
|
76
|
+
pointerEvents: 'none',
|
|
77
|
+
zIndex: 999,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{currentThreads.map((thread) => (
|
|
81
|
+
<CommentPin
|
|
82
|
+
key={thread.id}
|
|
83
|
+
xPercent={thread.xPercent}
|
|
84
|
+
yPercent={thread.yPercent}
|
|
85
|
+
commentCount={thread.comments.length}
|
|
86
|
+
isClosed={thread.status === 'closed'}
|
|
87
|
+
isSelected={selectedThreadId === thread.id}
|
|
88
|
+
onClick={() => setSelectedThreadId(thread.id)}
|
|
89
|
+
/>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { useLocation } from 'react-router-dom';
|
|
3
|
+
import {
|
|
4
|
+
ActionList,
|
|
5
|
+
ActionListItem,
|
|
6
|
+
Button,
|
|
7
|
+
Card,
|
|
8
|
+
CardBody,
|
|
9
|
+
Drawer,
|
|
10
|
+
DrawerActions,
|
|
11
|
+
DrawerCloseButton,
|
|
12
|
+
DrawerContent,
|
|
13
|
+
DrawerContentBody,
|
|
14
|
+
DrawerHead,
|
|
15
|
+
DrawerPanelBody,
|
|
16
|
+
DrawerPanelContent,
|
|
17
|
+
EmptyState,
|
|
18
|
+
EmptyStateBody,
|
|
19
|
+
Label,
|
|
20
|
+
Spinner,
|
|
21
|
+
Tab,
|
|
22
|
+
TabTitleText,
|
|
23
|
+
Tabs,
|
|
24
|
+
TextArea,
|
|
25
|
+
Title,
|
|
26
|
+
} from '@patternfly/react-core';
|
|
27
|
+
import { ExternalLinkAltIcon, GithubIcon, InfoCircleIcon, TrashIcon } from '@patternfly/react-icons';
|
|
28
|
+
import { useComments } from '../contexts/CommentContext';
|
|
29
|
+
import { DetailsTab } from './DetailsTab';
|
|
30
|
+
import { JiraTab } from './JiraTab';
|
|
31
|
+
import { FloatingWidget } from './FloatingWidget';
|
|
32
|
+
import { getVersionFromPathOrQuery } from '../utils/version';
|
|
33
|
+
|
|
34
|
+
interface CommentPanelProps {
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const CommentPanel: React.FunctionComponent<CommentPanelProps> = ({ children }) => {
|
|
39
|
+
const {
|
|
40
|
+
getThreadsForRoute,
|
|
41
|
+
selectedThreadId,
|
|
42
|
+
setSelectedThreadId,
|
|
43
|
+
drawerPinnedOpen,
|
|
44
|
+
setDrawerPinnedOpen,
|
|
45
|
+
floatingWidgetMode,
|
|
46
|
+
setFloatingWidgetMode,
|
|
47
|
+
addReply,
|
|
48
|
+
updateComment,
|
|
49
|
+
deleteComment,
|
|
50
|
+
closeThread,
|
|
51
|
+
reopenThread,
|
|
52
|
+
removePin,
|
|
53
|
+
retrySync,
|
|
54
|
+
} = useComments();
|
|
55
|
+
const location = useLocation();
|
|
56
|
+
const detectedVersion = getVersionFromPathOrQuery(location.pathname, location.search);
|
|
57
|
+
const [newCommentText, setNewCommentText] = React.useState('');
|
|
58
|
+
const [replyingToCommentId, setReplyingToCommentId] = React.useState<string | null>(null);
|
|
59
|
+
const [replyTextByCommentId, setReplyTextByCommentId] = React.useState<Record<string, string>>({});
|
|
60
|
+
const [editingCommentId, setEditingCommentId] = React.useState<string | null>(null);
|
|
61
|
+
const [editText, setEditText] = React.useState('');
|
|
62
|
+
const drawerRef = React.useRef<HTMLSpanElement>(null);
|
|
63
|
+
const [activeTabKey, setActiveTabKey] = React.useState<string | number>('comments');
|
|
64
|
+
|
|
65
|
+
const currentThreads = getThreadsForRoute(location.pathname, detectedVersion);
|
|
66
|
+
const selectedThread = currentThreads.find((t) => t.id === selectedThreadId);
|
|
67
|
+
const isExpanded = !!selectedThreadId || drawerPinnedOpen || floatingWidgetMode;
|
|
68
|
+
|
|
69
|
+
const onExpand = () => {
|
|
70
|
+
drawerRef.current && drawerRef.current.focus();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
React.useEffect(() => {
|
|
74
|
+
if (selectedThreadId) {
|
|
75
|
+
setActiveTabKey('comments');
|
|
76
|
+
}
|
|
77
|
+
}, [selectedThreadId]);
|
|
78
|
+
|
|
79
|
+
React.useEffect(() => {
|
|
80
|
+
if (drawerPinnedOpen && !selectedThreadId) {
|
|
81
|
+
setActiveTabKey('details');
|
|
82
|
+
}
|
|
83
|
+
}, [drawerPinnedOpen, selectedThreadId]);
|
|
84
|
+
|
|
85
|
+
const handleAddComment = () => {
|
|
86
|
+
if (newCommentText.trim() && selectedThread) {
|
|
87
|
+
addReply(selectedThread.id, newCommentText.trim());
|
|
88
|
+
setNewCommentText('');
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleStartReply = (commentId: string) => {
|
|
93
|
+
setReplyingToCommentId(commentId);
|
|
94
|
+
setReplyTextByCommentId((prev) => ({ ...prev, [commentId]: prev[commentId] ?? '' }));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleCancelReply = () => {
|
|
98
|
+
setReplyingToCommentId(null);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleSubmitReply = (parentCommentId: string) => {
|
|
102
|
+
if (!selectedThread) return;
|
|
103
|
+
const text = (replyTextByCommentId[parentCommentId] || '').trim();
|
|
104
|
+
if (!text) return;
|
|
105
|
+
addReply(selectedThread.id, text, parentCommentId);
|
|
106
|
+
setReplyTextByCommentId((prev) => ({ ...prev, [parentCommentId]: '' }));
|
|
107
|
+
setReplyingToCommentId(null);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleStartEdit = (commentId: string, currentText: string) => {
|
|
111
|
+
setEditingCommentId(commentId);
|
|
112
|
+
setEditText(currentText);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleSaveEdit = (commentId: string) => {
|
|
116
|
+
if (editText.trim() && selectedThread) {
|
|
117
|
+
updateComment(selectedThread.id, commentId, editText.trim());
|
|
118
|
+
setEditingCommentId(null);
|
|
119
|
+
setEditText('');
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const handleCancelEdit = () => {
|
|
124
|
+
setEditingCommentId(null);
|
|
125
|
+
setEditText('');
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const handleDeleteComment = (commentId: string) => {
|
|
129
|
+
if (selectedThread) {
|
|
130
|
+
deleteComment(selectedThread.id, commentId);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const handleCloseThread = () => {
|
|
135
|
+
if (selectedThread) {
|
|
136
|
+
closeThread(selectedThread.id);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const handleReopenThread = () => {
|
|
141
|
+
if (selectedThread) {
|
|
142
|
+
reopenThread(selectedThread.id);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleRemovePin = () => {
|
|
147
|
+
if (!selectedThread) return;
|
|
148
|
+
removePin(selectedThread.id);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const handleClose = () => {
|
|
152
|
+
setSelectedThreadId(null);
|
|
153
|
+
if (floatingWidgetMode) {
|
|
154
|
+
setFloatingWidgetMode(false);
|
|
155
|
+
} else {
|
|
156
|
+
setDrawerPinnedOpen(false);
|
|
157
|
+
}
|
|
158
|
+
setEditingCommentId(null);
|
|
159
|
+
setEditText('');
|
|
160
|
+
setNewCommentText('');
|
|
161
|
+
setReplyingToCommentId(null);
|
|
162
|
+
setReplyTextByCommentId({});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const formatCommentDate = (isoDate: string): string => {
|
|
166
|
+
const date = new Date(isoDate);
|
|
167
|
+
return date.toLocaleString(undefined, {
|
|
168
|
+
month: 'short',
|
|
169
|
+
day: 'numeric',
|
|
170
|
+
hour: '2-digit',
|
|
171
|
+
minute: '2-digit',
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const stripMarkersForDisplay = (text: string): string => {
|
|
176
|
+
return text
|
|
177
|
+
.replace(/<!--\s*hale-reply-to:\d+\s*-->\s*\n?/g, '')
|
|
178
|
+
.replace(/<!--\s*hale-reply-to-local\s*-->\s*\n?/g, '')
|
|
179
|
+
.trimEnd();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const deriveStatus = () => {
|
|
183
|
+
if (!selectedThread) return 'local' as const;
|
|
184
|
+
if (selectedThread.syncStatus === 'error') return 'error' as const;
|
|
185
|
+
// If we have an issue and any comment hasn't synced yet, treat as pending.
|
|
186
|
+
if (selectedThread.issueNumber && selectedThread.comments.some((c) => !c.githubCommentId)) return 'pending' as const;
|
|
187
|
+
if (selectedThread.issueNumber) return 'synced' as const;
|
|
188
|
+
return selectedThread.syncStatus || 'local';
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const renderSyncLabel = (status?: 'synced' | 'local' | 'pending' | 'syncing' | 'error') => {
|
|
192
|
+
switch (status) {
|
|
193
|
+
case 'synced':
|
|
194
|
+
return (
|
|
195
|
+
<Label color="green" icon={<GithubIcon />}>
|
|
196
|
+
Synced
|
|
197
|
+
</Label>
|
|
198
|
+
);
|
|
199
|
+
case 'local':
|
|
200
|
+
return <Label color="grey">Local</Label>;
|
|
201
|
+
case 'pending':
|
|
202
|
+
return <Label color="blue">Pending…</Label>;
|
|
203
|
+
case 'syncing':
|
|
204
|
+
return (
|
|
205
|
+
<Label color="blue" icon={<Spinner size="sm" />}>
|
|
206
|
+
Syncing…
|
|
207
|
+
</Label>
|
|
208
|
+
);
|
|
209
|
+
case 'error':
|
|
210
|
+
return <Label color="red">Sync error</Label>;
|
|
211
|
+
default:
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const panelContent = (
|
|
217
|
+
<>
|
|
218
|
+
<Tabs
|
|
219
|
+
activeKey={activeTabKey}
|
|
220
|
+
onSelect={(_event, tabKey) => setActiveTabKey(tabKey)}
|
|
221
|
+
aria-label="Hale Commenting System drawer tabs"
|
|
222
|
+
>
|
|
223
|
+
<Tab eventKey="details" title={<TabTitleText>Details</TabTitleText>}>
|
|
224
|
+
<div style={{ paddingTop: '1rem' }}>
|
|
225
|
+
<DetailsTab />
|
|
226
|
+
</div>
|
|
227
|
+
</Tab>
|
|
228
|
+
<Tab eventKey="jira" title={<TabTitleText>Jira</TabTitleText>}>
|
|
229
|
+
<div style={{ paddingTop: '1rem' }}>
|
|
230
|
+
<JiraTab />
|
|
231
|
+
</div>
|
|
232
|
+
</Tab>
|
|
233
|
+
<Tab eventKey="comments" title={<TabTitleText>Comments</TabTitleText>}>
|
|
234
|
+
<div style={{ paddingTop: '1rem' }}>
|
|
235
|
+
{!selectedThread ? (
|
|
236
|
+
<EmptyState icon={InfoCircleIcon} titleText="No pin selected" headingLevel="h3">
|
|
237
|
+
<EmptyStateBody>Select or create a comment pin to start a thread.</EmptyStateBody>
|
|
238
|
+
</EmptyState>
|
|
239
|
+
) : (
|
|
240
|
+
<>
|
|
241
|
+
{/* Thread summary header (scaffold) */}
|
|
242
|
+
<Card style={{ marginBottom: '1rem' }}>
|
|
243
|
+
<CardBody>
|
|
244
|
+
<div style={{ display: 'grid', gap: '0.5rem' }}>
|
|
245
|
+
<div style={{ fontSize: '0.875rem' }}>
|
|
246
|
+
<strong>Location:</strong> ({selectedThread.xPercent.toFixed(1)}%, {selectedThread.yPercent.toFixed(1)}%)
|
|
247
|
+
</div>
|
|
248
|
+
<div style={{ fontSize: '0.875rem' }}>
|
|
249
|
+
<strong>Comments:</strong> {selectedThread.comments.length}
|
|
250
|
+
</div>
|
|
251
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.875rem' }}>
|
|
252
|
+
<strong>Status:</strong>
|
|
253
|
+
{renderSyncLabel(deriveStatus()) ?? <Label color="grey">Local</Label>}
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div style={{ fontSize: '0.875rem' }}>
|
|
257
|
+
{selectedThread.issueNumber && selectedThread.issueUrl ? (
|
|
258
|
+
<a
|
|
259
|
+
href={selectedThread.issueUrl}
|
|
260
|
+
target="_blank"
|
|
261
|
+
rel="noopener noreferrer"
|
|
262
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
|
263
|
+
>
|
|
264
|
+
<GithubIcon />
|
|
265
|
+
Issue #{selectedThread.issueNumber}
|
|
266
|
+
<ExternalLinkAltIcon style={{ fontSize: '0.75rem' }} />
|
|
267
|
+
</a>
|
|
268
|
+
) : (
|
|
269
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25rem', color: 'var(--pf-t--global--text--color--subtle)' }}>
|
|
270
|
+
<GithubIcon />
|
|
271
|
+
Issue pending…
|
|
272
|
+
</span>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<div>
|
|
277
|
+
{/* AI summarize removed for now */}
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</CardBody>
|
|
281
|
+
</Card>
|
|
282
|
+
|
|
283
|
+
{/* Comments List */}
|
|
284
|
+
{selectedThread.comments.length > 0 && (
|
|
285
|
+
<div style={{ marginBottom: '1.5rem' }}>
|
|
286
|
+
{(() => {
|
|
287
|
+
const comments = selectedThread.comments;
|
|
288
|
+
|
|
289
|
+
const byId = new Map(comments.map((c) => [c.id, c]));
|
|
290
|
+
const byGitHubId = new Map<number, string>();
|
|
291
|
+
for (const c of comments) {
|
|
292
|
+
if (c.githubCommentId) byGitHubId.set(c.githubCommentId, c.id);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const childrenByParent = new Map<string, string[]>();
|
|
296
|
+
const topLevel: string[] = [];
|
|
297
|
+
|
|
298
|
+
for (const c of comments) {
|
|
299
|
+
const parentLocal =
|
|
300
|
+
c.parentCommentId ||
|
|
301
|
+
(c.parentGitHubCommentId ? byGitHubId.get(c.parentGitHubCommentId) : undefined);
|
|
302
|
+
|
|
303
|
+
if (parentLocal && byId.has(parentLocal)) {
|
|
304
|
+
const list = childrenByParent.get(parentLocal) || [];
|
|
305
|
+
list.push(c.id);
|
|
306
|
+
childrenByParent.set(parentLocal, list);
|
|
307
|
+
} else {
|
|
308
|
+
topLevel.push(c.id);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const sortByCreatedAt = (aId: string, bId: string) => {
|
|
313
|
+
const a = byId.get(aId);
|
|
314
|
+
const b = byId.get(bId);
|
|
315
|
+
const at = a ? Date.parse(a.createdAt) : 0;
|
|
316
|
+
const bt = b ? Date.parse(b.createdAt) : 0;
|
|
317
|
+
return at - bt;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
topLevel.sort(sortByCreatedAt);
|
|
321
|
+
childrenByParent.forEach((list, parentId) => {
|
|
322
|
+
list.sort(sortByCreatedAt);
|
|
323
|
+
childrenByParent.set(parentId, list);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const renderNode = (id: string, depth: number, topIndex?: number) => {
|
|
327
|
+
const comment = byId.get(id);
|
|
328
|
+
if (!comment) return null;
|
|
329
|
+
|
|
330
|
+
const isReply = depth > 0;
|
|
331
|
+
const title = isReply ? 'Reply' : `Comment #${(topIndex ?? 0) + 1}`;
|
|
332
|
+
|
|
333
|
+
const children = childrenByParent.get(id) || [];
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<div
|
|
337
|
+
key={id}
|
|
338
|
+
style={{
|
|
339
|
+
marginLeft: depth * 16,
|
|
340
|
+
marginTop: depth > 0 ? '8px' : undefined,
|
|
341
|
+
marginBottom: depth > 0 ? '8px' : '1rem',
|
|
342
|
+
}}
|
|
343
|
+
>
|
|
344
|
+
<Card>
|
|
345
|
+
<CardBody style={{ position: 'relative' }}>
|
|
346
|
+
<Button
|
|
347
|
+
variant="plain"
|
|
348
|
+
icon={<TrashIcon />}
|
|
349
|
+
isDanger
|
|
350
|
+
aria-label="Delete comment"
|
|
351
|
+
title="Delete comment"
|
|
352
|
+
onClick={() => handleDeleteComment(comment.id)}
|
|
353
|
+
style={{ position: 'absolute', top: '12px', right: '12px' }}
|
|
354
|
+
/>
|
|
355
|
+
<Title headingLevel="h3" size={isReply ? 'lg' : 'xl'} style={{ paddingRight: '2.5rem' }}>
|
|
356
|
+
{title}
|
|
357
|
+
</Title>
|
|
358
|
+
<div
|
|
359
|
+
style={{
|
|
360
|
+
marginTop: '0.25rem',
|
|
361
|
+
fontSize: '0.875rem',
|
|
362
|
+
color: 'var(--pf-t--global--text--color--subtle)',
|
|
363
|
+
paddingRight: '2.5rem',
|
|
364
|
+
}}
|
|
365
|
+
>
|
|
366
|
+
@{comment.author ?? '—'} {formatCommentDate(comment.createdAt)}
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
{editingCommentId === comment.id ? (
|
|
370
|
+
<div style={{ marginTop: '0.5rem' }}>
|
|
371
|
+
<TextArea
|
|
372
|
+
value={editText}
|
|
373
|
+
onChange={(_event, value) => setEditText(value)}
|
|
374
|
+
aria-label="Edit comment"
|
|
375
|
+
rows={3}
|
|
376
|
+
/>
|
|
377
|
+
<ActionList style={{ marginTop: '0.5rem' }}>
|
|
378
|
+
<ActionListItem>
|
|
379
|
+
<Button variant="primary" onClick={() => handleSaveEdit(comment.id)}>
|
|
380
|
+
Save
|
|
381
|
+
</Button>
|
|
382
|
+
</ActionListItem>
|
|
383
|
+
<ActionListItem>
|
|
384
|
+
<Button variant="link" onClick={handleCancelEdit}>
|
|
385
|
+
Cancel
|
|
386
|
+
</Button>
|
|
387
|
+
</ActionListItem>
|
|
388
|
+
</ActionList>
|
|
389
|
+
</div>
|
|
390
|
+
) : (
|
|
391
|
+
<div>
|
|
392
|
+
<div style={{ marginTop: '0.75rem', whiteSpace: 'pre-wrap' }}>
|
|
393
|
+
{stripMarkersForDisplay(comment.text)}
|
|
394
|
+
</div>
|
|
395
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '0.5rem' }}>
|
|
396
|
+
<Button variant="primary" onClick={() => handleStartReply(comment.id)}>
|
|
397
|
+
Reply
|
|
398
|
+
</Button>
|
|
399
|
+
<Button variant="link" onClick={() => handleStartEdit(comment.id, stripMarkersForDisplay(comment.text))}>
|
|
400
|
+
Edit
|
|
401
|
+
</Button>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
)}
|
|
405
|
+
|
|
406
|
+
{replyingToCommentId === comment.id && (
|
|
407
|
+
<div style={{ marginTop: '0.75rem' }}>
|
|
408
|
+
<Title headingLevel="h4" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
409
|
+
Reply to this comment
|
|
410
|
+
</Title>
|
|
411
|
+
<TextArea
|
|
412
|
+
value={replyTextByCommentId[comment.id] || ''}
|
|
413
|
+
onChange={(_event, value) =>
|
|
414
|
+
setReplyTextByCommentId((prev) => ({ ...prev, [comment.id]: value }))
|
|
415
|
+
}
|
|
416
|
+
placeholder="Type your reply..."
|
|
417
|
+
aria-label="Reply to comment"
|
|
418
|
+
rows={3}
|
|
419
|
+
/>
|
|
420
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '0.5rem' }}>
|
|
421
|
+
<Button
|
|
422
|
+
variant="primary"
|
|
423
|
+
onClick={() => handleSubmitReply(comment.id)}
|
|
424
|
+
isDisabled={!(replyTextByCommentId[comment.id] || '').trim()}
|
|
425
|
+
>
|
|
426
|
+
Post reply
|
|
427
|
+
</Button>
|
|
428
|
+
<Button variant="link" onClick={handleCancelReply}>
|
|
429
|
+
Cancel
|
|
430
|
+
</Button>
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
434
|
+
</CardBody>
|
|
435
|
+
</Card>
|
|
436
|
+
|
|
437
|
+
{children.map((childId) => renderNode(childId, depth + 1))}
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
return <>{topLevel.map((id, idx) => renderNode(id, 0, idx))}</>;
|
|
443
|
+
})()}
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{/* Add New Comment */}
|
|
448
|
+
<div>
|
|
449
|
+
{selectedThread.status === 'closed' ? (
|
|
450
|
+
<div style={{ marginBottom: '1rem', padding: '1rem', backgroundColor: 'var(--pf-t--global--background--color--secondary--default)', borderRadius: 'var(--pf-t--global--border--radius--medium)' }}>
|
|
451
|
+
<Title headingLevel="h3" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
452
|
+
🔒 Thread Closed
|
|
453
|
+
</Title>
|
|
454
|
+
<p style={{ color: 'var(--pf-t--global--text--color--subtle)', marginBottom: '1rem' }}>
|
|
455
|
+
This thread has been closed and locked. Reopen it to add new comments.
|
|
456
|
+
</p>
|
|
457
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
|
458
|
+
<Button variant="primary" onClick={handleReopenThread}>
|
|
459
|
+
Reopen Thread
|
|
460
|
+
</Button>
|
|
461
|
+
<Button variant="link" isDanger onClick={handleRemovePin}>
|
|
462
|
+
Remove pin
|
|
463
|
+
</Button>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
) : (
|
|
467
|
+
<>
|
|
468
|
+
<Title headingLevel="h3" size="md" style={{ marginBottom: '0.5rem' }}>
|
|
469
|
+
Add comment
|
|
470
|
+
</Title>
|
|
471
|
+
<TextArea
|
|
472
|
+
value={newCommentText}
|
|
473
|
+
onChange={(_event, value) => setNewCommentText(value)}
|
|
474
|
+
placeholder="Type your comment..."
|
|
475
|
+
aria-label="New comment"
|
|
476
|
+
rows={4}
|
|
477
|
+
/>
|
|
478
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '1rem' }}>
|
|
479
|
+
<Button variant="primary" onClick={handleAddComment} isDisabled={!newCommentText.trim()}>
|
|
480
|
+
Add Comment
|
|
481
|
+
</Button>
|
|
482
|
+
<Button variant="secondary" onClick={handleCloseThread}>
|
|
483
|
+
Close Thread
|
|
484
|
+
</Button>
|
|
485
|
+
<Button variant="link" isDanger onClick={handleRemovePin}>
|
|
486
|
+
Remove pin
|
|
487
|
+
</Button>
|
|
488
|
+
</div>
|
|
489
|
+
</>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
</>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
495
|
+
</Tab>
|
|
496
|
+
</Tabs>
|
|
497
|
+
</>
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
if (floatingWidgetMode && isExpanded) {
|
|
501
|
+
return (
|
|
502
|
+
<>
|
|
503
|
+
<FloatingWidget onClose={handleClose} title="Hale Commenting System">
|
|
504
|
+
<div style={{ padding: '1rem' }}>{panelContent}</div>
|
|
505
|
+
</FloatingWidget>
|
|
506
|
+
<div style={{ position: 'relative' }}>{children}</div>
|
|
507
|
+
</>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const drawerPanelContent = isExpanded ? (
|
|
512
|
+
<DrawerPanelContent isResizable defaultSize={'500px'} minSize={'300px'}>
|
|
513
|
+
<DrawerHead>
|
|
514
|
+
<span tabIndex={isExpanded ? 0 : -1} ref={drawerRef}>
|
|
515
|
+
<Title headingLevel="h2" size="lg">
|
|
516
|
+
Hale Commenting System
|
|
517
|
+
</Title>
|
|
518
|
+
</span>
|
|
519
|
+
<DrawerActions>
|
|
520
|
+
<DrawerCloseButton onClick={handleClose} />
|
|
521
|
+
</DrawerActions>
|
|
522
|
+
</DrawerHead>
|
|
523
|
+
<DrawerPanelBody>{panelContent}</DrawerPanelBody>
|
|
524
|
+
</DrawerPanelContent>
|
|
525
|
+
) : null;
|
|
526
|
+
|
|
527
|
+
return (
|
|
528
|
+
<Drawer isExpanded={isExpanded} isInline onExpand={onExpand}>
|
|
529
|
+
<DrawerContent panelContent={drawerPanelContent}>
|
|
530
|
+
<DrawerContentBody style={{ position: 'relative' }}>{children}</DrawerContentBody>
|
|
531
|
+
</DrawerContent>
|
|
532
|
+
</Drawer>
|
|
533
|
+
);
|
|
534
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Button } from '@patternfly/react-core';
|
|
3
|
+
import { CommentIcon } from '@patternfly/react-icons';
|
|
4
|
+
|
|
5
|
+
interface CommentPinProps {
|
|
6
|
+
xPercent: number;
|
|
7
|
+
yPercent: number;
|
|
8
|
+
commentCount: number;
|
|
9
|
+
isClosed?: boolean;
|
|
10
|
+
isSelected: boolean;
|
|
11
|
+
onClick: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const CommentPin: React.FunctionComponent<CommentPinProps> = ({
|
|
15
|
+
xPercent,
|
|
16
|
+
yPercent,
|
|
17
|
+
commentCount,
|
|
18
|
+
isClosed = false,
|
|
19
|
+
isSelected,
|
|
20
|
+
onClick,
|
|
21
|
+
}) => {
|
|
22
|
+
return (
|
|
23
|
+
<Button
|
|
24
|
+
variant="plain"
|
|
25
|
+
data-comment-pin
|
|
26
|
+
style={{
|
|
27
|
+
position: 'absolute',
|
|
28
|
+
left: `${xPercent}%`,
|
|
29
|
+
top: `${yPercent}%`,
|
|
30
|
+
transform: 'translate(-50%, -50%)',
|
|
31
|
+
width: '32px',
|
|
32
|
+
height: '32px',
|
|
33
|
+
borderRadius: '50%',
|
|
34
|
+
backgroundColor: isClosed ? 'var(--pf-t--global--icon--color--subtle)' : '#C9190B',
|
|
35
|
+
color: 'white',
|
|
36
|
+
border: isSelected ? '3px solid #0066CC' : '2px solid white',
|
|
37
|
+
boxShadow: isSelected
|
|
38
|
+
? '0 0 0 3px rgba(0, 102, 204, 0.3), 0 2px 8px rgba(0,0,0,0.3)'
|
|
39
|
+
: '0 2px 8px rgba(0,0,0,0.3)',
|
|
40
|
+
cursor: 'pointer',
|
|
41
|
+
padding: 0,
|
|
42
|
+
display: 'flex',
|
|
43
|
+
alignItems: 'center',
|
|
44
|
+
justifyContent: 'center',
|
|
45
|
+
transition: 'all 0.2s ease',
|
|
46
|
+
pointerEvents: 'auto',
|
|
47
|
+
}}
|
|
48
|
+
onClick={onClick}
|
|
49
|
+
aria-label={`${isClosed ? 'Closed ' : ''}comment thread with ${commentCount} comment${commentCount !== 1 ? 's' : ''}`}
|
|
50
|
+
>
|
|
51
|
+
{commentCount === 0 ? (
|
|
52
|
+
<CommentIcon style={{ fontSize: '16px' }} />
|
|
53
|
+
) : commentCount === 1 ? (
|
|
54
|
+
<CommentIcon style={{ fontSize: '16px' }} />
|
|
55
|
+
) : (
|
|
56
|
+
<span style={{ fontSize: '14px', fontWeight: 'bold' }}>{commentCount}</span>
|
|
57
|
+
)}
|
|
58
|
+
</Button>
|
|
59
|
+
);
|
|
60
|
+
};
|