@brftech/filex-core 0.40.0 → 0.41.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 (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-DxxjhYZD.js +124 -0
  3. package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
  4. package/dist/CsvViewer-CyuwENI_.js +141 -0
  5. package/dist/CsvViewer-CyuwENI_.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
  7. package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
  8. package/dist/EpubViewer-K9PjCMmS.js +163 -0
  9. package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
  10. package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
  11. package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
  12. package/dist/MermaidViewer-Bp73aId8.js +137 -0
  13. package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
  14. package/dist/PsdViewer-C2eLivw1.js +122 -0
  15. package/dist/PsdViewer-C2eLivw1.js.map +1 -0
  16. package/dist/TiffViewer-DsHg5gcz.js +142 -0
  17. package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
  18. package/dist/Viewer3D-C_dc2HN9.js +75 -0
  19. package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
  20. package/dist/filex-core.js +218 -20361
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-vxMZpYb-.js +26700 -0
  25. package/dist/index-vxMZpYb-.js.map +1 -0
  26. package/dist/index.d.ts +4212 -862
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2154 -506
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +39 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +951 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +172 -56
  39. package/src/components/GridView.vue +331 -82
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1173 -194
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +26 -1
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +6 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +135 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +35 -14
  60. package/src/components/Toolbar.vue +953 -465
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +89 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useLocale.ts +382 -26
  66. package/src/composables/useRowTouch.ts +95 -0
  67. package/src/index.ts +177 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +31 -2
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +151 -116
  80. package/src/lib/timezone.ts +501 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +416 -26
  84. package/src/locales/tr.ts +409 -26
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +557 -476
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5409 -8
  93. package/src/styles/variables.css +139 -65
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -0,0 +1,525 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * NewDocumentModal — "New document": pick a type, name it, choose where it
4
+ * goes, get an empty file of that type back.
5
+ *
6
+ * ## Why the type list is not in this file
7
+ *
8
+ * The obvious implementation is a constant array of extensions right here.
9
+ * It would be wrong in two directions at once.
10
+ *
11
+ * Downwards: the server is the only party that knows whether it can PRODUCE
12
+ * the bytes. An empty `.md` is an empty file, but an empty `.docx` is a ZIP of
13
+ * XML parts, and if this deployment's binary holds no template for a format
14
+ * then offering it creates a file no program will open. That answer arrives in
15
+ * `capabilities.newdoc_types` (internal/newdoc), and it is the `types` prop.
16
+ *
17
+ * Upwards: the server does NOT know whether this browser can OPEN the result.
18
+ * OnlyOffice may be unconfigured, may be configured but failing its probe, or
19
+ * may be overridden by the embedder to a URL the backend cannot see. So each
20
+ * row names the service its editor needs (`requires`) and the host answers
21
+ * with `onlyOfficeReady` / `drawioReady` — the same resolution the explorer
22
+ * already does for the viewers themselves.
23
+ *
24
+ * ⚠ The rule that falls out of this: **a type nobody here can open is not
25
+ * offered**. Creating `report.docx` on an install with no document server
26
+ * hands somebody a file and then refuses to open it, which is a worse outcome
27
+ * than the entry not being there. When a whole family is withheld this way the
28
+ * dialog says so in one quiet line, because "why is Word missing" should not
29
+ * require reading the source.
30
+ *
31
+ * ## Where "where" is asked
32
+ *
33
+ * ⚠ Not here. This dialog grew its own one-level folder browser — an Up
34
+ * button, a row list, a "Save here" — and `modals/DestinationPickerModal.vue`
35
+ * was then written for Move to / Copy to, which is the same question with the
36
+ * same rules (what counts as writable, whether an unwritable folder is hidden
37
+ * or greyed, what sits above a storage root). Two choosers is how those rules
38
+ * start disagreeing, so there is one: this mounts the picker and keeps only
39
+ * the answer.
40
+ *
41
+ * ## Where the write is checked
42
+ *
43
+ * In three places, and only the last one counts. The browser hides folders the
44
+ * person cannot write to; the dialog refuses to submit into one; the SERVER
45
+ * checks ACL and read-only state and refuses regardless of what arrived. The
46
+ * first two are courtesies that keep somebody from typing a name for thirty
47
+ * seconds before being told no.
48
+ *
49
+ * Name collisions are the same shape: this dialog asks the destination listing
50
+ * first and says "already here" BEFORE the write, and the create endpoint
51
+ * answers 409 anyway.
52
+ */
53
+ import { computed, nextTick, ref, watch } from 'vue';
54
+ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
55
+ import type { NewDocType } from '../types/FileNode';
56
+ import type { FileApi, ManagerResponse, NewFileResponse } from '../composables/useFileApi';
57
+ import { useLocale } from '../composables/useLocale';
58
+ import { iconTile, iconFamilyFor, typeLabelFor } from '../lib/fileIcons';
59
+ import { crumbsOfWire, permAllowsWrite, splitWire } from '../lib/destinationTree';
60
+ import Modal from './Modal.vue';
61
+ import DestinationPickerModal from './DestinationPickerModal.vue';
62
+
63
+ const props = defineProps<{
64
+ open: boolean;
65
+ locale: LocaleCode;
66
+ theme?: ThemeMode;
67
+ /** The host's API wrapper — the dialog does its own listing + create. */
68
+ api: FileApi;
69
+ /**
70
+ * `capabilities.newdoc_types`. Undefined/null on a server that predates the
71
+ * feature; the host should not offer the entry at all in that case, and if
72
+ * it does anyway the dialog degrades to its empty state rather than guessing.
73
+ */
74
+ types?: NewDocType[] | null;
75
+ /** Adapter-qualified folder in view, e.g. `main://docs`. Empty at the root. */
76
+ currentPath: string;
77
+ /** Storage names, for the destination browser's top level. */
78
+ storages?: string[];
79
+ /** Resolved by the host: is there a usable OnlyOffice / drawio? */
80
+ onlyOfficeReady?: boolean;
81
+ drawioReady?: boolean;
82
+ }>();
83
+
84
+ const emit = defineEmits<{
85
+ (e: 'close'): void;
86
+ (e: 'created', file: NewFileResponse): void;
87
+ (e: 'error', payload: { message: string }): void;
88
+ }>();
89
+
90
+ const { t } = useLocale(() => props.locale);
91
+
92
+ /* ================================================================== types */
93
+
94
+ function satisfied(ty: NewDocType): boolean {
95
+ if (ty.requires === 'onlyoffice') return !!props.onlyOfficeReady;
96
+ if (ty.requires === 'drawio') return !!props.drawioReady;
97
+ return true;
98
+ }
99
+
100
+ const offered = computed(() => (props.types ?? []).filter(satisfied));
101
+
102
+ /** Services that are holding a type back, so the dialog can say which. */
103
+ const withheld = computed(() => {
104
+ const out: string[] = [];
105
+ for (const ty of props.types ?? []) {
106
+ if (!satisfied(ty) && ty.requires && !out.includes(ty.requires)) out.push(ty.requires);
107
+ }
108
+ return out;
109
+ });
110
+
111
+ /**
112
+ * The withheld services as one sentence.
113
+ *
114
+ * ⚠ Joined in script rather than looped in the template with a `<span> </span>`
115
+ * separator: Vue's compiler drops a whitespace-only text node between
116
+ * elements, so the two sentences rendered welded together
117
+ * ("...here.Diagrams need...") — measured in a browser, invisible in a unit
118
+ * test that asserts on textContent.
119
+ */
120
+ const withheldText = computed(() =>
121
+ withheld.value.map((svc) => t('newdoc.withheld.' + svc)).join(' '),
122
+ );
123
+
124
+ /** Sections, in the order the server listed them. */
125
+ const groups = computed(() => {
126
+ const order: string[] = [];
127
+ const bucket = new Map<string, NewDocType[]>();
128
+ for (const ty of offered.value) {
129
+ if (!bucket.has(ty.group)) {
130
+ bucket.set(ty.group, []);
131
+ order.push(ty.group);
132
+ }
133
+ bucket.get(ty.group)!.push(ty);
134
+ }
135
+ return order.map((g) => ({ group: g, types: bucket.get(g)! }));
136
+ });
137
+
138
+ const selected = ref<string>('');
139
+ const selectedType = computed(() => offered.value.find((ty) => ty.ext === selected.value) ?? null);
140
+
141
+ /**
142
+ * The words for a type come from `typeLabelFor` — the same table the listing's
143
+ * Type column reads. A second table here would be a second answer to "what is
144
+ * a .csv", and the two would disagree the first week somebody edited one.
145
+ */
146
+ function labelFor(ext: string): string {
147
+ return typeLabelFor({ type: 'file', extension: ext }, t);
148
+ }
149
+ function tileFor(ext: string): string {
150
+ return iconTile(iconFamilyFor({ type: 'file', extension: ext, basename: 'x.' + ext }));
151
+ }
152
+
153
+ /* =========================================================== destinations */
154
+
155
+ const dest = ref('');
156
+ /** The picker is up. Also gates Create: a half-answered "where" is not a where. */
157
+ const pickingDest = ref(false);
158
+
159
+ /** Listing cache for the name check. The picker keeps its own for its rows. */
160
+ const dirCache = new Map<string, ManagerResponse>();
161
+
162
+ function canWrite(resp: ManagerResponse | undefined): boolean {
163
+ if (!resp) return false;
164
+ if (resp.read_only) return false;
165
+ return permAllowsWrite(resp.perm as string | undefined);
166
+ }
167
+
168
+ async function loadDir(p: string): Promise<ManagerResponse | undefined> {
169
+ if (dirCache.has(p)) return dirCache.get(p);
170
+ try {
171
+ const resp = await props.api.index(p);
172
+ dirCache.set(p, resp);
173
+ return resp;
174
+ } catch {
175
+ // A folder that cannot be listed is a folder we cannot offer. Silence is
176
+ // right here: the caller renders "you cannot write here" / "no subfolders",
177
+ // and a listing failure inside a picker is not an error the host should toast.
178
+ return undefined;
179
+ }
180
+ }
181
+
182
+ /** `main://a/b` → `main / a / b` — the trail, from the one place that
183
+ * decomposes a wire path (lib/destinationTree). */
184
+ const destLabel = computed(() =>
185
+ dest.value ? crumbsOfWire(dest.value).map((c) => c.label).join(' / ') : '',
186
+ );
187
+
188
+ const destWritable = ref(true);
189
+ const destChecked = ref(false);
190
+
191
+ /* ================================================================== naming */
192
+
193
+ const name = ref('');
194
+ const nameTouched = ref(false);
195
+ const collision = ref(false);
196
+ const busy = ref(false);
197
+ const failure = ref<string | null>(null);
198
+
199
+ const finalName = computed(() => {
200
+ const base = name.value.trim();
201
+ const ext = selectedType.value?.ext ?? '';
202
+ if (!base || !ext) return base;
203
+ return base.toLowerCase().endsWith('.' + ext) ? base : base + '.' + ext;
204
+ });
205
+
206
+ const nameHasSeparator = computed(() => /[\\/]/.test(name.value));
207
+
208
+ function freeName(taken: Set<string>, ext: string): string {
209
+ const base = t('newdoc.untitled');
210
+ if (!taken.has((base + '.' + ext).toLowerCase())) return base;
211
+ for (let i = 2; i < 100; i++) {
212
+ const cand = `${base} (${i})`;
213
+ if (!taken.has((cand + '.' + ext).toLowerCase())) return cand;
214
+ }
215
+ return base;
216
+ }
217
+
218
+ function takenNames(resp: ManagerResponse | undefined): Set<string> {
219
+ const s = new Set<string>();
220
+ for (const f of resp?.files ?? []) s.add(f.basename.toLowerCase());
221
+ return s;
222
+ }
223
+
224
+ let checkSeq = 0;
225
+ let checkTimer: ReturnType<typeof setTimeout> | null = null;
226
+
227
+ /**
228
+ * Ask the destination what is already in it: the write permission and the
229
+ * names, in one listing. Debounced because it runs on every keystroke.
230
+ */
231
+ async function refreshDestination(opts: { suggest?: boolean } = {}) {
232
+ const seq = ++checkSeq;
233
+ const target = dest.value;
234
+ if (!target) {
235
+ destChecked.value = false;
236
+ collision.value = false;
237
+ return;
238
+ }
239
+ const resp = await loadDir(target);
240
+ if (seq !== checkSeq || target !== dest.value) return;
241
+ destWritable.value = canWrite(resp);
242
+ destChecked.value = true;
243
+ const taken = takenNames(resp);
244
+ const ext = selectedType.value?.ext ?? '';
245
+ if (opts.suggest && !nameTouched.value && ext) {
246
+ name.value = freeName(taken, ext);
247
+ }
248
+ collision.value = !!finalName.value && taken.has(finalName.value.toLowerCase());
249
+ }
250
+
251
+ function scheduleCheck() {
252
+ if (checkTimer) clearTimeout(checkTimer);
253
+ checkTimer = setTimeout(() => void refreshDestination(), 220);
254
+ }
255
+
256
+ /* ================================================================ lifecycle */
257
+
258
+ function defaultDestination(): string {
259
+ const cur = (props.currentPath || '').trim();
260
+ const [adapter] = splitWire(cur);
261
+ // Standing in a real folder: that folder. Making somebody navigate to where
262
+ // they already are is the small insult that makes a feature feel unfinished.
263
+ if (adapter) return cur;
264
+ const names = props.storages ?? [];
265
+ if (names.length === 1) return names[0] + '://';
266
+ return '';
267
+ }
268
+
269
+ watch(
270
+ () => props.open,
271
+ async (isOpen) => {
272
+ if (!isOpen) return;
273
+ dirCache.clear();
274
+ failure.value = null;
275
+ collision.value = false;
276
+ nameTouched.value = false;
277
+ busy.value = false;
278
+ name.value = '';
279
+ selected.value = offered.value[0]?.ext ?? '';
280
+ dest.value = defaultDestination();
281
+ destChecked.value = false;
282
+ destWritable.value = true;
283
+ /* Nowhere to write yet (several storages and no folder in view): ask
284
+ * first, because a name typed against no destination is a name typed
285
+ * twice. */
286
+ pickingDest.value = !dest.value;
287
+ await refreshDestination({ suggest: true });
288
+ },
289
+ );
290
+
291
+ // A different type means a different extension, so the suggested name has to
292
+ // be re-asked against the destination (Untitled.md may be free where
293
+ // Untitled.docx is not).
294
+ watch(selected, () => {
295
+ void refreshDestination({ suggest: true });
296
+ });
297
+ watch(name, () => {
298
+ collision.value = false;
299
+ scheduleCheck();
300
+ });
301
+ watch(dest, () => {
302
+ void refreshDestination({ suggest: true });
303
+ });
304
+
305
+ /* ================================================================== actions */
306
+
307
+ function pick(ext: string) {
308
+ selected.value = ext;
309
+ }
310
+
311
+ /** Roving focus across the tiles — a radiogroup is expected to move on arrows. */
312
+ function onTileKey(e: KeyboardEvent, ext: string) {
313
+ const list = offered.value.map((ty) => ty.ext);
314
+ const i = list.indexOf(ext);
315
+ let next = -1;
316
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % list.length;
317
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (i - 1 + list.length) % list.length;
318
+ else if (e.key === 'Home') next = 0;
319
+ else if (e.key === 'End') next = list.length - 1;
320
+ if (next < 0) return;
321
+ e.preventDefault();
322
+ selected.value = list[next];
323
+ void nextTick(() => {
324
+ const el = document.querySelector<HTMLElement>(`[data-newdoc-ext="${list[next]}"]`);
325
+ el?.focus();
326
+ });
327
+ }
328
+
329
+ function onDestinationPicked(path: string) {
330
+ dest.value = path;
331
+ pickingDest.value = false;
332
+ }
333
+
334
+ /**
335
+ * ⚠ Escape with the picker open must close the PICKER, not this dialog.
336
+ *
337
+ * `Modal` listens for Escape on `document`, and there are two of them on
338
+ * screen; the outer one registered first, so it hears it first. Swallowing
339
+ * that first close here is what stops one keypress from throwing away a name
340
+ * somebody just typed. (The picker's own Modal then fires, closes the picker,
341
+ * and the flag is already down — the second close is a no-op.)
342
+ */
343
+ function onOuterClose() {
344
+ if (pickingDest.value) {
345
+ pickingDest.value = false;
346
+ return;
347
+ }
348
+ emit('close');
349
+ }
350
+
351
+ const canCreate = computed(
352
+ () =>
353
+ !busy.value &&
354
+ !!selectedType.value &&
355
+ !!dest.value &&
356
+ !pickingDest.value &&
357
+ !!name.value.trim() &&
358
+ !nameHasSeparator.value &&
359
+ !collision.value &&
360
+ (!destChecked.value || destWritable.value),
361
+ );
362
+
363
+ async function create() {
364
+ if (!canCreate.value || !selectedType.value) return;
365
+ busy.value = true;
366
+ failure.value = null;
367
+ try {
368
+ const res = await props.api.newFile(dest.value, name.value.trim(), selectedType.value.ext);
369
+ emit('created', res);
370
+ } catch (e) {
371
+ const err = e as Error & { status?: number };
372
+ if (err.status === 409) {
373
+ // The server refused for the reason the dialog warns about. Say it in
374
+ // the same place rather than throwing a toast over the form.
375
+ collision.value = true;
376
+ } else {
377
+ failure.value = err.message || String(e);
378
+ emit('error', { message: failure.value });
379
+ }
380
+ } finally {
381
+ busy.value = false;
382
+ }
383
+ }
384
+ </script>
385
+
386
+ <template>
387
+ <Modal
388
+ :open="open"
389
+ :title="t('newdoc.title')"
390
+ size="lg"
391
+ :theme="theme"
392
+ @close="onOuterClose"
393
+ >
394
+ <div class="fe-newdoc" data-testid="newdoc-modal">
395
+ <!-- Nothing to offer. Either the server sent no types (too old) or every
396
+ one of them needs a service this install has not got. -->
397
+ <div v-if="!offered.length" class="fe-newdoc__empty" data-testid="newdoc-empty">
398
+ <p class="fe-newdoc__empty-title">{{ t('newdoc.empty.title') }}</p>
399
+ <p class="fe-newdoc__empty-body">
400
+ {{ withheld.length ? t('newdoc.empty.blocked') : t('newdoc.empty.body') }}
401
+ </p>
402
+ </div>
403
+
404
+ <template v-else>
405
+ <div
406
+ class="fe-newdoc__types"
407
+ role="radiogroup"
408
+ :aria-label="t('newdoc.type.label')"
409
+ >
410
+ <section v-for="g in groups" :key="g.group" class="fe-newdoc__group">
411
+ <h3 class="fe-newdoc__grouplabel">{{ t('newdoc.group.' + g.group) }}</h3>
412
+ <div class="fe-newdoc__grid">
413
+ <button
414
+ v-for="ty in g.types"
415
+ :key="ty.ext"
416
+ type="button"
417
+ role="radio"
418
+ class="fe-newdoc__type"
419
+ :class="{ 'is-selected': selected === ty.ext }"
420
+ :aria-checked="selected === ty.ext"
421
+ :tabindex="selected === ty.ext ? 0 : -1"
422
+ :data-newdoc-ext="ty.ext"
423
+ :data-testid="'newdoc-type-' + ty.ext"
424
+ @click="pick(ty.ext)"
425
+ @keydown="onTileKey($event, ty.ext)"
426
+ >
427
+ <span class="fe-newdoc__tile" aria-hidden="true" v-html="tileFor(ty.ext)"></span>
428
+ <span class="fe-newdoc__ext">.{{ ty.ext }}</span>
429
+ <span class="fe-newdoc__kind">{{ labelFor(ty.ext) }}</span>
430
+ </button>
431
+ </div>
432
+ </section>
433
+
434
+ <!-- Why a family is missing. One quiet line, only when something was
435
+ actually withheld — an operator reading "Office documents need a
436
+ document server" knows what to do; an empty space teaches nobody. -->
437
+ <p v-if="withheldText" class="fe-newdoc__withheld" data-testid="newdoc-withheld">
438
+ {{ withheldText }}
439
+ </p>
440
+ </div>
441
+
442
+ <div class="fe-newdoc__field">
443
+ <label class="fe-newdoc__label" for="fe-newdoc-name">{{ t('newdoc.name') }}</label>
444
+ <div class="fe-newdoc__nameline">
445
+ <input
446
+ id="fe-newdoc-name"
447
+ v-model="name"
448
+ type="text"
449
+ class="fe-input fe-newdoc__name"
450
+ autocomplete="off"
451
+ spellcheck="false"
452
+ data-testid="newdoc-name"
453
+ :placeholder="t('newdoc.name.placeholder')"
454
+ @input="nameTouched = true"
455
+ @keydown.enter.prevent="create"
456
+ />
457
+ <span v-if="selectedType" class="fe-newdoc__suffix">.{{ selectedType.ext }}</span>
458
+ </div>
459
+ <p v-if="nameHasSeparator" class="fe-form__error">{{ t('newdoc.err.slash') }}</p>
460
+ <p v-else-if="collision" class="fe-form__error" data-testid="newdoc-collision">
461
+ {{ t('newdoc.err.exists', { name: finalName }) }}
462
+ </p>
463
+ </div>
464
+
465
+ <div class="fe-newdoc__field">
466
+ <span class="fe-newdoc__label">{{ t('newdoc.location') }}</span>
467
+ <div class="fe-newdoc__dest">
468
+ <span class="fe-newdoc__destpath" data-testid="newdoc-dest">
469
+ {{ dest ? destLabel : t('newdoc.location.none') }}
470
+ </span>
471
+ <button
472
+ type="button"
473
+ class="fe-newdoc__changebtn"
474
+ data-testid="newdoc-change"
475
+ @click="pickingDest = true"
476
+ >
477
+ {{ t('newdoc.location.change') }}
478
+ </button>
479
+ </div>
480
+ <p
481
+ v-if="destChecked && !destWritable"
482
+ class="fe-form__error"
483
+ data-testid="newdoc-noaccess"
484
+ >
485
+ {{ t('newdoc.location.noaccess') }}
486
+ </p>
487
+ </div>
488
+
489
+ <p v-if="failure" class="fe-form__error" data-testid="newdoc-failure">{{ failure }}</p>
490
+ </template>
491
+ </div>
492
+
493
+ <template #actions>
494
+ <button type="button" class="fe-btn" @click="onOuterClose">
495
+ {{ t('newdoc.cancel') }}
496
+ </button>
497
+ <button
498
+ v-if="offered.length"
499
+ type="button"
500
+ class="fe-btn fe-btn--primary"
501
+ :disabled="!canCreate"
502
+ data-testid="newdoc-create"
503
+ @click="create"
504
+ >
505
+ {{ busy ? t('newdoc.creating') : t('newdoc.create') }}
506
+ </button>
507
+ </template>
508
+ </Modal>
509
+
510
+ <!-- tasi:m1 — THE folder chooser, the same component Move to / Copy to
511
+ mount. A SIBLING of the dialog above rather than a child of it: `Modal`
512
+ is not teleported, so nesting would put a `position: fixed` overlay
513
+ inside a card that clips its overflow, and a backdrop click meant for
514
+ the picker would bubble into the dialog's own dismiss. -->
515
+ <DestinationPickerModal
516
+ :open="pickingDest"
517
+ :api="api"
518
+ :locale="locale"
519
+ mode="choose"
520
+ :storages="storages"
521
+ :start-at="dest || currentPath"
522
+ @close="pickingDest = false"
523
+ @pick="onDestinationPicked"
524
+ />
525
+ </template>
@@ -2,6 +2,7 @@
2
2
  import { ref, watch } from 'vue';
3
3
  import type { LocaleCode } from '../types/ExplorerConfig';
4
4
  import { useLocale } from '../composables/useLocale';
5
+ import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
5
6
  import Modal from './Modal.vue';
6
7
 
7
8
  const props = defineProps<{
@@ -48,14 +49,17 @@ function submit() {
48
49
  <template>
49
50
  <Modal :open="open" :title="t('modal.newfolder.title')" size="sm" @close="emit('close')">
50
51
  <form @submit.prevent="submit">
51
- <input
52
- v-model="name"
53
- type="text"
54
- class="fe-input"
55
- :placeholder="t('modal.newfolder.placeholder')"
56
- autocomplete="off"
57
- @keydown.enter.prevent="submit"
58
- />
52
+ <!-- A visible name, as in the encrypted-folder dialog this one opens. -->
53
+ <label class="fe-field">
54
+ <span class="fe-field__label">{{ t('modal.newfolder.placeholder') }}</span>
55
+ <input
56
+ v-model="name"
57
+ type="text"
58
+ class="fe-input"
59
+ autocomplete="off"
60
+ @keydown.enter.prevent="submit"
61
+ />
62
+ </label>
59
63
  <p v-if="err" class="fe-form__error">{{ err }}</p>
60
64
  <!-- wiring:e2 — encrypted-folder entry point lives inside the normal
61
65
  new-folder flow so every trigger (toolbar / context menu / palette)
@@ -66,7 +70,9 @@ function submit() {
66
70
  class="fe-e2e-optlink"
67
71
  @click="emit('encrypted')"
68
72
  >
69
- 🔒 {{ t('e2e.create.option') }}
73
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
74
+ <span class="fe-e2e-optlink__icon" aria-hidden="true" v-html="actionIconSvg('lock')"></span>
75
+ {{ t('e2e.create.option') }}
70
76
  </button>
71
77
  <!-- /wiring:e2 -->
72
78
  </form>