lula2 0.0.5 → 0.0.6
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 +291 -8
- package/dist/_app/env.js +1 -0
- package/dist/_app/immutable/assets/0.DtiRW3lO.css +1 -0
- package/dist/_app/immutable/assets/DynamicControlEditor.BkVTzFZ-.css +1 -0
- package/dist/_app/immutable/chunks/7x_q-1ab.js +1 -0
- package/dist/_app/immutable/chunks/B19gt6-g.js +2 -0
- package/dist/_app/immutable/chunks/BR-0Dorr.js +1 -0
- package/dist/_app/immutable/chunks/B_3ksxz5.js +2 -0
- package/dist/_app/immutable/chunks/Bg_R1qWi.js +3 -0
- package/dist/_app/immutable/chunks/D3aNP_lg.js +1 -0
- package/dist/_app/immutable/chunks/D4Q_ObIy.js +1 -0
- package/dist/_app/immutable/chunks/DsnmJJEf.js +1 -0
- package/dist/_app/immutable/chunks/XY2j_owG.js +66 -0
- package/dist/_app/immutable/chunks/rzN25oDf.js +1 -0
- package/dist/_app/immutable/entry/app.r0uOd9qg.js +2 -0
- package/dist/_app/immutable/entry/start.DvoqR0rc.js +1 -0
- package/dist/_app/immutable/nodes/0.Ct6FAss_.js +1 -0
- package/dist/_app/immutable/nodes/1.DLoKuy8Q.js +1 -0
- package/dist/_app/immutable/nodes/2.IRkwSmiB.js +1 -0
- package/dist/_app/immutable/nodes/3.BrTg-ZHv.js +1 -0
- package/dist/_app/immutable/nodes/4.Blq-4WQS.js +9 -0
- package/dist/_app/version.json +1 -0
- package/dist/cli/commands/crawl.js +128 -0
- package/dist/cli/commands/ui.js +2769 -0
- package/dist/cli/commands/version.js +30 -0
- package/dist/cli/server/index.js +2713 -0
- package/dist/cli/server/server.js +2702 -0
- package/dist/cli/server/serverState.js +1199 -0
- package/dist/cli/server/spreadsheetRoutes.js +788 -0
- package/dist/cli/server/types.js +0 -0
- package/dist/cli/server/websocketServer.js +2625 -0
- package/dist/cli/utils/debug.js +24 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +38 -0
- package/dist/index.js +2924 -37
- package/dist/lula.png +0 -0
- package/dist/lula2 +2 -0
- package/package.json +120 -72
- package/src/app.css +192 -0
- package/src/app.d.ts +13 -0
- package/src/app.html +13 -0
- package/src/lib/actions/fadeWhenScrollable.ts +39 -0
- package/src/lib/actions/modal.ts +230 -0
- package/src/lib/actions/tooltip.ts +82 -0
- package/src/lib/components/control-sets/ControlSetInfo.svelte +20 -0
- package/src/lib/components/control-sets/ControlSetSelector.svelte +46 -0
- package/src/lib/components/control-sets/index.ts +5 -0
- package/src/lib/components/controls/ControlDetailsPanel.svelte +235 -0
- package/src/lib/components/controls/ControlsList.svelte +608 -0
- package/src/lib/components/controls/DynamicControlEditor.svelte +298 -0
- package/src/lib/components/controls/MappingCard.svelte +105 -0
- package/src/lib/components/controls/MappingForm.svelte +188 -0
- package/src/lib/components/controls/index.ts +9 -0
- package/src/lib/components/controls/renderers/EditableFieldRenderer.svelte +103 -0
- package/src/lib/components/controls/renderers/FieldRenderer.svelte +49 -0
- package/src/lib/components/controls/renderers/index.ts +5 -0
- package/src/lib/components/controls/tabs/CustomFieldsTab.svelte +130 -0
- package/src/lib/components/controls/tabs/ImplementationTab.svelte +127 -0
- package/src/lib/components/controls/tabs/MappingsTab.svelte +182 -0
- package/src/lib/components/controls/tabs/OverviewTab.svelte +151 -0
- package/src/lib/components/controls/tabs/TimelineTab.svelte +41 -0
- package/src/lib/components/controls/tabs/index.ts +8 -0
- package/src/lib/components/controls/utils/ProcessedTextRenderer.svelte +63 -0
- package/src/lib/components/controls/utils/textProcessor.ts +164 -0
- package/src/lib/components/forms/DynamicControlForm.svelte +340 -0
- package/src/lib/components/forms/DynamicField.svelte +494 -0
- package/src/lib/components/forms/FormField.svelte +107 -0
- package/src/lib/components/forms/index.ts +6 -0
- package/src/lib/components/setup/ExistingControlSets.svelte +284 -0
- package/src/lib/components/setup/SpreadsheetImport.svelte +968 -0
- package/src/lib/components/setup/index.ts +5 -0
- package/src/lib/components/ui/Dropdown.svelte +107 -0
- package/src/lib/components/ui/EmptyState.svelte +80 -0
- package/src/lib/components/ui/FeatureToggle.svelte +50 -0
- package/src/lib/components/ui/SearchBar.svelte +73 -0
- package/src/lib/components/ui/StatusBadge.svelte +79 -0
- package/src/lib/components/ui/TabNavigation.svelte +48 -0
- package/src/lib/components/ui/Tooltip.svelte +120 -0
- package/src/lib/components/ui/index.ts +10 -0
- package/src/lib/components/version-control/DiffViewer.svelte +292 -0
- package/src/lib/components/version-control/TimelineItem.svelte +107 -0
- package/src/lib/components/version-control/YamlDiffViewer.svelte +428 -0
- package/src/lib/components/version-control/index.ts +6 -0
- package/src/lib/form-types.ts +57 -0
- package/src/lib/formatUtils.ts +17 -0
- package/src/lib/index.ts +5 -0
- package/src/lib/types.ts +180 -0
- package/src/lib/websocket.ts +359 -0
- package/src/routes/+layout.svelte +236 -0
- package/src/routes/+page.svelte +38 -0
- package/src/routes/control/[id]/+page.svelte +112 -0
- package/src/routes/setup/+page.svelte +241 -0
- package/src/stores/compliance.ts +95 -0
- package/src/styles/highlightjs.css +20 -0
- package/src/styles/modal.css +58 -0
- package/src/styles/tables.css +111 -0
- package/src/styles/tooltip.css +65 -0
- package/dist/controls/index.d.ts +0 -18
- package/dist/controls/index.d.ts.map +0 -1
- package/dist/controls/index.js +0 -18
- package/dist/crawl.d.ts +0 -62
- package/dist/crawl.d.ts.map +0 -1
- package/dist/crawl.js +0 -172
- package/dist/index.d.ts +0 -8
- package/dist/index.d.ts.map +0 -1
- package/src/controls/index.ts +0 -19
- package/src/crawl.ts +0 -227
- package/src/index.ts +0 -46
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import type { Control } from '$lib/types';
|
|
6
|
+
import type { ControlSchema, ValidationResult } from '$lib/form-types';
|
|
7
|
+
import DynamicField from './DynamicField.svelte';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
control: Control;
|
|
11
|
+
schema: ControlSchema;
|
|
12
|
+
readonly?: boolean;
|
|
13
|
+
onValidation?: (result: ValidationResult) => void;
|
|
14
|
+
onChange?: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let { control = $bindable(), schema, readonly = false, onValidation, onChange }: Props = $props();
|
|
18
|
+
|
|
19
|
+
// Track validation errors for each field
|
|
20
|
+
let fieldErrors = $state<Record<string, string>>({});
|
|
21
|
+
|
|
22
|
+
// Group fields for better layout
|
|
23
|
+
const fieldGroups = $derived.by(() => {
|
|
24
|
+
const groups: { [key: string]: typeof schema.fields } = {};
|
|
25
|
+
|
|
26
|
+
schema.fields.forEach((field) => {
|
|
27
|
+
const group = field.group || 'general';
|
|
28
|
+
if (!groups[group]) {
|
|
29
|
+
groups[group] = [];
|
|
30
|
+
}
|
|
31
|
+
groups[group].push(field);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return groups;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function handleFieldChange(fieldId: string) {
|
|
38
|
+
// Run field-specific validation
|
|
39
|
+
validateField(fieldId);
|
|
40
|
+
onChange?.();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateField(fieldId: string) {
|
|
44
|
+
const field = schema.fields.find((f) => f.id === fieldId);
|
|
45
|
+
if (!field) return;
|
|
46
|
+
|
|
47
|
+
const value = control[fieldId];
|
|
48
|
+
let error = '';
|
|
49
|
+
|
|
50
|
+
// Required field validation
|
|
51
|
+
if (field.required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
|
52
|
+
error = `${field.label} is required`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Custom validation rules
|
|
56
|
+
if (field.validation && value) {
|
|
57
|
+
for (const rule of field.validation) {
|
|
58
|
+
if (rule.type === 'minLength' && typeof value === 'string' && value.length < rule.value) {
|
|
59
|
+
error = rule.message || `${field.label} must be at least ${rule.value} characters`;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (rule.type === 'maxLength' && typeof value === 'string' && value.length > rule.value) {
|
|
63
|
+
error = rule.message || `${field.label} must be no more than ${rule.value} characters`;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
rule.type === 'pattern' &&
|
|
68
|
+
typeof value === 'string' &&
|
|
69
|
+
!new RegExp(rule.pattern!).test(value)
|
|
70
|
+
) {
|
|
71
|
+
error = rule.message || `${field.label} format is invalid`;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Update field errors
|
|
78
|
+
if (error) {
|
|
79
|
+
fieldErrors[fieldId] = error;
|
|
80
|
+
} else {
|
|
81
|
+
delete fieldErrors[fieldId];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Trigger validation callback
|
|
85
|
+
const hasErrors = Object.keys(fieldErrors).length > 0;
|
|
86
|
+
const validationResult: ValidationResult = {
|
|
87
|
+
valid: !hasErrors,
|
|
88
|
+
errors: Object.entries(fieldErrors).map(([field, message]) => ({ field, message })),
|
|
89
|
+
warnings: []
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
onValidation?.(validationResult);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Only validate on explicit changes, not on control object changes
|
|
96
|
+
let isInitialized = $state(false);
|
|
97
|
+
|
|
98
|
+
$effect(() => {
|
|
99
|
+
if (control && !isInitialized) {
|
|
100
|
+
isInitialized = true;
|
|
101
|
+
// Initial validation without triggering callbacks
|
|
102
|
+
schema.fields.forEach((field) => {
|
|
103
|
+
if (control[field.id] !== undefined) {
|
|
104
|
+
const fieldId = field.id;
|
|
105
|
+
const fieldDef = schema.fields.find((f) => f.id === fieldId);
|
|
106
|
+
if (!fieldDef) return;
|
|
107
|
+
|
|
108
|
+
const value = control[fieldId];
|
|
109
|
+
let error = '';
|
|
110
|
+
|
|
111
|
+
// Required field validation
|
|
112
|
+
if (fieldDef.required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
|
113
|
+
error = `${fieldDef.label} is required`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Update field errors without triggering validation callback
|
|
117
|
+
if (error) {
|
|
118
|
+
fieldErrors[fieldId] = error;
|
|
119
|
+
} else {
|
|
120
|
+
delete fieldErrors[fieldId];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
</script>
|
|
127
|
+
|
|
128
|
+
{#if readonly}
|
|
129
|
+
<!-- View Mode: Clean minimal layout -->
|
|
130
|
+
<div class="space-y-6">
|
|
131
|
+
{#each Object.entries(fieldGroups) as [groupName, fields]}
|
|
132
|
+
{#each [fields] as fieldList}
|
|
133
|
+
{@const importantFields = fieldList.filter((f) =>
|
|
134
|
+
['id', 'title', 'priority', 'status'].includes(f.id)
|
|
135
|
+
)}
|
|
136
|
+
{@const contentFields = fieldList.filter(
|
|
137
|
+
(f) =>
|
|
138
|
+
!importantFields.includes(f) &&
|
|
139
|
+
control[f.id] !== undefined &&
|
|
140
|
+
control[f.id] !== null &&
|
|
141
|
+
control[f.id] !== ''
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{#if importantFields.length > 0 || contentFields.length > 0}
|
|
145
|
+
<div class="space-y-6">
|
|
146
|
+
<!-- Key information with natural layout -->
|
|
147
|
+
{#if importantFields.length > 0}
|
|
148
|
+
<div class="pb-4 border-b border-gray-200 dark:border-gray-700">
|
|
149
|
+
<div class="flex flex-wrap items-center gap-6">
|
|
150
|
+
{#each importantFields as field}
|
|
151
|
+
{@const value = control[field.id]}
|
|
152
|
+
{#if value !== undefined && value !== null && value !== ''}
|
|
153
|
+
<div class="flex items-center space-x-3">
|
|
154
|
+
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
155
|
+
{field.label}:
|
|
156
|
+
</span>
|
|
157
|
+
<span class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
158
|
+
{#if field.type === 'boolean'}
|
|
159
|
+
<span
|
|
160
|
+
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {value
|
|
161
|
+
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
|
162
|
+
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'}"
|
|
163
|
+
>
|
|
164
|
+
{value ? 'Yes' : 'No'}
|
|
165
|
+
</span>
|
|
166
|
+
{:else if field.id === 'family'}
|
|
167
|
+
<span
|
|
168
|
+
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 uppercase"
|
|
169
|
+
>
|
|
170
|
+
{value}
|
|
171
|
+
</span>
|
|
172
|
+
{:else}
|
|
173
|
+
{value}
|
|
174
|
+
{/if}
|
|
175
|
+
</span>
|
|
176
|
+
</div>
|
|
177
|
+
{/if}
|
|
178
|
+
{/each}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
{/if}
|
|
182
|
+
|
|
183
|
+
<!-- Content sections -->
|
|
184
|
+
{#each contentFields as field}
|
|
185
|
+
{@const value = control[field.id]}
|
|
186
|
+
|
|
187
|
+
{#if field.type === 'textarea'}
|
|
188
|
+
<!-- Long text content -->
|
|
189
|
+
<div class="space-y-3">
|
|
190
|
+
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
|
191
|
+
{field.label}
|
|
192
|
+
</h3>
|
|
193
|
+
<div
|
|
194
|
+
class="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
|
|
195
|
+
>
|
|
196
|
+
{value}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
{:else if field.type === 'string-array' && Array.isArray(value) && value.length > 0}
|
|
200
|
+
<!-- Simple list -->
|
|
201
|
+
<div class="space-y-3">
|
|
202
|
+
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
|
203
|
+
{field.label}
|
|
204
|
+
<span class="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
|
|
205
|
+
({value.length})
|
|
206
|
+
</span>
|
|
207
|
+
</h3>
|
|
208
|
+
<div class="space-y-2">
|
|
209
|
+
{#each value as item}
|
|
210
|
+
<div class="flex items-start space-x-3 py-2">
|
|
211
|
+
<div class="flex-shrink-0 w-1.5 h-1.5 bg-blue-600 rounded-full mt-2"></div>
|
|
212
|
+
<div class="text-gray-900 dark:text-white leading-relaxed">
|
|
213
|
+
{item}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
{/each}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
{:else if field.type === 'object-array' && Array.isArray(value) && value.length > 0}
|
|
220
|
+
<!-- Object list -->
|
|
221
|
+
<div class="space-y-3">
|
|
222
|
+
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
|
223
|
+
{field.label}
|
|
224
|
+
<span class="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
|
|
225
|
+
({value.length})
|
|
226
|
+
</span>
|
|
227
|
+
</h3>
|
|
228
|
+
<div class="space-y-3">
|
|
229
|
+
{#each value as item, index}
|
|
230
|
+
<div
|
|
231
|
+
class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700"
|
|
232
|
+
>
|
|
233
|
+
{#if field.arraySchema}
|
|
234
|
+
<dl class="space-y-2">
|
|
235
|
+
{#each Object.entries(field.arraySchema) as [key, schema]}
|
|
236
|
+
{@const schemaObj = schema as any}
|
|
237
|
+
{#if item[key]}
|
|
238
|
+
<div class="flex justify-between">
|
|
239
|
+
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
240
|
+
{schemaObj.label || key}:
|
|
241
|
+
</dt>
|
|
242
|
+
<dd class="text-sm text-gray-900 dark:text-white">
|
|
243
|
+
{item[key]}
|
|
244
|
+
</dd>
|
|
245
|
+
</div>
|
|
246
|
+
{/if}
|
|
247
|
+
{/each}
|
|
248
|
+
</dl>
|
|
249
|
+
{/if}
|
|
250
|
+
</div>
|
|
251
|
+
{/each}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
{:else}
|
|
255
|
+
<!-- Simple field -->
|
|
256
|
+
<div class="flex justify-between py-2">
|
|
257
|
+
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
258
|
+
{field.label}:
|
|
259
|
+
</dt>
|
|
260
|
+
<dd class="text-sm text-gray-900 dark:text-white font-medium">
|
|
261
|
+
{#if field.type === 'boolean'}
|
|
262
|
+
<span
|
|
263
|
+
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {value
|
|
264
|
+
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
|
265
|
+
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'}"
|
|
266
|
+
>
|
|
267
|
+
{value ? 'Yes' : 'No'}
|
|
268
|
+
</span>
|
|
269
|
+
{:else}
|
|
270
|
+
{value}
|
|
271
|
+
{/if}
|
|
272
|
+
</dd>
|
|
273
|
+
</div>
|
|
274
|
+
{/if}
|
|
275
|
+
{/each}
|
|
276
|
+
</div>
|
|
277
|
+
{/if}
|
|
278
|
+
{/each}
|
|
279
|
+
{/each}
|
|
280
|
+
</div>
|
|
281
|
+
{:else}
|
|
282
|
+
<!-- Edit Mode: Enhanced form layout -->
|
|
283
|
+
<div class="space-y-10">
|
|
284
|
+
{#each Object.entries(fieldGroups) as [groupName, fields]}
|
|
285
|
+
<section
|
|
286
|
+
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-sm overflow-hidden"
|
|
287
|
+
>
|
|
288
|
+
{#if groupName !== 'general'}
|
|
289
|
+
<!-- Enhanced group header -->
|
|
290
|
+
<header
|
|
291
|
+
class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-8 py-6 border-b border-gray-200 dark:border-gray-600"
|
|
292
|
+
>
|
|
293
|
+
<h3 class="text-xl font-bold text-gray-900 dark:text-white tracking-tight">
|
|
294
|
+
{groupName.replace(/([A-Z])/g, ' $1').trim()}
|
|
295
|
+
</h3>
|
|
296
|
+
</header>
|
|
297
|
+
{/if}
|
|
298
|
+
|
|
299
|
+
<!-- Enhanced form content -->
|
|
300
|
+
<div class="p-8">
|
|
301
|
+
<!-- Improved grid layout for simple fields -->
|
|
302
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8 mb-10">
|
|
303
|
+
{#each fields as field}
|
|
304
|
+
{#if !['textarea', 'string-array', 'object-array'].includes(field.type)}
|
|
305
|
+
<div class="space-y-2">
|
|
306
|
+
<DynamicField
|
|
307
|
+
{field}
|
|
308
|
+
bind:value={control[field.id]}
|
|
309
|
+
{readonly}
|
|
310
|
+
error={fieldErrors[field.id]}
|
|
311
|
+
onChange={() => handleFieldChange(field.id)}
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
{/if}
|
|
315
|
+
{/each}
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<!-- Enhanced full-width fields with better spacing -->
|
|
319
|
+
<div class="space-y-10">
|
|
320
|
+
{#each fields as field}
|
|
321
|
+
{#if ['textarea', 'string-array', 'object-array'].includes(field.type)}
|
|
322
|
+
<div
|
|
323
|
+
class="bg-gray-50 dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700"
|
|
324
|
+
>
|
|
325
|
+
<DynamicField
|
|
326
|
+
{field}
|
|
327
|
+
bind:value={control[field.id]}
|
|
328
|
+
{readonly}
|
|
329
|
+
error={fieldErrors[field.id]}
|
|
330
|
+
onChange={() => handleFieldChange(field.id)}
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
{/if}
|
|
334
|
+
{/each}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</section>
|
|
338
|
+
{/each}
|
|
339
|
+
</div>
|
|
340
|
+
{/if}
|