@xeplr/ui-factory 1.0.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.
@@ -0,0 +1,238 @@
1
+ // THE CONTROLS a screen is made of — one entry per type, and nothing about any
2
+ // control lives anywhere else. The palette lists these, the property panel is
3
+ // generated from `properties`, the document checker reads `props`, and the
4
+ // form schema reads `valueType`. Adding a control is adding an entry.
5
+ //
6
+ // Pure data: no React. The renderers are in designs/ControlView.jsx, keyed by
7
+ // the same `type`, so this file also runs in node — the CLI and the tests read
8
+ // it, and so can Claude when it writes a screen.
9
+ //
10
+ // Sizes are FRACTIONS: `w` of the screen's width, `h` of one page's height
11
+ // (width × the screen's aspect). See document.js.
12
+
13
+ /** A dropdown binds `id` and shows `name` — always. */
14
+ export const OPTION_ID = 'id'
15
+ export const OPTION_LABEL = 'name'
16
+
17
+ // ── property panel contract ──────────────────────────────────────────────
18
+ // { key, title, fields: [{ path, label, type, options?, help? }] }
19
+ // The same shape as the BI dashboard's widgetProperties.js, so the two panels
20
+ // can become one. `path` points into the node (`props.label`), and the editor
21
+ // for each `type` is in designs/PropertyPanel.jsx.
22
+
23
+ const FIELD_BASICS = (extra) => ({
24
+ key: 'field',
25
+ title: 'Field',
26
+ fields: [
27
+ { path: 'props.label', label: 'Label', type: 'text' },
28
+ { path: 'props.name', label: 'Field name', type: 'text', help: 'The key this value is saved under — letters, digits and _' },
29
+ ...(extra || []),
30
+ { path: 'props.required', label: 'Required', type: 'toggle' }
31
+ ]
32
+ })
33
+
34
+ const PLACEHOLDER = { path: 'props.placeholder', label: 'Placeholder', type: 'text' }
35
+
36
+ export const CONTROLS = {
37
+ text: {
38
+ type: 'text',
39
+ label: 'Text',
40
+ group: 'Inputs',
41
+ input: true,
42
+ valueType: 'string',
43
+ defaultSize: { w: 0.44, h: 0.08 },
44
+ defaults: { label: 'Text' },
45
+ props: ['name', 'label', 'placeholder', 'required', 'default', 'validation'],
46
+ validation: ['minLength', 'maxLength', 'pattern', 'patternMessage'],
47
+ properties: [
48
+ FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'text' }]),
49
+ {
50
+ key: 'validation',
51
+ title: 'Validation',
52
+ fields: [
53
+ { path: 'props.validation.minLength', label: 'Min length', type: 'number' },
54
+ { path: 'props.validation.maxLength', label: 'Max length', type: 'number' },
55
+ { path: 'props.validation.pattern', label: 'Pattern (regex)', type: 'text' },
56
+ { path: 'props.validation.patternMessage', label: 'Pattern message', type: 'text', help: 'Shown when the pattern does not match' }
57
+ ]
58
+ }
59
+ ]
60
+ },
61
+
62
+ textarea: {
63
+ type: 'textarea',
64
+ label: 'Text area',
65
+ group: 'Inputs',
66
+ input: true,
67
+ valueType: 'string',
68
+ defaultSize: { w: 0.92, h: 0.18 },
69
+ defaults: { label: 'Notes' },
70
+ props: ['name', 'label', 'placeholder', 'required', 'default', 'validation'],
71
+ validation: ['minLength', 'maxLength'],
72
+ properties: [
73
+ FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'textarea' }]),
74
+ {
75
+ key: 'validation',
76
+ title: 'Validation',
77
+ fields: [
78
+ { path: 'props.validation.minLength', label: 'Min length', type: 'number' },
79
+ { path: 'props.validation.maxLength', label: 'Max length', type: 'number' }
80
+ ]
81
+ }
82
+ ]
83
+ },
84
+
85
+ number: {
86
+ type: 'number',
87
+ label: 'Number',
88
+ group: 'Inputs',
89
+ input: true,
90
+ valueType: 'number',
91
+ defaultSize: { w: 0.44, h: 0.08 },
92
+ defaults: { label: 'Number' },
93
+ props: ['name', 'label', 'placeholder', 'required', 'default', 'validation'],
94
+ validation: ['min', 'max', 'integer'],
95
+ properties: [
96
+ FIELD_BASICS([PLACEHOLDER, { path: 'props.default', label: 'Default value', type: 'number' }]),
97
+ {
98
+ key: 'validation',
99
+ title: 'Validation',
100
+ fields: [
101
+ { path: 'props.validation.min', label: 'Minimum', type: 'number' },
102
+ { path: 'props.validation.max', label: 'Maximum', type: 'number' },
103
+ { path: 'props.validation.integer', label: 'Whole numbers only', type: 'toggle' }
104
+ ]
105
+ }
106
+ ]
107
+ },
108
+
109
+ date: {
110
+ type: 'date',
111
+ label: 'Date',
112
+ group: 'Inputs',
113
+ input: true,
114
+ valueType: 'date',
115
+ defaultSize: { w: 0.44, h: 0.08 },
116
+ defaults: { label: 'Date' },
117
+ props: ['name', 'label', 'required', 'default', 'validation'],
118
+ validation: ['min', 'max'],
119
+ properties: [
120
+ FIELD_BASICS([{ path: 'props.default', label: 'Default value', type: 'date' }]),
121
+ {
122
+ key: 'validation',
123
+ title: 'Validation',
124
+ fields: [
125
+ { path: 'props.validation.min', label: 'Earliest', type: 'date' },
126
+ { path: 'props.validation.max', label: 'Latest', type: 'date' }
127
+ ]
128
+ }
129
+ ]
130
+ },
131
+
132
+ checkbox: {
133
+ type: 'checkbox',
134
+ label: 'Checkbox',
135
+ group: 'Inputs',
136
+ input: true,
137
+ valueType: 'boolean',
138
+ defaultSize: { w: 0.44, h: 0.06 },
139
+ defaults: { label: 'Checkbox', default: false },
140
+ // `required` on a checkbox means it must be ticked — "I accept".
141
+ props: ['name', 'label', 'required', 'default'],
142
+ validation: [],
143
+ properties: [
144
+ FIELD_BASICS([{ path: 'props.default', label: 'Ticked by default', type: 'toggle' }])
145
+ ]
146
+ },
147
+
148
+ dropdown: {
149
+ type: 'dropdown',
150
+ label: 'Dropdown',
151
+ group: 'Inputs',
152
+ input: true,
153
+ // Whatever the source's ids are — a table's may be numbers or strings.
154
+ valueType: null,
155
+ defaultSize: { w: 0.44, h: 0.08 },
156
+ defaults: { label: 'Dropdown', placeholder: 'Select…', data: { source: 'static', options: [] } },
157
+ props: ['name', 'label', 'placeholder', 'required', 'default', 'data'],
158
+ validation: [],
159
+ properties: [
160
+ FIELD_BASICS([PLACEHOLDER]),
161
+ {
162
+ key: 'data',
163
+ title: 'Options',
164
+ fields: [
165
+ { path: 'props.data', label: 'Options come from', type: 'dataSource' }
166
+ ]
167
+ }
168
+ ]
169
+ },
170
+
171
+ label: {
172
+ type: 'label',
173
+ label: 'Label',
174
+ group: 'Content',
175
+ input: false,
176
+ defaultSize: { w: 0.92, h: 0.06 },
177
+ defaults: { text: 'Label', variant: 'text' },
178
+ props: ['text', 'variant'],
179
+ validation: [],
180
+ properties: [
181
+ {
182
+ key: 'content',
183
+ title: 'Content',
184
+ fields: [
185
+ { path: 'props.text', label: 'Text', type: 'textarea' },
186
+ { path: 'props.variant', label: 'Style', type: 'select', options: [
187
+ { value: 'heading', label: 'Heading' },
188
+ { value: 'subheading', label: 'Subheading' },
189
+ { value: 'text', label: 'Text' }
190
+ ] }
191
+ ]
192
+ }
193
+ ]
194
+ },
195
+
196
+ button: {
197
+ type: 'button',
198
+ label: 'Button',
199
+ group: 'Actions',
200
+ input: false,
201
+ defaultSize: { w: 0.18, h: 0.07 },
202
+ defaults: { label: 'Save', action: 'submit' },
203
+ props: ['label', 'action'],
204
+ validation: [],
205
+ properties: [
206
+ {
207
+ key: 'button',
208
+ title: 'Button',
209
+ fields: [
210
+ { path: 'props.label', label: 'Label', type: 'text' },
211
+ { path: 'props.action', label: 'Does', type: 'select', options: [
212
+ { value: 'submit', label: 'Submit the form' },
213
+ { value: 'reset', label: 'Reset the form' }
214
+ ] }
215
+ ]
216
+ }
217
+ ]
218
+ }
219
+ }
220
+
221
+ export const LABEL_VARIANTS = ['heading', 'subheading', 'text']
222
+ export const BUTTON_ACTIONS = ['submit', 'reset']
223
+
224
+ /** Palette order: grouped, in declaration order. */
225
+ export function controlGroups(controls = CONTROLS) {
226
+ const groups = []
227
+ Object.values(controls).forEach((c) => {
228
+ let g = groups.find((x) => x.title === c.group)
229
+ if (!g) { g = { title: c.group, controls: [] }; groups.push(g) }
230
+ g.controls.push(c)
231
+ })
232
+ return groups
233
+ }
234
+
235
+ /** A registry with extra or replaced controls, leaving the built-ins untouched. */
236
+ export function withControls(extra, base = CONTROLS) {
237
+ return { ...base, ...extra }
238
+ }
@@ -0,0 +1,117 @@
1
+ import { useState } from 'react'
2
+ import { XeplrCanvas } from '@xeplr/ui-canvas'
3
+ import ControlView from './ControlView.jsx'
4
+ import Palette, { DRAG_TYPE } from './Palette.jsx'
5
+ import PropertyPanel from './PropertyPanel.jsx'
6
+
7
+ // The builder, drawn: name and Save on top, controls on the left, the screen
8
+ // on the canvas in the middle, the selection's properties on the right.
9
+ // Presentation only — everything comes from useFactoryBuilder.
10
+ //
11
+ // The canvas is @xeplr/ui-canvas in fraction units with pageAspect set to the
12
+ // screen's aspect, which is exactly the geometry ScreenSample renders with — so
13
+ // the builder shows the screen at its real proportions.
14
+
15
+ export default function BuilderSample({ ctrl, renderPreview, className, style }) {
16
+ const { document: doc } = ctrl
17
+ const [preview, setPreview] = useState(false)
18
+ const problemCount = ctrl.validation.errors.length
19
+
20
+ function onDrop(e) {
21
+ const type = e.dataTransfer.getData(DRAG_TYPE) || e.dataTransfer.getData('text/plain')
22
+ const def = ctrl.controls[type]
23
+ if (!def) return
24
+ e.preventDefault()
25
+ const inner = e.currentTarget.querySelector('.xeplr-canvas-inner')
26
+ if (!inner) return ctrl.addControl(type)
27
+ const rect = inner.getBoundingClientRect()
28
+ const pageH = rect.width * doc.aspect
29
+ // The pointer is the control's CENTRE — dropping a wide field by its
30
+ // top-left corner puts it somewhere you did not point at.
31
+ ctrl.addControl(type, {
32
+ x: (e.clientX - rect.left) / rect.width - def.defaultSize.w / 2,
33
+ y: (e.clientY - rect.top) / pageH - def.defaultSize.h / 2
34
+ })
35
+ }
36
+
37
+ return (
38
+ <div className={'xeplr-factory-builder' + (className ? ' ' + className : '')} style={style}>
39
+ <header className="xeplr-factory-bar">
40
+ <input
41
+ className="xeplr-factory-name"
42
+ value={doc.name}
43
+ onChange={(e) => ctrl.rename(e.target.value)}
44
+ placeholder="Screen name, e.g. New employee"
45
+ aria-label="Screen name"
46
+ />
47
+ <div className="xeplr-factory-bar-status" aria-live="polite">
48
+ {ctrl.saving ? 'Saving…' : ctrl.dirty ? 'Unsaved changes' : ''}
49
+ </div>
50
+ {problemCount > 0 && (
51
+ <span className={`xeplr-factory-problems${ctrl.showErrors ? ' is-loud' : ''}`} title={ctrl.validation.errors.map((x) => `${x.path}: ${x.message}`).join('\n')}>
52
+ {problemCount} problem{problemCount === 1 ? '' : 's'}
53
+ </span>
54
+ )}
55
+ {renderPreview && (
56
+ <button type="button" className="xeplr-factory-secondary" aria-pressed={preview} onClick={() => setPreview((p) => !p)}>
57
+ {preview ? 'Back to design' : 'Preview'}
58
+ </button>
59
+ )}
60
+ <button type="button" className="xeplr-factory-primary" onClick={ctrl.save} disabled={ctrl.saving}>
61
+ Save
62
+ </button>
63
+ </header>
64
+
65
+ {ctrl.saveError && <div className="xeplr-factory-save-error" role="alert">{ctrl.saveError}</div>}
66
+ {ctrl.showErrors && ctrl.errorsByNode._document && (
67
+ <div className="xeplr-factory-save-error" role="alert">
68
+ {ctrl.errorsByNode._document.map((e, i) => <div key={i}><code>{e.path}</code> {e.message}</div>)}
69
+ </div>
70
+ )}
71
+
72
+ {preview && renderPreview ? (
73
+ <div className="xeplr-factory-preview">{renderPreview(doc)}</div>
74
+ ) : (
75
+ <div className="xeplr-factory-body">
76
+ <Palette groups={ctrl.palette} onAdd={(type) => ctrl.addControl(type)} />
77
+
78
+ <div
79
+ className="xeplr-factory-stage"
80
+ onDragOver={(e) => { if ([...e.dataTransfer.types].includes(DRAG_TYPE)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }}
81
+ onDrop={onDrop}
82
+ >
83
+ <XeplrCanvas
84
+ className="xeplr-factory-canvas"
85
+ items={doc.nodes}
86
+ units="fraction"
87
+ pageAspect={doc.aspect}
88
+ features={{ resize: true, marquee: true }}
89
+ selection={ctrl.selected}
90
+ onSelectionChange={ctrl.setSelected}
91
+ onItemChange={ctrl.moveControl}
92
+ minSizeFor={() => ({ minWidth: 16, minHeight: 12 })}
93
+ renderItem={(node, { selected }) => (
94
+ <div className={`xeplr-factory-design-node${ctrl.showErrors && ctrl.errorsByNode[node.id] ? ' has-error' : ''}${selected ? ' is-selected' : ''}`}>
95
+ <ControlView node={node} mode="design" />
96
+ </div>
97
+ )}
98
+ underlay={doc.nodes.length === 0 && (
99
+ <div className="xeplr-factory-empty">Drag controls here from the left</div>
100
+ )}
101
+ />
102
+ </div>
103
+
104
+ <PropertyPanel
105
+ node={ctrl.selectedNode}
106
+ control={ctrl.selectedControl}
107
+ selectionCount={ctrl.selected.size}
108
+ errors={ctrl.selectedNode ? ctrl.errorsByNode[ctrl.selectedNode.id] : null}
109
+ tables={ctrl.tables}
110
+ onChange={(path, value) => ctrl.setProperty(ctrl.selectedNode.id, path, value)}
111
+ onRemove={ctrl.removeSelected}
112
+ />
113
+ </div>
114
+ )}
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,192 @@
1
+ // How each control LOOKS — the one renderer, used both on the builder's
2
+ // canvas (mode 'design': inert, so a press drags it) and on a live screen
3
+ // (mode 'live'). Sharing it is what makes the builder honest: what you place is
4
+ // what the form shows.
5
+ //
6
+ // Presentation only. Values, options and errors arrive as props.
7
+
8
+ const ids = (node) => `xeplr-factory-${node.id}`
9
+
10
+ export default function ControlView({ node, mode = 'live', value, error, options, onChange, onAction, disabled }) {
11
+ const View = VIEWS[node.type]
12
+ if (!View) {
13
+ return <div className="xeplr-factory-unknown">Unknown control “{node.type}”</div>
14
+ }
15
+ const design = mode === 'design'
16
+ return (
17
+ <View
18
+ node={node}
19
+ p={node.props || {}}
20
+ design={design}
21
+ value={value}
22
+ error={design ? null : error}
23
+ options={options || { loading: false, items: [], error: null }}
24
+ onChange={onChange || (() => {})}
25
+ onAction={onAction || (() => {})}
26
+ disabled={design || disabled}
27
+ />
28
+ )
29
+ }
30
+
31
+ function Field({ node, p, error, children, inline }) {
32
+ return (
33
+ <div className={`xeplr-factory-field${inline ? ' xeplr-factory-field--inline' : ''}${error ? ' has-error' : ''}`}>
34
+ {!inline && (
35
+ <label className="xeplr-factory-label" htmlFor={ids(node)}>
36
+ {p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}
37
+ </label>
38
+ )}
39
+ {children}
40
+ {error && <div className="xeplr-factory-error" id={`${ids(node)}-error`} role="alert">{error}</div>}
41
+ </div>
42
+ )
43
+ }
44
+
45
+ function inputProps(node, p, error, disabled) {
46
+ return {
47
+ id: ids(node),
48
+ name: p.name,
49
+ disabled,
50
+ required: undefined, // validation is ours, with our messages — not the browser's bubbles
51
+ 'aria-invalid': error ? true : undefined,
52
+ 'aria-describedby': error ? `${ids(node)}-error` : undefined,
53
+ tabIndex: disabled ? -1 : undefined
54
+ }
55
+ }
56
+
57
+ function TextView({ node, p, value, error, onChange, disabled }) {
58
+ return (
59
+ <Field node={node} p={p} error={error}>
60
+ <input
61
+ className="xeplr-factory-input"
62
+ type="text"
63
+ placeholder={p.placeholder || ''}
64
+ value={value ?? ''}
65
+ onChange={(e) => onChange(e.target.value)}
66
+ {...inputProps(node, p, error, disabled)}
67
+ />
68
+ </Field>
69
+ )
70
+ }
71
+
72
+ function TextareaView({ node, p, value, error, onChange, disabled }) {
73
+ return (
74
+ <Field node={node} p={p} error={error}>
75
+ <textarea
76
+ className="xeplr-factory-input xeplr-factory-textarea"
77
+ placeholder={p.placeholder || ''}
78
+ value={value ?? ''}
79
+ onChange={(e) => onChange(e.target.value)}
80
+ {...inputProps(node, p, error, disabled)}
81
+ />
82
+ </Field>
83
+ )
84
+ }
85
+
86
+ function NumberView({ node, p, value, error, onChange, disabled }) {
87
+ const v = p.validation || {}
88
+ return (
89
+ <Field node={node} p={p} error={error}>
90
+ <input
91
+ className="xeplr-factory-input"
92
+ type="number"
93
+ inputMode={v.integer ? 'numeric' : 'decimal'}
94
+ step={v.integer ? 1 : 'any'}
95
+ placeholder={p.placeholder || ''}
96
+ value={value ?? ''}
97
+ onChange={(e) => onChange(e.target.value)}
98
+ {...inputProps(node, p, error, disabled)}
99
+ />
100
+ </Field>
101
+ )
102
+ }
103
+
104
+ function DateView({ node, p, value, error, onChange, disabled }) {
105
+ const v = p.validation || {}
106
+ return (
107
+ <Field node={node} p={p} error={error}>
108
+ <input
109
+ className="xeplr-factory-input"
110
+ type="date"
111
+ min={v.min}
112
+ max={v.max}
113
+ value={value ?? ''}
114
+ onChange={(e) => onChange(e.target.value)}
115
+ {...inputProps(node, p, error, disabled)}
116
+ />
117
+ </Field>
118
+ )
119
+ }
120
+
121
+ function CheckboxView({ node, p, value, error, onChange, disabled }) {
122
+ return (
123
+ <Field node={node} p={p} error={error} inline>
124
+ <label className="xeplr-factory-check" htmlFor={ids(node)}>
125
+ <input
126
+ type="checkbox"
127
+ checked={Boolean(value ?? p.default)}
128
+ onChange={(e) => onChange(e.target.checked)}
129
+ {...inputProps(node, p, error, disabled)}
130
+ />
131
+ <span>{p.label}{p.required && <span className="xeplr-factory-required" aria-hidden="true"> *</span>}</span>
132
+ </label>
133
+ </Field>
134
+ )
135
+ }
136
+
137
+ function DropdownView({ node, p, design, value, error, options, onChange, disabled }) {
138
+ const data = p.data || {}
139
+ const items = data.source === 'static' ? (data.options || []) : options.items
140
+ return (
141
+ <Field node={node} p={p} error={error || (!design && options.error) || null}>
142
+ <select
143
+ className="xeplr-factory-input xeplr-factory-select"
144
+ value={value === undefined || value === null ? '' : String(value)}
145
+ onChange={(e) => onChange(e.target.value)}
146
+ {...inputProps(node, p, error, disabled || (!design && options.loading))}
147
+ >
148
+ <option value="">{!design && options.loading ? 'Loading…' : (p.placeholder || 'Select…')}</option>
149
+ {!design && items.map((o) => <option key={String(o.id)} value={String(o.id)}>{o.name}</option>)}
150
+ </select>
151
+ {design && (
152
+ <div className="xeplr-factory-hint">
153
+ {data.source === 'table'
154
+ ? (data.table ? `from table ${data.table}` : 'no table chosen')
155
+ : `${(data.options || []).length} option${(data.options || []).length === 1 ? '' : 's'}`}
156
+ </div>
157
+ )}
158
+ </Field>
159
+ )
160
+ }
161
+
162
+ function LabelView({ p }) {
163
+ const variant = p.variant || 'text'
164
+ const Tag = variant === 'heading' ? 'h2' : variant === 'subheading' ? 'h3' : 'p'
165
+ return <Tag className={`xeplr-factory-text xeplr-factory-text--${variant}`}>{p.text}</Tag>
166
+ }
167
+
168
+ function ButtonView({ p, design, onAction, disabled }) {
169
+ const action = p.action || 'submit'
170
+ return (
171
+ <button
172
+ className={`xeplr-factory-button xeplr-factory-button--${action}`}
173
+ type={design ? 'button' : action === 'reset' ? 'button' : 'submit'}
174
+ disabled={disabled}
175
+ tabIndex={design ? -1 : undefined}
176
+ onClick={action === 'reset' && !design ? () => onAction('reset') : undefined}
177
+ >
178
+ {p.label}
179
+ </button>
180
+ )
181
+ }
182
+
183
+ export const VIEWS = {
184
+ text: TextView,
185
+ textarea: TextareaView,
186
+ number: NumberView,
187
+ date: DateView,
188
+ checkbox: CheckboxView,
189
+ dropdown: DropdownView,
190
+ label: LabelView,
191
+ button: ButtonView
192
+ }
@@ -0,0 +1,34 @@
1
+ // The controls you can put on a screen. Drag one onto the canvas to place it
2
+ // where it lands, or click it to add it below everything else.
3
+
4
+ export const DRAG_TYPE = 'application/x-xeplr-factory-control'
5
+
6
+ export default function Palette({ groups, onAdd }) {
7
+ return (
8
+ <nav className="xeplr-factory-palette" aria-label="Controls">
9
+ {groups.map((g) => (
10
+ <section key={g.title} className="xeplr-factory-palette-group">
11
+ <h4 className="xeplr-factory-palette-title">{g.title}</h4>
12
+ {g.controls.map((c) => (
13
+ <button
14
+ key={c.type}
15
+ type="button"
16
+ className="xeplr-factory-palette-item"
17
+ draggable
18
+ onDragStart={(e) => {
19
+ e.dataTransfer.setData(DRAG_TYPE, c.type)
20
+ e.dataTransfer.setData('text/plain', c.type)
21
+ e.dataTransfer.effectAllowed = 'copy'
22
+ }}
23
+ onClick={() => onAdd(c.type)}
24
+ title={`Drag onto the screen, or click to add ${c.label.toLowerCase()} at the bottom`}
25
+ >
26
+ <span className={`xeplr-factory-palette-icon xeplr-factory-palette-icon--${c.type}`} aria-hidden="true" />
27
+ {c.label}
28
+ </button>
29
+ ))}
30
+ </section>
31
+ ))}
32
+ </nav>
33
+ )
34
+ }