@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
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * StorageTags — what kind of storage a row is, in the same words everywhere.
4
+ *
5
+ * ⚠⚠ ONE vocabulary (release-candidate sweep, 2026-09-21, QA #34): the admin
6
+ * panel had TWO lists of the same storages, and they described them in two
7
+ * dialects — Admin → Storages marked a read-only storage "RO" next to a raw
8
+ * driver id ("local"), Connections → Storages marked it "SALT OKUNUR" next to
9
+ * "LOCAL" (the same id, upper-cased by CSS), and the explorer's side bar says
10
+ * "Salt okunur". This component is the one place those words are chosen: the
11
+ * driver's NAME (the same `storages.driver.*` names the storage form offers),
12
+ * then "Read-only" and "Disabled" when they apply. Both lists draw it.
13
+ *
14
+ * A driver this build has no name for (an external plugin's) is shown as its
15
+ * id rather than hidden.
16
+ *
17
+ * ⚠ It is also THE read-only mark of the explorer: the side panel's storage
18
+ * row and the Home card draw it with no `driver` (only the fact that matters
19
+ * there). Wave 2 had grown two looks for one fact — the explorer's tinted pill
20
+ * ("Salt okunur" on the panel and on Home) and this bordered one in the admin
21
+ * lists — so the explorer now draws this component too.
22
+ */
23
+ import { computed } from 'vue';
24
+ import type { LocaleCode } from '../types/ExplorerConfig';
25
+ import { useLocale } from '../composables/useLocale';
26
+
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ /** The driver id; absent draws no driver tag (the explorer's read-only mark). */
30
+ driver?: string;
31
+ readOnly?: boolean;
32
+ /** `false` draws "Disabled"; `undefined` / `true` draws nothing. */
33
+ enabled?: boolean;
34
+ locale?: LocaleCode | string;
35
+ }>(),
36
+ // ⚠ An explicit `undefined` default. Vue casts an ABSENT boolean prop to
37
+ // `false`, so without it every caller that does not pass `enabled` (the
38
+ // explorer's marks, Admin → Replica) drew "Disabled" beside "Read-only".
39
+ { enabled: undefined },
40
+ );
41
+
42
+ const { t } = useLocale(() => props.locale ?? 'en');
43
+
44
+ const driverName = computed(() => {
45
+ if (!props.driver) return '';
46
+ const key = `storages.driver.${props.driver}`;
47
+ const out = t(key);
48
+ return out === key ? props.driver : out;
49
+ });
50
+ </script>
51
+
52
+ <template>
53
+ <span class="fe-stags" data-testid="storage-tags">
54
+ <span v-if="driver" class="fe-stag" :title="driver" data-testid="storage-tag-driver">{{ driverName }}</span>
55
+ <span v-if="readOnly" class="fe-stag fe-stag--warn" data-testid="storage-tag-readonly">{{
56
+ t('sidenav.storage.readOnly')
57
+ }}</span>
58
+ <span v-if="enabled === false" class="fe-stag" data-testid="storage-tag-disabled">{{
59
+ t('conn.list.disabled')
60
+ }}</span>
61
+ </span>
62
+ </template>
@@ -162,7 +162,7 @@ function onAux(id: string, ev: MouseEvent) {
162
162
  @dragend="onDragEnd"
163
163
  >
164
164
  <span v-if="tab.split" class="fe-tabs__splitdot" aria-hidden="true"></span>
165
- <span class="fe-tabs__label">{{ tab.label }}</span>
165
+ <span class="fe-tabs__label"><bdi>{{ tab.label }}</bdi></span>
166
166
  <!-- ⚠ Not drawn when this is the only tab. Owner's decision,
167
167
  2026-09-13, verbatim: *"tek kalan tab'de x gözükmemeli, onu da
168
168
  kaldırırsın."* It is not a style choice — `useTabs.closeTab()`
@@ -0,0 +1,56 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * TagKindIcon — the one glyph that says which KIND a tag is.
4
+ *
5
+ * ⚠⚠ Why this exists (v0.43.0): tags used to be one kind, silently shared with
6
+ * every account on the server while the code called them per-user; a tester's
7
+ * "müşteri teklifi" showed up in other people's panels and they could remove
8
+ * it, and nothing on screen had ever said a tag was shared. With two kinds the
9
+ * screen has to say it EVERYWHERE a tag appears — the picker, the chips on a
10
+ * file, the navigation panel, the tag view's heading, the search filter — and
11
+ * one component is the only way those five cannot draw it five ways.
12
+ *
13
+ * personal — one figure: yours, like a star.
14
+ * team — two figures: everyone in your team who can see the file.
15
+ *
16
+ * Decorative (`aria-hidden`): every caller also puts the kind into words
17
+ * (`tags.kind.*` in the title / accessible name), because an icon alone is
18
+ * not an explanation.
19
+ */
20
+ import type { TagKind } from '../lib/tags';
21
+
22
+ defineProps<{
23
+ kind: TagKind;
24
+ /** Edge length in px. */
25
+ size?: number;
26
+ }>();
27
+ </script>
28
+
29
+ <template>
30
+ <svg
31
+ class="fe-tagkind"
32
+ :class="`fe-tagkind--${kind}`"
33
+ :width="size ?? 12"
34
+ :height="size ?? 12"
35
+ viewBox="0 0 24 24"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ stroke-width="2"
39
+ stroke-linecap="round"
40
+ stroke-linejoin="round"
41
+ aria-hidden="true"
42
+ focusable="false"
43
+ :data-tag-kind="kind"
44
+ >
45
+ <template v-if="kind === 'team'">
46
+ <circle cx="9" cy="8" r="3.2" />
47
+ <path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6" />
48
+ <circle cx="17" cy="9" r="2.6" />
49
+ <path d="M16.5 14.2c2.6.3 4.5 2.6 4.5 5.3" />
50
+ </template>
51
+ <template v-else>
52
+ <circle cx="12" cy="8" r="3.6" />
53
+ <path d="M5 20c0-3.9 3.1-7 7-7s7 3.1 7 7" />
54
+ </template>
55
+ </svg>
56
+ </template>
@@ -1,13 +1,36 @@
1
1
  <script setup lang="ts">
2
+ import { requestFailure } from '../lib/errorWords';
2
3
  /**
3
4
  * TagPicker — small chip list with add/remove for a node.
4
5
  *
5
6
  * Reads `GET /api/files/manager/tags?node_id=…` on mount, writes the
6
- * full new array via `POST /api/files/manager/tags`.
7
+ * full new set via `POST /api/files/manager/tags`.
7
8
  *
8
9
  * Tag colors are deterministic: hash(name) → palette index. Consumers
9
10
  * can override with the `palette` prop.
10
11
  *
12
+ * === etiket:k2 — TWO KINDS, AND THE CHIP SAYS WHICH (v0.43.0) ===========
13
+ * ⚠⚠ Tester, 2026-09-22: a non-admin's tag "müşteri teklifi" appeared in
14
+ * another user's and the admin's panel and on the file, and the other user
15
+ * could remove it. Tags were one shared label per file while the code called
16
+ * them per-user, and this component never said a word about who could see
17
+ * one. The owner's decision: personal and team tags.
18
+ *
19
+ * - Every chip carries its kind — `TagKindIcon` plus the words in its title
20
+ * and accessible name — so nobody has to guess who sees it.
21
+ * - Adding asks WHO SEES IT, with the answer written under each choice.
22
+ * Personal is the default: a label nobody chose to share is not shared.
23
+ * - "Team" is offered only where saving it would succeed: the server says
24
+ * `can_edit_team` (edit permission on the file). For a viewer it stays on
25
+ * screen, disabled, with the reason — a choice that vanishes cannot tell
26
+ * the person it exists. A team chip's × is likewise absent for them.
27
+ * - The name keeps its capitals ("Müşteri Teklifi"); the server decides
28
+ * sameness (lib/tags `tagKey` mirrors it only to avoid a pointless POST).
29
+ * - An OLDER server (no `items` in its answer) has one shared kind: the
30
+ * chips read as team and the kind choice is not offered, because the old
31
+ * server would ignore it and the person would believe a label was private
32
+ * when it is not.
33
+ *
11
34
  * === etiket:t1 — THE CHIP IS A DOOR ==================================
12
35
  * ⚠⚠ Owner, 2026-09-13: "taglediğim dosya klasör tag'ine gitmiyor." The tag
13
36
  * VIEW existed and the panel listed every tag, but from a file there was no way
@@ -28,6 +51,9 @@
28
51
  import { ref, watch, onMounted, computed } from 'vue';
29
52
  import { useLocale } from '../composables/useLocale';
30
53
  import type { LocaleCode } from '../types/ExplorerConfig';
54
+ import { announceTagsChanged, tagItemsOf, tagKey, type TagItem, type TagKind } from '../lib/tags';
55
+ import TagKindIcon from './TagKindIcon.vue';
56
+ import ChoiceButtons, { type ChoiceOption } from './ChoiceButtons.vue';
31
57
 
32
58
  const props = defineProps<{
33
59
  nodeId: number;
@@ -52,24 +78,50 @@ const props = defineProps<{
52
78
  }>();
53
79
 
54
80
  const emit = defineEmits<{
55
- (e: 'change', tags: string[]): void;
81
+ /** The names (the pre-v0.43 payload, kept for embedders), then the items. */
82
+ (e: 'change', tags: string[], items: TagItem[]): void;
56
83
  (e: 'error', message: string): void;
57
- /** etiket:t1 — "show me everything tagged this". The host owns navigation. */
58
- (e: 'open', tag: string): void;
84
+ /** etiket:t1 — "show me everything tagged this". The host owns navigation.
85
+ * `kind` (v0.43) says WHICH of two same-named tags was clicked. */
86
+ (e: 'open', tag: string, kind: TagKind): void;
59
87
  }>();
60
88
 
61
89
  const { t } = useLocale(() => props.locale ?? 'en');
62
90
 
63
- /** "Open tag: invoices" — the chip's title and its accessible name, one value
64
- * so the two cannot say different things. */
65
- function openTitle(tag: string): string {
66
- return t('tags.open', { tag });
91
+ /** "Open tag: invoices · Personal tag …" — the chip's title and its accessible
92
+ * name, one value so the two cannot say different things. The kind sentence
93
+ * rides along, so a screen reader hears WHO sees the tag, not only its name. */
94
+ function openTitle(item: TagItem): string {
95
+ return `${t('tags.open', { tag: item.name })} · ${t(`tags.chip.${item.kind}`, { tag: item.name })}`;
67
96
  }
68
97
 
69
- const tags = ref<string[]>([]);
98
+ const items = ref<TagItem[]>([]);
99
+ /** Whether TEAM tags may be added or removed here (server: `can_edit_team`). */
100
+ const canEditTeam = ref(true);
101
+ /** The server predates kinds: one shared kind, no choice to offer. */
102
+ const legacy = ref(false);
70
103
  const loading = ref(false);
71
104
  const adding = ref(false);
72
105
  const newTag = ref('');
106
+ const newKind = ref<TagKind>('personal');
107
+ const addForm = ref<HTMLFormElement | null>(null);
108
+
109
+ /** Personal chips first, then team — the grouping the navigation panel uses,
110
+ * so the same file reads the same in every place. */
111
+ const shown = computed(() => [
112
+ ...items.value.filter((i) => i.kind === 'personal'),
113
+ ...items.value.filter((i) => i.kind === 'team'),
114
+ ]);
115
+
116
+ const kindOptions = computed<ChoiceOption[]>(() => [
117
+ { value: 'personal', label: t('tags.kind.personal'), help: t('tags.kind.personal_help') },
118
+ {
119
+ value: 'team',
120
+ label: t('tags.kind.team'),
121
+ help: canEditTeam.value ? t('tags.kind.team_help') : t('tags.kind.team_locked'),
122
+ disabled: !canEditTeam.value,
123
+ },
124
+ ]);
73
125
 
74
126
  const palette = computed(() => props.palette ?? [
75
127
  '#ef4444', '#f59e0b', '#10b981', '#3b82f6',
@@ -83,10 +135,22 @@ function colorFor(tag: string): string {
83
135
  return arr[Math.abs(h) % arr.length];
84
136
  }
85
137
 
138
+ /** A team chip a viewer cannot take off draws no ×. */
139
+ function canRemove(item: TagItem): boolean {
140
+ return item.kind === 'personal' || canEditTeam.value;
141
+ }
142
+
86
143
  async function buildHeaders(extra: Record<string, string> = {}): Promise<Record<string, string>> {
87
144
  return { ...(await (props.authHeaders ?? (() => ({})))()), ...extra };
88
145
  }
89
146
 
147
+ function absorb(body: unknown) {
148
+ const b = (body ?? {}) as { items?: unknown; can_edit_team?: unknown };
149
+ legacy.value = !Array.isArray(b.items);
150
+ items.value = tagItemsOf(body);
151
+ if (typeof b.can_edit_team === 'boolean') canEditTeam.value = b.can_edit_team;
152
+ }
153
+
90
154
  async function load() {
91
155
  loading.value = true;
92
156
  try {
@@ -95,10 +159,7 @@ async function load() {
95
159
  headers: await buildHeaders(),
96
160
  credentials: props.authCredentials ?? 'same-origin',
97
161
  });
98
- if (res.ok) {
99
- const body = await res.json();
100
- tags.value = Array.isArray(body.tags) ? body.tags : [];
101
- }
162
+ if (res.ok) absorb(await res.json());
102
163
  } catch (err) {
103
164
  emit('error', err instanceof Error ? err.message : String(err));
104
165
  } finally {
@@ -106,39 +167,82 @@ async function load() {
106
167
  }
107
168
  }
108
169
 
109
- async function persist(next: string[]) {
110
- const previous = [...tags.value];
111
- tags.value = next;
170
+ async function persist(next: TagItem[]) {
171
+ const previous = [...items.value];
172
+ items.value = next;
112
173
  try {
113
174
  const base = props.apiBase ?? '';
175
+ // ⚠ An older server gets the old shape: it would ignore `items` and read
176
+ // the missing `tags` as "clear every tag".
177
+ const body = legacy.value
178
+ ? { node_id: props.nodeId, tags: next.map((i) => i.name) }
179
+ : { node_id: props.nodeId, items: next };
114
180
  const res = await fetch(`${base}/api/files/manager/tags`, {
115
181
  method: 'POST',
116
182
  headers: await buildHeaders({ 'Content-Type': 'application/json' }),
117
183
  credentials: props.authCredentials ?? 'same-origin',
118
- body: JSON.stringify({ node_id: props.nodeId, tags: next }),
184
+ body: JSON.stringify(body),
119
185
  });
120
- if (!res.ok) throw new Error(`tag save failed: ${res.status}`);
121
- emit('change', next);
186
+ if (!res.ok) throw requestFailure(res.status, await res.text().catch(() => ''), props.locale);
187
+ // The server's answer is the truth: it keeps the FIRST spelling of a name
188
+ // ("müşteri teklifi" typed on a second file lands as "Müşteri Teklifi").
189
+ const answer: unknown = await res.json().catch(() => null);
190
+ if (!legacy.value && answer && Array.isArray((answer as { items?: unknown }).items)) {
191
+ items.value = tagItemsOf(answer);
192
+ }
193
+ // ⚠ Announced, not only emitted: if the dialog was closed while the
194
+ // request was in flight this component is gone and Vue drops its emit —
195
+ // the panel would keep the list from before the save (lib/tags).
196
+ announceTagsChanged();
197
+ emit('change', items.value.map((i) => i.name), items.value);
122
198
  } catch (err) {
123
- tags.value = previous;
199
+ items.value = previous;
124
200
  emit('error', err instanceof Error ? err.message : String(err));
125
201
  }
126
202
  }
127
203
 
204
+ function closeAdd() {
205
+ newTag.value = '';
206
+ newKind.value = 'personal';
207
+ adding.value = false;
208
+ }
209
+
128
210
  function add() {
129
211
  const v = newTag.value.trim();
130
- if (!v || tags.value.includes(v)) {
131
- newTag.value = '';
132
- adding.value = false;
212
+ const kind: TagKind = legacy.value ? 'team' : newKind.value;
213
+ if (!v || items.value.some((i) => i.kind === kind && tagKey(i.name) === tagKey(v))) {
214
+ closeAdd();
133
215
  return;
134
216
  }
135
- persist([...tags.value, v]);
136
- newTag.value = '';
137
- adding.value = false;
217
+ void persist([...items.value, { name: v, kind }]);
218
+ closeAdd();
219
+ }
220
+
221
+ /**
222
+ * What leaving the name field does.
223
+ *
224
+ * ⚠⚠ Before kinds, leaving the field SAVED what was typed. With a kind choice
225
+ * beside the field that is a trap: the pointer's way to "Team" is a blur of
226
+ * the field, so the tag would be saved as PERSONAL on the way to the button —
227
+ * and Safari does not focus a clicked button at all, so there is no
228
+ * `relatedTarget` to recognise the move by. So with kinds, only Enter or the
229
+ * Add button saves; the choice and the button swallow `mousedown` (below) so
230
+ * a pointer never takes focus off the field; and leaving an EMPTY field closes
231
+ * the form, which is the tidy-up the old blur gave. An older server has no
232
+ * choice to lose, so it keeps the old save-on-leave.
233
+ */
234
+ function onFieldBlur(e: FocusEvent) {
235
+ if (legacy.value) {
236
+ add();
237
+ return;
238
+ }
239
+ const to = e.relatedTarget as Node | null;
240
+ if (to && addForm.value?.contains(to)) return;
241
+ if (!newTag.value.trim()) closeAdd();
138
242
  }
139
243
 
140
- function remove(tag: string) {
141
- persist(tags.value.filter((t) => t !== tag));
244
+ function remove(item: TagItem) {
245
+ void persist(items.value.filter((i) => i !== item));
142
246
  }
143
247
 
144
248
  onMounted(load);
@@ -148,31 +252,53 @@ watch(() => props.nodeId, load);
148
252
  <template>
149
253
  <div class="filex-tag-picker" :class="{ 'is-loading': loading }">
150
254
  <span
151
- v-for="tag in tags"
152
- :key="tag"
255
+ v-for="item in shown"
256
+ :key="`${item.kind}:${item.name}`"
153
257
  class="filex-tag"
154
- :style="{ '--filex-tag-color': colorFor(tag) }"
258
+ :class="`filex-tag--${item.kind}`"
259
+ :data-tag-kind="item.kind"
260
+ :style="{ '--filex-tag-color': colorFor(item.name) }"
155
261
  >
262
+ <TagKindIcon :kind="item.kind" />
156
263
  <button
157
264
  class="filex-tag-open"
158
265
  type="button"
159
- :title="openTitle(tag)"
160
- :aria-label="openTitle(tag)"
161
- :data-testid="`tag-open-${tag}`"
162
- @click="emit('open', tag)"
163
- >{{ tag }}</button>
164
- <button class="filex-tag-x" type="button" @click="remove(tag)" :aria-label="t('tags.remove')">×</button>
266
+ :title="openTitle(item)"
267
+ :aria-label="openTitle(item)"
268
+ :data-testid="`tag-open-${item.name}`"
269
+ @click="emit('open', item.name, item.kind)"
270
+ >{{ item.name }}</button>
271
+ <button
272
+ v-if="canRemove(item)"
273
+ class="filex-tag-x"
274
+ type="button"
275
+ :aria-label="`${t('tags.remove')}: ${item.name}`"
276
+ @click="remove(item)"
277
+ >×</button>
165
278
  </span>
166
279
 
167
- <form v-if="adding" class="filex-tag-add" @submit.prevent="add">
280
+ <form v-if="adding" ref="addForm" class="filex-tag-add" @submit.prevent="add">
168
281
  <input
169
282
  v-model="newTag"
170
283
  autofocus
284
+ maxlength="64"
171
285
  :placeholder="t('tags.name')"
172
286
  :aria-label="t('tags.name')"
173
- @blur="add"
174
- @keydown.escape="adding = false; newTag = ''"
287
+ @blur="onFieldBlur"
288
+ @keydown.escape="closeAdd"
175
289
  />
290
+ <!-- `mousedown.prevent`: a pointer on the choice or the button must not
291
+ take focus off the field (see onFieldBlur). -->
292
+ <div v-if="!legacy" class="filex-tag-kindwrap" @mousedown.prevent>
293
+ <ChoiceButtons
294
+ :model-value="newKind"
295
+ :options="kindOptions"
296
+ :aria-label="t('tags.kind.label')"
297
+ testid-prefix="tag-kind"
298
+ @update:model-value="(v: string | string[]) => (newKind = v === 'team' ? 'team' : 'personal')"
299
+ />
300
+ </div>
301
+ <button class="filex-tag-add-submit" type="submit" @mousedown.prevent>{{ t('tags.add_submit') }}</button>
176
302
  </form>
177
303
  <button v-else class="filex-tag-add-btn" type="button" @click="adding = true">
178
304
  {{ t('tags.add') }}
@@ -247,6 +373,12 @@ watch(() => props.nodeId, load);
247
373
  font-size: 12px;
248
374
  width: 110px;
249
375
  }
376
+ .filex-tag-add > input {
377
+ width: auto;
378
+ min-width: 0;
379
+ grid-column: 1;
380
+ grid-row: 1;
381
+ }
250
382
  .filex-tag-add-btn {
251
383
  border: 1px dashed var(--filex-border, #d1d5db);
252
384
  background: transparent;
@@ -265,4 +397,58 @@ watch(() => props.nodeId, load);
265
397
  border-color: var(--filex-accent, var(--fe-primary, #2f6ceb));
266
398
  color: var(--filex-accent, var(--fe-primary, #2f6ceb));
267
399
  }
400
+ /* etiket:k2 — the kind rides inside the chip, before the name, in the chip's
401
+ own colour: the icon is the at-a-glance half, the title the spelled-out one.
402
+ A personal chip is OUTLINED and a team chip FILLED, so the two read apart
403
+ even for someone who cannot tell two 12px glyphs apart. */
404
+ .filex-tag .fe-tagkind {
405
+ flex: none;
406
+ opacity: 0.9;
407
+ }
408
+ .filex-tag--personal {
409
+ background: transparent;
410
+ /* The page's own text colour, not the tag colour: an amber outline is
411
+ legible, amber letters on white are not. */
412
+ color: var(--fe-text, inherit);
413
+ box-shadow: inset 0 0 0 1.5px var(--filex-tag-color);
414
+ }
415
+ .filex-tag--personal .fe-tagkind {
416
+ color: var(--filex-tag-color);
417
+ opacity: 1;
418
+ }
419
+ .filex-tag--personal .filex-tag-x {
420
+ color: inherit;
421
+ opacity: 0.7;
422
+ }
423
+ /* The add form: the name and Add on one line, the kind choice under them at
424
+ full width with its two answers side by side, so "who sees this" reads as
425
+ one question with two answers rather than two stray boxes. */
426
+ .filex-tag-add {
427
+ display: grid;
428
+ grid-template-columns: minmax(0, 1fr) auto;
429
+ align-items: start;
430
+ gap: 6px;
431
+ flex-basis: 100%;
432
+ }
433
+ .filex-tag-add .filex-tag-add-submit {
434
+ grid-column: 2;
435
+ grid-row: 1;
436
+ }
437
+ .filex-tag-kindwrap {
438
+ grid-column: 1 / -1;
439
+ grid-row: 2;
440
+ }
441
+ .filex-tag-kindwrap .fe-choice {
442
+ display: grid;
443
+ grid-template-columns: repeat(2, minmax(0, 1fr));
444
+ }
445
+ .filex-tag-add-submit {
446
+ border: 1px solid var(--filex-border, #d1d5db);
447
+ background: transparent;
448
+ border-radius: 12px;
449
+ padding: 2px 12px;
450
+ font-size: 12px;
451
+ color: inherit;
452
+ cursor: pointer;
453
+ }
268
454
  </style>
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import type { LocaleCode } from '../types/ExplorerConfig';
27
27
  import { useLocale } from '../composables/useLocale';
28
- import { THEMES, type ThemeDef } from '../lib/themes';
28
+ import { allThemes, themeName, type ThemeDef } from '../lib/themes';
29
29
 
30
30
  const props = defineProps<{
31
31
  locale: LocaleCode;
@@ -52,7 +52,7 @@ function cardVars(th: ThemeDef): Record<string, string> {
52
52
  <p v-if="hint !== false" class="fe-themes__hint">{{ t('theme.hint') }}</p>
53
53
  <div class="fe-themes" role="listbox" :aria-label="t('theme.title')">
54
54
  <button
55
- v-for="th in THEMES"
55
+ v-for="th in allThemes()"
56
56
  :key="th.id"
57
57
  type="button"
58
58
  class="fe-themecard"
@@ -83,7 +83,7 @@ function cardVars(th: ThemeDef): Record<string, string> {
83
83
  </span>
84
84
  </span>
85
85
  <span class="fe-themecard__name">
86
- {{ t(th.nameKey) }}
86
+ {{ themeName(th, t) }}
87
87
  <span v-if="th.id === current" class="fe-themecard__check" :title="t('theme.selected')">✓</span>
88
88
  </span>
89
89
  </button>
@@ -121,7 +121,7 @@ function cardVars(th: ThemeDef): Record<string, string> {
121
121
  background: var(--fe-bg);
122
122
  cursor: pointer;
123
123
  overflow: hidden;
124
- text-align: left;
124
+ text-align: start;
125
125
  transition: transform 0.12s ease, box-shadow 0.12s ease;
126
126
  font: inherit;
127
127
  }
@@ -141,14 +141,33 @@ const ALIASES: Record<string, string> = {
141
141
  'Asia/Kolkata': 'india calcutta ist',
142
142
  };
143
143
 
144
- /** Every way a person might type an offset the platform prints as "GMT+03:00". */
145
- function offsetAliases(offset: string): string {
146
- const m = /GMT([+-])(\d{1,2})(?::?(\d{2}))?/.exec(offset);
147
- if (!m) return 'gmt utc';
148
- const [, sign, hRaw, minRaw] = m;
149
- const h = String(Number(hRaw));
144
+ /**
145
+ * A zone's offset from UTC in minutes, or null when the engine will not say.
146
+ *
147
+ * ⚠⚠ Read in ONE fixed shape — `en-US` — and parsed as a NUMBER. The offset
148
+ * the row DISPLAYS is in the interface language, and that is not "GMT…"
149
+ * everywhere: Arabic prints "غرينتش+3", other languages print their own word
150
+ * or their own digits. The search aliases used to be parsed out of that
151
+ * localised text with a `GMT` pattern, so under Arabic not one zone could be
152
+ * found by typing "+3" or "UTC+3" (translator finding, v0.43.0).
153
+ */
154
+ function offsetMinutes(zone: string): number | null {
155
+ const m = /^GMT(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(namePart(zone, 'en-US', 'shortOffset').trim());
156
+ if (!m) return null;
157
+ if (!m[1]) return 0;
158
+ const minutes = Number(m[2]) * 60 + Number(m[3] ?? 0);
159
+ return m[1] === '-' ? -minutes : minutes;
160
+ }
161
+
162
+ /** Every way a person might type an offset — "+3", "+03", "+03:00", "gmt+3", "utc+0300". */
163
+ function offsetAliases(minutes: number | null): string {
164
+ if (minutes === null) return '';
165
+ if (minutes === 0) return 'gmt utc';
166
+ const sign = minutes < 0 ? '-' : '+';
167
+ const abs = Math.abs(minutes);
168
+ const h = String(Math.floor(abs / 60));
150
169
  const hh = h.padStart(2, '0');
151
- const mm = minRaw ?? '00';
170
+ const mm = String(abs % 60).padStart(2, '0');
152
171
  const forms = [`${sign}${h}`, `${sign}${hh}`, `${sign}${hh}:${mm}`, `${sign}${hh}${mm}`];
153
172
  return [...forms.map((f) => `gmt${f}`), ...forms.map((f) => `utc${f}`), ...forms].join(' ');
154
173
  }
@@ -226,7 +245,7 @@ function buildIndex(): TzRow[] {
226
245
  const genericLocal = namePart(zone, loc, 'longGeneric');
227
246
  const genericEn = loc === 'en-US' ? '' : namePart(zone, 'en-US', 'longGeneric');
228
247
  const haystack = fold(
229
- [zone, city, region, genericLocal, genericEn, ALIASES[zone] ?? '', offsetAliases(offset)]
248
+ [zone, city, region, genericLocal, genericEn, ALIASES[zone] ?? '', offset, offsetAliases(offsetMinutes(zone))]
230
249
  .filter(Boolean)
231
250
  .join(' '),
232
251
  );
@@ -605,8 +624,8 @@ function onKeydown(ev: KeyboardEvent): void {
605
624
  .fe-tzpick__list {
606
625
  position: absolute;
607
626
  top: calc(100% + 4px);
608
- left: 0;
609
- right: 0;
627
+ inset-inline-start: 0;
628
+ inset-inline-end: 0;
610
629
  z-index: 5;
611
630
  box-sizing: border-box;
612
631
  margin: 0;