@brftech/filex-core 0.42.2 → 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 (234) 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-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-CAoTSXTO.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-CdZeK4j4.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 +5227 -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 +1055 -253
  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 +31 -15
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -62
  76. package/src/components/SSHKeysPanel.vue +79 -30
  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 -31
  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 +18 -3
  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 +515 -117
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +536 -152
  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 +2890 -260
  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 +50 -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-BUQWLhwX.js +0 -124
  216. package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
  217. package/dist/CsvViewer-Bg2_aqzc.js +0 -141
  218. package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
  219. package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
  220. package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
  221. package/dist/IpynbViewer-D0GwGpnE.js +0 -184
  222. package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
  223. package/dist/MermaidViewer-DGwAvRZg.js +0 -137
  224. package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
  225. package/dist/PsdViewer-DhGweA0m.js +0 -122
  226. package/dist/PsdViewer-DhGweA0m.js.map +0 -1
  227. package/dist/TiffViewer-D0AurvAh.js +0 -142
  228. package/dist/TiffViewer-D0AurvAh.js.map +0 -1
  229. package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
  230. package/dist/index-BArI-B1M.js +0 -26911
  231. package/dist/index-BArI-B1M.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
  234. package/src/composables/useScrolledX.ts +0 -21
@@ -41,16 +41,19 @@ import {
41
41
  } from '../lib/shareTtl';
42
42
  import { EXPLORER_CLOCK } from '../lib/timezone';
43
43
  import { resolveLocale } from '../locales/resolve';
44
- import { formatByteSize, useLocale } from '../composables/useLocale';
44
+ import { localeTag, useLocale } from '../composables/useLocale';
45
+ import { personInitial, personName } from '../lib/personName';
46
+ import { splitList } from '../lib/listInput';
45
47
  import { actionIconSvg } from '../lib/actionIcons';
46
48
  import { fileIconTile } from '../lib/fileIcons';
49
+ import { gateOnService } from '../lib/serviceGate';
47
50
 
48
51
  const props = defineProps<{
49
52
  api: FileApi;
50
53
  path: string; // adapter://rel of the target item
51
54
  isDir?: boolean; // folder → grants cascade; file → no `/…` inheritance hint
52
55
  size?: number; // bytes, for the share-mail body (files only)
53
- locale?: 'tr' | 'en';
56
+ locale?: string;
54
57
  /** Server ceiling on a new link's life in days (capabilities.share_max_ttl_days).
55
58
  * undefined/0 = no ceiling. The expiry choices are derived from it. */
56
59
  shareMaxTtlDays?: number;
@@ -67,15 +70,36 @@ const props = defineProps<{
67
70
  * theirs to see.
68
71
  */
69
72
  initialTab?: 'perms' | 'share' | 'drop';
73
+ /**
74
+ * Can outgoing mail be sent right now (`capabilities.mail.ready`)? `false`
75
+ * only when the server SAID so — absent (an older server) keeps the "Send
76
+ * by email" rows exactly as they were.
77
+ */
78
+ mailReady?: boolean;
79
+ /** Could this person set mail up (`capabilities.caller_admin`)? */
80
+ canConfigure?: boolean;
81
+ /**
82
+ * The caller's level on this item, when the host already knows it (the
83
+ * listing row's `perm`). Anything below `owner` cannot read the grant list,
84
+ * so the dialog does not ask: it used to send the request anyway, swallow
85
+ * the 403 and quietly draw no people section — a question the host had
86
+ * already answered, asked again on every open. Absent = ask.
87
+ */
88
+ perm?: string;
70
89
  }>();
71
90
  const emit = defineEmits<{ (e: 'close'): void }>();
72
91
 
92
+ /**
93
+ * "Send by email", when mail cannot be sent — the owner's rule for a missing
94
+ * service (2026-09-21, lib/serviceGate): an administrator sees the row greyed
95
+ * with where to set mail up; everybody else is not offered it. It used to be
96
+ * offered to everyone and answer only after the click.
97
+ */
98
+ const mailMissing = computed(() => props.mailReady === false);
99
+ const mailRowShown = computed(() => !mailMissing.value || props.canConfigure === true);
100
+
73
101
  const localeCode = computed(() => resolveLocale(props.locale));
74
- const { t } = useLocale(() => localeCode.value);
75
- const tr = computed(() => localeCode.value !== 'en');
76
- function L(t: string, e: string): string {
77
- return tr.value ? t : e;
78
- }
102
+ const { t, formatSize } = useLocale(() => localeCode.value);
79
103
 
80
104
  // Split adapter://rel for a friendlier path chip.
81
105
  const pathParts = computed(() => {
@@ -120,15 +144,33 @@ const suggestions = ref<UserSuggestion[]>([]);
120
144
  const showSuggest = ref(false);
121
145
  let searchTimer: ReturnType<typeof setTimeout> | undefined;
122
146
 
123
- const levels: Array<{ v: 'viewer' | 'editor' | 'owner'; l: string; d: string }> = [
124
- { v: 'viewer', l: L('Görüntüleyen', 'Viewer'), d: L('görüntüle + indir', 'view + download') },
125
- { v: 'editor', l: L('Düzenleyen', 'Editor'), d: L('oku + yaz + sil', 'read + write + delete') },
126
- { v: 'owner', l: L('Sahip', 'Owner'), d: L('düzenle + izin yönet', 'edit + manage access') },
127
- ];
147
+ // ⚠ computed, not a plain array: labels built once at setup kept the language
148
+ // the dialog was opened in.
149
+ const levels = computed<Array<{ v: 'viewer' | 'editor' | 'owner'; l: string; d: string }>>(() => [
150
+ { v: 'viewer', l: t('access.ui.level_viewer'), d: t('access.ui.level_viewer_desc') },
151
+ { v: 'editor', l: t('access.ui.level_editor'), d: t('access.ui.level_editor_desc') },
152
+ { v: 'owner', l: t('access.ui.level_owner'), d: t('access.ui.level_owner_desc') },
153
+ ]);
128
154
  function levelLabel(v: string): string {
129
- return levels.find((o) => o.v === v)?.l ?? v;
155
+ return levels.value.find((o) => o.v === v)?.l ?? v;
130
156
  }
131
157
 
158
+ /**
159
+ * Per-item access with RBAC OFF on the storage.
160
+ *
161
+ * ⚠ The add-people form used to work there while a box above it said grants
162
+ * do not apply (QA, 2026-09-21): a form that succeeds at something that has no
163
+ * effect. It is the same case as a missing service (lib/serviceGate): an
164
+ * administrator — who can switch RBAC on — sees the section with the form
165
+ * greyed and where to switch it on; anybody else is not offered the section.
166
+ * Grants that already exist stay listed, so they can still be removed.
167
+ */
168
+ const rbacGate = computed(() =>
169
+ storageRbac.value ? {} : gateOnService(false, props.canConfigure === true, t('access.ui.rbac_off_here')),
170
+ );
171
+ const peopleShown = computed(() => canManage.value && rbacGate.value.hidden !== true);
172
+ const addBlocked = computed(() => rbacGate.value.disabled === true);
173
+
132
174
  // ── share state ──
133
175
  const shares = ref<ShareInfo[]>([]);
134
176
  const shareBusy = ref(false);
@@ -163,11 +205,10 @@ const dropShares = computed(() => shares.value.filter((s) => shareKind(s) === 'd
163
205
  // on a server that keeps links for 7 would show a choice that is not one.
164
206
  // The same helper drives every surface (see lib/shareTtl.ts).
165
207
  function expiryLabel(days: number): string {
166
- if (days === 0) return L('Süresiz', 'Never');
167
- return tr.value ? `${days} gün` : `${days} day${days === 1 ? '' : 's'}`;
208
+ return days === 0 ? t('access.ui.never') : t('access.ui.days', { days });
168
209
  }
169
210
  const expiryOptions = computed(() => clampExpiryOptions(STOCK_EXPIRY_DAYS, props.shareMaxTtlDays, expiryLabel));
170
- const ttlHint = computed(() => ttlCeilingHint(props.shareMaxTtlDays, tr.value ? 'tr' : 'en'));
211
+ const ttlHint = computed(() => ttlCeilingHint(props.shareMaxTtlDays, localeCode.value));
171
212
  watch(
172
213
  () => props.shareMaxTtlDays,
173
214
  () => {
@@ -185,14 +226,10 @@ watch(
185
226
  // rather than a number box: "let this be opened three times" is the whole use
186
227
  // case, and typing a number to say it is friction.
187
228
  const shareMaxDl = ref(0); // 0 = unlimited
188
- const maxDlOptions = [
189
- { v: 0, l: L('Sınırsız', 'Unlimited') },
190
- { v: 1, l: L('1 indirme', '1 download') },
191
- { v: 3, l: L('3 indirme', '3 downloads') },
192
- { v: 5, l: L('5 indirme', '5 downloads') },
193
- { v: 10, l: L('10 indirme', '10 downloads') },
194
- { v: 25, l: L('25 indirme', '25 downloads') },
195
- ];
229
+ const maxDlOptions = computed(() => [
230
+ { v: 0, l: t('access.ui.unlimited') },
231
+ ...[1, 3, 5, 10, 25].map((count) => ({ v: count, l: t('access.ui.downloads', { count }) })),
232
+ ]);
196
233
 
197
234
  // ⚠ The one-line curl went missing the same way the download cap did: it was
198
235
  // part of the old standalone share dialog, and link creation moved here
@@ -229,10 +266,11 @@ const dropMailNotice = ref('');
229
266
 
230
267
  // splitEmails turns a free-text recipient field into a deduped address list —
231
268
  // comma / semicolon / whitespace separated, so one input handles many people.
269
+ // ⚠ lib/listInput: an Arabic keyboard's "،" and "؛" separate addresses too.
232
270
  function splitEmails(raw: string): string[] {
233
271
  const seen = new Set<string>();
234
272
  const out: string[] = [];
235
- for (const part of raw.split(/[,;\s]+/)) {
273
+ for (const part of splitList(raw, { spaces: true, semicolons: true })) {
236
274
  const e = part.trim().toLowerCase();
237
275
  if (e && e.includes('@') && !seen.has(e)) {
238
276
  seen.add(e);
@@ -246,13 +284,19 @@ function splitEmails(raw: string): string[] {
246
284
  function mailResultNotice(res: { sent?: string[]; failed?: string[] }): string {
247
285
  const sent = res.sent?.length ?? 0;
248
286
  const failed = res.failed?.length ?? 0;
249
- if (failed === 0) return L(`E-posta gönderildi ✓ (${sent})`, `Email sent ✓ (${sent})`);
250
- return L(`${sent} gönderildi, ${failed} başarısız`, `${sent} sent, ${failed} failed`);
287
+ if (failed === 0) return t('access.ui.mail_sent', { sent });
288
+ return t('access.ui.mail_partial', { sent, failed });
251
289
  }
252
290
 
253
291
  async function reload() {
254
292
  loading.value = true;
255
293
  err.value = '';
294
+ if (props.perm && props.perm !== 'owner') {
295
+ canManage.value = false;
296
+ open.value = { ...open.value, people: false };
297
+ loading.value = false;
298
+ return;
299
+ }
256
300
  try {
257
301
  const r = await props.api.listPermissions(props.path);
258
302
  direct.value = r.direct ?? [];
@@ -331,6 +375,7 @@ function onEmailInput() {
331
375
  }, 180);
332
376
  }
333
377
  async function pickUser(u: UserSuggestion) {
378
+ if (addBlocked.value) return;
334
379
  showSuggest.value = false;
335
380
  email.value = u.email;
336
381
  let lvl = level.value;
@@ -342,7 +387,7 @@ async function pickUser(u: UserSuggestion) {
342
387
  email.value = '';
343
388
  suggestions.value = [];
344
389
  await reload();
345
- notice.value = L('Yetki verildi.', 'Access granted.');
390
+ notice.value = t('access.ui.access_granted');
346
391
  } catch (e) {
347
392
  notice.value = e instanceof Error ? e.message : String(e);
348
393
  } finally {
@@ -350,9 +395,10 @@ async function pickUser(u: UserSuggestion) {
350
395
  }
351
396
  }
352
397
  async function submitEmail() {
398
+ if (addBlocked.value) return;
353
399
  const addr = email.value.trim().toLowerCase();
354
400
  if (!addr || !addr.includes('@')) {
355
- notice.value = L('Geçerli bir e-posta girin.', 'Enter a valid email.');
401
+ notice.value = t('access.ui.enter_a_valid_email');
356
402
  return;
357
403
  }
358
404
  showSuggest.value = false;
@@ -367,7 +413,7 @@ async function submitEmail() {
367
413
  await props.api.addPermission({ path: props.path, user_id: res.user.id, level: lvl, is_dir: !!props.isDir });
368
414
  email.value = '';
369
415
  await reload();
370
- notice.value = L('Yetki verildi.', 'Access granted.');
416
+ notice.value = t('access.ui.access_granted');
371
417
  } else {
372
418
  noAccount.value = true;
373
419
  }
@@ -388,8 +434,8 @@ async function inviteCreateUser() {
388
434
  });
389
435
  inviteResult.value = { tempPassword: r.temp_password };
390
436
  notice.value = r.emailed
391
- ? L('Kullanıcı açıldı, davet e-postası gönderildi.', 'User created, invite emailed.')
392
- : L('Kullanıcı açıldı. Geçici parolayı iletin.', 'User created. Share the temp password.');
437
+ ? t('access.ui.user_created_invite_emailed')
438
+ : t('access.ui.user_created_share_the_temp_password');
393
439
  noAccount.value = false; email.value = '';
394
440
  await reload();
395
441
  } catch (e) {
@@ -420,11 +466,13 @@ async function removeGrant(g: Grant) {
420
466
  catch (e) { notice.value = e instanceof Error ? e.message : String(e); }
421
467
  finally { busy.value = false; }
422
468
  }
469
+ /* One rule for naming a person (lib/personName): the server's name, the
470
+ display name, the username, the address. */
423
471
  function glabel(g: Grant): string {
424
- return g.user_display_name || g.user_email || `#${g.user_id}`;
472
+ return personName({ name: g.user_name, display_name: g.user_display_name, email: g.user_email }) || `#${g.user_id}`;
425
473
  }
426
474
  function ginitial(g: Grant): string {
427
- return (g.user_display_name || g.user_email || '?').charAt(0).toUpperCase();
475
+ return personInitial({ name: g.user_name, display_name: g.user_display_name, email: g.user_email }, localeTag(localeCode.value)) || '?';
428
476
  }
429
477
 
430
478
  // ── share actions ──
@@ -432,12 +480,23 @@ function expiresAtISO(): string | null {
432
480
  if (!shareExpiry.value) return null;
433
481
  return new Date(Date.now() + shareExpiry.value * 86400000).toISOString();
434
482
  }
483
+ /**
484
+ * Make THE link with the options as they stand.
485
+ *
486
+ * ⚠⚠ ONE download link per item, whichever control made it. The switch at the
487
+ * top mints one; this button used to mint ANOTHER beside it, so a person who
488
+ * turned the link on and then picked a PIN walked away with two live links —
489
+ * the old one without the PIN (QA, 2026-09-21). When a link is on, the button
490
+ * REPLACES it (its label and the note under it say so): the old address stops
491
+ * working and the new one carries the new options.
492
+ */
435
493
  async function createLink() {
436
494
  shareBusy.value = true;
437
495
  shareErr.value = '';
438
- shareResult.value = null;
439
496
  shareMailNotice.value = '';
440
497
  try {
498
+ for (const s of downloadShares.value) await props.api.revokeShare(s.uuid);
499
+ shareResult.value = null;
441
500
  const r = await props.api.createShare({
442
501
  path: props.path,
443
502
  password: sharePwd.value,
@@ -495,7 +554,7 @@ async function toggleLink() {
495
554
  async function sendShareMail() {
496
555
  const list = splitEmails(shareMailTo.value);
497
556
  if (!list.length) {
498
- shareMailNotice.value = L('Geçerli bir e-posta girin.', 'Enter a valid email.');
557
+ shareMailNotice.value = t('access.ui.enter_a_valid_email');
499
558
  return;
500
559
  }
501
560
  if (!shareResult.value?.url) return;
@@ -514,9 +573,9 @@ async function sendShareMail() {
514
573
  } catch (e) {
515
574
  const detail = (e as { detail?: string }).detail ?? '';
516
575
  if (detail.includes('not_configured')) {
517
- shareMailNotice.value = L('SMTP ayarlı/doğrulanmış değil — linki elle iletin.', 'SMTP not set up/verified — share the link manually.');
576
+ shareMailNotice.value = t('access.ui.smtp_not_set_up_verified_share_the_link');
518
577
  } else if (detail.includes('send_failed')) {
519
- shareMailNotice.value = L('Gönderilemedi (geçici hata) — tekrar deneyin.', 'Send failed (temporary) — please retry.');
578
+ shareMailNotice.value = t('access.ui.send_failed_temporary_please_retry');
520
579
  } else {
521
580
  shareMailNotice.value = e instanceof Error ? e.message : String(e);
522
581
  }
@@ -539,7 +598,7 @@ async function createDropLink() {
539
598
  const drop_settings: Record<string, unknown> = { ask_name: dropAskName.value };
540
599
  if (dropMaxFiles.value) drop_settings.max_files = Number(dropMaxFiles.value);
541
600
  if (dropMaxSizeMB.value) drop_settings.max_file_size_mb = Number(dropMaxSizeMB.value);
542
- const exts = dropAllowedExt.value.split(/[,\s]+/).map((s) => s.trim().replace(/^\./, '')).filter(Boolean);
601
+ const exts = splitList(dropAllowedExt.value, { spaces: true }).map((s) => s.replace(/^\./, '')).filter(Boolean);
543
602
  if (exts.length) drop_settings.allowed_ext = exts;
544
603
  const r = await props.api.createShare({
545
604
  path: props.path,
@@ -564,7 +623,7 @@ async function createDropLink() {
564
623
  async function sendDropMail() {
565
624
  const list = splitEmails(dropMailTo.value);
566
625
  if (!list.length) {
567
- dropMailNotice.value = L('Geçerli bir e-posta girin.', 'Enter a valid email.');
626
+ dropMailNotice.value = t('access.ui.enter_a_valid_email');
568
627
  return;
569
628
  }
570
629
  if (!dropResult.value?.url) return;
@@ -583,7 +642,7 @@ async function sendDropMail() {
583
642
  } catch (e) {
584
643
  const detail = (e as { detail?: string }).detail ?? '';
585
644
  if (detail.includes('not_configured')) {
586
- dropMailNotice.value = L('SMTP ayarlı/doğrulanmış değil — linki elle iletin.', 'SMTP not set up/verified — share the link manually.');
645
+ dropMailNotice.value = t('access.ui.smtp_not_set_up_verified_share_the_link');
587
646
  } else {
588
647
  dropMailNotice.value = e instanceof Error ? e.message : String(e);
589
648
  }
@@ -619,28 +678,24 @@ function copy(text: string, tag = 'url') {
619
678
  const canShare = computed(() => typeof navigator !== 'undefined' && typeof navigator.share === 'function');
620
679
 
621
680
  /**
622
- * The size line in the share message, spelled the way the SERVER spells it.
681
+ * The size line in the share message — the product's one byte format, in the
682
+ * viewer's language.
623
683
  *
624
- * ⚠ This one really does have to mirror `humanSize()` in
625
- * backend/internal/api/handlers/mail_templates.go — the same file forwarded
626
- * by e-mail and by the OS share sheet must not carry two different sizes. So
627
- * the mirroring is stated as arguments to the one formatter (1024, always one
628
- * decimal, a dot and English letters, because that is what Go's `%.1f %cB`
629
- * prints) instead of being a fifth private copy of the arithmetic.
630
- *
631
- * ⚠ It is therefore the one place in the UI that is NOT decimal, so a share
632
- * message reads "1.4 MB" where the listing row reads "1.5 MB". The register
633
- * entry for this was right about the real fix: the e-mail should render from
634
- * one source rather than the UI copying its rounding.
684
+ * ⚠ It used to mirror the server's `humanSize()` (base 1024, always one
685
+ * decimal, a dot and English letters) so that the message forwarded by the OS
686
+ * share sheet and the one e-mailed would agree — and both disagreed with the
687
+ * listing: "1.4 MB" in the message, "1.5 MB" on the row. The server writes
688
+ * sizes with the interface's rules now (srvtext.Bytes), so this is simply the
689
+ * listing's formatter.
635
690
  */
636
691
  function humanSize(b?: number): string {
637
692
  if (!b || b <= 0) return '';
638
- return formatByteSize(b, { base: 1024, digits: 'fixed1', numberLocale: null });
693
+ return formatSize(b);
639
694
  }
640
695
 
641
696
  function expiryLine(days: number): string {
642
- if (days > 0) return L(`Bu bağlantı ${days} gün geçerlidir.`, `This link is valid for ${days} day(s).`);
643
- return L('Bu bağlantının süresi yoktur.', 'This link does not expire.');
697
+ if (days > 0) return t('access.ui.valid_for', { days });
698
+ return t('share.ttl.never');
644
699
  }
645
700
  // What the server actually stored — shown under a fresh link so the real
646
701
  // expiry is visible even when the server shortened the request.
@@ -648,7 +703,7 @@ function expiryLine(days: number): string {
648
703
  const clock = inject(EXPLORER_CLOCK, undefined);
649
704
 
650
705
  function validUntil(r: { expiresAt?: string | null } | null): string {
651
- return validUntilLine(r?.expiresAt ?? null, tr.value ? 'tr' : 'en', clock);
706
+ return validUntilLine(r?.expiresAt ?? null, localeCode.value, clock);
652
707
  }
653
708
 
654
709
  /* ── the top tier: one switch, one sentence, one link ──────────────────── */
@@ -657,6 +712,15 @@ function validUntil(r: { expiresAt?: string | null } | null): string {
657
712
  const primaryLink = computed(() => shareResult.value?.url ?? downloadShares.value[0]?.url ?? '');
658
713
  const linkOn = computed(() => !!primaryLink.value);
659
714
 
715
+ /**
716
+ * The links the header does NOT already show. ⚠ The header's link used to be
717
+ * listed again right under it, with a second Copy button — one link, two
718
+ * places (QA, 2026-09-21). What is left is an older extra link, kept listed
719
+ * so it can still be copied or revoked.
720
+ */
721
+ const otherDownloadShares = computed(() => downloadShares.value.filter((s) => s.url !== primaryLink.value));
722
+ const otherDropShares = computed(() => dropShares.value.filter((s) => s.url !== dropResult.value?.url));
723
+
660
724
  /**
661
725
  * The plain sentence. It says who can open the item RIGHT NOW, which is the
662
726
  * question the dialog exists to answer.
@@ -678,12 +742,12 @@ const linkDetail = computed(() => {
678
742
  const bits: string[] = [];
679
743
  if (shareResult.value) {
680
744
  bits.push(validUntil(shareResult.value));
681
- if (shareResult.value.clamped) bits.push(L('(sunucu sınırı uygulandı)', '(server limit applied)'));
682
- if (shareMaxDl.value) bits.push(maxDlOptions.find((o) => o.v === shareMaxDl.value)?.l ?? '');
745
+ if (shareResult.value.clamped) bits.push(t('access.ui.server_limit_applied'));
746
+ if (shareMaxDl.value) bits.push(maxDlOptions.value.find((o) => o.v === shareMaxDl.value)?.l ?? '');
683
747
  } else if (downloadShares.value[0]) {
684
748
  const s = downloadShares.value[0];
685
749
  bits.push(validUntil({ expiresAt: s.expires_at ?? null }));
686
- if (s.max_downloads) bits.push(maxDlOptions.find((o) => o.v === s.max_downloads)?.l ?? String(s.max_downloads));
750
+ if (s.max_downloads) bits.push(maxDlOptions.value.find((o) => o.v === s.max_downloads)?.l ?? String(s.max_downloads));
687
751
  }
688
752
  return shareDetailLine(bits);
689
753
  });
@@ -694,7 +758,7 @@ const linkSummary = computed(() => {
694
758
  const bits: string[] = [];
695
759
  bits.push(sharePwd.value ? t('access.sum.pin_on') : t('access.sum.pin_off'));
696
760
  bits.push(expiryLabel(shareExpiry.value));
697
- if (shareMaxDl.value) bits.push(maxDlOptions.find((o) => o.v === shareMaxDl.value)?.l ?? '');
761
+ if (shareMaxDl.value) bits.push(maxDlOptions.value.find((o) => o.v === shareMaxDl.value)?.l ?? '');
698
762
  return bits.filter(Boolean).join(' · ');
699
763
  });
700
764
  const peopleSummary = computed(() => {
@@ -710,22 +774,21 @@ function shareBody(): { title: string; text: string } {
710
774
  const name = pathParts.value.name;
711
775
  const url = shareResult.value?.url ?? '';
712
776
  const pin = shareResult.value?.pin ?? '';
713
- const kind = props.isDir ? L('klasör', 'folder') : L('dosya', 'file');
714
- const title = tr.value
715
- ? `${name} ${props.isDir ? 'klasörü' : 'dosyası'} sizinle paylaşıldı`
716
- : `${name} has been shared with you`;
777
+ // ⚠ A sentence per kind, not "a {kind}": a word dropped into another
778
+ // sentence cannot agree with it in a language with gender or case.
779
+ const title = t(props.isDir ? 'access.ui.mail_title_folder' : 'access.ui.mail_title_file', { name });
717
780
  const lines: string[] = [];
718
- lines.push(L('Merhaba,', 'Hello,'), '');
719
- lines.push(L(`Sizinle bir ${kind} paylaşıldı:`, `A ${kind} has been shared with you:`), '');
781
+ lines.push(t('access.ui.mail_hello'), '');
782
+ lines.push(t(props.isDir ? 'access.ui.mail_shared_folder' : 'access.ui.mail_shared_file'), '');
720
783
  if (props.isDir) {
721
- lines.push(L(`Klasör: ${name}`, `Folder: ${name}`));
784
+ lines.push(t('access.ui.mail_folder', { name }));
722
785
  } else {
723
- lines.push(L(`Dosya: ${name}`, `File: ${name}`));
786
+ lines.push(t('access.ui.mail_file', { name }));
724
787
  const sz = humanSize(props.size);
725
- if (sz) lines.push(L(`Boyut: ${sz}`, `Size: ${sz}`));
788
+ if (sz) lines.push(t('access.ui.mail_size', { size: sz }));
726
789
  }
727
- lines.push('', L('İndirmek için:', 'Download it here:'), url);
728
- if (pin) lines.push('', L(`PIN (erişim kodu): ${pin}`, `PIN (access code): ${pin}`));
790
+ lines.push('', t('access.ui.mail_download_here'), url);
791
+ if (pin) lines.push('', t('access.ui.mail_pin', { pin }));
729
792
  lines.push('', expiryLine(shareExpiry.value));
730
793
  return { title, text: lines.join('\n') };
731
794
  }
@@ -737,18 +800,18 @@ function dropBody(): { title: string; text: string } {
737
800
  const pin = dropResult.value?.pin ?? '';
738
801
  const maxFiles = Number(dropMaxFiles.value) || 20;
739
802
  const maxMB = Number(dropMaxSizeMB.value) || 500;
740
- const exts = dropAllowedExt.value.split(/[,\s]+/).map((s) => s.trim().replace(/^\./, '')).filter(Boolean);
741
- const title = L(`${folder} adlı klasöre dosya eklemeniz istendi`, `You've been asked to add files to ${folder}`);
803
+ const exts = splitList(dropAllowedExt.value, { spaces: true }).map((s) => s.replace(/^\./, '')).filter(Boolean);
804
+ const title = t('access.ui.drop_title', { folder });
742
805
  const lines: string[] = [];
743
- lines.push(L('Merhaba,', 'Hello,'), '');
744
- lines.push(L('Dosya yüklemeniz istendi.', "You've been invited to upload files."), '');
745
- lines.push(L(`Klasör: ${folder}`, `Folder: ${folder}`));
746
- lines.push(L(`Sınır: en fazla ${maxFiles} dosya, dosya başına ${maxMB} MB.`, `Limit: up to ${maxFiles} file(s), ${maxMB} MB per file.`));
806
+ lines.push(t('access.ui.mail_hello'), '');
807
+ lines.push(t('access.ui.drop_invited'), '');
808
+ lines.push(t('access.ui.mail_folder', { name: folder }));
809
+ lines.push(t('access.ui.drop_limit', { files: maxFiles, mb: maxMB }));
747
810
  lines.push(exts.length
748
- ? L(`İzinli türler: ${exts.join(', ')}`, `Allowed types: ${exts.join(', ')}`)
749
- : L('İzinli türler: tüm türler', 'Allowed types: all'));
750
- lines.push('', L('Dosyalarınızı buradan yükleyebilirsiniz:', 'Upload your files here:'), url);
751
- if (pin) lines.push('', L(`PIN (erişim kodu): ${pin}`, `PIN (access code): ${pin}`));
811
+ ? t('access.ui.drop_types', { types: exts.join(', ') })
812
+ : t('access.ui.drop_types_all'));
813
+ lines.push('', t('access.ui.drop_upload_here'), url);
814
+ if (pin) lines.push('', t('access.ui.mail_pin', { pin }));
752
815
  lines.push('', expiryLine(dropExpiry.value));
753
816
  return { title, text: lines.join('\n') };
754
817
  }
@@ -772,7 +835,7 @@ async function nativeShare(body: { title: string; text: string }) {
772
835
  <span class="fe-share__tile" aria-hidden="true" v-html="titleTile"></span>
773
836
  <div class="fe-share__headtext">
774
837
  <h3 class="fe-share__title">{{ t('access.title', { name: pathParts.name }) }}</h3>
775
- <span class="fe-share__path" :title="path">{{ pathParts.adapter }}</span>
838
+ <span class="fe-share__path" :title="path"><bdi>{{ pathParts.adapter }}</bdi></span>
776
839
  </div>
777
840
  <button type="button" class="fe-share__close" :aria-label="t('access.close')" :title="t('access.close')" @click="emit('close')">
778
841
  <span aria-hidden="true" v-html="actionIconSvg('close')"></span>
@@ -810,8 +873,9 @@ async function nativeShare(body: { title: string; text: string }) {
810
873
  <div v-if="shareResult?.pin" class="fe-share__pinrow">
811
874
  <span class="fe-share__pinlabel">PIN</span>
812
875
  <code class="fe-share__pin">{{ shareResult?.pin }}</code>
813
- <button type="button" class="fe-share__mini" @click="copy(shareResult?.pin ?? '', 'sharepin')">
814
- {{ copied === 'sharepin' ? t('access.copied') : t('access.copy') }}
876
+ <button type="button" class="fe-share__copy" @click="copy(shareResult?.pin ?? '', 'sharepin')">
877
+ <span aria-hidden="true" v-html="actionIconSvg('copy')"></span>
878
+ <span>{{ copied === 'sharepin' ? t('access.copied') : t('access.copy') }}</span>
815
879
  </button>
816
880
  </div>
817
881
  </template>
@@ -837,72 +901,97 @@ async function nativeShare(body: { title: string; text: string }) {
837
901
  </button>
838
902
  <div v-if="open.link" class="fe-share__panel">
839
903
  <div class="fe-share__opts">
840
- <label class="fe-share__check">
841
- <input type="checkbox" v-model="sharePwd" />
842
- <span>{{ L('PIN ile koru', 'Protect with a PIN') }}</span>
904
+ <!-- ⚠ ONE on/off control in this dialog: the switch. The PIN
905
+ used to be a native checkbox under a switch — two shapes
906
+ for one kind of answer (QA, 2026-09-21). -->
907
+ <label class="fe-share__togglerow">
908
+ <span class="fe-share__togglelabel">{{ t('access.ui.protect_with_a_pin') }}</span>
909
+ <button
910
+ type="button"
911
+ class="fe-share__switch"
912
+ role="switch"
913
+ :aria-checked="sharePwd ? 'true' : 'false'"
914
+ data-testid="share-pin-switch"
915
+ @click="sharePwd = !sharePwd"
916
+ ><span class="fe-share__knob"></span></button>
843
917
  </label>
844
918
  <label class="fe-share__field">
845
- <span class="fe-share__fieldlabel">{{ L('Süre', 'Expiry') }}</span>
919
+ <span class="fe-share__fieldlabel">{{ t('access.ui.expiry') }}</span>
846
920
  <select v-model.number="shareExpiry" class="fe-share__select" data-testid="share-expiry">
847
921
  <option v-for="o in expiryOptions" :key="o.v" :value="o.v">{{ o.l }}</option>
848
922
  </select>
849
923
  </label>
850
924
  <label class="fe-share__field">
851
- <span class="fe-share__fieldlabel">{{ L('İndirme limiti', 'Download limit') }}</span>
925
+ <span class="fe-share__fieldlabel">{{ t('access.ui.download_limit') }}</span>
852
926
  <select v-model.number="shareMaxDl" class="fe-share__select">
853
927
  <option v-for="o in maxDlOptions" :key="o.v" :value="o.v">{{ o.l }}</option>
854
928
  </select>
855
929
  </label>
856
930
  </div>
857
931
  <p v-if="ttlHint" class="fe-share__hint" data-testid="share-ttl-hint">{{ ttlHint }}</p>
858
- <button type="button" class="fx-perm-create fe-share__btn fe-share__btn--primary fe-share__btn--wide" :disabled="shareBusy" @click="createLink">
859
- {{ L('Bağlantı oluştur', 'Create link') }}
932
+ <button
933
+ type="button"
934
+ class="fx-perm-create fe-share__btn fe-share__btn--primary fe-share__btn--wide"
935
+ data-testid="share-create"
936
+ :disabled="shareBusy"
937
+ @click="createLink"
938
+ >
939
+ {{ linkOn ? t('access.ui.replace_link') : t('access.ui.create_link') }}
860
940
  </button>
941
+ <p v-if="linkOn" class="fe-share__hint" data-testid="share-replace-hint">{{ t('access.ui.replace_link_hint') }}</p>
861
942
 
943
+ <!-- ⚠ One message with the address IN it: the sentence used to
944
+ be two halves around a <strong>, which put a space before
945
+ the English full stop and could not be ordered by a
946
+ language that puts the address elsewhere. -->
862
947
  <p v-if="!shareResult && shareMailTo" class="fe-share__hint">
863
- {{ L('Bir bağlantı oluşturun, ardından', 'Create a link, then it will be sent to') }}
864
- <strong>{{ shareMailTo }}</strong> {{ L('adresine gönderin.', '.') }}
948
+ {{ t('access.ui.create_then_send', { email: shareMailTo }) }}
865
949
  </p>
866
950
 
867
951
  <template v-if="shareResult">
868
952
  <!-- one-line download command, for pulling the file onto a server -->
869
953
  <div class="fx-perm-cli fe-share__cli">
870
- <span class="fe-share__fieldlabel">{{ L('Komut satırı', 'Command line') }}</span>
954
+ <span class="fe-share__fieldlabel">{{ t('access.ui.command_line') }}</span>
871
955
  <div class="fe-share__clirow">
872
956
  <code class="fe-share__clicmd" :title="shareCli">{{ shareCli }}</code>
873
- <button type="button" class="fe-share__mini" @click="copy(shareCli, 'sharecli')">
874
- {{ copied === 'sharecli' ? t('access.copied') : t('access.copy') }}
957
+ <button type="button" class="fe-share__copy" @click="copy(shareCli, 'sharecli')">
958
+ <span aria-hidden="true" v-html="actionIconSvg('copy')"></span>
959
+ <span>{{ copied === 'sharecli' ? t('access.copied') : t('access.copy') }}</span>
875
960
  </button>
876
961
  </div>
877
962
  </div>
878
963
 
879
964
  <!-- send by email (one or more, comma/space separated) -->
880
- <div class="fe-share__mailrow">
965
+ <div v-if="mailRowShown" class="fe-share__mailrow" data-testid="share-mail-row">
881
966
  <input v-model="shareMailTo" type="text" class="fe-share__input" autocomplete="off"
882
- :placeholder="L('e-posta(lar) — virgülle ayırın', 'email(s) — comma separated')" @keyup.enter="sendShareMail" />
883
- <button type="button" class="fe-share__btn" :disabled="shareMailBusy" @click="sendShareMail">
884
- {{ L('Gönder', 'Send') }}
967
+ :disabled="mailMissing"
968
+ :placeholder="t('access.ui.emails_comma_separated')" @keyup.enter="sendShareMail" />
969
+ <button type="button" class="fe-share__btn" :disabled="shareMailBusy || mailMissing" @click="sendShareMail">
970
+ {{ t('access.ui.send') }}
885
971
  </button>
886
972
  </div>
973
+ <div v-if="mailRowShown && mailMissing" class="fe-share__notice" data-testid="share-mail-unavailable">
974
+ {{ t('access.ui.mail_not_set_up') }}
975
+ </div>
887
976
  <div v-if="shareMailNotice" class="fe-share__notice">{{ shareMailNotice }}</div>
888
977
 
889
978
  <!-- native share (OS share sheet) — same as the fishapp Share button -->
890
979
  <button v-if="canShare" type="button" class="fe-share__btn fe-share__btn--wide" @click="nativeShare(shareBody())">
891
980
  <span aria-hidden="true" v-html="actionIconSvg('access')"></span>
892
- <span>{{ L('Paylaş', 'Share') }}</span>
981
+ <span>{{ t('access.ui.share') }}</span>
893
982
  </button>
894
983
  </template>
895
984
 
896
- <div class="fe-share__list">
897
- <h4 class="fe-share__listhead">{{ L('Mevcut bağlantılar', 'Existing links') }}</h4>
898
- <p v-if="!downloadShares.length" class="fe-share__empty">{{ L('Yok', 'None') }}</p>
899
- <div v-for="s in downloadShares" :key="s.uuid" class="fe-share__row">
985
+ <div v-if="otherDownloadShares.length" class="fe-share__list" data-testid="share-other-links">
986
+ <h4 class="fe-share__listhead">{{ t('access.ui.other_links') }}</h4>
987
+ <div v-for="s in otherDownloadShares" :key="s.uuid" class="fe-share__row">
900
988
  <span class="fe-share__url" :title="s.url">{{ s.url }}</span>
901
- <button type="button" class="fe-share__mini" @click="copy(s.url, s.uuid)">
902
- {{ copied === s.uuid ? t('access.copied') : t('access.copy') }}
989
+ <button type="button" class="fe-share__copy" @click="copy(s.url, s.uuid)">
990
+ <span aria-hidden="true" v-html="actionIconSvg('copy')"></span>
991
+ <span>{{ copied === s.uuid ? t('access.copied') : t('access.copy') }}</span>
903
992
  </button>
904
- <button type="button" class="fe-share__del" :disabled="shareBusy" :title="L('İptal', 'Revoke')"
905
- :aria-label="L('İptal', 'Revoke')" @click="revoke(s)">
993
+ <button type="button" class="fe-share__del" :disabled="shareBusy" :title="t('access.ui.revoke')"
994
+ :aria-label="t('access.ui.revoke')" @click="revoke(s)">
906
995
  <span aria-hidden="true" v-html="actionIconSvg('close')"></span>
907
996
  </button>
908
997
  </div>
@@ -911,7 +1000,7 @@ async function nativeShare(body: { title: string; text: string }) {
911
1000
  </section>
912
1001
 
913
1002
  <!-- People with access: the per-user / inherited grants -->
914
- <section v-if="canManage" class="fe-share__section">
1003
+ <section v-if="peopleShown" class="fe-share__section" data-testid="share-people">
915
1004
  <button
916
1005
  type="button"
917
1006
  class="fe-share__sechead"
@@ -926,59 +1015,60 @@ async function nativeShare(body: { title: string; text: string }) {
926
1015
  <span class="fe-share__secmeta">{{ peopleSummary }}</span>
927
1016
  </button>
928
1017
  <div v-if="open.people" class="fe-share__panel">
929
- <div v-if="!storageRbac" class="fe-share__warn">
930
- {{ L('Bu diskte RBAC kapalı — izinler yalnızca RBAC açık disklerde geçerli.', 'RBAC is off on this storage — grants only apply when RBAC is enabled.') }}
1018
+ <div v-if="addBlocked" class="fe-share__warn" data-testid="share-rbac-off">
1019
+ {{ t('access.ui.rbac_off_here') }}
931
1020
  </div>
932
1021
  <div v-if="err" class="fe-share__warn">{{ err }}</div>
933
- <p v-if="loading" class="fe-share__hint">{{ L('Yükleniyor…', 'Loading…') }}</p>
1022
+ <p v-if="loading" class="fe-share__hint">{{ t('access.ui.loading') }}</p>
934
1023
  <template v-else>
935
- <div class="fe-share__add">
1024
+ <div class="fe-share__add" data-testid="share-add-person">
936
1025
  <div class="fe-share__emailwrap">
937
1026
  <input v-model="email" type="email" class="fe-share__input" autocomplete="off"
938
- :placeholder="L('İsim veya e-posta', 'Name or email')"
1027
+ :disabled="addBlocked"
1028
+ :placeholder="t('access.ui.name_or_email')"
939
1029
  @input="onEmailInput" @keyup.enter="submitEmail" @focus="onEmailInput" />
940
1030
  <ul v-if="showSuggest" class="fe-share__suggest">
941
1031
  <li v-for="u in suggestions" :key="u.id" @mousedown.prevent="pickUser(u)">
942
- <span class="fe-share__av fe-share__av--sm">{{ (u.display_name || u.email).charAt(0).toUpperCase() }}</span>
1032
+ <span class="fe-share__av fe-share__av--sm">{{ personInitial(u, localeTag(localeCode)) }}</span>
943
1033
  <span class="fe-share__suggesttxt">
944
- <span class="fe-share__suggestname">{{ u.display_name || u.email }}</span>
945
- <span class="fe-share__suggestmeta">{{ u.email }} · {{ u.role }}</span>
1034
+ <span class="fe-share__suggestname">{{ personName(u) }}</span>
1035
+ <span class="fe-share__suggestmeta"><bdi>{{ u.email }}</bdi> · {{ u.role }}</span>
946
1036
  </span>
947
1037
  </li>
948
1038
  </ul>
949
1039
  </div>
950
- <select v-model="level" class="fe-share__select" :title="levels.find(o => o.v === level)?.d">
1040
+ <select v-model="level" class="fe-share__select" :disabled="addBlocked" :title="levels.find(o => o.v === level)?.d">
951
1041
  <option v-for="o in levels" :key="o.v" :value="o.v">{{ o.l }}</option>
952
1042
  </select>
953
- <button type="button" class="fe-share__btn fe-share__btn--primary" :disabled="busy" @click="submitEmail">
954
- {{ L('Ekle', 'Add') }}
1043
+ <button type="button" class="fe-share__btn fe-share__btn--primary" :disabled="busy || addBlocked" @click="submitEmail">
1044
+ {{ t('access.ui.add') }}
955
1045
  </button>
956
1046
  </div>
957
1047
 
958
1048
  <div v-if="noAccount" class="fe-share__invite">
959
- <p class="fe-share__hint">{{ L('Bu e-postada hesap yok. Ne yapmak istersiniz?', 'No account for this email — what next?') }}</p>
1049
+ <p class="fe-share__hint">{{ t('access.ui.no_account_for_this_email_what_next') }}</p>
960
1050
  <div class="fe-share__inviterow">
961
1051
  <select v-model="createRole" class="fe-share__select">
962
- <option value="user">{{ L('Kullanıcı', 'User') }}</option>
963
- <option value="viewer">{{ L('Görüntüleyen', 'Viewer') }}</option>
1052
+ <option value="user">{{ t('access.ui.user') }}</option>
1053
+ <option value="viewer">{{ t('access.ui.viewer') }}</option>
964
1054
  </select>
965
1055
  <button type="button" class="fe-share__btn fe-share__btn--primary" :disabled="busy" @click="inviteCreateUser">
966
- {{ L('Kullanıcı oluştur + yetki ver', 'Create user + grant') }}
1056
+ {{ t('access.ui.create_user_grant') }}
967
1057
  </button>
968
1058
  </div>
969
1059
  <button type="button" class="fe-share__linkbtn" :disabled="busy" @click="gotoShareWithMail">
970
- {{ L('Sadece paylaşım linki gönder →', 'Just send a share link →') }}
1060
+ {{ t('access.ui.just_send_a_share_link') }}
971
1061
  </button>
972
1062
  </div>
973
1063
  <div v-if="notice" class="fe-share__notice">{{ notice }}</div>
974
1064
  <div v-if="inviteResult?.tempPassword" class="fe-share__notice">
975
- {{ L('Geçici parola:', 'Temp password:') }} <code class="fe-share__pin">{{ inviteResult.tempPassword }}</code>
1065
+ {{ t('access.ui.temp_password') }} <code class="fe-share__pin">{{ inviteResult.tempPassword }}</code>
976
1066
  </div>
977
1067
 
978
1068
  <div class="fe-share__list">
979
- <h4 class="fe-share__listhead">{{ L('Erişimi olanlar', 'People with access') }}</h4>
1069
+ <h4 class="fe-share__listhead">{{ t('access.ui.people_with_access') }}</h4>
980
1070
  <p v-if="!direct.length && !inherited.length" class="fe-share__empty">
981
- {{ L('Henüz kimseyle paylaşılmadı.', 'Not shared with anyone yet.') }}
1071
+ {{ t('access.ui.not_shared_with_anyone_yet') }}
982
1072
  </p>
983
1073
  <div v-for="g in direct" :key="'d' + g.id" class="fe-share__row">
984
1074
  <span class="fe-share__av">{{ ginitial(g) }}</span>
@@ -987,8 +1077,8 @@ async function nativeShare(body: { title: string; text: string }) {
987
1077
  @change="changeLevel(g, ($event.target as HTMLSelectElement).value)">
988
1078
  <option v-for="o in levels" :key="o.v" :value="o.v">{{ o.l }}</option>
989
1079
  </select>
990
- <button type="button" class="fe-share__del" :disabled="busy" :title="L('Kaldır', 'Remove')"
991
- :aria-label="L('Kaldır', 'Remove')" @click="removeGrant(g)">
1080
+ <button type="button" class="fe-share__del" :disabled="busy" :title="t('access.ui.remove')"
1081
+ :aria-label="t('access.ui.remove')" @click="removeGrant(g)">
992
1082
  <span aria-hidden="true" v-html="actionIconSvg('close')"></span>
993
1083
  </button>
994
1084
  </div>
@@ -996,7 +1086,7 @@ async function nativeShare(body: { title: string; text: string }) {
996
1086
  <span class="fe-share__av fe-share__av--dim">{{ ginitial(g) }}</span>
997
1087
  <span class="fe-share__person" :title="g.user_email">{{ glabel(g) }}</span>
998
1088
  <span class="fe-share__badge">{{ levelLabel(g.level) }}</span>
999
- <span class="fe-share__from" :title="L('Üst klasörden gelir', 'Inherited from') + ': ' + (g.path_prefix || '/')">
1089
+ <span class="fe-share__from" :title="t('access.ui.inherited_from') + ': ' + (g.path_prefix || '/')">
1000
1090
  {{ g.path_prefix || '/' }}
1001
1091
  </span>
1002
1092
  </div>
@@ -1022,15 +1112,22 @@ async function nativeShare(body: { title: string; text: string }) {
1022
1112
  </button>
1023
1113
  <div v-if="open.drop" class="fe-share__panel">
1024
1114
  <p class="fe-share__hint">
1025
- {{ L('Bu klasöre herkesin dosya YÜKLEYEBİLECEĞİ herkese açık bir bağlantı. Yükleyenler klasördeki mevcut dosyaları göremez.', 'A public link that lets anyone UPLOAD files into this folder. Uploaders never see the folder\'s existing files.') }}
1115
+ {{ t('access.ui.a_public_link_that_lets_anyone_upload_fi') }}
1026
1116
  </p>
1027
1117
  <div class="fe-share__opts">
1028
- <label class="fe-share__check">
1029
- <input type="checkbox" v-model="dropPwd" />
1030
- <span>{{ L('PIN ile koru', 'Protect with a PIN') }}</span>
1118
+ <label class="fe-share__togglerow">
1119
+ <span class="fe-share__togglelabel">{{ t('access.ui.protect_with_a_pin') }}</span>
1120
+ <button
1121
+ type="button"
1122
+ class="fe-share__switch"
1123
+ role="switch"
1124
+ :aria-checked="dropPwd ? 'true' : 'false'"
1125
+ data-testid="drop-pin-switch"
1126
+ @click="dropPwd = !dropPwd"
1127
+ ><span class="fe-share__knob"></span></button>
1031
1128
  </label>
1032
1129
  <label class="fe-share__field">
1033
- <span class="fe-share__fieldlabel">{{ L('Süre', 'Expiry') }}</span>
1130
+ <span class="fe-share__fieldlabel">{{ t('access.ui.expiry') }}</span>
1034
1131
  <select v-model.number="dropExpiry" class="fe-share__select" data-testid="drop-expiry">
1035
1132
  <option v-for="o in expiryOptions" :key="o.v" :value="o.v">{{ o.l }}</option>
1036
1133
  </select>
@@ -1038,28 +1135,35 @@ async function nativeShare(body: { title: string; text: string }) {
1038
1135
  </div>
1039
1136
  <p v-if="ttlHint" class="fe-share__hint">{{ ttlHint }}</p>
1040
1137
  <button type="button" class="fe-share__btn fe-share__btn--primary fe-share__btn--wide" data-testid="drop-create" :disabled="dropBusy" @click="createDropLink">
1041
- {{ L('Bağlantı oluştur', 'Create link') }}
1138
+ {{ t('access.ui.create_link') }}
1042
1139
  </button>
1043
1140
 
1044
1141
  <button type="button" class="fe-share__linkbtn" :aria-expanded="dropShowAdv ? 'true' : 'false'" @click="dropShowAdv = !dropShowAdv">
1045
- {{ dropShowAdv ? L('Yükleme sınırlarını gizle', 'Hide upload limits') : L('Yükleme sınırları', 'Upload limits') }}
1142
+ {{ dropShowAdv ? t('access.ui.hide_upload_limits') : t('access.ui.upload_limits') }}
1046
1143
  </button>
1047
1144
  <div v-if="dropShowAdv" class="fe-share__adv">
1048
1145
  <label class="fe-share__advrow">
1049
- <span class="fe-share__fieldlabel">{{ L('En fazla dosya', 'Max files') }}</span>
1146
+ <span class="fe-share__fieldlabel">{{ t('access.ui.max_files') }}</span>
1050
1147
  <input v-model="dropMaxFiles" type="number" min="1" class="fe-share__input fe-share__input--sm" placeholder="20" />
1051
1148
  </label>
1052
1149
  <label class="fe-share__advrow">
1053
- <span class="fe-share__fieldlabel">{{ L('Dosya başı MB', 'MB / file') }}</span>
1150
+ <span class="fe-share__fieldlabel">{{ t('access.ui.mb_file') }}</span>
1054
1151
  <input v-model="dropMaxSizeMB" type="number" min="1" class="fe-share__input fe-share__input--sm" placeholder="500" />
1055
1152
  </label>
1056
1153
  <label class="fe-share__advrow">
1057
- <span class="fe-share__fieldlabel">{{ L('İzinli türler', 'Allowed types') }}</span>
1058
- <input v-model="dropAllowedExt" type="text" class="fe-share__input fe-share__input--sm" :placeholder="L('hepsi (örn. pdf, jpg)', 'all (e.g. pdf, jpg)')" />
1154
+ <span class="fe-share__fieldlabel">{{ t('access.ui.allowed_types') }}</span>
1155
+ <input v-model="dropAllowedExt" type="text" class="fe-share__input fe-share__input--sm" :placeholder="t('access.ui.all_e_g_pdf_jpg')" />
1059
1156
  </label>
1060
- <label class="fe-share__check">
1061
- <input type="checkbox" v-model="dropAskName" />
1062
- <span>{{ L('Yükleyenin adını sor', 'Ask uploader name') }}</span>
1157
+ <label class="fe-share__togglerow">
1158
+ <span class="fe-share__togglelabel">{{ t('access.ui.ask_uploader_name') }}</span>
1159
+ <button
1160
+ type="button"
1161
+ class="fe-share__switch"
1162
+ role="switch"
1163
+ :aria-checked="dropAskName ? 'true' : 'false'"
1164
+ data-testid="drop-askname-switch"
1165
+ @click="dropAskName = !dropAskName"
1166
+ ><span class="fe-share__knob"></span></button>
1063
1167
  </label>
1064
1168
  </div>
1065
1169
 
@@ -1075,43 +1179,48 @@ async function nativeShare(body: { title: string; text: string }) {
1075
1179
  </div>
1076
1180
  <p class="fe-share__detail">
1077
1181
  {{ validUntil(dropResult) }}
1078
- <span v-if="dropResult.clamped">{{ L('(sunucu sınırı uygulandı)', '(server limit applied)') }}</span>
1182
+ <span v-if="dropResult.clamped">{{ t('access.ui.server_limit_applied') }}</span>
1079
1183
  </p>
1080
1184
  <div v-if="dropResult.pin" class="fe-share__pinrow">
1081
1185
  <span class="fe-share__pinlabel">PIN</span>
1082
1186
  <code class="fe-share__pin">{{ dropResult.pin }}</code>
1083
- <button type="button" class="fe-share__mini" @click="copy(dropResult.pin, 'droppin')">
1084
- {{ copied === 'droppin' ? t('access.copied') : t('access.copy') }}
1187
+ <button type="button" class="fe-share__copy" @click="copy(dropResult.pin, 'droppin')">
1188
+ <span aria-hidden="true" v-html="actionIconSvg('copy')"></span>
1189
+ <span>{{ copied === 'droppin' ? t('access.copied') : t('access.copy') }}</span>
1085
1190
  </button>
1086
1191
  </div>
1087
1192
 
1088
1193
  <!-- email the upload link to one or more people -->
1089
- <div class="fe-share__mailrow">
1194
+ <div v-if="mailRowShown" class="fe-share__mailrow">
1090
1195
  <input v-model="dropMailTo" type="text" class="fe-share__input" autocomplete="off"
1091
- :placeholder="L('e-posta(lar) — virgülle ayırın', 'email(s) — comma separated')" @keyup.enter="sendDropMail" />
1092
- <button type="button" class="fe-share__btn" :disabled="dropMailBusy" @click="sendDropMail">
1093
- {{ L('Gönder', 'Send') }}
1196
+ :disabled="mailMissing"
1197
+ :placeholder="t('access.ui.emails_comma_separated')" @keyup.enter="sendDropMail" />
1198
+ <button type="button" class="fe-share__btn" :disabled="dropMailBusy || mailMissing" @click="sendDropMail">
1199
+ {{ t('access.ui.send') }}
1094
1200
  </button>
1095
1201
  </div>
1202
+ <div v-if="mailRowShown && mailMissing" class="fe-share__notice">
1203
+ {{ t('access.ui.mail_not_set_up') }}
1204
+ </div>
1096
1205
  <div v-if="dropMailNotice" class="fe-share__notice">{{ dropMailNotice }}</div>
1097
1206
 
1098
1207
  <!-- native share (OS share sheet) — same as the fishapp Share button -->
1099
1208
  <button v-if="canShare" type="button" class="fe-share__btn fe-share__btn--wide" @click="nativeShare(dropBody())">
1100
1209
  <span aria-hidden="true" v-html="actionIconSvg('access')"></span>
1101
- <span>{{ L('Paylaş', 'Share') }}</span>
1210
+ <span>{{ t('access.ui.share') }}</span>
1102
1211
  </button>
1103
1212
  </template>
1104
1213
 
1105
- <div class="fe-share__list">
1106
- <h4 class="fe-share__listhead">{{ L('Mevcut bağlantılar', 'Existing links') }}</h4>
1107
- <p v-if="!dropShares.length" class="fe-share__empty">{{ L('Yok', 'None') }}</p>
1108
- <div v-for="s in dropShares" :key="s.uuid" class="fe-share__row">
1214
+ <div v-if="otherDropShares.length" class="fe-share__list" data-testid="drop-other-links">
1215
+ <h4 class="fe-share__listhead">{{ dropResult ? t('access.ui.other_links') : t('access.ui.existing_links') }}</h4>
1216
+ <div v-for="s in otherDropShares" :key="s.uuid" class="fe-share__row">
1109
1217
  <span class="fe-share__url" :title="s.url">{{ s.url }}</span>
1110
- <button type="button" class="fe-share__mini" @click="copy(s.url, s.uuid)">
1111
- {{ copied === s.uuid ? t('access.copied') : t('access.copy') }}
1218
+ <button type="button" class="fe-share__copy" @click="copy(s.url, s.uuid)">
1219
+ <span aria-hidden="true" v-html="actionIconSvg('copy')"></span>
1220
+ <span>{{ copied === s.uuid ? t('access.copied') : t('access.copy') }}</span>
1112
1221
  </button>
1113
- <button type="button" class="fe-share__del" :disabled="shareBusy" :title="L('İptal', 'Revoke')"
1114
- :aria-label="L('İptal', 'Revoke')" @click="revoke(s)">
1222
+ <button type="button" class="fe-share__del" :disabled="shareBusy" :title="t('access.ui.revoke')"
1223
+ :aria-label="t('access.ui.revoke')" @click="revoke(s)">
1115
1224
  <span aria-hidden="true" v-html="actionIconSvg('close')"></span>
1116
1225
  </button>
1117
1226
  </div>