hale-commenting-system 3.5.2 → 3.7.0
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/README.md +187 -8
- package/package.json +1 -1
- package/src/app/commenting-system/components/CommentOverlay.tsx +282 -14
- package/src/app/commenting-system/components/CommentPanel.tsx +67 -12
- package/src/app/commenting-system/components/CommentPin.tsx +87 -5
- package/src/app/commenting-system/components/FloatingWidget.tsx +79 -9
- package/src/app/commenting-system/components/JiraTab.tsx +379 -164
- package/src/app/commenting-system/contexts/CommentContext.tsx +77 -33
- package/src/app/commenting-system/index.ts +4 -1
- package/src/app/commenting-system/services/githubAdapter.ts +9 -2
- package/src/app/commenting-system/types/index.ts +14 -2
- package/src/app/commenting-system/utils/componentUtils.ts +242 -0
- package/src/app/commenting-system/utils/selectorUtils.ts +155 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { findElementBySelector } from '../utils/selectorUtils';
|
|
2
3
|
import { useLocation } from 'react-router-dom';
|
|
3
4
|
import {
|
|
4
5
|
ActionList,
|
|
@@ -121,7 +122,11 @@ export const CommentPanel: React.FunctionComponent<CommentPanelProps> = ({ child
|
|
|
121
122
|
}
|
|
122
123
|
};
|
|
123
124
|
|
|
124
|
-
const handleRemovePin = () => {
|
|
125
|
+
const handleRemovePin = (e?: React.MouseEvent) => {
|
|
126
|
+
if (e) {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
}
|
|
125
130
|
if (!selectedThread) return;
|
|
126
131
|
removePin(selectedThread.id);
|
|
127
132
|
};
|
|
@@ -211,13 +216,67 @@ export const CommentPanel: React.FunctionComponent<CommentPanelProps> = ({ child
|
|
|
211
216
|
</EmptyState>
|
|
212
217
|
) : (
|
|
213
218
|
<>
|
|
214
|
-
{/* Thread summary header
|
|
219
|
+
{/* Thread summary header with component information */}
|
|
215
220
|
<Card style={{ marginBottom: '1rem' }}>
|
|
216
221
|
<CardBody>
|
|
217
|
-
<div style={{ display: 'grid', gap: '0.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
222
|
+
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
|
223
|
+
{/* Component Information (Component-Based) */}
|
|
224
|
+
{selectedThread.componentMetadata ? (
|
|
225
|
+
<div style={{ display: 'grid', gap: '0.5rem', padding: '0.75rem', backgroundColor: 'var(--pf-t--global--background--color--secondary--default)', borderRadius: 'var(--pf-t--global--border--radius--small)' }}>
|
|
226
|
+
<div style={{ fontSize: '0.875rem', fontWeight: 'bold' }}>
|
|
227
|
+
React Component
|
|
228
|
+
</div>
|
|
229
|
+
<div style={{ fontSize: '0.875rem' }}>
|
|
230
|
+
<strong>Name:</strong> {selectedThread.componentMetadata.componentName || selectedThread.componentMetadata.displayName || 'Unknown'}
|
|
231
|
+
{selectedThread.componentMetadata.componentType && selectedThread.componentMetadata.componentType !== 'native' && (
|
|
232
|
+
<span style={{ color: 'var(--pf-t--global--text--color--subtle)', marginLeft: '0.5rem' }}>
|
|
233
|
+
({selectedThread.componentMetadata.componentType})
|
|
234
|
+
</span>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
{selectedThread.componentMetadata.componentPath && selectedThread.componentMetadata.componentPath.length > 0 && (
|
|
238
|
+
<div style={{ fontSize: '0.875rem' }}>
|
|
239
|
+
<strong>Path:</strong>{' '}
|
|
240
|
+
<span style={{ fontFamily: 'monospace', color: 'var(--pf-t--global--text--color--subtle)' }}>
|
|
241
|
+
{selectedThread.componentMetadata.componentPath.join(' > ')}
|
|
242
|
+
</span>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
{selectedThread.componentMetadata.props && Object.keys(selectedThread.componentMetadata.props).length > 0 && (
|
|
246
|
+
<details style={{ fontSize: '0.875rem' }}>
|
|
247
|
+
<summary style={{ cursor: 'pointer', marginBottom: '0.25rem' }}>
|
|
248
|
+
<strong>Props</strong> ({Object.keys(selectedThread.componentMetadata.props).length})
|
|
249
|
+
</summary>
|
|
250
|
+
<pre
|
|
251
|
+
style={{
|
|
252
|
+
marginTop: '0.5rem',
|
|
253
|
+
padding: '0.5rem',
|
|
254
|
+
backgroundColor: 'var(--pf-t--global--background--color--default)',
|
|
255
|
+
borderRadius: 'var(--pf-t--global--border--radius--small)',
|
|
256
|
+
fontSize: '0.75rem',
|
|
257
|
+
overflow: 'auto',
|
|
258
|
+
maxHeight: '200px',
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
{JSON.stringify(selectedThread.componentMetadata.props, null, 2)}
|
|
262
|
+
</pre>
|
|
263
|
+
</details>
|
|
264
|
+
)}
|
|
265
|
+
{selectedThread.cssSelector && !findElementBySelector(selectedThread.cssSelector) && (
|
|
266
|
+
<div style={{ fontSize: '0.875rem', color: 'var(--pf-t--global--color--status--danger--default)' }}>
|
|
267
|
+
⚠️ Component element not found in DOM
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
) : (
|
|
272
|
+
<div style={{ fontSize: '0.875rem' }}>
|
|
273
|
+
<strong>Element:</strong> {selectedThread.elementDescription || 'unknown'}
|
|
274
|
+
{selectedThread.cssSelector && !findElementBySelector(selectedThread.cssSelector) && (
|
|
275
|
+
<span style={{ color: 'var(--pf-t--global--color--status--danger--default)' }}> [deleted]</span>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
|
|
221
280
|
<div style={{ fontSize: '0.875rem' }}>
|
|
222
281
|
<strong>Comments:</strong> {selectedThread.comments.length}
|
|
223
282
|
</div>
|
|
@@ -245,10 +304,6 @@ export const CommentPanel: React.FunctionComponent<CommentPanelProps> = ({ child
|
|
|
245
304
|
</span>
|
|
246
305
|
)}
|
|
247
306
|
</div>
|
|
248
|
-
|
|
249
|
-
<div>
|
|
250
|
-
{/* AI summarize removed for now */}
|
|
251
|
-
</div>
|
|
252
307
|
</div>
|
|
253
308
|
</CardBody>
|
|
254
309
|
</Card>
|
|
@@ -431,7 +486,7 @@ export const CommentPanel: React.FunctionComponent<CommentPanelProps> = ({ child
|
|
|
431
486
|
<Button variant="primary" onClick={handleReopenThread}>
|
|
432
487
|
Reopen Thread
|
|
433
488
|
</Button>
|
|
434
|
-
<Button variant="link" isDanger onClick={handleRemovePin}>
|
|
489
|
+
<Button variant="link" isDanger onClick={handleRemovePin} aria-label="Remove pin">
|
|
435
490
|
Remove pin
|
|
436
491
|
</Button>
|
|
437
492
|
</div>
|
|
@@ -455,7 +510,7 @@ export const CommentPanel: React.FunctionComponent<CommentPanelProps> = ({ child
|
|
|
455
510
|
<Button variant="secondary" onClick={handleCloseThread}>
|
|
456
511
|
Close Thread
|
|
457
512
|
</Button>
|
|
458
|
-
<Button variant="link" isDanger onClick={handleRemovePin}>
|
|
513
|
+
<Button variant="link" isDanger onClick={handleRemovePin} aria-label="Remove pin">
|
|
459
514
|
Remove pin
|
|
460
515
|
</Button>
|
|
461
516
|
</div>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { Button } from '@patternfly/react-core';
|
|
3
3
|
import { CommentIcon } from '@patternfly/react-icons';
|
|
4
|
+
import { findElementBySelector } from '../utils/selectorUtils';
|
|
4
5
|
|
|
5
6
|
interface CommentPinProps {
|
|
7
|
+
cssSelector?: string;
|
|
6
8
|
xPercent: number;
|
|
7
9
|
yPercent: number;
|
|
8
10
|
commentCount: number;
|
|
@@ -12,6 +14,7 @@ interface CommentPinProps {
|
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export const CommentPin: React.FunctionComponent<CommentPinProps> = ({
|
|
17
|
+
cssSelector,
|
|
15
18
|
xPercent,
|
|
16
19
|
yPercent,
|
|
17
20
|
commentCount,
|
|
@@ -19,15 +22,90 @@ export const CommentPin: React.FunctionComponent<CommentPinProps> = ({
|
|
|
19
22
|
isSelected,
|
|
20
23
|
onClick,
|
|
21
24
|
}) => {
|
|
25
|
+
const [position, setPosition] = React.useState({ left: `${xPercent}%`, top: `${yPercent}%` });
|
|
26
|
+
const [elementExists, setElementExists] = React.useState(true);
|
|
27
|
+
|
|
28
|
+
const updatePosition = React.useCallback(() => {
|
|
29
|
+
if (!cssSelector) {
|
|
30
|
+
// No selector - use fallback coordinates
|
|
31
|
+
setPosition({ left: `${xPercent}%`, top: `${yPercent}%` });
|
|
32
|
+
setElementExists(true);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const element = findElementBySelector(cssSelector);
|
|
37
|
+
if (element) {
|
|
38
|
+
// Element found - position pin at top-left of element
|
|
39
|
+
const rect = element.getBoundingClientRect();
|
|
40
|
+
|
|
41
|
+
// Find the overlay container - it should be a parent with data-comment-overlay
|
|
42
|
+
// or find the CommentPanel's relative container
|
|
43
|
+
let overlayContainer: Element | null = null;
|
|
44
|
+
let current: Element | null = element;
|
|
45
|
+
|
|
46
|
+
// Walk up the DOM to find the overlay or its parent container
|
|
47
|
+
while (current && current !== document.body) {
|
|
48
|
+
if (current.hasAttribute('data-comment-overlay')) {
|
|
49
|
+
overlayContainer = current;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
// Also check if parent has position relative (likely the CommentPanel wrapper)
|
|
53
|
+
const parent = current.parentElement;
|
|
54
|
+
if (parent) {
|
|
55
|
+
const style = window.getComputedStyle(parent);
|
|
56
|
+
if (style.position === 'relative') {
|
|
57
|
+
overlayContainer = parent;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
current = parent;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// If no overlay found, use the element's offsetParent or body
|
|
65
|
+
if (!overlayContainer) {
|
|
66
|
+
overlayContainer = (element as HTMLElement).offsetParent as Element || document.body;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const overlayRect = overlayContainer.getBoundingClientRect();
|
|
70
|
+
|
|
71
|
+
// Position at top-left of element, offset by 4px (just outside the element border)
|
|
72
|
+
// Use absolute pixel positioning relative to the overlay container
|
|
73
|
+
const leftPx = rect.left - overlayRect.left + 4;
|
|
74
|
+
const topPx = rect.top - overlayRect.top + 4;
|
|
75
|
+
|
|
76
|
+
setPosition({ left: `${leftPx}px`, top: `${topPx}px` });
|
|
77
|
+
setElementExists(true);
|
|
78
|
+
} else {
|
|
79
|
+
// Element not found - fall back to stored coordinates and fade
|
|
80
|
+
setPosition({ left: `${xPercent}%`, top: `${yPercent}%` });
|
|
81
|
+
setElementExists(false);
|
|
82
|
+
}
|
|
83
|
+
}, [cssSelector, xPercent, yPercent]);
|
|
84
|
+
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
updatePosition();
|
|
87
|
+
|
|
88
|
+
// Update position on scroll and resize
|
|
89
|
+
window.addEventListener('scroll', updatePosition, true);
|
|
90
|
+
window.addEventListener('resize', updatePosition);
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
window.removeEventListener('scroll', updatePosition, true);
|
|
94
|
+
window.removeEventListener('resize', updatePosition);
|
|
95
|
+
};
|
|
96
|
+
}, [updatePosition]);
|
|
97
|
+
|
|
98
|
+
const opacity = elementExists ? 1.0 : 0.4;
|
|
99
|
+
|
|
22
100
|
return (
|
|
23
101
|
<Button
|
|
24
102
|
variant="plain"
|
|
25
103
|
data-comment-pin
|
|
26
104
|
style={{
|
|
27
105
|
position: 'absolute',
|
|
28
|
-
left:
|
|
29
|
-
top:
|
|
30
|
-
transform: 'translate(
|
|
106
|
+
left: position.left,
|
|
107
|
+
top: position.top,
|
|
108
|
+
transform: 'translate(0, 0)',
|
|
31
109
|
width: '32px',
|
|
32
110
|
height: '32px',
|
|
33
111
|
borderRadius: '50%',
|
|
@@ -44,9 +122,13 @@ export const CommentPin: React.FunctionComponent<CommentPinProps> = ({
|
|
|
44
122
|
justifyContent: 'center',
|
|
45
123
|
transition: 'all 0.2s ease',
|
|
46
124
|
pointerEvents: 'auto',
|
|
125
|
+
opacity,
|
|
126
|
+
}}
|
|
127
|
+
onClick={(e) => {
|
|
128
|
+
e.stopPropagation();
|
|
129
|
+
onClick();
|
|
47
130
|
}}
|
|
48
|
-
|
|
49
|
-
aria-label={`${isClosed ? 'Closed ' : ''}comment thread with ${commentCount} comment${commentCount !== 1 ? 's' : ''}`}
|
|
131
|
+
aria-label={`${isClosed ? 'Closed ' : ''}comment thread with ${commentCount} comment${commentCount !== 1 ? 's' : ''}${!elementExists ? ' (element deleted)' : ''}`}
|
|
50
132
|
>
|
|
51
133
|
{commentCount === 0 ? (
|
|
52
134
|
<CommentIcon style={{ fontSize: '16px' }} />
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import { Button, Switch, Title } from '@patternfly/react-core';
|
|
4
|
-
import { GripVerticalIcon, WindowMinimizeIcon, GithubIcon } from '@patternfly/react-icons';
|
|
4
|
+
import { GripVerticalIcon, WindowMinimizeIcon, GithubIcon, ArrowsAltVIcon } from '@patternfly/react-icons';
|
|
5
5
|
import { useComments } from '../contexts/CommentContext';
|
|
6
6
|
import { useGitHubAuth } from '../contexts/GitHubAuthContext';
|
|
7
7
|
|
|
@@ -16,9 +16,13 @@ export const FloatingWidget: React.FunctionComponent<FloatingWidgetProps> = ({ c
|
|
|
16
16
|
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
|
|
17
17
|
const [isMinimized, setIsMinimized] = React.useState(false);
|
|
18
18
|
const [viewportHeight, setViewportHeight] = React.useState(window.innerHeight);
|
|
19
|
+
const [widgetSize, setWidgetSize] = React.useState({ width: 500, height: window.innerHeight * 0.8 });
|
|
20
|
+
const [isResizing, setIsResizing] = React.useState(false);
|
|
21
|
+
const [resizeStart, setResizeStart] = React.useState({ x: 0, y: 0, width: 0, height: 0 });
|
|
19
22
|
const widgetRef = React.useRef<HTMLDivElement>(null);
|
|
23
|
+
const resizeHandleRef = React.useRef<HTMLDivElement>(null);
|
|
20
24
|
|
|
21
|
-
const { commentsEnabled, setCommentsEnabled } = useComments();
|
|
25
|
+
const { commentsEnabled, setCommentsEnabled, showPinsEnabled, setShowPinsEnabled } = useComments();
|
|
22
26
|
const { isAuthenticated, user, login, logout } = useGitHubAuth();
|
|
23
27
|
|
|
24
28
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
@@ -64,11 +68,52 @@ export const FloatingWidget: React.FunctionComponent<FloatingWidgetProps> = ({ c
|
|
|
64
68
|
return () => window.removeEventListener('resize', handleResize);
|
|
65
69
|
}, []);
|
|
66
70
|
|
|
71
|
+
// Handle resize
|
|
72
|
+
const handleResizeStart = (e: React.MouseEvent) => {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
e.stopPropagation();
|
|
75
|
+
if (!widgetRef.current) return;
|
|
76
|
+
const rect = widgetRef.current.getBoundingClientRect();
|
|
77
|
+
setIsResizing(true);
|
|
78
|
+
setResizeStart({
|
|
79
|
+
x: e.clientX,
|
|
80
|
+
y: e.clientY,
|
|
81
|
+
width: rect.width,
|
|
82
|
+
height: rect.height,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
if (!isResizing) return;
|
|
88
|
+
|
|
89
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
90
|
+
const deltaX = e.clientX - resizeStart.x;
|
|
91
|
+
const deltaY = e.clientY - resizeStart.y;
|
|
92
|
+
|
|
93
|
+
setWidgetSize({
|
|
94
|
+
width: Math.max(300, Math.min(800, resizeStart.width + deltaX)),
|
|
95
|
+
height: Math.max(200, Math.min(viewportHeight - 100, resizeStart.height + deltaY)),
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleMouseUp = () => {
|
|
100
|
+
setIsResizing(false);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
104
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
108
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
109
|
+
};
|
|
110
|
+
}, [isResizing, resizeStart, viewportHeight]);
|
|
111
|
+
|
|
67
112
|
// Constrain to viewport but allow moving into topbar area (just keep drag handle accessible)
|
|
68
113
|
const constrainedPosition = React.useMemo(() => {
|
|
69
|
-
const widgetWidth =
|
|
70
|
-
// Calculate actual widget height
|
|
71
|
-
const widgetHeight = isMinimized ? 120 :
|
|
114
|
+
const widgetWidth = widgetSize.width;
|
|
115
|
+
// Calculate actual widget height
|
|
116
|
+
const widgetHeight = isMinimized ? 120 : widgetSize.height;
|
|
72
117
|
const maxX = window.innerWidth - 50; // Allow 50px of widget to be visible for dragging
|
|
73
118
|
const maxY = viewportHeight - 50;
|
|
74
119
|
// Allow widget to move into topbar, but keep at least 60px of drag handle visible
|
|
@@ -77,7 +122,7 @@ export const FloatingWidget: React.FunctionComponent<FloatingWidgetProps> = ({ c
|
|
|
77
122
|
x: Math.max(-widgetWidth + 50, Math.min(position.x, maxX)),
|
|
78
123
|
y: Math.max(minY, Math.min(position.y, maxY)),
|
|
79
124
|
};
|
|
80
|
-
}, [position, isMinimized, viewportHeight]);
|
|
125
|
+
}, [position, isMinimized, viewportHeight, widgetSize]);
|
|
81
126
|
|
|
82
127
|
const widgetContent = (
|
|
83
128
|
<div
|
|
@@ -87,9 +132,9 @@ export const FloatingWidget: React.FunctionComponent<FloatingWidgetProps> = ({ c
|
|
|
87
132
|
position: 'fixed',
|
|
88
133
|
left: `${constrainedPosition.x}px`,
|
|
89
134
|
top: `${constrainedPosition.y}px`,
|
|
90
|
-
width:
|
|
91
|
-
height: isMinimized ? 'fit-content' :
|
|
92
|
-
maxHeight:
|
|
135
|
+
width: `${widgetSize.width}px`,
|
|
136
|
+
height: isMinimized ? 'fit-content' : `${widgetSize.height}px`,
|
|
137
|
+
maxHeight: `${viewportHeight - 100}px`,
|
|
93
138
|
zIndex: 99999,
|
|
94
139
|
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.2)',
|
|
95
140
|
borderRadius: 'var(--pf-t--global--border--radius--medium)',
|
|
@@ -155,6 +200,13 @@ export const FloatingWidget: React.FunctionComponent<FloatingWidgetProps> = ({ c
|
|
|
155
200
|
onChange={(_event, checked) => setCommentsEnabled(checked)}
|
|
156
201
|
aria-label="Enable or disable comments"
|
|
157
202
|
/>
|
|
203
|
+
<Switch
|
|
204
|
+
id="floating-show-pins-switch"
|
|
205
|
+
label="Show pins"
|
|
206
|
+
isChecked={showPinsEnabled}
|
|
207
|
+
onChange={(_event, checked) => setShowPinsEnabled(checked)}
|
|
208
|
+
aria-label="Show or hide comment pins"
|
|
209
|
+
/>
|
|
158
210
|
<div style={{ flex: 1 }} />
|
|
159
211
|
{isAuthenticated ? (
|
|
160
212
|
<>
|
|
@@ -188,6 +240,24 @@ export const FloatingWidget: React.FunctionComponent<FloatingWidgetProps> = ({ c
|
|
|
188
240
|
{children}
|
|
189
241
|
</div>
|
|
190
242
|
)}
|
|
243
|
+
{/* Resize handle */}
|
|
244
|
+
{!isMinimized && (
|
|
245
|
+
<div
|
|
246
|
+
ref={resizeHandleRef}
|
|
247
|
+
onMouseDown={handleResizeStart}
|
|
248
|
+
style={{
|
|
249
|
+
position: 'absolute',
|
|
250
|
+
bottom: 0,
|
|
251
|
+
right: 0,
|
|
252
|
+
width: '20px',
|
|
253
|
+
height: '20px',
|
|
254
|
+
cursor: 'nwse-resize',
|
|
255
|
+
background: 'linear-gradient(135deg, transparent 0%, transparent 40%, var(--pf-t--global--border--color--default) 40%, var(--pf-t--global--border--color--default) 45%, transparent 45%, transparent 55%, var(--pf-t--global--border--color--default) 55%, var(--pf-t--global--border--color--default) 60%, transparent 60%)',
|
|
256
|
+
borderRadius: '0 0 var(--pf-t--global--border--radius--medium) 0',
|
|
257
|
+
}}
|
|
258
|
+
aria-label="Resize widget"
|
|
259
|
+
/>
|
|
260
|
+
)}
|
|
191
261
|
</div>
|
|
192
262
|
);
|
|
193
263
|
|