@brftech/filex-core 0.42.1 → 0.43.0

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 (233) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5241 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1207 -343
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +47 -17
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -56
  76. package/src/components/SSHKeysPanel.vue +79 -26
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -27
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +121 -0
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +522 -119
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +539 -155
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2895 -241
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +58 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-CLNevfPE.js +0 -124
  216. package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
  217. package/dist/CsvViewer-K8XA3z7N.js +0 -141
  218. package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
  219. package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
  220. package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
  221. package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
  222. package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
  223. package/dist/MermaidViewer-DqucH_io.js +0 -137
  224. package/dist/MermaidViewer-DqucH_io.js.map +0 -1
  225. package/dist/PsdViewer-CwiVENmq.js +0 -122
  226. package/dist/PsdViewer-CwiVENmq.js.map +0 -1
  227. package/dist/TiffViewer-CpewSVmQ.js +0 -142
  228. package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
  229. package/dist/Viewer3D-CIrOn63I.js.map +0 -1
  230. package/dist/index-Dx0IbBcI.js +0 -26831
  231. package/dist/index-Dx0IbBcI.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
@@ -30,7 +30,12 @@
30
30
  */
31
31
  import { computed, ref } from 'vue';
32
32
  import { useLocale } from '../composables/useLocale';
33
+ import { inlineStartX } from '../lib/direction';
33
34
  import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
35
+ import { actionIconSvg } from '../lib/actionIcons';
36
+ import type { TagItem, TagKind } from '../lib/tags';
37
+ import TagKindIcon from './TagKindIcon.vue';
38
+ import StorageTags from './StorageTags.vue';
34
39
  import ContextMenu, { type ContextAction } from './ContextMenu.vue';
35
40
 
36
41
  /** The virtual listings the panel can open. '' = an ordinary folder.
@@ -61,6 +66,18 @@ export interface NavStorage {
61
66
  readOnly?: boolean;
62
67
  }
63
68
 
69
+ /**
70
+ * A row of the "Apps" section — an app plugin's `home` view, already read in
71
+ * the viewer's language by the host (docs/APP-PLUGINS-API.md → Placements).
72
+ */
73
+ export interface NavApp {
74
+ /** `<plugin>/<view>` — what `open-app` carries back. */
75
+ key: string;
76
+ label: string;
77
+ /** A manifest icon name; drawn when `lib/actionIcons` has it, else the plugin glyph. */
78
+ icon?: string;
79
+ }
80
+
64
81
  const props = defineProps<{
65
82
  /** Expanded (labels visible) vs collapsed to the icon rail. */
66
83
  expanded: boolean;
@@ -86,14 +103,17 @@ const props = defineProps<{
86
103
  * dynamic in name. Still presentational here — the host fetches
87
104
  * `tags/all` (once, cached) and hands the list over, exactly as it does
88
105
  * for storages. */
89
- /** Every tag that exists, alphabetical. Empty → the section shows its own
90
- * "no tags yet" line rather than disappearing. */
91
- tags?: string[];
106
+ /** Every tag the person can see, alphabetical, WITH its kind (v0.43:
107
+ * personal or team). Empty → the section shows its own "no tags yet" line
108
+ * rather than disappearing. */
109
+ tags?: TagItem[];
92
110
  /** False until the first answer arrives, so "no tags yet" is never shown
93
111
  * to somebody who is simply still waiting. */
94
112
  tagsLoaded?: boolean;
95
113
  /** The tag currently on screen (activeView === 'tag'). */
96
114
  activeTag?: string;
115
+ /** …and its kind ('' = a view of both kinds, e.g. an old `#.tag~x` link). */
116
+ activeTagKind?: TagKind | '';
97
117
  /**
98
118
  * Show the Connections entries — "How to connect" and "API keys".
99
119
  * ⚠ Never derived from a role here or anywhere: the backend decides what a
@@ -101,6 +121,18 @@ const props = defineProps<{
101
121
  * accounts that need it (see ExplorerConfig.connections).
102
122
  */
103
123
  showConnections?: boolean;
124
+ /**
125
+ * paylas:m1 — draw the "My shares" row (mirrors ExplorerConfig.mySharesVisible).
126
+ *
127
+ * ⚠ Absent means NO, and that is the one place this prop differs from
128
+ * `trashVisible` beside it: the row's destination is a page of the HOST, so
129
+ * a panel that draws it without being asked draws a button that does
130
+ * nothing. Vue casts an absent Boolean prop to `false`, which is exactly the
131
+ * answer wanted here — a host that says nothing has not promised a screen.
132
+ * (The inverse default is the trap lib-wide; see ExplorerConfig.trashVisible,
133
+ * read as `!== false`, and `withDefaults` everywhere a default is ON.)
134
+ */
135
+ showMyShares?: boolean;
104
136
  /**
105
137
  * Draw the surfaces that only mean something for ONE person: API keys,
106
138
  * Recent, Starred, Shared with me. False when the caller is an app token —
@@ -152,12 +184,18 @@ const props = defineProps<{
152
184
  quota?: { used: number; total: number; unlimited: boolean } | null;
153
185
  /** Resolved theme — the teleported New menu leaves the `.fe` variable scope. */
154
186
  theme?: ThemeMode;
187
+ /**
188
+ * App plugins — the `home` views, one row each under "Apps". Empty or
189
+ * absent hides the section entirely: a heading over nothing is a promise
190
+ * the deployment cannot keep.
191
+ */
192
+ apps?: NavApp[];
155
193
  }>();
156
194
 
157
195
  const emit = defineEmits<{
158
196
  (e: 'toggle'): void;
159
197
  (e: 'open-view', view: NavDest): void;
160
- (e: 'open-tag', tag: string): void;
198
+ (e: 'open-tag', tag: string, kind: TagKind): void;
161
199
  (e: 'open-storage', name: string): void;
162
200
  (e: 'open-root'): void;
163
201
  (e: 'upload'): void;
@@ -165,13 +203,25 @@ const emit = defineEmits<{
165
203
  (e: 'new-document'): void;
166
204
  (e: 'open-connections'): void;
167
205
  (e: 'open-tokens'): void;
206
+ /**
207
+ * "My shares" — the public links THIS person handed out.
208
+ *
209
+ * ⚠ Unlike every other row in the views group, it does not change the
210
+ * listing: the screen lives in the HOST (the SPA's `my-shares` route), so
211
+ * the panel can only announce the intent and the host decides where it
212
+ * lands. A host with no such screen does not listen, and nothing here
213
+ * pretends otherwise.
214
+ */
215
+ (e: 'open-my-shares'): void;
216
+ /** App plugins — an "Apps" row: open that `home` view. */
217
+ (e: 'open-app', key: string): void;
168
218
  /* surucu:d1 — "Request files": the access modal on THIS folder, drop tab. */
169
219
  (e: 'request-files'): void;
170
220
  /** Drawer scrim / Esc — narrow mode only. */
171
221
  (e: 'close'): void;
172
222
  }>();
173
223
 
174
- const { t, formatSize } = useLocale(() => props.locale);
224
+ const { t, formatSize, dir } = useLocale(() => props.locale);
175
225
 
176
226
  // In drawer mode "expanded" is the only meaningful state: a rail inside an
177
227
  // overlay would be an overlay that shows nothing but icons while covering the
@@ -242,6 +292,14 @@ const views = computed(() => {
242
292
 
243
293
  const writable = computed(() => props.canWrite !== false);
244
294
 
295
+ /** App plugins — the "Apps" rows, with a glyph the icon library knows. */
296
+ const appRows = computed(() =>
297
+ (props.apps ?? []).map((a) => ({
298
+ ...a,
299
+ svg: actionIconSvg(a.icon && actionIconSvg(a.icon) !== '' ? a.icon : 'plugin'),
300
+ })),
301
+ );
302
+
245
303
  /**
246
304
  * The storage the listing is in is mounted read-only. Its "+ New" menu is not
247
305
  * disabled but GONE: on a read-only mount there is no folder where any of
@@ -252,6 +310,14 @@ const activeReadOnly = computed(
252
310
  () => !!props.activeStorage && props.storages.some((s) => s.name === props.activeStorage && s.readOnly === true),
253
311
  );
254
312
 
313
+ /**
314
+ * The Trash is read-only in the same sense, and gets the same answer: no
315
+ * "+ New". It used to stand there with every row greyed (QA, 2026-09-21) —
316
+ * nothing is ever made inside the Trash, so a menu of things you cannot make
317
+ * there only asks a question it cannot answer.
318
+ */
319
+ const newShown = computed(() => !activeReadOnly.value && props.activeView !== 'trash');
320
+
255
321
  /**
256
322
  * Which row reads as the one you are standing on.
257
323
  *
@@ -283,10 +349,28 @@ function isActiveDest(key: NavDest): boolean {
283
349
  const TAG_PEEK = 8;
284
350
  const tagsExpanded = ref(false);
285
351
  const allTags = computed(() => props.tags ?? []);
286
- const visibleTags = computed(() =>
287
- tagsExpanded.value ? allTags.value : allTags.value.slice(0, TAG_PEEK),
352
+ /* etiket:k2 (v0.43.0) — the list is two GROUPS, Personal then Team, each under
353
+ * its own caption with the kind's glyph, and each row repeats the glyph. Until
354
+ * this release there was one kind, shared with every account on the server,
355
+ * and the panel never said so (tester, 2026-09-22). The peek applies per
356
+ * group: sixty personal tags must not push the team's off the panel. */
357
+ const tagGroups = computed(() =>
358
+ (['personal', 'team'] as TagKind[])
359
+ .map((kind) => {
360
+ const list = allTags.value.filter((tag) => tag.kind === kind);
361
+ return { kind, list, visible: tagsExpanded.value ? list : list.slice(0, TAG_PEEK) };
362
+ })
363
+ .filter((g) => g.list.length > 0),
288
364
  );
289
- const hiddenTagCount = computed(() => Math.max(0, allTags.value.length - visibleTags.value.length));
365
+ const hiddenTagCount = computed(() =>
366
+ tagGroups.value.reduce((n, g) => n + g.list.length - g.visible.length, 0),
367
+ );
368
+ const anyGroupLong = computed(() => tagGroups.value.some((g) => g.list.length > TAG_PEEK));
369
+
370
+ function tagIsActive(tag: TagItem): boolean {
371
+ if (props.activeView !== 'tag' || props.activeTag !== tag.name) return false;
372
+ return !props.activeTagKind || props.activeTagKind === tag.kind;
373
+ }
290
374
  /* The section is rendered as soon as the panel knows there ARE tags, and also
291
375
  * once the answer came back empty — an empty section that says why is how a
292
376
  * user learns the feature exists at all. It stays hidden only while the first
@@ -341,7 +425,8 @@ const newActions = computed<ContextAction[]>(() => {
341
425
  function openNewMenu() {
342
426
  const r = newBtnEl.value?.getBoundingClientRect();
343
427
  newMenuRef.value?.show(
344
- { clientX: r ? r.left : 0, clientY: r ? r.bottom + 6 : 0 } as MouseEvent,
428
+ // ⚠ RTL: hangs from the pill's START edge (its right side in RTL).
429
+ { clientX: r ? inlineStartX(r, dir.value) : 0, clientY: r ? r.bottom + 6 : 0 } as MouseEvent,
345
430
  [],
346
431
  );
347
432
  }
@@ -436,7 +521,7 @@ const toggleLabel = computed(() => t('sidenav.close'));
436
521
  stand here behind `v-if="!newMenu"` (Upload + New folder) is gone:
437
522
  both verbs are the first two rows of this menu. -->
438
523
  <button
439
- v-if="!activeReadOnly"
524
+ v-if="newShown"
440
525
  ref="newBtnEl"
441
526
  type="button"
442
527
  class="fe-sidenav__new"
@@ -494,58 +579,113 @@ const toggleLabel = computed(() => t('sidenav.close'));
494
579
 
495
580
  <div class="fe-sidenav__scroll">
496
581
  <ul class="fe-sidenav__group" :aria-label="t('sidenav.views')">
497
- <li v-for="v in views" :key="v.key">
498
- <button
499
- type="button"
500
- class="fe-sidenav__item"
501
- :class="{ 'is-active': isActiveDest(v.key) }"
502
- :aria-current="isActiveDest(v.key) ? 'page' : undefined"
503
- :title="v.label"
504
- :aria-label="v.label"
505
- :data-testid="`sidenav-view-${v.key}`"
506
- @click="emit('open-view', v.key)"
507
- >
508
- <svg
509
- class="fe-ficon"
510
- viewBox="0 0 24 24"
511
- fill="none"
512
- stroke="currentColor"
513
- stroke-width="1.8"
514
- stroke-linecap="round"
515
- stroke-linejoin="round"
516
- aria-hidden="true"
517
- focusable="false"
582
+ <template v-for="v in views" :key="v.key">
583
+ <li>
584
+ <button
585
+ type="button"
586
+ class="fe-sidenav__item"
587
+ :class="{ 'is-active': isActiveDest(v.key) }"
588
+ :aria-current="isActiveDest(v.key) ? 'page' : undefined"
589
+ :title="v.label"
590
+ :aria-label="v.label"
591
+ :data-testid="`sidenav-view-${v.key}`"
592
+ @click="emit('open-view', v.key)"
518
593
  >
519
- <template v-if="v.key === 'home'">
520
- <path d="M4 10.5L12 4l8 6.5" />
521
- <path d="M6 9.8V19a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9.8" />
522
- <path d="M10 20v-5.5h4V20" />
523
- </template>
524
- <template v-else-if="v.key === 'myfiles'">
525
- <path d="M3.5 7.5A1.5 1.5 0 0 1 5 6h4l2 2.5h8A1.5 1.5 0 0 1 20.5 10v7.5A1.5 1.5 0 0 1 19 19H5a1.5 1.5 0 0 1-1.5-1.5z" />
526
- </template>
527
- <template v-else-if="v.key === 'recent'">
528
- <circle cx="12" cy="12" r="8.5" />
529
- <path d="M12 7.5V12l3 2" />
530
- </template>
531
- <template v-else-if="v.key === 'starred'">
532
- <path d="M12 4l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4-3.9-3.8 5.4-.8z" />
533
- </template>
534
- <template v-else-if="v.key === 'shared'">
535
- <circle cx="17.5" cy="6.5" r="2.5" />
536
- <circle cx="6.5" cy="12" r="2.5" />
537
- <circle cx="17.5" cy="17.5" r="2.5" />
538
- <path d="M8.8 10.8l6.4-3.2M8.8 13.2l6.4 3.2" />
539
- </template>
540
- <template v-else>
541
- <path d="M4.5 7h15" />
542
- <path d="M9.5 7V5.2A1.2 1.2 0 0 1 10.7 4h2.6a1.2 1.2 0 0 1 1.2 1.2V7" />
543
- <path d="M6.5 7l.9 11.1A1.4 1.4 0 0 0 8.8 19.4h6.4a1.4 1.4 0 0 0 1.4-1.3L17.5 7" />
544
- </template>
545
- </svg>
546
- <span v-if="showLabels" class="fe-sidenav__text">{{ v.label }}</span>
547
- </button>
548
- </li>
594
+ <svg
595
+ class="fe-ficon"
596
+ viewBox="0 0 24 24"
597
+ fill="none"
598
+ stroke="currentColor"
599
+ stroke-width="1.8"
600
+ stroke-linecap="round"
601
+ stroke-linejoin="round"
602
+ aria-hidden="true"
603
+ focusable="false"
604
+ >
605
+ <template v-if="v.key === 'home'">
606
+ <path d="M4 10.5L12 4l8 6.5" />
607
+ <path d="M6 9.8V19a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9.8" />
608
+ <path d="M10 20v-5.5h4V20" />
609
+ </template>
610
+ <template v-else-if="v.key === 'myfiles'">
611
+ <path d="M3.5 7.5A1.5 1.5 0 0 1 5 6h4l2 2.5h8A1.5 1.5 0 0 1 20.5 10v7.5A1.5 1.5 0 0 1 19 19H5a1.5 1.5 0 0 1-1.5-1.5z" />
612
+ </template>
613
+ <template v-else-if="v.key === 'recent'">
614
+ <circle cx="12" cy="12" r="8.5" />
615
+ <path d="M12 7.5V12l3 2" />
616
+ </template>
617
+ <template v-else-if="v.key === 'starred'">
618
+ <path d="M12 4l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4-3.9-3.8 5.4-.8z" />
619
+ </template>
620
+ <template v-else-if="v.key === 'shared'">
621
+ <circle cx="17.5" cy="6.5" r="2.5" />
622
+ <circle cx="6.5" cy="12" r="2.5" />
623
+ <circle cx="17.5" cy="17.5" r="2.5" />
624
+ <path d="M8.8 10.8l6.4-3.2M8.8 13.2l6.4 3.2" />
625
+ </template>
626
+ <template v-else>
627
+ <path d="M4.5 7h15" />
628
+ <path d="M9.5 7V5.2A1.2 1.2 0 0 1 10.7 4h2.6a1.2 1.2 0 0 1 1.2 1.2V7" />
629
+ <path d="M6.5 7l.9 11.1A1.4 1.4 0 0 0 8.8 19.4h6.4a1.4 1.4 0 0 0 1.4-1.3L17.5 7" />
630
+ </template>
631
+ </svg>
632
+ <span v-if="showLabels" class="fe-sidenav__text">{{ v.label }}</span>
633
+ </button>
634
+ </li>
635
+
636
+ <!-- paylas:m1 — "My shares", directly under "Shared with me".
637
+ The pair is one question asked in both directions: what other
638
+ people handed to me, and what I handed out. A person hunting for
639
+ a link they minted looks where the sharing rows are, not under
640
+ Connections — that group is the one-time setup corner (how to
641
+ mount this, which API keys exist), and burying a daily lookup in
642
+ it is how a screen stays undiscovered.
643
+
644
+ ⚠ Anchored to the 'shared' row rather than appended to the group:
645
+ it has to sit BESIDE its mirror, and `views` may or may not carry
646
+ "My files" above it. That anchoring also carries the app-token
647
+ rule for free — `views` drops the identity rows for an app token,
648
+ so "Shared with me" goes and this row goes with it, which is
649
+ right: an app token is not a person and has shared nothing.
650
+
651
+ ⚠ It emits rather than navigates. The destination is a host
652
+ route, not a listing this panel can open — which is exactly why
653
+ `showMyShares` gates it and why that gate is OFF unless a host
654
+ asks. In an embed (`<filex-explorer>` on work.example.com, in the
655
+ fishapp, on fm.example.com) nothing listens for `open-my-shares`, so
656
+ an ungated row would be the one entry in this panel that draws,
657
+ takes a click and leads nowhere. Every other row is either
658
+ handled inside FileExplorer or gated by a flag of its own
659
+ (`trashVisible`, `showConnections`); this one now is too. -->
660
+ <li v-if="v.key === 'shared' && showMyShares">
661
+ <button
662
+ type="button"
663
+ class="fe-sidenav__item"
664
+ :title="t('sidenav.myshares')"
665
+ :aria-label="t('sidenav.myshares')"
666
+ data-testid="sidenav-my-shares"
667
+ @click="emit('open-my-shares')"
668
+ >
669
+ <!-- A chain link, against the three-node graph the row above
670
+ draws: what leaves here is a URL. -->
671
+ <svg
672
+ class="fe-ficon"
673
+ viewBox="0 0 24 24"
674
+ fill="none"
675
+ stroke="currentColor"
676
+ stroke-width="1.8"
677
+ stroke-linecap="round"
678
+ stroke-linejoin="round"
679
+ aria-hidden="true"
680
+ focusable="false"
681
+ >
682
+ <path d="M10 13.5a4.5 4.5 0 0 0 6.8.5l2.7-2.7a4.5 4.5 0 0 0-6.36-6.36L11.6 6.5" />
683
+ <path d="M14 10.5a4.5 4.5 0 0 0-6.8-.5l-2.7 2.7a4.5 4.5 0 0 0 6.36 6.36l1.53-1.53" />
684
+ </svg>
685
+ <span v-if="showLabels" class="fe-sidenav__text">{{ t('sidenav.myshares') }}</span>
686
+ </button>
687
+ </li>
688
+ </template>
549
689
  </ul>
550
690
 
551
691
  <!-- etiket:t1 — Tags. Between the views and the storages because a tag
@@ -557,35 +697,36 @@ const toggleLabel = computed(() => t('sidenav.close'));
557
697
  <div v-if="showTags" class="fe-sidenav__section">
558
698
  <template v-if="showLabels">
559
699
  <p class="fe-sidenav__heading">{{ t('sidenav.tags') }}</p>
560
- <ul class="fe-sidenav__group" :aria-label="t('sidenav.tags')">
561
- <li v-for="tag in visibleTags" :key="tag">
562
- <button
563
- type="button"
564
- class="fe-sidenav__item fe-sidenav__item--tag"
565
- :class="{ 'is-active': activeView === 'tag' && activeTag === tag }"
566
- :aria-current="activeView === 'tag' && activeTag === tag ? 'page' : undefined"
567
- :title="tag"
568
- :aria-label="tag"
569
- :data-testid="`sidenav-tag-${tag}`"
570
- @click="emit('open-tag', tag)"
571
- >
572
- <svg
573
- class="fe-ficon"
574
- viewBox="0 0 24 24"
575
- fill="none"
576
- stroke="currentColor"
577
- stroke-width="1.8"
578
- stroke-linecap="round"
579
- stroke-linejoin="round"
580
- aria-hidden="true"
581
- focusable="false"
700
+ <template v-for="g in tagGroups" :key="g.kind">
701
+ <!-- etiket:k2 — whose tags these are, in words and in the glyph
702
+ every tag chip carries. -->
703
+ <p class="fe-sidenav__subheading" :data-testid="`sidenav-tags-${g.kind}`">
704
+ <TagKindIcon :kind="g.kind" :size="12" />
705
+ <span>{{ t(`sidenav.tags.${g.kind}`) }}</span>
706
+ </p>
707
+ <ul
708
+ class="fe-sidenav__group"
709
+ :aria-label="`${t('sidenav.tags')} — ${t(`sidenav.tags.${g.kind}`)}`"
710
+ >
711
+ <li v-for="tag in g.visible" :key="`${tag.kind}:${tag.name}`">
712
+ <button
713
+ type="button"
714
+ class="fe-sidenav__item fe-sidenav__item--tag"
715
+ :class="{ 'is-active': tagIsActive(tag) }"
716
+ :aria-current="tagIsActive(tag) ? 'page' : undefined"
717
+ :title="t(`tags.chip.${tag.kind}`, { tag: tag.name })"
718
+ :aria-label="t(`tags.chip.${tag.kind}`, { tag: tag.name })"
719
+ :data-testid="`sidenav-tag-${tag.name}`"
720
+ :data-tag-kind="tag.kind"
721
+ @click="emit('open-tag', tag.name, tag.kind)"
582
722
  >
583
- <path d="M4 4.5h7l9 9-6.5 6.5-9-9z" />
584
- <circle cx="8" cy="8.5" r="1.4" />
585
- </svg>
586
- <span class="fe-sidenav__text">{{ tag }}</span>
587
- </button>
588
- </li>
723
+ <TagKindIcon class="fe-ficon" :kind="tag.kind" :size="16" />
724
+ <span class="fe-sidenav__text">{{ tag.name }}</span>
725
+ </button>
726
+ </li>
727
+ </ul>
728
+ </template>
729
+ <ul class="fe-sidenav__group" :aria-label="t('sidenav.tags')">
589
730
  <!-- Nothing tagged yet: the section stays, and says how tags get
590
731
  made. A section that only exists once you already know the
591
732
  feature teaches nobody. -->
@@ -602,7 +743,7 @@ const toggleLabel = computed(() => t('sidenav.close'));
602
743
  {{ t('sidenav.tags.more', { count: hiddenTagCount }) }}
603
744
  </button>
604
745
  </li>
605
- <li v-else-if="tagsExpanded && allTags.length > 8">
746
+ <li v-else-if="tagsExpanded && anyGroupLong">
606
747
  <button
607
748
  type="button"
608
749
  class="fe-sidenav__more"
@@ -707,14 +848,37 @@ const toggleLabel = computed(() => t('sidenav.close'));
707
848
  <!-- A read-only mount says so on the row, where the user looks
708
849
  for it. The admin list had an "RO" badge; the people it
709
850
  applied to never saw one (issue #30). -->
710
- <span
851
+ <StorageTags
711
852
  v-if="showLabels && s.readOnly"
712
- class="fe-sidenav__tag"
713
- role="img"
853
+ read-only
854
+ :locale="locale"
855
+ class="fe-sidenav__stags"
714
856
  data-testid="sidenav-storage-readonly"
715
- :aria-label="t('sidenav.storage.readOnly')"
716
- >{{ t('sidenav.storage.readOnly') }}</span
717
- >
857
+ />
858
+ </button>
859
+ </li>
860
+ </ul>
861
+ </div>
862
+
863
+ <!-- App plugins — "Apps": one row per `home` view. Between the storages
864
+ and Connections: an app is a place you go, like a drive, not a
865
+ setting. Hidden entirely when there are none. -->
866
+ <div v-if="appRows.length" class="fe-sidenav__section" data-testid="sidenav-apps">
867
+ <p v-if="showLabels" class="fe-sidenav__heading">{{ t('sidenav.apps') }}</p>
868
+ <hr v-else class="fe-sidenav__rule" aria-hidden="true" />
869
+ <ul class="fe-sidenav__group" :aria-label="t('sidenav.apps')">
870
+ <li v-for="a in appRows" :key="a.key">
871
+ <button
872
+ type="button"
873
+ class="fe-sidenav__item"
874
+ :title="a.label"
875
+ :aria-label="a.label"
876
+ :data-testid="`sidenav-app-${a.key}`"
877
+ @click="emit('open-app', a.key)"
878
+ >
879
+ <!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/actionIcons -->
880
+ <span class="fe-sidenav__appicon" aria-hidden="true" v-html="a.svg"></span>
881
+ <span v-if="showLabels" class="fe-sidenav__text">{{ a.label }}</span>
718
882
  </button>
719
883
  </li>
720
884
  </ul>
@@ -57,6 +57,7 @@ async function toggle() {
57
57
  apiBase: props.apiBase,
58
58
  authHeaders: props.authHeaders,
59
59
  authCredentials: props.authCredentials,
60
+ locale: resolveLocale(props.locale),
60
61
  });
61
62
  emit('change', next);
62
63
  } catch (err) {
@@ -20,6 +20,7 @@ import type { LocaleCode } from '../types/ExplorerConfig';
20
20
  import type { StorageField, StorageFieldOption } from '../types/Connections';
21
21
  import { useLocale } from '../composables/useLocale';
22
22
  import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
23
+ import ChoiceButtons, { type ChoiceOption } from './ChoiceButtons.vue';
23
24
 
24
25
  const props = defineProps<{
25
26
  fields: StorageField[];
@@ -27,6 +28,12 @@ const props = defineProps<{
27
28
  locale: LocaleCode;
28
29
  /** Keys to mark as failing validation (missing required). */
29
30
  invalid?: string[];
31
+ /**
32
+ * Field-level messages, keyed by field key — what a server (an app
33
+ * plugin's `surface.errors`, a driver's 400) said about the value. A key
34
+ * here is invalid too, and its own words replace the generic "required".
35
+ */
36
+ errors?: Record<string, string>;
30
37
  disabled?: boolean;
31
38
  }>();
32
39
 
@@ -98,7 +105,53 @@ function bool(f: StorageField): boolean {
98
105
  }
99
106
 
100
107
  function isInvalid(f: StorageField): boolean {
101
- return (props.invalid ?? []).includes(f.key);
108
+ return (props.invalid ?? []).includes(f.key) || !!props.errors?.[f.key];
109
+ }
110
+
111
+ function errorText(f: StorageField): string {
112
+ return props.errors?.[f.key] || (isInvalid(f) ? t('conn.form.required') : '');
113
+ }
114
+
115
+ /**
116
+ * The options a `choice` field draws as buttons.
117
+ *
118
+ * A `bool` has two by definition and the catalogue names them, unless the
119
+ * declaration named them itself — "Keep the original / Replace it" reads
120
+ * better than "Yes / No" and is the plugin author's call, not ours.
121
+ */
122
+ function choiceOptions(f: StorageField): ChoiceOption[] {
123
+ if (f.type === 'bool') {
124
+ const given = (f.options ?? []).filter((o) => o.value === 'true' || o.value === 'false');
125
+ const yes = given.find((o) => o.value === 'true');
126
+ const no = given.find((o) => o.value === 'false');
127
+ return [
128
+ { value: 'true', label: yes ? optionLabel(yes) : t('conn.form.yes') },
129
+ { value: 'false', label: no ? optionLabel(no) : t('conn.form.no') },
130
+ ];
131
+ }
132
+ return (f.options ?? []).map((o) => ({ value: o.value, label: optionLabel(o) }));
133
+ }
134
+
135
+ /** What a `choice` field holds right now, in the shape ChoiceButtons reads. */
136
+ function choiceValue(f: StorageField): string | string[] {
137
+ const v = raw(f);
138
+ if (f.type === 'bool') return v === true || v === 'true' ? 'true' : v === false || v === 'false' ? 'false' : '';
139
+ if (f.multi) return Array.isArray(v) ? v.map((x) => String(x)) : v === undefined || v === null || v === '' ? [] : [String(v)];
140
+ return v === undefined || v === null ? '' : String(v);
141
+ }
142
+
143
+ /** …and back: a bool becomes a boolean again, everything else stays as declared. */
144
+ function setChoice(f: StorageField, v: string | string[]): void {
145
+ if (f.type === 'bool') {
146
+ set(f.key, v === 'true');
147
+ return;
148
+ }
149
+ set(f.key, v);
150
+ }
151
+
152
+ /** A field drawn as buttons — never as a dropdown, never as a lone checkbox. */
153
+ function isChoice(f: StorageField): boolean {
154
+ return f.choice === true && (f.type === 'select' || f.type === 'bool');
102
155
  }
103
156
 
104
157
  function inputType(f: StorageField): string {
@@ -118,8 +171,10 @@ function toggleReveal(key: string) {
118
171
 
119
172
  <template v-for="f in basic" :key="f.key">
120
173
  <div class="fe-cfield__row" :class="{ 'is-invalid': isInvalid(f) }">
121
- <!-- bool: the label belongs next to the box, not above it -->
122
- <label v-if="f.type === 'bool'" class="fe-cfield__check">
174
+ <!-- bool: the label belongs next to the box, not above it — unless
175
+ the field is a DECISION, which is drawn as two buttons under a
176
+ label of its own (v3 §2.2). -->
177
+ <label v-if="f.type === 'bool' && !isChoice(f)" class="fe-cfield__check">
123
178
  <input
124
179
  type="checkbox"
125
180
  :checked="bool(f)"
@@ -132,8 +187,20 @@ function toggleReveal(key: string) {
132
187
  {{ label(f) }}<span v-if="f.required" class="fe-cfield__req" aria-hidden="true">*</span>
133
188
  </label>
134
189
 
190
+ <ChoiceButtons
191
+ v-if="isChoice(f)"
192
+ :options="choiceOptions(f)"
193
+ :model-value="choiceValue(f)"
194
+ :multi="f.multi === true"
195
+ :disabled="disabled"
196
+ :invalid="isInvalid(f)"
197
+ :aria-label="label(f)"
198
+ :testid-prefix="`fe-choice-${f.key}`"
199
+ @update:model-value="(v: string | string[]) => setChoice(f, v)"
200
+ />
201
+
135
202
  <select
136
- v-if="f.type === 'select'"
203
+ v-else-if="f.type === 'select'"
137
204
  :id="`fe-cf-${f.key}`"
138
205
  class="fe-cfield__input"
139
206
  :value="str(f)"
@@ -213,7 +280,7 @@ function toggleReveal(key: string) {
213
280
  />
214
281
 
215
282
  <p v-if="help(f)" class="fe-cfield__help">{{ help(f) }}</p>
216
- <p v-if="isInvalid(f)" class="fe-cfield__error">{{ t('conn.form.required') }}</p>
283
+ <p v-if="errorText(f)" class="fe-cfield__error">{{ errorText(f) }}</p>
217
284
  </div>
218
285
  </template>
219
286
 
@@ -255,6 +322,7 @@ function toggleReveal(key: string) {
255
322
  />
256
323
  </template>
257
324
  <p v-if="help(f)" class="fe-cfield__help">{{ help(f) }}</p>
325
+ <p v-if="errorText(f)" class="fe-cfield__error">{{ errorText(f) }}</p>
258
326
  </div>
259
327
  </div>
260
328
  </div>
@@ -284,7 +352,7 @@ function toggleReveal(key: string) {
284
352
  }
285
353
  .fe-cfield__req {
286
354
  color: var(--fe-danger);
287
- margin-left: 3px;
355
+ margin-inline-start: 3px;
288
356
  }
289
357
  .fe-cfield__input {
290
358
  font: inherit;