@brftech/filex-core 0.39.1 → 0.41.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 (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -25,9 +25,12 @@ import type { Component } from 'vue';
25
25
  import type { FileNode } from '../types/FileNode';
26
26
  import type { LocaleCode } from '../types/ExplorerConfig';
27
27
  import Modal from './Modal.vue';
28
+ import StarButton from '../components/StarButton.vue';
28
29
  import { ensureMonaco, getMonaco, ensureHighlight } from '../composables/useMonacoLoader';
29
30
  import { useLocale } from '../composables/useLocale';
30
31
  import { browserProbeURL } from '../lib/externalReach';
32
+ import { fileIconTile } from '../lib/fileIcons';
33
+ import { actionIconSvg } from '../lib/actionIcons';
31
34
 
32
35
  const props = defineProps<{
33
36
  open: boolean;
@@ -68,13 +71,40 @@ const props = defineProps<{
68
71
  * vs-dark). When unset Monaco falls back to `prefers-color-scheme`
69
72
  * and the modal cascade follows whatever `.fe` parent it gets. */
70
73
  theme?: 'light' | 'dark' | 'auto';
74
+ /* === gorunum:v1-viewer — the full-bleed overlay's own contract ===
75
+ * Everything below is OPTIONAL and answered by the host, because the
76
+ * modal cannot invent any of it: it is handed ONE file and has no idea
77
+ * what list that file came out of, nor where the API lives. */
78
+ /** 1-based position of `file` in the host's current file list. */
79
+ index?: number;
80
+ /** How many files that list holds. `index` + `total` draw "1 of 9";
81
+ * with either missing the counter is simply absent — a counter that
82
+ * guesses is worse than no counter. */
83
+ total?: number;
84
+ /** Draw the prev/next chevrons even when index/total are unknown.
85
+ * QuickLook sets it because its host already answers `nav`. */
86
+ navEnabled?: boolean;
87
+ /** API origin for the star toggle — same meaning as ExplorerConfig.apiBase.
88
+ * Omitted = same origin, which is what the admin UI wants. */
89
+ apiBase?: string;
90
+ /** Draw the share action. Off by default on purpose: a share button with
91
+ * no host listening to `@share` is a control that lies. */
92
+ shareEnabled?: boolean;
71
93
  }>();
72
94
 
73
95
  const emit = defineEmits<{
74
96
  (e: 'close'): void;
97
+ /** Previous / next file. −1 and +1, the SAME contract QuickLook already
98
+ * emits upward — the chevrons are a second trigger for it, not a second
99
+ * navigation mechanism. The host keeps the `file` prop in sync. */
100
+ (e: 'nav', delta: number): void;
101
+ /** The host opens its own share dialog. Only reachable when `shareEnabled`. */
102
+ (e: 'share'): void;
103
+ /** The star toggle succeeded — so the host can update its listing row. */
104
+ (e: 'starred', value: boolean): void;
75
105
  }>();
76
106
 
77
- const { t } = useLocale(() => props.locale);
107
+ const { t, formatSize, formatDate, nodeDisplayName } = useLocale(() => props.locale);
78
108
 
79
109
  function ext(f: FileNode | null): string {
80
110
  return (f?.extension || '').toLowerCase();
@@ -298,6 +328,148 @@ function stripAdapter(p: string): string {
298
328
  return idx === -1 ? p : p.slice(idx + 3);
299
329
  }
300
330
 
331
+ /* === gorunum:v1-viewer — full-bleed overlay chrome ===
332
+ *
333
+ * The card modal (title bar + Download/Close footer) is gone; what replaces
334
+ * it is one bar across the top, a chevron on each screen edge and a floating
335
+ * zoom pill. `chromeless` is untouched: the standalone /files/edit route still
336
+ * gets the bare viewer with no chrome at all, which is the whole point of that
337
+ * flag — the browser tab is its container.
338
+ */
339
+
340
+ /** Chrome is drawn for the in-page overlay only. */
341
+ const showChrome = computed(() => !props.chromeless);
342
+
343
+ const viewerEl = ref<HTMLElement | null>(null);
344
+
345
+ const tileHtml = computed(() => (props.file ? fileIconTile(props.file) : ''));
346
+ const displayName = computed(() => (props.file ? nodeDisplayName(props.file) : ''));
347
+
348
+ /** "1 of 9" — only when the host answered BOTH halves. */
349
+ const counterText = computed(() => {
350
+ const i = props.index;
351
+ const n = props.total;
352
+ if (!i || !n || n < 1 || i < 1) return '';
353
+ return t('viewer.counter', { i, n });
354
+ });
355
+
356
+ /** `246.3 KB • Sep 9, 2026 • 1 of 9`, with any unknown part left out
357
+ * rather than printed as a placeholder. */
358
+ const metaLine = computed(() => {
359
+ const f = props.file;
360
+ if (!f) return '';
361
+ const parts: string[] = [];
362
+ if (typeof f.size === 'number' && f.type === 'file') parts.push(formatSize(f.size));
363
+ const when = formatDate(f.last_modified);
364
+ if (when) parts.push(when);
365
+ if (counterText.value) parts.push(counterText.value);
366
+ return parts.join(' • ');
367
+ });
368
+
369
+ /** The chevrons appear only where prev/next actually goes somewhere. */
370
+ const canNav = computed(
371
+ () => showChrome.value && (props.navEnabled === true || (props.total ?? 0) > 1),
372
+ );
373
+
374
+ /** Starring is the real thing, not a second implementation: StarButton +
375
+ * lib/star.ts, the same pair the listing rows use. It needs the DB node id,
376
+ * which client-synthesized rows do not carry — no id, no button. */
377
+ const starNodeId = computed(() =>
378
+ typeof props.file?.id === 'number' ? (props.file.id as number) : null,
379
+ );
380
+
381
+ /**
382
+ * Which kinds get the stage stretched instead of centred. Editors and
383
+ * document surfaces want every pixel; a photo wants to sit in the middle
384
+ * of the ground with room around it.
385
+ */
386
+ const FILL_KINDS = new Set(['pdf', 'markdown', 'code', 'text', 'office', 'viewer']);
387
+ const stageModifier = computed(() =>
388
+ FILL_KINDS.has(kind.value) ? 'fe-preview--fill' : 'fe-preview--center',
389
+ );
390
+
391
+ /* --- Zoom (image only) ----------------------------------------------
392
+ *
393
+ * ⚠ The pill is hidden for pdf / office / drawio. Those three paint their
394
+ * OWN zoom control inside their surface, and a second one wired to nothing
395
+ * is a control that lies — worse than no control, because the user reads a
396
+ * percentage that the thing on screen does not obey.
397
+ */
398
+ const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];
399
+ const zoom = ref(1);
400
+ const imgEl = ref<HTMLImageElement | null>(null);
401
+ /** The width the image settles at when it is fit to the stage (zoom = 1).
402
+ * Everything else is a multiple of it, which is what makes "100%" mean
403
+ * "the size you first saw" rather than "the pixel size of the file". */
404
+ const fitWidth = ref(0);
405
+
406
+ const canZoom = computed(() => showChrome.value && kind.value === 'image');
407
+
408
+ function measureFit(): void {
409
+ if (zoom.value !== 1 || !imgEl.value) return;
410
+ const w = imgEl.value.getBoundingClientRect().width;
411
+ if (w > 0) fitWidth.value = w;
412
+ }
413
+
414
+ const imageStyle = computed(() => {
415
+ if (zoom.value === 1 || !fitWidth.value) return undefined;
416
+ return {
417
+ width: `${Math.round(fitWidth.value * zoom.value)}px`,
418
+ height: 'auto',
419
+ maxWidth: 'none',
420
+ maxHeight: 'none',
421
+ } as Record<string, string>;
422
+ });
423
+
424
+ function stepZoom(dir: 1 | -1): void {
425
+ const cur = zoom.value;
426
+ if (dir > 0) {
427
+ zoom.value = ZOOM_STEPS.find((s) => s > cur + 1e-6) ?? ZOOM_STEPS[ZOOM_STEPS.length - 1];
428
+ } else {
429
+ const lower = ZOOM_STEPS.filter((s) => s < cur - 1e-6);
430
+ zoom.value = lower.length ? lower[lower.length - 1] : ZOOM_STEPS[0];
431
+ }
432
+ }
433
+
434
+ function resetZoom(): void {
435
+ zoom.value = 1;
436
+ void nextTick(measureFit);
437
+ }
438
+
439
+ const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
440
+ const canZoomIn = computed(() => zoom.value < ZOOM_STEPS[ZOOM_STEPS.length - 1] - 1e-6);
441
+ const canZoomOut = computed(() => zoom.value > ZOOM_STEPS[0] + 1e-6);
442
+
443
+ /* --- Fullscreen ------------------------------------------------------ */
444
+ const isFullscreen = ref(false);
445
+
446
+ function onFullscreenChange(): void {
447
+ isFullscreen.value = !!document.fullscreenElement;
448
+ }
449
+
450
+ function toggleFullscreen(): void {
451
+ try {
452
+ if (document.fullscreenElement) {
453
+ void document.exitFullscreen?.();
454
+ } else {
455
+ void viewerEl.value?.requestFullscreen?.();
456
+ }
457
+ } catch {
458
+ /* a browser that refuses fullscreen just leaves the overlay as it is */
459
+ }
460
+ }
461
+
462
+ /**
463
+ * The overlay has no backdrop ring to click — the card IS the viewport — so
464
+ * the ground around the content takes that job. `.self` is what keeps it
465
+ * honest: it only fires when the click landed on the ground itself, never on
466
+ * an editor, an iframe or the image.
467
+ */
468
+ function onGroundClick(): void {
469
+ if (!showChrome.value) return;
470
+ emit('close');
471
+ }
472
+
301
473
  const loading = ref(false);
302
474
  const fetchError = ref<string | null>(null);
303
475
  const rawText = ref<string>('');
@@ -713,6 +885,10 @@ async function runOrchestration(open: boolean, url: string, k: string): Promise<
713
885
  viewerCmp.value = null;
714
886
  viewerLoadError.value = null;
715
887
  pdfFallbackToNative.value = false;
888
+ /* gorunum:v1-viewer — a new file starts at 100%. Carrying 400% over from
889
+ * the previous photo means the next one opens mid-crop with no hint why. */
890
+ zoom.value = 1;
891
+ fitWidth.value = 0;
716
892
  disposeOnlyOfficeEditor();
717
893
  disposeMonaco();
718
894
  if (!open || !url) return;
@@ -772,6 +948,7 @@ watch(
772
948
  // `officeEditor`/Monaco state below. Doing this in onMounted+nextTick
773
949
  // guarantees every `let`/`function` in the file has been hoisted.
774
950
  onMounted(() => {
951
+ document.addEventListener('fullscreenchange', onFullscreenChange);
775
952
  void nextTick(() => {
776
953
  if (!props.open) return;
777
954
  void runOrchestration(props.open, src.value, kind.value);
@@ -851,6 +1028,7 @@ function disposeOnlyOfficeEditor(): void {
851
1028
  }
852
1029
 
853
1030
  onBeforeUnmount(() => {
1031
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
854
1032
  disposeOnlyOfficeEditor();
855
1033
  disposeMonaco();
856
1034
  });
@@ -909,171 +1087,353 @@ function loadOnlyOfficeScript(base: string): Promise<void> {
909
1087
  </script>
910
1088
 
911
1089
  <template>
912
- <Modal :open="open" size="xl" :title="file?.basename || ''" :chromeless="chromeless" :theme="theme" @close="emit('close')">
913
- <div v-if="file" class="fe-preview">
914
- <template v-if="kind === 'image'">
915
- <img :src="src" :alt="file.basename" class="fe-preview__image" />
916
- </template>
917
- <template v-else-if="kind === 'video'">
918
- <video :src="src" controls preload="metadata" class="fe-preview__video" />
919
- </template>
920
- <template v-else-if="kind === 'audio'">
921
- <audio :src="src" controls class="fe-preview__audio" />
922
- </template>
923
- <template v-else-if="kind === 'pdf'">
924
- <object :data="src" type="application/pdf" class="fe-preview__iframe">
925
- <div class="fe-preview__fallback">
926
- <span class="fe-preview__fallback-icon">📕</span>
927
- <p>{{ t('viewer.pdf_inline_failed') }}</p>
928
- <a :href="download" class="fe-btn fe-btn--primary" target="_blank" rel="noopener">{{ t('viewer.open_in_new_tab') }}</a>
929
- </div>
930
- </object>
931
- </template>
932
-
933
- <template v-else-if="kind === 'markdown'">
934
- <div v-if="loading" class="fe-preview__fallback">{{ t('viewer.loading') }}</div>
935
- <div v-else-if="tooLarge" class="fe-preview__fallback">
936
- {{ t('viewer.too_large') }} <a :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
1090
+ <Modal
1091
+ :open="open"
1092
+ size="xl"
1093
+ :title="file?.basename || ''"
1094
+ :chromeless="chromeless"
1095
+ :fullbleed="!chromeless"
1096
+ :theme="theme"
1097
+ @close="emit('close')"
1098
+ >
1099
+ <!-- === gorunum:v1-viewer ===
1100
+ A full-bleed overlay, not a card: one bar across the top, a chevron on
1101
+ each screen edge, a floating zoom pill at the bottom. `chromeless`
1102
+ renders the viewer BARE (no bar, no chevrons, no pill) because the
1103
+ standalone /files/edit route's container is the browser tab itself. -->
1104
+ <div ref="viewerEl" class="fe-viewer" :class="{ 'fe-viewer--bare': chromeless }">
1105
+ <header v-if="showChrome && file" class="fe-viewer__bar">
1106
+ <div class="fe-viewer__ident">
1107
+ <span class="fe-viewer__tile" aria-hidden="true" v-html="tileHtml"></span>
1108
+ <span class="fe-viewer__idcol">
1109
+ <span class="fe-viewer__name" :title="file.path">{{ displayName }}</span>
1110
+ <span v-if="metaLine" class="fe-viewer__meta">{{ metaLine }}</span>
1111
+ </span>
937
1112
  </div>
938
- <div
939
- v-else-if="openMode === 'edit' && saveTextEndpoint"
940
- class="fe-preview__md-split"
941
- >
942
- <div class="fe-preview__md-split-bar">
943
- <span class="fe-preview__md-split-label">MARKDOWN</span>
944
- <span v-if="fetchError" class="fe-preview__md-split-error">{{ fetchError }}</span>
945
- <button
946
- type="button"
947
- class="fe-btn fe-btn--primary"
948
- :disabled="!mdDirty || mdSaving"
949
- @click="saveMarkdown"
950
- >
951
- {{ mdSaving ? t('viewer.saving') : (mdDirty ? t('viewer.save') : t('viewer.saved')) }}
952
- </button>
953
- </div>
954
- <div class="fe-preview__md-split-body">
955
- <textarea
956
- class="fe-preview__md-split-input"
957
- v-model="rawText"
958
- @input="onMdInput"
959
- @keydown.ctrl.s.prevent="saveMarkdown"
960
- @keydown.meta.s.prevent="saveMarkdown"
961
- spellcheck="false"
962
- placeholder="# Markdown buraya…"
1113
+
1114
+ <div class="fe-viewer__acts">
1115
+ <a
1116
+ :href="download"
1117
+ class="fe-viewer__act"
1118
+ :title="t('viewer.download')"
1119
+ :aria-label="t('viewer.download')"
1120
+ ><span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('download')"></span></a>
1121
+ <button
1122
+ v-if="shareEnabled"
1123
+ type="button"
1124
+ class="fe-viewer__act"
1125
+ :title="t('viewer.share')"
1126
+ :aria-label="t('viewer.share')"
1127
+ @click="emit('share')"
1128
+ ><span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('access')"></span></button>
1129
+ <!-- The listing's StarButton, not a copy of it: same component, same
1130
+ lib/star.ts request, same optimistic rollback. -->
1131
+ <StarButton
1132
+ v-if="starNodeId !== null"
1133
+ class="fe-viewer__act fe-viewer__act--star"
1134
+ :starred="file.starred === true"
1135
+ :node-id="starNodeId"
1136
+ :api-base="apiBase"
1137
+ :auth-headers="authHeaders"
1138
+ :auth-credentials="authCredentials"
1139
+ :locale="locale"
1140
+ compact
1141
+ @change="(v: boolean) => emit('starred', v)"
1142
+ />
1143
+ <button
1144
+ v-if="openMode === 'view' && canEditKind && newTabEnabled !== false /* wiring:e2 */"
1145
+ type="button"
1146
+ class="fe-viewer__act"
1147
+ :title="t('viewer.edit')"
1148
+ :aria-label="t('viewer.edit')"
1149
+ @click="openEditInNewTab"
1150
+ ><span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('rename')"></span></button>
1151
+ <button
1152
+ v-if="newTabEnabled !== false /* wiring:e2 */"
1153
+ type="button"
1154
+ class="fe-viewer__act"
1155
+ :title="t('viewer.open_in_new_tab')"
1156
+ :aria-label="t('viewer.open_in_new_tab')"
1157
+ @click="openInNewTab"
1158
+ ><span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('open-tab')"></span></button>
1159
+ </div>
1160
+
1161
+ <div class="fe-viewer__tail">
1162
+ <button
1163
+ type="button"
1164
+ class="fe-viewer__act fe-viewer__close"
1165
+ :title="t('viewer.close')"
1166
+ :aria-label="t('viewer.close')"
1167
+ @click="emit('close')"
1168
+ ><span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('close')"></span></button>
1169
+ </div>
1170
+ </header>
1171
+
1172
+ <div class="fe-viewer__stage" @click.self="onGroundClick">
1173
+ <div v-if="file" class="fe-preview" :class="stageModifier" @click.self="onGroundClick">
1174
+ <template v-if="kind === 'image'">
1175
+ <img
1176
+ ref="imgEl"
1177
+ :src="src"
1178
+ :alt="file.basename"
1179
+ class="fe-preview__image"
1180
+ :style="imageStyle"
1181
+ @load="measureFit"
963
1182
  />
1183
+ </template>
1184
+ <template v-else-if="kind === 'video'">
1185
+ <video :src="src" controls preload="metadata" class="fe-preview__video" />
1186
+ </template>
1187
+ <template v-else-if="kind === 'audio'">
1188
+ <audio :src="src" controls class="fe-preview__audio" />
1189
+ </template>
1190
+ <template v-else-if="kind === 'pdf'">
1191
+ <object :data="src" type="application/pdf" class="fe-preview__iframe">
1192
+ <div class="fe-preview__fallback">
1193
+ <!-- ikon:emoji — the file's own tile, the one the row behind
1194
+ this overlay is wearing. -->
1195
+ <!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/fileIcons -->
1196
+ <span class="fe-preview__fallback-icon" aria-hidden="true" v-html="tileHtml"></span>
1197
+ <p>{{ t('viewer.pdf_inline_failed') }}</p>
1198
+ <a :href="download" class="fe-btn fe-btn--primary" target="_blank" rel="noopener">{{ t('viewer.open_in_new_tab') }}</a>
1199
+ </div>
1200
+ </object>
1201
+ </template>
1202
+
1203
+ <template v-else-if="kind === 'markdown'">
1204
+ <div v-if="loading" class="fe-preview__fallback">{{ t('viewer.loading') }}</div>
1205
+ <div v-else-if="tooLarge" class="fe-preview__fallback">
1206
+ {{ t('viewer.too_large') }} <a :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
1207
+ </div>
964
1208
  <div
965
- v-if="markdownUnavailable"
966
- class="fe-preview__md-split-output fe-preview__md"
967
- >{{ t('viewer.peer_not_installed') }}</div>
968
- <div
1209
+ v-else-if="openMode === 'edit' && saveTextEndpoint"
1210
+ class="fe-preview__md-split"
1211
+ >
1212
+ <div class="fe-preview__md-split-bar">
1213
+ <span class="fe-preview__md-split-label">MARKDOWN</span>
1214
+ <span v-if="fetchError" class="fe-preview__md-split-error">{{ fetchError }}</span>
1215
+ <button
1216
+ type="button"
1217
+ class="fe-btn fe-btn--primary"
1218
+ :disabled="!mdDirty || mdSaving"
1219
+ @click="saveMarkdown"
1220
+ >
1221
+ {{ mdSaving ? t('viewer.saving') : (mdDirty ? t('viewer.save') : t('viewer.saved')) }}
1222
+ </button>
1223
+ </div>
1224
+ <div class="fe-preview__md-split-body">
1225
+ <textarea
1226
+ class="fe-preview__md-split-input"
1227
+ v-model="rawText"
1228
+ @input="onMdInput"
1229
+ @keydown.ctrl.s.prevent="saveMarkdown"
1230
+ @keydown.meta.s.prevent="saveMarkdown"
1231
+ spellcheck="false"
1232
+ :placeholder="t('viewer.md_placeholder')"
1233
+ />
1234
+ <div
1235
+ v-if="markdownUnavailable"
1236
+ class="fe-preview__md-split-output fe-preview__md"
1237
+ >{{ t('viewer.peer_not_installed') }}</div>
1238
+ <div
1239
+ v-else
1240
+ ref="markdownEl"
1241
+ class="fe-preview__md-split-output fe-preview__md"
1242
+ v-html="markdownHtml"
1243
+ ></div>
1244
+ </div>
1245
+ </div>
1246
+ <div v-else-if="fetchError" class="fe-preview__fallback">
1247
+ <p>{{ fetchError }}</p>
1248
+ <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
1249
+ </div>
1250
+ <div v-else-if="markdownHtml" ref="markdownEl" class="fe-preview__md" v-html="markdownHtml"></div>
1251
+ <pre v-else class="fe-preview__pre">{{ rawText }}</pre>
1252
+ </template>
1253
+
1254
+ <template v-else-if="kind === 'viewer'">
1255
+ <div v-if="viewerLoadError && !pdfFallbackToNative" class="fe-preview__fallback">
1256
+ <!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/actionIcons -->
1257
+ <span
1258
+ class="fe-preview__fallback-icon fe-preview__fallback-icon--alert"
1259
+ aria-hidden="true"
1260
+ v-html="actionIconSvg('alert')"
1261
+ ></span>
1262
+ <p>{{ viewerLoadError }}</p>
1263
+ <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
1264
+ </div>
1265
+ <div v-else-if="!viewerCmp" class="fe-preview__fallback">
1266
+ <!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/actionIcons -->
1267
+ <span
1268
+ class="fe-preview__fallback-icon fe-preview__fallback-icon--spin"
1269
+ aria-hidden="true"
1270
+ v-html="actionIconSvg('progress')"
1271
+ ></span>
1272
+ <p>{{ t('viewer.loading') }}</p>
1273
+ </div>
1274
+ <component
969
1275
  v-else
970
- ref="markdownEl"
971
- class="fe-preview__md-split-output fe-preview__md"
972
- v-html="markdownHtml"
973
- ></div>
974
- </div>
975
- </div>
976
- <div v-else-if="fetchError" class="fe-preview__fallback">
977
- <p>{{ fetchError }}</p>
978
- <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
979
- </div>
980
- <div v-else-if="markdownHtml" ref="markdownEl" class="fe-preview__md" v-html="markdownHtml"></div>
981
- <pre v-else class="fe-preview__pre">{{ rawText }}</pre>
982
- </template>
983
-
984
- <template v-else-if="kind === 'viewer'">
985
- <div v-if="viewerLoadError && !pdfFallbackToNative" class="fe-preview__fallback">
986
- <span class="fe-preview__fallback-icon">⚠️</span>
987
- <p>{{ viewerLoadError }}</p>
988
- <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
989
- </div>
990
- <div v-else-if="!viewerCmp" class="fe-preview__fallback">
991
- <span class="fe-preview__fallback-icon">⏳</span>
992
- <p>{{ t('viewer.loading') }}</p>
993
- </div>
994
- <component
995
- v-else
996
- :is="viewerCmp"
997
- v-bind="viewerProps"
998
- class="fe-preview__viewer"
999
- @fallback="onPdfFallback"
1000
- />
1001
- </template>
1002
-
1003
- <template v-else-if="kind === 'code' || kind === 'text'">
1004
- <div v-if="loading" class="fe-preview__fallback">{{ t('viewer.loading') }}</div>
1005
- <div v-else-if="tooLarge" class="fe-preview__fallback">
1006
- {{ t('viewer.too_large') }} <a :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
1007
- </div>
1008
- <div v-else-if="fetchError" class="fe-preview__fallback">
1009
- <p>{{ fetchError }}</p>
1010
- <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
1011
- </div>
1012
- <div v-else class="fe-preview__code-wrap">
1013
- <div class="fe-preview__code-toolbar">
1014
- <span class="fe-preview__code-lang">{{ codeLanguage }}</span>
1015
- <span v-if="!monacoReady && codeHtml" class="fe-preview__code-status">
1016
- {{ saveTextEndpoint && !monacoUnavailable ? t('viewer.editor_loading') : t('viewer.read_only') }}
1017
- </span>
1018
- <span v-if="saveOk" class="fe-preview__code-status fe-preview__code-status--ok">✓ {{ t('viewer.saved') }}</span>
1019
- <span v-if="saveError" class="fe-preview__code-status fe-preview__code-status--err" :title="saveError">✗ {{ t('viewer.save_error') }}</span>
1020
- <span v-if="openMode === 'view'" class="fe-preview__code-status">{{ t('viewer.read_only') }}</span>
1021
- <button
1022
- v-else-if="saveTextEndpoint && monacoReady"
1023
- type="button"
1024
- class="fe-btn fe-btn--primary"
1025
- :disabled="saving"
1026
- @click="saveCode"
1027
- >{{ saving ? t('viewer.saving') : t('viewer.save') }}</button>
1028
- </div>
1029
- <!-- Monaco target — hidden until ready, then occupies the slot. -->
1030
- <div ref="monacoEl" class="fe-preview__code-editor" :class="{ 'is-hidden': !monacoReady }" />
1031
- <!-- Highlight.js read-only fallback — visible until Monaco mounts. -->
1032
- <pre
1033
- v-if="!monacoReady && codeHtml"
1034
- class="fe-preview__pre fe-preview__code hljs"
1035
- ><code :class="`language-${codeLanguage}`" v-html="codeHtml"></code></pre>
1036
- <pre
1037
- v-else-if="!monacoReady && !codeHtml"
1038
- class="fe-preview__pre"
1039
- :data-lang="codeLanguage"
1040
- >{{ rawText }}</pre>
1276
+ :is="viewerCmp"
1277
+ v-bind="viewerProps"
1278
+ class="fe-preview__viewer"
1279
+ @fallback="onPdfFallback"
1280
+ />
1281
+ </template>
1282
+
1283
+ <template v-else-if="kind === 'code' || kind === 'text'">
1284
+ <div v-if="loading" class="fe-preview__fallback">{{ t('viewer.loading') }}</div>
1285
+ <div v-else-if="tooLarge" class="fe-preview__fallback">
1286
+ {{ t('viewer.too_large') }} <a :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
1287
+ </div>
1288
+ <div v-else-if="fetchError" class="fe-preview__fallback">
1289
+ <p>{{ fetchError }}</p>
1290
+ <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
1291
+ </div>
1292
+ <div v-else class="fe-preview__code-wrap">
1293
+ <div class="fe-preview__code-toolbar">
1294
+ <!-- ⚠ lang="en". This badge holds a highlight.js language ID
1295
+ ("typescript", "ini", "nginx") — a technical token, never
1296
+ translated prose — and the CSS uppercases it. CSS
1297
+ text-transform is LOCALE-AWARE: under the Turkish UI the
1298
+ document is lang="tr", so "typescript" uppercased to
1299
+ "TYPESCRİPT" with a dotted İ (measured 2026-09-13). The
1300
+ sibling uppercase labels (.fe-sidenav__heading and friends)
1301
+ hold real Turkish words where "ETİKETLER" is exactly right,
1302
+ so the transform stays and only this one token opts out. -->
1303
+ <span class="fe-preview__code-lang" lang="en">{{ codeLanguage }}</span>
1304
+ <span v-if="!monacoReady && codeHtml" class="fe-preview__code-status">
1305
+ {{ saveTextEndpoint && !monacoUnavailable ? t('viewer.editor_loading') : t('viewer.read_only') }}
1306
+ </span>
1307
+ <span v-if="saveOk" class="fe-preview__code-status fe-preview__code-status--ok">
1308
+ <!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/actionIcons -->
1309
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('check')"></span>
1310
+ {{ t('viewer.saved') }}
1311
+ </span>
1312
+ <span v-if="saveError" class="fe-preview__code-status fe-preview__code-status--err" :title="saveError">
1313
+ <!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/actionIcons -->
1314
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('close')"></span>
1315
+ {{ t('viewer.save_error') }}
1316
+ </span>
1317
+ <span v-if="openMode === 'view'" class="fe-preview__code-status">{{ t('viewer.read_only') }}</span>
1318
+ <button
1319
+ v-else-if="saveTextEndpoint && monacoReady"
1320
+ type="button"
1321
+ class="fe-btn fe-btn--primary"
1322
+ :disabled="saving"
1323
+ @click="saveCode"
1324
+ >{{ saving ? t('viewer.saving') : t('viewer.save') }}</button>
1325
+ </div>
1326
+ <!-- Monaco target hidden until ready, then occupies the slot. -->
1327
+ <div ref="monacoEl" class="fe-preview__code-editor" :class="{ 'is-hidden': !monacoReady }" />
1328
+ <!-- Highlight.js read-only fallback — visible until Monaco mounts. -->
1329
+ <pre
1330
+ v-if="!monacoReady && codeHtml"
1331
+ class="fe-preview__pre fe-preview__code hljs"
1332
+ ><code :class="`language-${codeLanguage}`" v-html="codeHtml"></code></pre>
1333
+ <pre
1334
+ v-else-if="!monacoReady && !codeHtml"
1335
+ class="fe-preview__pre"
1336
+ :data-lang="codeLanguage"
1337
+ >{{ rawText }}</pre>
1338
+ </div>
1339
+ </template>
1340
+
1341
+ <template v-else-if="kind === 'office'">
1342
+ <div v-if="officeError" class="fe-preview__fallback" data-testid="office-fallback">
1343
+ <!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/fileIcons -->
1344
+ <span class="fe-preview__fallback-icon" aria-hidden="true" v-html="tileHtml"></span>
1345
+ <p>{{ officeError }}</p>
1346
+ <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
1347
+ </div>
1348
+ <div v-else ref="officeEl" class="fe-preview__office" />
1349
+ </template>
1350
+
1351
+ <template v-else>
1352
+ <div class="fe-preview__fallback">
1353
+ <!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/fileIcons -->
1354
+ <span class="fe-preview__fallback-icon" aria-hidden="true" v-html="tileHtml"></span>
1355
+ <p>{{ t('viewer.no_preview') }}</p>
1356
+ <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
1357
+ </div>
1358
+ </template>
1041
1359
  </div>
1042
- </template>
1360
+ </div>
1043
1361
 
1044
- <template v-else-if="kind === 'office'">
1045
- <div v-if="officeError" class="fe-preview__fallback" data-testid="office-fallback">
1046
- <span class="fe-preview__fallback-icon">📄</span>
1047
- <p>{{ officeError }}</p>
1048
- <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
1049
- </div>
1050
- <div v-else ref="officeEl" class="fe-preview__office" />
1051
- </template>
1052
-
1053
- <template v-else>
1054
- <div class="fe-preview__fallback">
1055
- <span class="fe-preview__fallback-icon">📎</span>
1056
- <p>{{ t('viewer.no_preview') }}</p>
1057
- <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
1058
- </div>
1059
- </template>
1060
- </div>
1061
- <template #actions>
1062
1362
  <button
1063
- v-if="file && openMode === 'view' && canEditKind && newTabEnabled !== false /* wiring:e2 */"
1363
+ v-if="canNav"
1064
1364
  type="button"
1065
- class="fe-btn fe-btn--primary"
1066
- @click="openEditInNewTab"
1067
- >✏ {{ t('viewer.edit') }}</button>
1365
+ class="fe-viewer__chev fe-viewer__chev--prev"
1366
+ :title="t('viewer.nav_prev')"
1367
+ :aria-label="t('viewer.nav_prev')"
1368
+ @click="emit('nav', -1)"
1369
+ >
1370
+ <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
1371
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"
1372
+ aria-hidden="true" focusable="false"><path d="M15 4.5L7.5 12 15 19.5" /></svg>
1373
+ </button>
1068
1374
  <button
1069
- v-if="file && newTabEnabled !== false /* wiring:e2 */"
1375
+ v-if="canNav"
1070
1376
  type="button"
1071
- class="fe-btn"
1072
- @click="openInNewTab"
1073
- >↗ {{ t('viewer.open_in_new_tab') }}</button>
1074
- <a v-if="file" :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
1075
- <button type="button" class="fe-btn" @click="emit('close')">{{ t('viewer.close') }}</button>
1076
- </template>
1377
+ class="fe-viewer__chev fe-viewer__chev--next"
1378
+ :title="t('viewer.nav_next')"
1379
+ :aria-label="t('viewer.nav_next')"
1380
+ @click="emit('nav', 1)"
1381
+ >
1382
+ <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
1383
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"
1384
+ aria-hidden="true" focusable="false"><path d="M9 4.5L16.5 12 9 19.5" /></svg>
1385
+ </button>
1386
+
1387
+ <!-- Zoom pill. Shown for the image viewer only - pdf, office and drawio
1388
+ paint their own zoom inside their surface, and a second percentage
1389
+ that the content does not obey is a control that lies. -->
1390
+ <div v-if="canZoom" class="fe-viewer__zoom">
1391
+ <button
1392
+ type="button"
1393
+ class="fe-viewer__zoom-btn"
1394
+ :disabled="!canZoomOut"
1395
+ :title="t('viewer.zoom_out')"
1396
+ :aria-label="t('viewer.zoom_out')"
1397
+ @click="stepZoom(-1)"
1398
+ >&#8722;</button>
1399
+ <button
1400
+ type="button"
1401
+ class="fe-viewer__zoom-level"
1402
+ :title="t('viewer.zoom_reset')"
1403
+ :aria-label="t('viewer.zoom_reset')"
1404
+ @click="resetZoom"
1405
+ >{{ zoomLabel }}</button>
1406
+ <button
1407
+ type="button"
1408
+ class="fe-viewer__zoom-btn"
1409
+ :disabled="!canZoomIn"
1410
+ :title="t('viewer.zoom_in')"
1411
+ :aria-label="t('viewer.zoom_in')"
1412
+ @click="stepZoom(1)"
1413
+ >+</button>
1414
+ <span class="fe-viewer__zoom-sep" aria-hidden="true"></span>
1415
+ <button
1416
+ type="button"
1417
+ class="fe-viewer__zoom-btn"
1418
+ :title="isFullscreen ? t('viewer.exit_fullscreen') : t('viewer.fullscreen')"
1419
+ :aria-label="isFullscreen ? t('viewer.exit_fullscreen') : t('viewer.fullscreen')"
1420
+ @click="toggleFullscreen"
1421
+ >
1422
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
1423
+ stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"
1424
+ aria-hidden="true" focusable="false">
1425
+ <template v-if="isFullscreen">
1426
+ <path d="M9.5 4.5v5h-5" /><path d="M14.5 4.5v5h5" />
1427
+ <path d="M9.5 19.5v-5h-5" /><path d="M14.5 19.5v-5h5" />
1428
+ </template>
1429
+ <template v-else>
1430
+ <path d="M4.5 9.5v-5h5" /><path d="M19.5 9.5v-5h-5" />
1431
+ <path d="M4.5 14.5v5h5" /><path d="M19.5 14.5v5h-5" />
1432
+ </template>
1433
+ </svg>
1434
+ </button>
1435
+ </div>
1436
+ </div>
1077
1437
  </Modal>
1078
1438
  </template>
1079
1439