@nyaruka/temba-components 0.156.1 → 0.156.3

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.
@@ -2,14 +2,15 @@ import { html, TemplateResult } from 'lit-html';
2
2
  import { css } from 'lit';
3
3
  import { RapidElement } from '../RapidElement';
4
4
  import {
5
+ Action,
5
6
  Category,
6
7
  FlowDefinition,
7
- Node,
8
- SendMsg
8
+ Node
9
9
  } from '../store/flow-definition';
10
10
  import { AppState, fromStore, zustand } from '../store/AppState';
11
11
  import { CustomEventType } from '../interfaces';
12
12
  import { renderHighlightedText } from './utils';
13
+ import { Icon } from '../Icons';
13
14
  import { ACTION_CONFIG, NODE_CONFIG } from './config';
14
15
  import { ACTION_GROUP_METADATA, SPLIT_GROUP_METADATA } from './types';
15
16
  import { getOperatorConfig } from './operators';
@@ -18,7 +19,7 @@ import { getTranslatableCategoriesForNode } from './categoryLocalization';
18
19
  interface MessageEntry {
19
20
  kind: 'message';
20
21
  node: Node;
21
- action: SendMsg;
22
+ action: Action & Record<string, any>;
22
23
  nodeIndex: number;
23
24
  }
24
25
 
@@ -66,6 +67,7 @@ export class MessageTable extends RapidElement {
66
67
  padding: 12px 16px;
67
68
  border-bottom: 1px solid #f0f0f0;
68
69
  vertical-align: top;
70
+ position: relative;
69
71
  }
70
72
 
71
73
  .message-table td.translation-td {
@@ -145,12 +147,27 @@ export class MessageTable extends RapidElement {
145
147
  flex: 1;
146
148
  }
147
149
 
150
+ .message-table td.rail-td {
151
+ padding: 8px 8px 8px 20px;
152
+ height: 1px;
153
+ }
154
+
155
+ .message-table td.rail-td::before {
156
+ content: '';
157
+ position: absolute;
158
+ left: 10px;
159
+ top: 8px;
160
+ bottom: 8px;
161
+ width: 4px;
162
+ background: var(--node-rail-color, #d1d5db);
163
+ }
164
+
148
165
  .message-cell {
149
166
  cursor: pointer;
150
- position: relative;
151
- border-radius: 0 6px 6px 0;
152
- padding: 10px 12px 10px 16px;
153
- margin: -4px -6px;
167
+ border-radius: 6px;
168
+ padding: 12px 12px;
169
+ min-height: 100%;
170
+ box-sizing: border-box;
154
171
  transition: background 0.15s;
155
172
  word-wrap: break-word;
156
173
  line-height: 1.5;
@@ -158,16 +175,6 @@ export class MessageTable extends RapidElement {
158
175
  color: #333;
159
176
  }
160
177
 
161
- .message-cell::before {
162
- content: '';
163
- position: absolute;
164
- left: 0;
165
- top: 0;
166
- bottom: 0;
167
- width: 4px;
168
- background: var(--node-rail-color, #d1d5db);
169
- }
170
-
171
178
  .message-cell:hover {
172
179
  background: #f5f8ff;
173
180
  }
@@ -314,6 +321,33 @@ export class MessageTable extends RapidElement {
314
321
  background: #fff;
315
322
  }
316
323
 
324
+ .attachments {
325
+ display: flex;
326
+ flex-wrap: wrap;
327
+ gap: 4px;
328
+ margin-top: 0.5em;
329
+ }
330
+
331
+ .attachments.standalone {
332
+ margin-top: 0;
333
+ }
334
+
335
+ .attachment-icon {
336
+ display: inline-flex;
337
+ align-items: center;
338
+ justify-content: center;
339
+ width: 32px;
340
+ height: 32px;
341
+ border: 1px solid #d1d5db;
342
+ border-radius: 6px;
343
+ background: #fafafa;
344
+ }
345
+
346
+ .attachment-icon temba-icon {
347
+ --icon-size: 18px;
348
+ --icon-color: #888;
349
+ }
350
+
317
351
  .empty-state {
318
352
  display: flex;
319
353
  align-items: center;
@@ -345,11 +379,15 @@ export class MessageTable extends RapidElement {
345
379
  for (const node of this.definition.nodes) {
346
380
  nodeIndex++;
347
381
  for (const action of node.actions || []) {
348
- if (action.type === 'send_msg') {
382
+ const actionConfig = ACTION_CONFIG[action.type];
383
+ if (
384
+ action.type === 'send_msg' ||
385
+ (actionConfig?.localizable && actionConfig.localizable.length > 0)
386
+ ) {
349
387
  entries.push({
350
388
  kind: 'message',
351
389
  node,
352
- action: action as SendMsg,
390
+ action,
353
391
  nodeIndex
354
392
  });
355
393
  }
@@ -478,6 +516,189 @@ export class MessageTable extends RapidElement {
478
516
  return typeof localization.name === 'string' ? localization.name : null;
479
517
  }
480
518
 
519
+ private static ATTACHMENT_ICONS: Record<string, string> = {
520
+ image: Icon.attachment_image,
521
+ audio: Icon.attachment_audio,
522
+ video: Icon.attachment_video,
523
+ application: Icon.attachment_document
524
+ };
525
+
526
+ private getTranslatedField(actionUuid: string, field: string): string | null {
527
+ if (!this.isTranslating || !this.languageCode) return null;
528
+ const localization =
529
+ this.definition?.localization?.[this.languageCode]?.[actionUuid];
530
+ if (localization?.[field] && Array.isArray(localization[field])) {
531
+ return localization[field][0] || null;
532
+ }
533
+ return null;
534
+ }
535
+
536
+ private getTranslatedArrayField(actionUuid: string, field: string): string[] {
537
+ if (!this.isTranslating || !this.languageCode) return [];
538
+ const localization =
539
+ this.definition?.localization?.[this.languageCode]?.[actionUuid];
540
+ if (Array.isArray(localization?.[field])) {
541
+ return localization[field];
542
+ }
543
+ return [];
544
+ }
545
+
546
+ private hasAnyTranslation(entry: MessageEntry): boolean {
547
+ const config = ACTION_CONFIG[entry.action.type];
548
+ if (!config?.localizable) return false;
549
+ const localization =
550
+ this.definition?.localization?.[this.languageCode]?.[entry.action.uuid];
551
+ if (!localization) return false;
552
+ return config.localizable.some((key) => {
553
+ const val = localization[key];
554
+ if (Array.isArray(val)) {
555
+ return val.some((v: any) => typeof v === 'string' && v.trim());
556
+ }
557
+ return false;
558
+ });
559
+ }
560
+
561
+ /**
562
+ * Whether an action should render as paired rows (one per localizable field).
563
+ * Used for actions with multiple text fields like send_email.
564
+ */
565
+ private usesPairedRows(action: Action): boolean {
566
+ const config = ACTION_CONFIG[action.type];
567
+ if (!config?.localizable) return false;
568
+ // Count string-type localizable fields (exclude arrays like attachments, quick_replies)
569
+ const textFields = config.localizable.filter((key) => {
570
+ const fieldConfig = config.form?.[key];
571
+ return fieldConfig && (fieldConfig.type === 'text' || fieldConfig.type === 'textarea');
572
+ });
573
+ return textFields.length > 1;
574
+ }
575
+
576
+ /**
577
+ * Get the localizable text fields for paired-row rendering.
578
+ */
579
+ private getPairedFields(action: Action & Record<string, any>): Array<{
580
+ key: string;
581
+ label: string;
582
+ original: string;
583
+ translated: string | null;
584
+ }> {
585
+ const config = ACTION_CONFIG[action.type];
586
+ if (!config?.localizable || !config.form) return [];
587
+ return config.localizable
588
+ .filter((key) => {
589
+ const fieldConfig = config.form?.[key];
590
+ return fieldConfig && (fieldConfig.type === 'text' || fieldConfig.type === 'textarea');
591
+ })
592
+ .map((key) => {
593
+ const fieldConfig = config.form![key];
594
+ const label = typeof fieldConfig.label === 'string' ? fieldConfig.label : key;
595
+ return {
596
+ key,
597
+ label,
598
+ original: action[key] || '',
599
+ translated: this.getTranslatedField(action.uuid, key)
600
+ };
601
+ });
602
+ }
603
+
604
+ private renderAttachments(attachments: string[]): TemplateResult {
605
+ if (!attachments || attachments.length === 0) return html``;
606
+ return html`<div class="attachments">
607
+ ${attachments.map((att) => {
608
+ const colonIdx = att.indexOf(':');
609
+ if (colonIdx < 0) return html``;
610
+ const contentType = att.substring(0, colonIdx);
611
+ const baseType = contentType.split('/')[0];
612
+ const iconName = MessageTable.ATTACHMENT_ICONS[baseType] || Icon.attachment;
613
+ return html`<div class="attachment-icon">
614
+ <temba-icon name="${iconName}"></temba-icon>
615
+ </div>`;
616
+ })}
617
+ </div>`;
618
+ }
619
+
620
+ private renderOriginalContent(entry: MessageEntry): TemplateResult {
621
+ const action = entry.action;
622
+ const config = ACTION_CONFIG[action.type];
623
+ const localizable = config?.localizable || [];
624
+
625
+ const parts: TemplateResult[] = [];
626
+
627
+ // Render all localizable text fields
628
+ if (config?.form) {
629
+ for (const key of localizable) {
630
+ const fc = config.form[key];
631
+ if (fc && (fc.type === 'text' || fc.type === 'textarea' || fc.type === 'message-editor')) {
632
+ const text = action[key] || '';
633
+ if (text) {
634
+ parts.push(renderHighlightedText(this.stripLeadingLineBreaks(text), true));
635
+ }
636
+ }
637
+ }
638
+ }
639
+
640
+ // Quick replies (send_msg)
641
+ const quickReplies: string[] = action.quick_replies || [];
642
+ if (quickReplies.length > 0) {
643
+ parts.push(html`<div class="quick-replies ${parts.length === 0 ? 'standalone' : ''}">${quickReplies.map(
644
+ (reply) => html`<div class="quick-reply">${reply}</div>`
645
+ )}</div>`);
646
+ }
647
+
648
+ // Attachments
649
+ const attachments: string[] = action.attachments || [];
650
+ if (attachments.length > 0) {
651
+ parts.push(this.renderAttachments(attachments));
652
+ }
653
+
654
+ if (parts.length === 0) {
655
+ return html`<span style="color: #bbb; font-style: italic;">Empty</span>`;
656
+ }
657
+
658
+ return html`${parts}`;
659
+ }
660
+
661
+ private renderTranslatedContent(entry: MessageEntry): TemplateResult {
662
+ const action = entry.action;
663
+ const config = ACTION_CONFIG[action.type];
664
+ const localizable = config?.localizable || [];
665
+
666
+ const parts: TemplateResult[] = [];
667
+
668
+ // Translated text fields
669
+ if (config?.form) {
670
+ for (const key of localizable) {
671
+ const fc = config.form[key];
672
+ if (fc && (fc.type === 'text' || fc.type === 'textarea' || fc.type === 'message-editor')) {
673
+ const translatedText = this.getTranslatedField(action.uuid, key);
674
+ if (typeof translatedText === 'string') {
675
+ parts.push(renderHighlightedText(this.stripLeadingLineBreaks(translatedText), true));
676
+ }
677
+ }
678
+ }
679
+ }
680
+
681
+ // Translated quick replies
682
+ const translatedQuickReplies = this.getTranslatedQuickReplies(action.uuid);
683
+ if (translatedQuickReplies.length > 0) {
684
+ parts.push(html`<div class="quick-replies ${parts.length === 0 ? 'standalone' : ''}">${translatedQuickReplies.map(
685
+ (reply) => html`<div class="quick-reply">${reply}</div>`
686
+ )}</div>`);
687
+ }
688
+
689
+ // Translated attachments
690
+ const translatedAttachments = this.getTranslatedArrayField(action.uuid, 'attachments');
691
+ if (translatedAttachments.length > 0) {
692
+ parts.push(this.renderAttachments(translatedAttachments));
693
+ }
694
+
695
+ if (parts.length === 0) {
696
+ return html`No translation`;
697
+ }
698
+
699
+ return html`${parts}`;
700
+ }
701
+
481
702
  private handleBaseTextClick(entry: MessageEntry): void {
482
703
  this.fireCustomEvent(CustomEventType.ActionEditRequested, {
483
704
  action: entry.action,
@@ -573,6 +794,55 @@ export class MessageTable extends RapidElement {
573
794
  );
574
795
  }
575
796
 
797
+ private renderPairedRows(
798
+ items: Array<{ original: TemplateResult; translated: TemplateResult | null }>,
799
+ entry: TableEntry,
800
+ handleBaseClick: () => void,
801
+ handleTranslationClick: () => void,
802
+ showTranslation: boolean
803
+ ): TemplateResult {
804
+ return html`
805
+ ${items.map(
806
+ (item, idx) => html`
807
+ <tr
808
+ class="category-row localization-paired-row ${idx === 0 ? 'localization-paired-first' : ''} ${idx === items.length - 1 ? 'localization-paired-last' : ''}"
809
+ style=${`--node-rail-color: ${this.getEntryRailColor(entry)};`}
810
+ data-node-uuid=${entry.node.uuid}
811
+ data-entry-kind=${entry.kind}
812
+ data-action-uuid=${entry.kind === 'message' ? entry.action.uuid : ''}
813
+ >
814
+ <td>
815
+ <div
816
+ class="message-cell category-message-cell"
817
+ @click=${handleBaseClick}
818
+ title="Click to edit"
819
+ >
820
+ <div class="category-item category-original-item">
821
+ <span>${item.original}</span>
822
+ </div>
823
+ </div>
824
+ </td>
825
+ ${showTranslation
826
+ ? html`<td class="translation-td">
827
+ <div
828
+ class="translation-cell category-translation-cell"
829
+ @click=${handleTranslationClick}
830
+ title="Click to edit translation"
831
+ >
832
+ <div
833
+ class="category-item category-translation-item ${item.translated !== null ? '' : 'missing'}"
834
+ >
835
+ <span>${item.translated !== null ? item.translated : 'No translation'}</span>
836
+ </div>
837
+ </div>
838
+ </td>`
839
+ : ''}
840
+ </tr>
841
+ `
842
+ )}
843
+ `;
844
+ }
845
+
576
846
  public render(): TemplateResult {
577
847
  const entries = this.getEntries();
578
848
 
@@ -595,33 +865,6 @@ export class MessageTable extends RapidElement {
595
865
  <tbody>
596
866
  ${entries.map((entry) => {
597
867
  const isGroupEntry = entry.kind === 'localization-group';
598
- const translatedText =
599
- showTranslation && entry.kind === 'message'
600
- ? this.getTranslatedText(entry.action.uuid)
601
- : null;
602
- const translatedQuickReplies =
603
- showTranslation && entry.kind === 'message'
604
- ? this.getTranslatedQuickReplies(entry.action.uuid)
605
- : [];
606
- const groupTranslations =
607
- showTranslation && isGroupEntry
608
- ? this.getGroupTranslations(entry)
609
- : null;
610
- const hasGroupTranslation =
611
- Array.isArray(groupTranslations) &&
612
- groupTranslations.some((item) => !!item.translated);
613
- const hasMessageTranslation =
614
- entry.kind === 'message' &&
615
- (!!translatedText || translatedQuickReplies.length > 0);
616
- const hasTranslation =
617
- entry.kind === 'message'
618
- ? hasMessageTranslation
619
- : hasGroupTranslation;
620
- const translationCellClass = isGroupEntry
621
- ? 'translation-cell category-translation-cell'
622
- : `translation-cell ${hasTranslation
623
- ? 'has-translation'
624
- : 'missing-translation'}`;
625
868
 
626
869
  const handleBaseClick = () => {
627
870
  if (entry.kind === 'message') this.handleBaseTextClick(entry);
@@ -632,55 +875,48 @@ export class MessageTable extends RapidElement {
632
875
  else if (isGroupEntry) this.handleGroupTranslationClick(entry);
633
876
  };
634
877
 
878
+ // Localization group entries (rules/categories) - always paired rows
635
879
  if (isGroupEntry && showTranslation) {
636
- // Render localization groups as paired rows so originals and translations align
637
- return html`
638
- ${groupTranslations.map(
639
- (item, idx) => html`
640
- <tr
641
- class="category-row localization-paired-row ${idx === 0 ? 'localization-paired-first' : ''} ${idx === groupTranslations.length - 1 ? 'localization-paired-last' : ''}"
642
- style=${`--node-rail-color: ${this.getEntryRailColor(entry)};`}
643
- data-node-uuid=${entry.node.uuid}
644
- data-entry-kind=${entry.kind}
645
- >
646
- <td>
647
- <div
648
- class="message-cell category-message-cell"
649
- @click=${handleBaseClick}
650
- title="Click to edit"
651
- >
652
- <div class="category-item category-original-item">
653
- <span>${item.isRule && item.operatorName
654
- ? html`<span class="rule-operator">${item.operatorName}</span> `
655
- : ''}${renderHighlightedText(item.original, true)}</span>
656
- </div>
657
- </div>
658
- </td>
659
- <td class="translation-td">
660
- <div
661
- class="translation-cell category-translation-cell"
662
- @click=${handleTranslationClickFn}
663
- title="Click to edit translation"
664
- >
665
- <div
666
- class="category-item category-translation-item ${item.translated
667
- ? ''
668
- : 'missing'}"
669
- >
670
- <span>${item.isRule && item.operatorName
671
- ? html`<span class="rule-operator">${item.operatorName}</span> `
672
- : ''}${item.translated
673
- ? renderHighlightedText(item.translated, true)
674
- : 'No translation'}</span>
675
- </div>
676
- </div>
677
- </td>
678
- </tr>
679
- `
680
- )}
681
- `;
880
+ const groupTranslations = this.getGroupTranslations(entry);
881
+ const items = groupTranslations.map((item) => ({
882
+ original: html`${item.isRule && item.operatorName
883
+ ? html`<span class="rule-operator">${item.operatorName}</span> `
884
+ : ''}${renderHighlightedText(item.original, true)}`,
885
+ translated: item.translated !== null
886
+ ? html`${item.isRule && item.operatorName
887
+ ? html`<span class="rule-operator">${item.operatorName}</span> `
888
+ : ''}${renderHighlightedText(item.translated, true)}`
889
+ : null
890
+ }));
891
+ return this.renderPairedRows(items, entry, handleBaseClick, handleTranslationClickFn, showTranslation);
892
+ }
893
+
894
+ // Multi-field actions (e.g. send_email with subject + body) - paired rows
895
+ if (
896
+ entry.kind === 'message' &&
897
+ this.usesPairedRows(entry.action)
898
+ ) {
899
+ const fields = this.getPairedFields(entry.action);
900
+ const items = fields.map((f) => ({
901
+ original: html`${f.original
902
+ ? renderHighlightedText(this.stripLeadingLineBreaks(f.original), true)
903
+ : html`<span style="color: #bbb; font-style: italic;">Empty</span>`}`,
904
+ translated: f.translated !== null
905
+ ? html`${renderHighlightedText(this.stripLeadingLineBreaks(f.translated), true)}`
906
+ : null
907
+ }));
908
+ return this.renderPairedRows(items, entry, handleBaseClick, handleTranslationClickFn, showTranslation);
682
909
  }
683
910
 
911
+ // Single-row entries (send_msg, send_broadcast, etc.)
912
+ const hasTranslation =
913
+ entry.kind === 'message' &&
914
+ showTranslation &&
915
+ this.hasAnyTranslation(entry);
916
+ const translationCellClass = `translation-cell ${hasTranslation
917
+ ? 'has-translation'
918
+ : 'missing-translation'}`;
919
+
684
920
  return html`
685
921
  <tr
686
922
  style=${`--node-rail-color: ${this.getEntryRailColor(entry)};`}
@@ -690,22 +926,14 @@ export class MessageTable extends RapidElement {
690
926
  ? entry.action.uuid
691
927
  : ''}
692
928
  >
693
- <td>
929
+ <td class="rail-td">
694
930
  <div
695
931
  class="message-cell"
696
932
  @click=${handleBaseClick}
697
- title="Click to edit message"
933
+ title="Click to edit"
698
934
  >
699
935
  ${entry.kind === 'message'
700
- ? html`${renderHighlightedText(
701
- this.stripLeadingLineBreaks(entry.action.text || ''),
702
- true
703
- )}${(entry.action.quick_replies || [])
704
- .length > 0
705
- ? html`<div class="quick-replies">${(entry.action.quick_replies || []).map(
706
- (reply) => html`<div class="quick-reply">${reply}</div>`
707
- )}</div>`
708
- : ''}`
936
+ ? this.renderOriginalContent(entry)
709
937
  : isGroupEntry
710
938
  ? html`
711
939
  <div class="category-stack category-stack-original">
@@ -736,25 +964,7 @@ export class MessageTable extends RapidElement {
736
964
  title="Click to edit translation"
737
965
  >
738
966
  ${entry.kind === 'message'
739
- ? html`${typeof translatedText === 'string'
740
- ? renderHighlightedText(
741
- this.stripLeadingLineBreaks(translatedText),
742
- true
743
- )
744
- : translatedQuickReplies.length === 0
745
- ? 'No translation'
746
- : ''}${translatedQuickReplies.length > 0
747
- ? html`<div
748
- class="quick-replies ${translatedText
749
- ? ''
750
- : 'standalone'}"
751
- >
752
- ${translatedQuickReplies.map(
753
- (reply) =>
754
- html`<div class="quick-reply">${reply}</div>`
755
- )}
756
- </div>`
757
- : ''}`
967
+ ? this.renderTranslatedContent(entry)
758
968
  : 'No translation'}
759
969
  </div>
760
970
  </td>`
@@ -1928,6 +1928,15 @@ export class NodeEditor extends RapidElement {
1928
1928
 
1929
1929
  switch (item.type) {
1930
1930
  case 'field':
1931
+ // In translation mode, skip fields not in the localizable list
1932
+ if (
1933
+ this.isTranslating &&
1934
+ Array.isArray((config as ActionConfig).localizable) &&
1935
+ !(config as ActionConfig).localizable!.includes(item.field)
1936
+ ) {
1937
+ renderedFields.add(item.field);
1938
+ return html``;
1939
+ }
1931
1940
  if (config.form![item.field] && !renderedFields.has(item.field)) {
1932
1941
  renderedFields.add(item.field);
1933
1942
  const fieldConfig = config.form![item.field] as FieldConfig;
@@ -2484,6 +2493,14 @@ export class NodeEditor extends RapidElement {
2484
2493
  !renderedFields.has(fieldName) &&
2485
2494
  !gutterFields.has(fieldName)
2486
2495
  ) {
2496
+ // In translation mode, skip fields not in the localizable list
2497
+ if (
2498
+ this.isTranslating &&
2499
+ Array.isArray((config as ActionConfig).localizable) &&
2500
+ !(config as ActionConfig).localizable!.includes(fieldName)
2501
+ ) {
2502
+ return html``;
2503
+ }
2487
2504
  return this.renderNewField(
2488
2505
  fieldName,
2489
2506
  fieldConfig as FieldConfig,
@@ -2497,13 +2514,21 @@ export class NodeEditor extends RapidElement {
2497
2514
  } else {
2498
2515
  // Default rendering without layout
2499
2516
  return html`
2500
- ${Object.entries(config.form).map(([fieldName, fieldConfig]) =>
2501
- this.renderNewField(
2517
+ ${Object.entries(config.form).map(([fieldName, fieldConfig]) => {
2518
+ // In translation mode, skip fields not in the localizable list
2519
+ if (
2520
+ this.isTranslating &&
2521
+ Array.isArray((config as ActionConfig).localizable) &&
2522
+ !(config as ActionConfig).localizable!.includes(fieldName)
2523
+ ) {
2524
+ return html``;
2525
+ }
2526
+ return this.renderNewField(
2502
2527
  fieldName,
2503
2528
  fieldConfig as FieldConfig,
2504
2529
  this.formData[fieldName]
2505
- )
2506
- )}
2530
+ );
2531
+ })}
2507
2532
  `;
2508
2533
  }
2509
2534
  }
@@ -31,6 +31,7 @@ export const add_contact_groups: ActionConfig = {
31
31
  valueKey: 'uuid',
32
32
  nameKey: 'name',
33
33
  placeholder: 'Search for groups...',
34
+ shouldExclude: (option: any) => !!option.query,
34
35
  allowCreate: true,
35
36
  createArbitraryOption: (input: string, options: any[]) => {
36
37
  // Check if a label with this name already exists
@@ -28,6 +28,7 @@ export const say_msg: ActionConfig = {
28
28
  required: true,
29
29
  evaluated: true,
30
30
  placeholder: 'Enter message to speak...',
31
+ maxLength: 10000,
31
32
  minHeight: 80
32
33
  },
33
34
  audio_url: {