@brftech/filex-core 0.19.0 → 0.20.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.
Files changed (39) hide show
  1. package/README.md +34 -4
  2. package/dist/filex-core.js +10626 -6501
  3. package/dist/filex-core.js.map +1 -1
  4. package/dist/filex-core.umd.cjs +94 -63
  5. package/dist/filex-core.umd.cjs.map +1 -1
  6. package/dist/index.d.ts +1026 -7
  7. package/dist/style.css +1 -1
  8. package/package.json +3 -3
  9. package/src/FileExplorer.vue +103 -68
  10. package/src/components/ConnectionGuideView.vue +333 -0
  11. package/src/components/ConnectionsPanel.vue +912 -0
  12. package/src/components/NFSExportsPanel.vue +283 -0
  13. package/src/components/S3KeysPanel.vue +381 -0
  14. package/src/components/SSHKeysPanel.vue +222 -0
  15. package/src/components/StorageFields.vue +362 -0
  16. package/src/components/TokensPanel.vue +191 -0
  17. package/src/components/UploadProgress.vue +5 -1
  18. package/src/composables/useConnections.ts +271 -0
  19. package/src/composables/useFileApi.ts +15 -2
  20. package/src/composables/useNFSExports.ts +148 -0
  21. package/src/composables/useS3Keys.ts +175 -0
  22. package/src/composables/useSSHKeys.ts +119 -0
  23. package/src/composables/useThumbs.ts +1 -1
  24. package/src/composables/useTokens.ts +121 -0
  25. package/src/composables/useUploadChunked.ts +433 -164
  26. package/src/index.ts +77 -2
  27. package/src/lib/connectionGuides.ts +1279 -0
  28. package/src/lib/realtime.ts +1 -1
  29. package/src/lib/uploadResume.ts +157 -0
  30. package/src/locales/en.ts +413 -0
  31. package/src/locales/tr.ts +416 -0
  32. package/src/modals/ConvertModal.vue +1 -1
  33. package/src/styles/base.css +12 -12
  34. package/src/types/Connections.ts +122 -0
  35. package/src/types/ExplorerConfig.ts +23 -2
  36. package/src/types/NFSExports.ts +47 -0
  37. package/src/types/S3Keys.ts +55 -0
  38. package/src/types/SSHKeys.ts +54 -0
  39. package/src/types/Tokens.ts +39 -0
@@ -0,0 +1,222 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * SSHKeysPanel — register, disable and remove the keys this account signs in
4
+ * with over SFTP.
5
+ *
6
+ * ⚠⚠ This is not a convenience screen. `ssh-copy-id` — the command everybody
7
+ * reaches for — appends to `~/.ssh/authorized_keys` over a shell, and filex has
8
+ * no shell. Without a box to paste a key into, public-key authentication is
9
+ * unreachable and every user ends up sending their account password to a file
10
+ * server instead. That is why the screen shipped WITH the endpoint rather than
11
+ * after it.
12
+ *
13
+ * One component, every surface: the desktop app and the web app mount this same
14
+ * file (see S3KeysPanel for the same rule stated at length).
15
+ */
16
+ import { computed, onMounted, ref } from 'vue';
17
+ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
18
+ import type { FTPSFacts, SSHPublicKey } from '../types/SSHKeys';
19
+ import { useLocale } from '../composables/useLocale';
20
+ import { useSSHKeys } from '../composables/useSSHKeys';
21
+
22
+ const props = defineProps<{
23
+ config: ExplorerConfig;
24
+ /**
25
+ * Draw the key list and the paste box.
26
+ *
27
+ * ⚠ False for FTPS, which authenticates with a password or an API token and
28
+ * has no use for an SSH key — the panel is still MOUNTED there because it is
29
+ * the call that reports where the FTP endpoint listens and under what login
30
+ * name. Showing a key box on the FTPS page would invite somebody to register
31
+ * a key that protocol will never look at.
32
+ */
33
+ keysVisible?: boolean;
34
+ }>();
35
+
36
+ const showKeys = computed(() => props.keysVisible !== false);
37
+
38
+ const emit = defineEmits<{
39
+ /** What the guide should render with: where to connect and as whom. */
40
+ (e: 'active', v: {
41
+ host: string;
42
+ port: number;
43
+ login: string;
44
+ enabled: boolean;
45
+ hasKey: boolean;
46
+ ftps?: FTPSFacts;
47
+ }): void;
48
+ }>();
49
+
50
+ const locale = computed<LocaleCode>(() => props.config.locale ?? 'tr');
51
+ const { t } = useLocale(locale);
52
+
53
+ const { keys, connection, loading, error, canAdd, hasUsableKey, load, add, setDisabled, remove } =
54
+ useSSHKeys(props.config);
55
+
56
+ const pasted = ref('');
57
+ const name = ref('');
58
+ const busy = ref(false);
59
+ const confirmRemove = ref<number | null>(null);
60
+
61
+ function publish() {
62
+ const c = connection.value;
63
+ if (!c) return;
64
+ emit('active', {
65
+ host: c.host,
66
+ port: c.port,
67
+ login: c.login,
68
+ enabled: c.enabled,
69
+ hasKey: hasUsableKey.value,
70
+ // The FTPS endpoint travels with the SSH facts: for the user this is one
71
+ // question ("how do I reach this from a program?"), and two calls to
72
+ // answer it would be two chances to disagree.
73
+ ftps: c.ftps,
74
+ });
75
+ }
76
+
77
+ onMounted(async () => {
78
+ await load();
79
+ publish();
80
+ });
81
+
82
+ async function submit() {
83
+ if (!pasted.value.trim()) return;
84
+ busy.value = true;
85
+ try {
86
+ if (await add(pasted.value.trim(), name.value.trim() || undefined)) {
87
+ pasted.value = '';
88
+ name.value = '';
89
+ }
90
+ publish();
91
+ } finally {
92
+ busy.value = false;
93
+ }
94
+ }
95
+
96
+ async function toggle(k: SSHPublicKey) {
97
+ busy.value = true;
98
+ try {
99
+ await setDisabled(k.id, !k.disabled_at);
100
+ publish();
101
+ } finally {
102
+ busy.value = false;
103
+ }
104
+ }
105
+
106
+ async function drop(k: SSHPublicKey) {
107
+ if (confirmRemove.value !== k.id) {
108
+ confirmRemove.value = k.id;
109
+ return;
110
+ }
111
+ confirmRemove.value = null;
112
+ busy.value = true;
113
+ try {
114
+ await remove(k.id);
115
+ publish();
116
+ } finally {
117
+ busy.value = false;
118
+ }
119
+ }
120
+
121
+ function shortDate(v?: string | null): string {
122
+ if (!v) return '';
123
+ const d = new Date(v);
124
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
125
+ }
126
+
127
+ /** Shown the way OpenSSH prints it, so it can be compared with `ssh-keygen -lf`. */
128
+ function fingerprintOf(k: SSHPublicKey): string {
129
+ return 'SHA256:' + k.fingerprint;
130
+ }
131
+ </script>
132
+
133
+ <template>
134
+ <section v-if="showKeys" class="fe-s3keys" data-testid="ssh-keys">
135
+ <header class="fe-s3keys__head">
136
+ <h4 class="fe-s3keys__title">{{ t('conn.sshkeys.title') }}</h4>
137
+ <p class="fe-s3keys__lead">{{ t('conn.sshkeys.lead') }}</p>
138
+ </header>
139
+
140
+ <p v-if="connection && !connection.enabled" class="fe-s3keys__warn">
141
+ {{ t('conn.sshkeys.disabled') }}
142
+ </p>
143
+ <p v-if="canAdd === false" class="fe-s3keys__muted">{{ t('conn.sshkeys.cannotAdd') }}</p>
144
+ <p v-if="error" class="fe-s3keys__warn">{{ error }}</p>
145
+
146
+ <div v-if="canAdd" class="fe-sshkeys__form">
147
+ <textarea
148
+ v-model="pasted"
149
+ class="fe-cfield__input fe-sshkeys__paste"
150
+ rows="2"
151
+ spellcheck="false"
152
+ :placeholder="t('conn.sshkeys.paste')"
153
+ data-testid="ssh-key-input"
154
+ ></textarea>
155
+ <div class="fe-sshkeys__row">
156
+ <input
157
+ v-model="name"
158
+ class="fe-cfield__input"
159
+ :placeholder="t('conn.sshkeys.name')"
160
+ data-testid="ssh-key-name"
161
+ />
162
+ <button class="fe-s3keys__btn" :disabled="busy" data-testid="ssh-key-add" @click="submit">
163
+ {{ t('conn.sshkeys.add') }}
164
+ </button>
165
+ </div>
166
+ <p class="fe-s3keys__hint">{{ t('conn.sshkeys.noCopyId') }}</p>
167
+ </div>
168
+
169
+ <p v-if="loading" class="fe-s3keys__muted">…</p>
170
+ <table v-else-if="keys.length" class="fe-s3keys__table">
171
+ <thead>
172
+ <tr>
173
+ <th>{{ t('conn.sshkeys.col.name') }}</th>
174
+ <th>{{ t('conn.sshkeys.col.fingerprint') }}</th>
175
+ <th>{{ t('conn.sshkeys.col.lastUsed') }}</th>
176
+ <th></th>
177
+ </tr>
178
+ </thead>
179
+ <tbody>
180
+ <tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
181
+ <td>{{ k.name || t('conn.sshkeys.noName') }}</td>
182
+ <td><code>{{ fingerprintOf(k) }}</code></td>
183
+ <td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.sshkeys.neverUsed') }}</td>
184
+ <td class="fe-s3keys__actions">
185
+ <button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
186
+ {{ k.disabled_at ? t('conn.sshkeys.enable') : t('conn.sshkeys.disable') }}
187
+ </button>
188
+ <button class="fe-s3keys__link is-danger" :disabled="busy" @click="drop(k)">
189
+ {{ confirmRemove === k.id ? t('conn.sshkeys.confirm') : t('conn.sshkeys.remove') }}
190
+ </button>
191
+ </td>
192
+ </tr>
193
+ </tbody>
194
+ </table>
195
+ <p v-else-if="canAdd" class="fe-s3keys__muted">{{ t('conn.sshkeys.empty') }}</p>
196
+ </section>
197
+ </template>
198
+
199
+ <style>
200
+ /* The layout is shared with the access-key panel (fe-s3keys__*); only the
201
+ paste box is different, because a public key is two lines long. */
202
+ .fe-sshkeys__form {
203
+ display: flex;
204
+ flex-direction: column;
205
+ gap: 8px;
206
+ }
207
+ .fe-sshkeys__paste {
208
+ width: 100%;
209
+ font-family: var(--fe-mono, ui-monospace, monospace);
210
+ font-size: 12px;
211
+ resize: vertical;
212
+ overflow-wrap: anywhere;
213
+ }
214
+ .fe-sshkeys__row {
215
+ display: flex;
216
+ gap: 8px;
217
+ align-items: center;
218
+ }
219
+ .fe-sshkeys__row .fe-cfield__input {
220
+ flex: 1;
221
+ }
222
+ </style>
@@ -0,0 +1,362 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * StorageFields — a driver's config form, rendered from the driver's own
4
+ * descriptor (`GET /api/admin/storage-drivers`) and from nothing else.
5
+ *
6
+ * ⚠ There is no per-driver template here and there must never be one. The
7
+ * form this replaces on the web was a `v-if` chain that drifted away from
8
+ * the backend it posted to — it asked for `base_path` where the driver read
9
+ * `root` and never offered the s3 `prefix` at all, so three of the four
10
+ * drivers it offered answered 400 ROOT_PATH_FORBIDDEN on every submit. The
11
+ * descriptor fixes the class; a hand-written form re-opens it.
12
+ *
13
+ * It lives in the shared package so the desktop app and the web app render
14
+ * the same fields, in the same order, with the same help text, from the
15
+ * same declaration — which is the only way "the same form on every surface"
16
+ * survives the next driver.
17
+ */
18
+ import { computed, ref } from 'vue';
19
+ import type { LocaleCode } from '../types/ExplorerConfig';
20
+ import type { StorageField, StorageFieldOption } from '../types/Connections';
21
+ import { useLocale } from '../composables/useLocale';
22
+
23
+ const props = defineProps<{
24
+ fields: StorageField[];
25
+ modelValue: Record<string, unknown>;
26
+ locale: LocaleCode;
27
+ /** Keys to mark as failing validation (missing required). */
28
+ invalid?: string[];
29
+ disabled?: boolean;
30
+ }>();
31
+
32
+ const emit = defineEmits<{
33
+ (e: 'update:modelValue', v: Record<string, unknown>): void;
34
+ }>();
35
+
36
+ const { t } = useLocale(() => props.locale);
37
+
38
+ const showAdvanced = ref(false);
39
+ const revealed = ref<Record<string, boolean>>({});
40
+
41
+ const basic = computed(() => props.fields.filter((f) => !f.advanced));
42
+ const advanced = computed(() => props.fields.filter((f) => f.advanced));
43
+
44
+ /**
45
+ * Translation first, the driver's English fallback second.
46
+ *
47
+ * A driver shipped after this bundle carries labels the catalogue has never
48
+ * heard of. Falling back to the descriptor's own English is how the field
49
+ * still reads as a field instead of as `storages.fields.something`.
50
+ */
51
+ function label(f: StorageField): string {
52
+ const s = t(f.i18n_key);
53
+ return s === f.i18n_key ? f.label : s;
54
+ }
55
+
56
+ function help(f: StorageField): string {
57
+ if (!f.help_i18n_key) return f.help ?? '';
58
+ const s = t(f.help_i18n_key);
59
+ return s === f.help_i18n_key ? (f.help ?? '') : s;
60
+ }
61
+
62
+ function optionLabel(o: StorageFieldOption): string {
63
+ if (!o.i18n_key) return o.label;
64
+ const s = t(o.i18n_key);
65
+ return s === o.i18n_key ? o.label : s;
66
+ }
67
+
68
+ function set(key: string, value: unknown) {
69
+ emit('update:modelValue', { ...props.modelValue, [key]: value });
70
+ }
71
+
72
+ /** Current value, honouring the legacy spellings the driver still reads —
73
+ * an old row keyed `base_path` shows up in the `root` box rather than as
74
+ * an empty field the operator would refill by hand. */
75
+ function raw(f: StorageField): unknown {
76
+ const v = props.modelValue?.[f.key];
77
+ if (v !== undefined && v !== null) return v;
78
+ for (const alias of f.aliases ?? []) {
79
+ const a = props.modelValue?.[alias];
80
+ if (a !== undefined && a !== null) return a;
81
+ }
82
+ return f.default ?? undefined;
83
+ }
84
+
85
+ function str(f: StorageField): string {
86
+ const v = raw(f);
87
+ return v === undefined || v === null ? '' : String(v);
88
+ }
89
+
90
+ function num(f: StorageField): string {
91
+ const v = raw(f);
92
+ return v === '' || v === undefined || v === null ? '' : String(v);
93
+ }
94
+
95
+ function bool(f: StorageField): boolean {
96
+ return raw(f) === true;
97
+ }
98
+
99
+ function isInvalid(f: StorageField): boolean {
100
+ return (props.invalid ?? []).includes(f.key);
101
+ }
102
+
103
+ function inputType(f: StorageField): string {
104
+ if (f.type === 'int') return 'number';
105
+ if (f.type === 'password' && !revealed.value[f.key]) return 'password';
106
+ return 'text';
107
+ }
108
+
109
+ function toggleReveal(key: string) {
110
+ revealed.value = { ...revealed.value, [key]: !revealed.value[key] };
111
+ }
112
+ </script>
113
+
114
+ <template>
115
+ <div class="fe-cfield">
116
+ <p v-if="!fields.length" class="fe-cfield__empty">{{ t('conn.form.noFields') }}</p>
117
+
118
+ <template v-for="f in basic" :key="f.key">
119
+ <div class="fe-cfield__row" :class="{ 'is-invalid': isInvalid(f) }">
120
+ <!-- bool: the label belongs next to the box, not above it -->
121
+ <label v-if="f.type === 'bool'" class="fe-cfield__check">
122
+ <input
123
+ type="checkbox"
124
+ :checked="bool(f)"
125
+ :disabled="disabled"
126
+ @change="set(f.key, ($event.target as HTMLInputElement).checked)"
127
+ />
128
+ <span>{{ label(f) }}</span>
129
+ </label>
130
+ <label v-else class="fe-cfield__label" :for="`fe-cf-${f.key}`">
131
+ {{ label(f) }}<span v-if="f.required" class="fe-cfield__req" aria-hidden="true">*</span>
132
+ </label>
133
+
134
+ <select
135
+ v-if="f.type === 'select'"
136
+ :id="`fe-cf-${f.key}`"
137
+ class="fe-cfield__input"
138
+ :value="str(f)"
139
+ :disabled="disabled"
140
+ @change="set(f.key, ($event.target as HTMLSelectElement).value)"
141
+ >
142
+ <option v-for="o in f.options ?? []" :key="o.value" :value="o.value">
143
+ {{ optionLabel(o) }}
144
+ </option>
145
+ </select>
146
+
147
+ <textarea
148
+ v-else-if="f.multiline"
149
+ :id="`fe-cf-${f.key}`"
150
+ class="fe-cfield__input fe-cfield__input--area"
151
+ :class="{ 'fe-cfield__input--mono': f.monospace }"
152
+ rows="4"
153
+ :value="str(f)"
154
+ :placeholder="f.placeholder"
155
+ :disabled="disabled"
156
+ @input="set(f.key, ($event.target as HTMLTextAreaElement).value)"
157
+ ></textarea>
158
+
159
+ <div v-else-if="f.type === 'password'" class="fe-cfield__secret">
160
+ <input
161
+ :id="`fe-cf-${f.key}`"
162
+ class="fe-cfield__input fe-cfield__input--mono"
163
+ :type="inputType(f)"
164
+ autocomplete="new-password"
165
+ spellcheck="false"
166
+ :value="str(f)"
167
+ :placeholder="f.placeholder"
168
+ :disabled="disabled"
169
+ @input="set(f.key, ($event.target as HTMLInputElement).value)"
170
+ />
171
+ <button
172
+ type="button"
173
+ class="fe-cfield__eye"
174
+ :title="revealed[f.key] ? t('conn.form.hide') : t('conn.form.reveal')"
175
+ :aria-label="revealed[f.key] ? t('conn.form.hide') : t('conn.form.reveal')"
176
+ @click="toggleReveal(f.key)"
177
+ >
178
+ {{ revealed[f.key] ? '🙈' : '👁' }}
179
+ </button>
180
+ </div>
181
+
182
+ <input
183
+ v-else-if="f.type !== 'bool'"
184
+ :id="`fe-cf-${f.key}`"
185
+ class="fe-cfield__input"
186
+ :class="{ 'fe-cfield__input--mono': f.monospace }"
187
+ :type="inputType(f)"
188
+ :value="f.type === 'int' ? num(f) : str(f)"
189
+ :placeholder="f.placeholder"
190
+ :min="f.min"
191
+ :max="f.max"
192
+ :disabled="disabled"
193
+ @input="
194
+ set(
195
+ f.key,
196
+ f.type === 'int'
197
+ ? ($event.target as HTMLInputElement).value === ''
198
+ ? null
199
+ : Number(($event.target as HTMLInputElement).value)
200
+ : ($event.target as HTMLInputElement).value,
201
+ )
202
+ "
203
+ />
204
+
205
+ <p v-if="help(f)" class="fe-cfield__help">{{ help(f) }}</p>
206
+ <p v-if="isInvalid(f)" class="fe-cfield__error">{{ t('conn.form.required') }}</p>
207
+ </div>
208
+ </template>
209
+
210
+ <div v-if="advanced.length" class="fe-cfield__adv">
211
+ <button type="button" class="fe-cfield__advtoggle" @click="showAdvanced = !showAdvanced">
212
+ {{ showAdvanced ? '▾' : '▸' }} {{ t('conn.form.advanced') }}
213
+ </button>
214
+ <div v-if="showAdvanced" class="fe-cfield__advbody">
215
+ <div v-for="f in advanced" :key="f.key" class="fe-cfield__row">
216
+ <label v-if="f.type === 'bool'" class="fe-cfield__check">
217
+ <input
218
+ type="checkbox"
219
+ :checked="bool(f)"
220
+ :disabled="disabled"
221
+ @change="set(f.key, ($event.target as HTMLInputElement).checked)"
222
+ />
223
+ <span>{{ label(f) }}</span>
224
+ </label>
225
+ <template v-else>
226
+ <label class="fe-cfield__label" :for="`fe-cfa-${f.key}`">{{ label(f) }}</label>
227
+ <input
228
+ :id="`fe-cfa-${f.key}`"
229
+ class="fe-cfield__input"
230
+ :class="{ 'fe-cfield__input--mono': f.monospace }"
231
+ :type="inputType(f)"
232
+ :value="f.type === 'int' ? num(f) : str(f)"
233
+ :placeholder="f.placeholder"
234
+ :disabled="disabled"
235
+ @input="
236
+ set(
237
+ f.key,
238
+ f.type === 'int'
239
+ ? ($event.target as HTMLInputElement).value === ''
240
+ ? null
241
+ : Number(($event.target as HTMLInputElement).value)
242
+ : ($event.target as HTMLInputElement).value,
243
+ )
244
+ "
245
+ />
246
+ </template>
247
+ <p v-if="help(f)" class="fe-cfield__help">{{ help(f) }}</p>
248
+ </div>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </template>
253
+
254
+ <style>
255
+ .fe-cfield {
256
+ display: flex;
257
+ flex-direction: column;
258
+ gap: 14px;
259
+ }
260
+ .fe-cfield__empty {
261
+ margin: 0;
262
+ color: var(--fe-text-muted);
263
+ font-size: 13px;
264
+ }
265
+ .fe-cfield__row {
266
+ display: flex;
267
+ flex-direction: column;
268
+ gap: 5px;
269
+ }
270
+ .fe-cfield__label {
271
+ font-size: 13px;
272
+ font-weight: 600;
273
+ color: var(--fe-text);
274
+ }
275
+ .fe-cfield__req {
276
+ color: var(--fe-danger);
277
+ margin-left: 3px;
278
+ }
279
+ .fe-cfield__input {
280
+ font: inherit;
281
+ font-size: 13.5px;
282
+ padding: 8px 10px;
283
+ border-radius: var(--fe-radius);
284
+ border: 1px solid var(--fe-border-strong);
285
+ background: var(--fe-bg);
286
+ color: var(--fe-text);
287
+ width: 100%;
288
+ /* ⚠ Explicit, because the host's own form styling reaches in here
289
+ (shadowRoot: false). The desktop shell sets `input[type="text"]
290
+ { min-width: 260px }` for its settings rows; inherited, that floors
291
+ every field in this form and overflows a narrow embed. */
292
+ min-width: 0;
293
+ box-sizing: border-box;
294
+ }
295
+ .fe-cfield__input:focus {
296
+ outline: 2px solid var(--fe-primary);
297
+ outline-offset: -1px;
298
+ }
299
+ .fe-cfield__input--mono {
300
+ font-family: var(--fe-font-mono);
301
+ font-size: 12.5px;
302
+ }
303
+ .fe-cfield__input--area {
304
+ resize: vertical;
305
+ }
306
+ .fe-cfield__row.is-invalid .fe-cfield__input {
307
+ border-color: var(--fe-danger);
308
+ }
309
+ .fe-cfield__secret {
310
+ display: flex;
311
+ gap: 6px;
312
+ align-items: stretch;
313
+ }
314
+ .fe-cfield__eye {
315
+ flex: 0 0 auto;
316
+ border: 1px solid var(--fe-border-strong);
317
+ background: var(--fe-bg-elev);
318
+ color: var(--fe-text);
319
+ border-radius: var(--fe-radius);
320
+ padding: 0 10px;
321
+ cursor: pointer;
322
+ font-size: 14px;
323
+ }
324
+ .fe-cfield__check {
325
+ display: flex;
326
+ align-items: center;
327
+ gap: 8px;
328
+ font-size: 13.5px;
329
+ color: var(--fe-text);
330
+ cursor: pointer;
331
+ }
332
+ .fe-cfield__help {
333
+ margin: 0;
334
+ font-size: 12px;
335
+ color: var(--fe-text-muted);
336
+ line-height: 1.45;
337
+ }
338
+ .fe-cfield__error {
339
+ margin: 0;
340
+ font-size: 12px;
341
+ color: var(--fe-danger);
342
+ }
343
+ .fe-cfield__advtoggle {
344
+ border: 0;
345
+ background: none;
346
+ color: var(--fe-text-muted);
347
+ font: inherit;
348
+ font-size: 12.5px;
349
+ font-weight: 600;
350
+ cursor: pointer;
351
+ padding: 2px 0;
352
+ }
353
+ .fe-cfield__advtoggle:hover {
354
+ color: var(--fe-text);
355
+ }
356
+ .fe-cfield__advbody {
357
+ display: flex;
358
+ flex-direction: column;
359
+ gap: 14px;
360
+ margin-top: 12px;
361
+ }
362
+ </style>