@startsimpli/ui 0.4.14 → 0.4.15
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 +457 -398
- package/package.json +18 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
- package/src/components/__tests__/chat.test.tsx +129 -0
- package/src/components/__tests__/meetings-list.test.tsx +114 -0
- package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
- package/src/components/__tests__/workspace.test.tsx +106 -0
- package/src/components/account/__tests__/account.test.tsx +5 -32
- package/src/components/account/change-password-form.tsx +1 -28
- package/src/components/calendar/calendar-view.tsx +31 -0
- package/src/components/calendar/index.ts +7 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +5 -5
- package/src/components/chat/ChatComposer.tsx +113 -0
- package/src/components/chat/ChatMessage.tsx +81 -0
- package/src/components/chat/ChatThread.tsx +57 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +20 -0
- package/src/components/index.ts +13 -0
- package/src/components/slide-deck/SlideCanvas.tsx +68 -0
- package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
- package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
- package/src/components/slide-deck/index.ts +7 -0
- package/src/components/slide-deck/types.ts +18 -0
- package/src/components/team/DomainClaimCard.tsx +170 -0
- package/src/components/team/InviteMemberDialog.tsx +182 -0
- package/src/components/team/LeaveTeamDialog.tsx +130 -0
- package/src/components/team/MembersTable.tsx +138 -0
- package/src/components/team/OrgSwitcher.tsx +68 -0
- package/src/components/team/PendingInvitationCallout.tsx +106 -0
- package/src/components/team/RoleSelector.tsx +68 -0
- package/src/components/team/__tests__/team-components.test.tsx +352 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +57 -0
- package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
- package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
- package/src/components/team/members-table-default-class-names.ts +39 -0
- package/src/components/team/org-switcher-default-class-names.ts +13 -0
- package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
- package/src/components/team/role-selector-default-class-names.ts +11 -0
- package/src/components/team/types.ts +97 -0
- package/src/components/workflows/ExecNodeDetails.tsx +83 -0
- package/src/components/workflows/ExecutionTimeline.tsx +146 -0
- package/src/components/workflows/NodeInspector.tsx +257 -0
- package/src/components/workflows/NodePalette.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +113 -0
- package/src/components/workflows/WorkflowEdge.tsx +65 -0
- package/src/components/workflows/WorkflowEditor.tsx +130 -0
- package/src/components/workflows/WorkflowNode.tsx +198 -0
- package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
- package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
- package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
- package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
- package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
- package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
- package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
- package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
- package/src/components/workflows/__tests__/serialization.test.ts +278 -0
- package/src/components/workflows/exec-status.ts +90 -0
- package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
- package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
- package/src/components/workflows/index.ts +78 -0
- package/src/components/workflows/layout/auto-layout.ts +142 -0
- package/src/components/workflows/node-icons.ts +31 -0
- package/src/components/workflows/serialization.ts +171 -0
- package/src/components/workflows/theme/categories.ts +96 -0
- package/src/components/workflows/types.ts +231 -0
- package/src/components/workflows/workflows.css +29 -0
- package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
- package/src/components/workspace/SplitPane.tsx +174 -0
- package/src/components/workspace/index.ts +4 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
import { SplitPane } from './SplitPane'
|
|
7
|
+
|
|
8
|
+
export type WorkspaceLayout = 'split' | 'stack' | 'auto'
|
|
9
|
+
|
|
10
|
+
export interface DualPaneWorkspaceProps {
|
|
11
|
+
left: React.ReactNode
|
|
12
|
+
right: React.ReactNode
|
|
13
|
+
/** Optional toolbar rendered above both panes (full width). */
|
|
14
|
+
toolbar?: React.ReactNode
|
|
15
|
+
leftLabel?: string
|
|
16
|
+
rightLabel?: string
|
|
17
|
+
/** Initial size of the left pane as a percentage. Default 42. */
|
|
18
|
+
initialLeftSize?: number
|
|
19
|
+
minLeftSize?: number
|
|
20
|
+
maxLeftSize?: number
|
|
21
|
+
/** Persist the split position under this key. */
|
|
22
|
+
storageKey?: string
|
|
23
|
+
/**
|
|
24
|
+
* `split` = resizable side-by-side, `stack` = vertically stacked,
|
|
25
|
+
* `auto` = stack below `stackBreakpoint`, split above. Default `auto`.
|
|
26
|
+
*/
|
|
27
|
+
layout?: WorkspaceLayout
|
|
28
|
+
/** Pixel width below which `auto` stacks. Default 768. */
|
|
29
|
+
stackBreakpoint?: number
|
|
30
|
+
/** Show collapse/expand controls for each pane (split layout only). */
|
|
31
|
+
collapsible?: boolean
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function useResolvedLayout(layout: WorkspaceLayout, stackBreakpoint: number): 'split' | 'stack' {
|
|
36
|
+
const [isNarrow, setIsNarrow] = React.useState(false)
|
|
37
|
+
React.useEffect(() => {
|
|
38
|
+
if (layout !== 'auto' || typeof window === 'undefined') return
|
|
39
|
+
const mql = window.matchMedia(`(max-width: ${stackBreakpoint - 1}px)`)
|
|
40
|
+
const update = () => setIsNarrow(mql.matches)
|
|
41
|
+
update()
|
|
42
|
+
mql.addEventListener?.('change', update)
|
|
43
|
+
return () => mql.removeEventListener?.('change', update)
|
|
44
|
+
}, [layout, stackBreakpoint])
|
|
45
|
+
|
|
46
|
+
if (layout === 'split') return 'split'
|
|
47
|
+
if (layout === 'stack') return 'stack'
|
|
48
|
+
return isNarrow ? 'stack' : 'split'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface PaneHeaderProps {
|
|
52
|
+
label?: string
|
|
53
|
+
side: 'left' | 'right'
|
|
54
|
+
collapsible?: boolean
|
|
55
|
+
collapsed: boolean
|
|
56
|
+
onToggle: () => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function PaneHeader({ label, side, collapsible, collapsed, onToggle }: PaneHeaderProps) {
|
|
60
|
+
if (!label && !collapsible) return null
|
|
61
|
+
const CollapseIcon = side === 'left' ? PanelLeftClose : PanelRightClose
|
|
62
|
+
const ExpandIcon = side === 'left' ? PanelLeftOpen : PanelRightOpen
|
|
63
|
+
const Icon = collapsed ? ExpandIcon : CollapseIcon
|
|
64
|
+
const verb = collapsed ? 'Expand' : 'Collapse'
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex h-9 shrink-0 items-center justify-between border-b border-border bg-muted/30 px-3">
|
|
67
|
+
{label ? (
|
|
68
|
+
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
|
|
69
|
+
) : (
|
|
70
|
+
<span />
|
|
71
|
+
)}
|
|
72
|
+
{collapsible && (
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={onToggle}
|
|
76
|
+
aria-label={`${verb} ${label ?? side} pane`}
|
|
77
|
+
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
78
|
+
>
|
|
79
|
+
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
80
|
+
</button>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Opinionated two-pane workspace shell built on {@link SplitPane}: an optional
|
|
88
|
+
* full-width toolbar, two labelled/collapsible panes side-by-side, and
|
|
89
|
+
* automatic vertical stacking on narrow viewports. Fully app-agnostic — used by
|
|
90
|
+
* present-web for chat | slides, but free of any presentation-specific logic.
|
|
91
|
+
*/
|
|
92
|
+
export function DualPaneWorkspace({
|
|
93
|
+
left,
|
|
94
|
+
right,
|
|
95
|
+
toolbar,
|
|
96
|
+
leftLabel,
|
|
97
|
+
rightLabel,
|
|
98
|
+
initialLeftSize = 42,
|
|
99
|
+
minLeftSize = 24,
|
|
100
|
+
maxLeftSize = 72,
|
|
101
|
+
storageKey,
|
|
102
|
+
layout = 'auto',
|
|
103
|
+
stackBreakpoint = 768,
|
|
104
|
+
collapsible = false,
|
|
105
|
+
className,
|
|
106
|
+
}: DualPaneWorkspaceProps) {
|
|
107
|
+
const resolved = useResolvedLayout(layout, stackBreakpoint)
|
|
108
|
+
const [collapsed, setCollapsed] = React.useState<null | 'left' | 'right'>(null)
|
|
109
|
+
|
|
110
|
+
const leftHeader = (
|
|
111
|
+
<PaneHeader
|
|
112
|
+
label={leftLabel}
|
|
113
|
+
side="left"
|
|
114
|
+
collapsible={collapsible}
|
|
115
|
+
collapsed={collapsed === 'left'}
|
|
116
|
+
onToggle={() => setCollapsed((c) => (c === 'left' ? null : 'left'))}
|
|
117
|
+
/>
|
|
118
|
+
)
|
|
119
|
+
const rightHeader = (
|
|
120
|
+
<PaneHeader
|
|
121
|
+
label={rightLabel}
|
|
122
|
+
side="right"
|
|
123
|
+
collapsible={collapsible}
|
|
124
|
+
collapsed={collapsed === 'right'}
|
|
125
|
+
onToggle={() => setCollapsed((c) => (c === 'right' ? null : 'right'))}
|
|
126
|
+
/>
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const leftPane = (
|
|
130
|
+
<section className="flex h-full min-h-0 flex-col" aria-label={leftLabel}>
|
|
131
|
+
{leftHeader}
|
|
132
|
+
{collapsed !== 'left' && <div className="min-h-0 flex-1 overflow-hidden">{left}</div>}
|
|
133
|
+
</section>
|
|
134
|
+
)
|
|
135
|
+
const rightPane = (
|
|
136
|
+
<section className="flex h-full min-h-0 flex-col" aria-label={rightLabel}>
|
|
137
|
+
{rightHeader}
|
|
138
|
+
{collapsed !== 'right' && <div className="min-h-0 flex-1 overflow-hidden">{right}</div>}
|
|
139
|
+
</section>
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// When one pane is collapsed, the other takes the full width (no resizer).
|
|
143
|
+
let body: React.ReactNode
|
|
144
|
+
if (resolved === 'stack') {
|
|
145
|
+
body = (
|
|
146
|
+
<div className="flex min-h-0 flex-1 flex-col divide-y divide-border" data-layout="stack">
|
|
147
|
+
<div className="min-h-0 flex-1 overflow-hidden">{leftPane}</div>
|
|
148
|
+
<div className="min-h-0 flex-1 overflow-hidden">{rightPane}</div>
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
} else if (collapsed === 'left') {
|
|
152
|
+
body = (
|
|
153
|
+
<div className="flex min-h-0 flex-1 flex-row" data-layout="split" data-collapsed="left">
|
|
154
|
+
<div className="shrink-0">{leftHeader}</div>
|
|
155
|
+
<div className="min-h-0 flex-1 overflow-hidden">{rightPane}</div>
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
} else if (collapsed === 'right') {
|
|
159
|
+
body = (
|
|
160
|
+
<div className="flex min-h-0 flex-1 flex-row" data-layout="split" data-collapsed="right">
|
|
161
|
+
<div className="min-h-0 flex-1 overflow-hidden">{leftPane}</div>
|
|
162
|
+
<div className="shrink-0">{rightHeader}</div>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
} else {
|
|
166
|
+
body = (
|
|
167
|
+
<div className="min-h-0 flex-1" data-layout="split">
|
|
168
|
+
<SplitPane
|
|
169
|
+
first={leftPane}
|
|
170
|
+
second={rightPane}
|
|
171
|
+
initialSize={initialLeftSize}
|
|
172
|
+
minSize={minLeftSize}
|
|
173
|
+
maxSize={maxLeftSize}
|
|
174
|
+
storageKey={storageKey}
|
|
175
|
+
aria-label="Resize workspace panes"
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className={cn('flex h-full min-h-0 w-full flex-col', className)}>
|
|
183
|
+
{toolbar && <div className="shrink-0 border-b border-border">{toolbar}</div>}
|
|
184
|
+
{body}
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
|
|
6
|
+
export interface SplitPaneProps {
|
|
7
|
+
/** Content of the first (left / top) pane. */
|
|
8
|
+
first: React.ReactNode
|
|
9
|
+
/** Content of the second (right / bottom) pane. */
|
|
10
|
+
second: React.ReactNode
|
|
11
|
+
/** Split direction. `vertical` = side-by-side (a vertical divider). Default `vertical`. */
|
|
12
|
+
orientation?: 'vertical' | 'horizontal'
|
|
13
|
+
/** Initial size of the first pane, as a percentage (0-100). Default 50. */
|
|
14
|
+
initialSize?: number
|
|
15
|
+
/** Minimum size of the first pane as a percentage. Default 20. */
|
|
16
|
+
minSize?: number
|
|
17
|
+
/** Maximum size of the first pane as a percentage. Default 80. */
|
|
18
|
+
maxSize?: number
|
|
19
|
+
/** Keyboard nudge / step size in percentage points. Default 3. */
|
|
20
|
+
step?: number
|
|
21
|
+
/** Persist the size under this key in localStorage. */
|
|
22
|
+
storageKey?: string
|
|
23
|
+
/** Notified with the new first-pane percentage whenever it changes. */
|
|
24
|
+
onResize?: (size: number) => void
|
|
25
|
+
className?: string
|
|
26
|
+
'aria-label'?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clamp(v: number, min: number, max: number) {
|
|
30
|
+
return Math.min(max, Math.max(min, v))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generic, app-agnostic resizable two-pane split. Drag the divider with a
|
|
35
|
+
* pointer or nudge it with the arrow keys. Nothing domain-specific lives here.
|
|
36
|
+
*/
|
|
37
|
+
export function SplitPane({
|
|
38
|
+
first,
|
|
39
|
+
second,
|
|
40
|
+
orientation = 'vertical',
|
|
41
|
+
initialSize = 50,
|
|
42
|
+
minSize = 20,
|
|
43
|
+
maxSize = 80,
|
|
44
|
+
step = 3,
|
|
45
|
+
storageKey,
|
|
46
|
+
onResize,
|
|
47
|
+
className,
|
|
48
|
+
'aria-label': ariaLabel = 'Resize panes',
|
|
49
|
+
}: SplitPaneProps) {
|
|
50
|
+
const isVertical = orientation === 'vertical'
|
|
51
|
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
52
|
+
const draggingRef = React.useRef(false)
|
|
53
|
+
|
|
54
|
+
const [size, setSizeState] = React.useState<number>(() => {
|
|
55
|
+
if (typeof window !== 'undefined' && storageKey) {
|
|
56
|
+
const saved = window.localStorage.getItem(storageKey)
|
|
57
|
+
if (saved != null) {
|
|
58
|
+
const n = Number(saved)
|
|
59
|
+
if (!Number.isNaN(n)) return clamp(n, minSize, maxSize)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return clamp(initialSize, minSize, maxSize)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const setSize = React.useCallback(
|
|
66
|
+
(next: number) => {
|
|
67
|
+
const clamped = clamp(next, minSize, maxSize)
|
|
68
|
+
setSizeState(clamped)
|
|
69
|
+
onResize?.(clamped)
|
|
70
|
+
if (typeof window !== 'undefined' && storageKey) {
|
|
71
|
+
window.localStorage.setItem(storageKey, String(clamped))
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[minSize, maxSize, onResize, storageKey]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
78
|
+
const grow = isVertical ? 'ArrowRight' : 'ArrowDown'
|
|
79
|
+
const shrink = isVertical ? 'ArrowLeft' : 'ArrowUp'
|
|
80
|
+
if (e.key === grow) {
|
|
81
|
+
e.preventDefault()
|
|
82
|
+
setSize(size + step)
|
|
83
|
+
} else if (e.key === shrink) {
|
|
84
|
+
e.preventDefault()
|
|
85
|
+
setSize(size - step)
|
|
86
|
+
} else if (e.key === 'Home') {
|
|
87
|
+
e.preventDefault()
|
|
88
|
+
setSize(minSize)
|
|
89
|
+
} else if (e.key === 'End') {
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
setSize(maxSize)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const onPointerMove = React.useCallback(
|
|
96
|
+
(e: PointerEvent) => {
|
|
97
|
+
if (!draggingRef.current || !containerRef.current) return
|
|
98
|
+
const rect = containerRef.current.getBoundingClientRect()
|
|
99
|
+
const pct = isVertical
|
|
100
|
+
? ((e.clientX - rect.left) / rect.width) * 100
|
|
101
|
+
: ((e.clientY - rect.top) / rect.height) * 100
|
|
102
|
+
setSize(pct)
|
|
103
|
+
},
|
|
104
|
+
[isVertical, setSize]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const stopDragging = React.useCallback(() => {
|
|
108
|
+
draggingRef.current = false
|
|
109
|
+
document.body.style.cursor = ''
|
|
110
|
+
document.body.style.userSelect = ''
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
React.useEffect(() => {
|
|
114
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
115
|
+
window.addEventListener('pointerup', stopDragging)
|
|
116
|
+
return () => {
|
|
117
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
118
|
+
window.removeEventListener('pointerup', stopDragging)
|
|
119
|
+
}
|
|
120
|
+
}, [onPointerMove, stopDragging])
|
|
121
|
+
|
|
122
|
+
const startDragging = () => {
|
|
123
|
+
draggingRef.current = true
|
|
124
|
+
document.body.style.cursor = isVertical ? 'col-resize' : 'row-resize'
|
|
125
|
+
document.body.style.userSelect = 'none'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
ref={containerRef}
|
|
131
|
+
className={cn(
|
|
132
|
+
'flex h-full w-full overflow-hidden',
|
|
133
|
+
isVertical ? 'flex-row' : 'flex-col',
|
|
134
|
+
className
|
|
135
|
+
)}
|
|
136
|
+
data-orientation={orientation}
|
|
137
|
+
>
|
|
138
|
+
<div
|
|
139
|
+
className="min-h-0 min-w-0 overflow-hidden"
|
|
140
|
+
style={isVertical ? { width: `${size}%` } : { height: `${size}%` }}
|
|
141
|
+
data-pane="first"
|
|
142
|
+
>
|
|
143
|
+
{first}
|
|
144
|
+
</div>
|
|
145
|
+
<div
|
|
146
|
+
role="separator"
|
|
147
|
+
aria-label={ariaLabel}
|
|
148
|
+
aria-orientation={orientation}
|
|
149
|
+
aria-valuenow={Math.round(size)}
|
|
150
|
+
aria-valuemin={minSize}
|
|
151
|
+
aria-valuemax={maxSize}
|
|
152
|
+
tabIndex={0}
|
|
153
|
+
onKeyDown={onKeyDown}
|
|
154
|
+
onPointerDown={startDragging}
|
|
155
|
+
className={cn(
|
|
156
|
+
'group relative shrink-0 bg-border transition-colors hover:bg-primary/40 focus-visible:bg-primary/60 focus:outline-none',
|
|
157
|
+
isVertical ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize'
|
|
158
|
+
)}
|
|
159
|
+
data-testid="split-pane-resizer"
|
|
160
|
+
>
|
|
161
|
+
<span
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
className={cn(
|
|
164
|
+
'absolute bg-transparent',
|
|
165
|
+
isVertical ? '-inset-x-1.5 inset-y-0' : '-inset-y-1.5 inset-x-0'
|
|
166
|
+
)}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
<div className="min-h-0 min-w-0 flex-1 overflow-hidden" data-pane="second">
|
|
170
|
+
{second}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|