@brftech/filex-core 0.13.4 → 0.15.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 (53) hide show
  1. package/dist/{ArchiveViewer-CxdOyBhs.js → ArchiveViewer-B9JSFdX6.js} +2 -2
  2. package/dist/ArchiveViewer-B9JSFdX6.js.map +1 -0
  3. package/dist/{CsvViewer-JG3nzTgf.js → CsvViewer-DxujFEM3.js} +2 -2
  4. package/dist/CsvViewer-DxujFEM3.js.map +1 -0
  5. package/dist/{DrawioViewer-BEYLdtPG.js → DrawioViewer-DCvWnX2t.js} +3 -3
  6. package/dist/DrawioViewer-DCvWnX2t.js.map +1 -0
  7. package/dist/{EpubViewer-BwUDmJm4.js → EpubViewer-BPmlImG7.js} +2 -2
  8. package/dist/EpubViewer-BPmlImG7.js.map +1 -0
  9. package/dist/{IpynbViewer-CuHQoAHU.js → IpynbViewer-CDlQmuCV.js} +2 -2
  10. package/dist/IpynbViewer-CDlQmuCV.js.map +1 -0
  11. package/dist/{MermaidViewer-DLmijDXo.js → MermaidViewer-sfZWdR-d.js} +19 -19
  12. package/dist/MermaidViewer-sfZWdR-d.js.map +1 -0
  13. package/dist/{PsdViewer-D-FM4eNi.js → PsdViewer-DwuySZsb.js} +2 -2
  14. package/dist/PsdViewer-DwuySZsb.js.map +1 -0
  15. package/dist/{TiffViewer-DOw4z_dO.js → TiffViewer-BDQ3d3CQ.js} +8 -8
  16. package/dist/TiffViewer-BDQ3d3CQ.js.map +1 -0
  17. package/dist/common-xdTjul80.js +12982 -0
  18. package/dist/common-xdTjul80.js.map +1 -0
  19. package/dist/filex-core.js +4950 -4822
  20. package/dist/filex-core.js.map +1 -1
  21. package/dist/filex-core.umd.cjs +45 -41
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/index.d.ts +34 -2
  24. package/dist/style.css +1 -1
  25. package/package.json +1 -1
  26. package/src/FileExplorer.vue +94 -26
  27. package/src/components/QuickLook.vue +1 -1
  28. package/src/components/ThemeGallery.vue +64 -1
  29. package/src/composables/useFileApi.ts +29 -9
  30. package/src/composables/useMonacoLoader.ts +1 -1
  31. package/src/lib/themes.ts +67 -0
  32. package/src/locales/en.ts +5 -1
  33. package/src/locales/tr.ts +5 -1
  34. package/src/modals/PreviewModal.vue +33 -6
  35. package/src/styles/base.css +125 -0
  36. package/src/types/ExplorerConfig.ts +13 -0
  37. package/src/viewers/ArchiveViewer.vue +2 -2
  38. package/src/viewers/CsvViewer.vue +2 -2
  39. package/src/viewers/DrawioViewer.vue +3 -3
  40. package/src/viewers/EpubViewer.vue +2 -2
  41. package/src/viewers/IpynbViewer.vue +2 -2
  42. package/src/viewers/MermaidViewer.vue +2 -2
  43. package/src/viewers/PdfViewer.vue +3 -3
  44. package/src/viewers/PsdViewer.vue +2 -2
  45. package/src/viewers/TiffViewer.vue +2 -2
  46. package/dist/ArchiveViewer-CxdOyBhs.js.map +0 -1
  47. package/dist/CsvViewer-JG3nzTgf.js.map +0 -1
  48. package/dist/DrawioViewer-BEYLdtPG.js.map +0 -1
  49. package/dist/EpubViewer-BwUDmJm4.js.map +0 -1
  50. package/dist/IpynbViewer-CuHQoAHU.js.map +0 -1
  51. package/dist/MermaidViewer-DLmijDXo.js.map +0 -1
  52. package/dist/PsdViewer-D-FM4eNi.js.map +0 -1
  53. package/dist/TiffViewer-DOw4z_dO.js.map +0 -1
@@ -15,7 +15,7 @@
15
15
  * the difference.
16
16
  */
17
17
  import { computed, customRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
18
- import type { ExplorerConfig } from './types/ExplorerConfig';
18
+ import type { ExplorerConfig, ThemeMode } from './types/ExplorerConfig';
19
19
  import type {
20
20
  FileNode,
21
21
  ShareInfo,
@@ -53,7 +53,13 @@ import ShortcutsHelp from './components/ShortcutsHelp.vue';
53
53
  /* /cila:c wiring */
54
54
  /* wiring:c1 — tema galerisi */
55
55
  import ThemeGallery from './components/ThemeGallery.vue';
56
- import { useThemeState, applyThemeToEl, syncThemeStyle } from './lib/themes';
56
+ import {
57
+ useThemeState,
58
+ useThemeModeState,
59
+ applyThemeToEl,
60
+ syncThemeStyle,
61
+ type ThemeModePref,
62
+ } from './lib/themes';
57
63
  /* /wiring:c1 */
58
64
  /* wiring:c2 — shortcut settings modal + Space quick-look overlay */
59
65
  import ShortcutSettings from './components/ShortcutSettings.vue';
@@ -1372,9 +1378,10 @@ function openNodeInNewTab(n: FileNode) {
1372
1378
  // mode so OnlyOffice / Monaco mount with write permissions.
1373
1379
  // Read-only inspection lives on the "Önizle" entry + the dbl-click
1374
1380
  // in-page modal.
1375
- const sep = base.includes('?') ? '&' : '?';
1381
+ const abs = absolutePageUrl(base);
1382
+ const sep = abs.includes('?') ? '&' : '?';
1376
1383
  const url =
1377
- `${base}${sep}path=${encodeURIComponent(n.path)}` +
1384
+ `${abs}${sep}path=${encodeURIComponent(n.path)}` +
1378
1385
  `&type=${encodeURIComponent(ext)}` +
1379
1386
  `&mode=edit`;
1380
1387
  window.open(url, '_blank', 'noopener');
@@ -2287,14 +2294,50 @@ async function onCopyPath(adapterPath: string) {
2287
2294
  }
2288
2295
  }
2289
2296
 
2290
- // Sync auth-headers builder for PreviewModal fetches against the
2291
- // OnlyOffice config endpoint and the saveText endpoint need real
2292
- // headers, not promises. Function-token bearers will use the cached
2293
- // token; first-call resolution happens via the async path elsewhere.
2297
+ // Auth-headers builder handed to PreviewModal, QuickLook and every viewer.
2298
+ //
2299
+ // ⚠⚠ ASYNC on purpose. This used to call `authHeadersSync`, which drops the
2300
+ // bearer entirely when the embedder supplies a token FUNCTION rather than a
2301
+ // string — the shape the desktop app uses so the credential is fetched per
2302
+ // call instead of sitting in the page. The result was an Authorization-less
2303
+ // request and a 401 on the OnlyOffice config endpoint, the starred list, the
2304
+ // recently-opened POST and every viewer that fetches its own bytes. Consumers
2305
+ // must `await` this; the guard test in web/tests/api/authHeaders.test.ts fails
2306
+ // the build if one forgets.
2294
2307
  function buildAuthHeaders(extra: Record<string, string> = {}) {
2295
- return api.authHeadersSync({ ...extra });
2308
+ return api.authHeaders({ ...extra });
2296
2309
  }
2297
2310
 
2311
+ /** The standalone viewer/editor route the backend serves next to the API. */
2312
+ const STANDALONE_ROUTE = '/files/edit';
2313
+
2314
+ /**
2315
+ * Makes a page route absolute against the API host.
2316
+ *
2317
+ * ⚠ A root-relative route like `/files/edit` is resolved by the browser
2318
+ * against the PAGE, not the API — which is right when the explorer is embedded
2319
+ * in the same app that serves the route, and wrong for every embed served from
2320
+ * a different origin. In the desktop app the page origin is `app://filex`, so
2321
+ * "Open in new tab" asked the OS to open `app://filex/files/edit?…`: no handler
2322
+ * for that scheme exists, so the click did nothing at all, silently. Measured
2323
+ * 2026-08-10.
2324
+ */
2325
+ function absolutePageUrl(base: string): string {
2326
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(base)) return base; // already absolute
2327
+ const api = props.config.apiBase;
2328
+ if (!api) return base; // same-origin embed — the browser resolves it correctly
2329
+ try {
2330
+ return new URL(base, api.endsWith('/') ? api : `${api}/`).toString();
2331
+ } catch {
2332
+ return base;
2333
+ }
2334
+ }
2335
+
2336
+ /** Where the modal's "Open in new tab" / "Edit" buttons should land. */
2337
+ const effectiveViewerBaseUrl = computed(() =>
2338
+ absolutePageUrl(props.config.viewerBaseUrl || STANDALONE_ROUTE),
2339
+ );
2340
+
2298
2341
  /* === wiring:c1 — tema galerisi ===
2299
2342
  * Selected theme (shared module state, localStorage `filex.theme`) is applied
2300
2343
  * as inline `--fe-*` variables on the explorer root, resolved to the active
@@ -2304,8 +2347,20 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
2304
2347
  * WHICH variant of the theme paints. */
2305
2348
  const showThemeGallery = ref(false);
2306
2349
  const { themeId: activeThemeId, setTheme: setActiveTheme } = useThemeState();
2307
- // Resolved mode: explicit config wins, otherwise the OS preference the same
2308
- // logic variables.css encodes in CSS. The inline root variables beat every
2350
+ // The light/dark MODE the person looking at it picked in the gallery. 'host'
2351
+ // (the default) defers to whatever the embedder passed, so no existing embed
2352
+ // changes appearance until someone actually chooses.
2353
+ //
2354
+ // ⚠ Everything that used to read `config.theme` reads `themeMode` instead — a
2355
+ // choice that only reached the token resolution would leave the root class, the
2356
+ // modals and the teleported menus painting the OLD mode, which is exactly the
2357
+ // half-dark UI that bug looks like.
2358
+ const { themeMode: themeModePref, setThemeMode: setThemeModePref } = useThemeModeState();
2359
+ const themeMode = computed<ThemeMode>(() =>
2360
+ themeModePref.value === 'host' ? props.config.theme || 'auto' : themeModePref.value,
2361
+ );
2362
+ // Resolved mode: an explicit choice wins, otherwise the OS preference — the
2363
+ // same logic variables.css encodes in CSS. The inline root variables beat every
2309
2364
  // stylesheet rule, so they must track this resolution at runtime.
2310
2365
  const themeMq =
2311
2366
  typeof window !== 'undefined' && window.matchMedia
@@ -2318,7 +2373,7 @@ function onThemeMqChange(e: MediaQueryListEvent) {
2318
2373
  onMounted(() => themeMq?.addEventListener?.('change', onThemeMqChange));
2319
2374
  onBeforeUnmount(() => themeMq?.removeEventListener?.('change', onThemeMqChange));
2320
2375
  const themeResolvedDark = computed(
2321
- () => props.config.theme === 'dark' || (props.config.theme !== 'light' && themeOsDark.value),
2376
+ () => themeMode.value === 'dark' || (themeMode.value !== 'light' && themeOsDark.value),
2322
2377
  );
2323
2378
  watch(
2324
2379
  [activeThemeId, themeResolvedDark, rootEl],
@@ -2527,7 +2582,6 @@ const tabsApi = useTabs({ storageKey: tabsStorageKey() });
2527
2582
  const tabsRestored = tabsApi.restore();
2528
2583
  const tabsActiveId = tabsApi.activeId;
2529
2584
 
2530
- const tabsVisible = computed(() => tabsApi.hasMultiple.value);
2531
2585
  const activeSplit = computed(() => tabsApi.activeTab.value?.split ?? null);
2532
2586
 
2533
2587
  // Sekme adı OTOMATİK = güncel klasör adı (kök = depo adı / kök etiketi).
@@ -2541,6 +2595,17 @@ const tabItems = computed(() =>
2541
2595
  tabsApi.tabs.value.map((tb) => ({ id: tb.id, label: tabLabel(tb.path), split: !!tb.split })),
2542
2596
  );
2543
2597
 
2598
+ // A second tab always shows the strip; `tabStrip: 'always'` keeps it there for
2599
+ // the first one too (see ExplorerConfig).
2600
+ // ⚠ Gated on there being a tab at all, not on the flag alone: tabs are seeded
2601
+ // in onMounted, so an 'always' host would otherwise paint one frame of an empty
2602
+ // strip — a lone `+` floating above the toolbar.
2603
+ const tabsVisible = computed(
2604
+ () =>
2605
+ tabsApi.hasMultiple.value ||
2606
+ (props.config.tabStrip === 'always' && tabItems.value.length > 0),
2607
+ );
2608
+
2544
2609
  // Aktif tab kullanıcıyı izler: gezinme + görünüm değişimi snapshot'a yazılır.
2545
2610
  watch(currentPath, (p) => tabsApi.syncActive({ path: p }));
2546
2611
  watch(viewMode, (v) => tabsApi.syncActive({ viewMode: v }));
@@ -2994,8 +3059,8 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
2994
3059
  ref="rootEl"
2995
3060
  class="fe"
2996
3061
  :class="{
2997
- 'fe--theme-light': config.theme === 'light',
2998
- 'fe--theme-dark': config.theme === 'dark',
3062
+ 'fe--theme-light': themeMode === 'light',
3063
+ 'fe--theme-dark': themeMode === 'dark',
2999
3064
  'fe--is-dragover': dragOver,
3000
3065
  'fe--density-compact': density === 'compact' /* cila:a density */,
3001
3066
  'fe--narrow': isNarrow /* bag:b4 */,
@@ -3036,7 +3101,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3036
3101
  :can-write="canWriteHere"
3037
3102
  :locale="locale"
3038
3103
  :narrow="isNarrow /* bag:b4 */"
3039
- :theme="config.theme || 'auto' /* bag:b4 */"
3104
+ :theme="themeMode /* bag:b4 */"
3040
3105
  :inspector-open="showInspector /* koru:k1 */"
3041
3106
  @toggle-inspector="toggleInspector /* koru:k1 */"
3042
3107
  @open-theme="showThemeGallery = true /* wiring:c1 */"
@@ -3474,7 +3539,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3474
3539
  <ContextMenu
3475
3540
  ref="ctxRef"
3476
3541
  :locale="locale"
3477
- :theme="config.theme || 'auto'"
3542
+ :theme="themeMode"
3478
3543
  :sheet="isCoarse /* bag:b4 */"
3479
3544
  :actions="contextActions"
3480
3545
  @select="onContextAction"
@@ -3523,7 +3588,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3523
3588
  :open="showPreview"
3524
3589
  :locale="locale"
3525
3590
  :file="previewTarget"
3526
- :theme="config.theme || 'auto'"
3591
+ :theme="themeMode"
3527
3592
  :preview-url="(p) => e2ePreviewSrc(p) /* wiring:e2 — çözülmüş blob > ham URL */"
3528
3593
  :download-url="(p) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
3529
3594
  :only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 — OO ciphertext açamaz */"
@@ -3536,7 +3601,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3536
3601
  :drawio-url="effectiveDrawioUrl"
3537
3602
  :pdf-worker-url="props.config.pdfWorkerUrl || null"
3538
3603
  :pdf-save-url="props.config.pdfSaveUrl || null"
3539
- :viewer-base-url="props.config.viewerBaseUrl || null"
3604
+ :viewer-base-url="effectiveViewerBaseUrl"
3540
3605
  @close="showPreview = false"
3541
3606
  />
3542
3607
  <ConvertModal
@@ -3568,8 +3633,8 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3568
3633
  v-if="showRecents"
3569
3634
  class="fe fe-modal__backdrop fe-recents__backdrop"
3570
3635
  :class="{
3571
- 'fe--theme-light': config.theme === 'light',
3572
- 'fe--theme-dark': config.theme === 'dark',
3636
+ 'fe--theme-light': themeMode === 'light',
3637
+ 'fe--theme-dark': themeMode === 'dark',
3573
3638
  }"
3574
3639
  @click="showRecents = false"
3575
3640
  >
@@ -3656,11 +3721,14 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3656
3721
  <ThemeGallery
3657
3722
  :open="showThemeGallery"
3658
3723
  :locale="locale"
3659
- :theme="config.theme || 'auto'"
3724
+ :theme="themeMode"
3660
3725
  :dark="themeResolvedDark"
3661
3726
  :current="activeThemeId"
3727
+ :mode="themeModePref"
3728
+ :host-mode="config.theme || 'auto'"
3662
3729
  @close="showThemeGallery = false"
3663
3730
  @select="setActiveTheme"
3731
+ @mode="(m: ThemeModePref) => setThemeModePref(m)"
3664
3732
  />
3665
3733
  <!-- /wiring:c1 -->
3666
3734
 
@@ -3694,14 +3762,14 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3694
3762
  <ShortcutSettings
3695
3763
  :open="showShortcutSettings"
3696
3764
  :locale="locale"
3697
- :theme="config.theme || 'auto'"
3765
+ :theme="themeMode"
3698
3766
  @close="showShortcutSettings = false"
3699
3767
  />
3700
3768
  <QuickLook
3701
3769
  :open="quickLookOpen"
3702
3770
  :locale="locale"
3703
3771
  :file="quickLookTarget"
3704
- :theme="config.theme || 'auto'"
3772
+ :theme="themeMode"
3705
3773
  :preview-url="(p: string) => e2ePreviewSrc(p) /* wiring:e2 */"
3706
3774
  :download-url="(p: string) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
3707
3775
  :only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 */"
@@ -3710,7 +3778,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3710
3778
  :auth-credentials="api.credentialsMode()"
3711
3779
  :drawio-url="effectiveDrawioUrl"
3712
3780
  :pdf-worker-url="props.config.pdfWorkerUrl || null"
3713
- :viewer-base-url="props.config.viewerBaseUrl || null"
3781
+ :viewer-base-url="effectiveViewerBaseUrl"
3714
3782
  @close="quickLookOpen = false"
3715
3783
  @nav="quickLookNav"
3716
3784
  @open-full="quickLookOpenFull"
@@ -3721,7 +3789,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3721
3789
  :open="showTour"
3722
3790
  :locale="locale"
3723
3791
  :root="rootEl"
3724
- :theme="config.theme || 'auto'"
3792
+ :theme="themeMode"
3725
3793
  @close="onTourClose"
3726
3794
  />
3727
3795
  <!-- /wiring:c4 -->
@@ -32,7 +32,7 @@ const props = defineProps<{
32
32
  downloadUrl: (path: string) => string;
33
33
  onlyOfficeBase?: string | null;
34
34
  onlyOfficeConfigEndpoint?: string | null;
35
- authHeaders?: () => Record<string, string>;
35
+ authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
36
36
  authCredentials?: RequestCredentials;
37
37
  drawioUrl?: string | null;
38
38
  pdfWorkerUrl?: string | null;
@@ -11,9 +11,10 @@
11
11
  * Presentational only — selection state + persistence live in
12
12
  * lib/themes.ts (shared across instances); the parent forwards `select`.
13
13
  */
14
+ import { computed } from 'vue';
14
15
  import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
15
16
  import { useLocale } from '../composables/useLocale';
16
- import { THEMES, DEFAULT_THEME_ID, type ThemeDef } from '../lib/themes';
17
+ import { THEMES, DEFAULT_THEME_ID, type ThemeDef, type ThemeModePref } from '../lib/themes';
17
18
  import Modal from '../modals/Modal.vue';
18
19
 
19
20
  const props = defineProps<{
@@ -25,15 +26,37 @@ const props = defineProps<{
25
26
  dark: boolean;
26
27
  /** Currently selected theme id. */
27
28
  current: string;
29
+ /** The user's own light/dark preference; 'host' = never chosen. */
30
+ mode: ThemeModePref;
31
+ /** What the embedder asked for — what 'host' resolves to. */
32
+ hostMode: ThemeMode;
28
33
  }>();
29
34
 
30
35
  const emit = defineEmits<{
31
36
  (e: 'close'): void;
32
37
  (e: 'select', id: string): void;
38
+ (e: 'mode', mode: ThemeModePref): void;
33
39
  }>();
34
40
 
35
41
  const { t } = useLocale(() => props.locale);
36
42
 
43
+ /* ---- light / dark / auto -------------------------------------------
44
+ * A theme says which palette; this says whether it paints its light or its
45
+ * dark variant. Two separate questions, one place to answer both — the theme
46
+ * button is where people come looking for "night mode".
47
+ *
48
+ * Only three choices are ever offered. 'host' is an internal default, not an
49
+ * option: when it is still in force the control highlights whichever of the
50
+ * three the embedder asked for, so the strip always shows the truth, and the
51
+ * first click pins an explicit choice. */
52
+ const MODES: { id: Exclude<ThemeModePref, 'host'>; labelKey: string }[] = [
53
+ { id: 'light', labelKey: 'theme.mode.light' },
54
+ { id: 'dark', labelKey: 'theme.mode.dark' },
55
+ { id: 'auto', labelKey: 'theme.mode.auto' },
56
+ ];
57
+
58
+ const shownMode = computed(() => (props.mode === 'host' ? props.hostMode : props.mode));
59
+
37
60
  /** Inline variables for one card — a self-contained mini `--fe-*` scope,
38
61
  * so the preview markup can use the exact same tokens the real UI uses. */
39
62
  function cardVars(th: ThemeDef): Record<string, string> {
@@ -45,6 +68,46 @@ const DEFAULT_ID = DEFAULT_THEME_ID;
45
68
 
46
69
  <template>
47
70
  <Modal :open="open" :title="t('theme.title')" size="lg" :theme="theme" @close="emit('close')">
71
+ <div class="fe-thememode" role="group" :aria-label="t('theme.mode.label')">
72
+ <span class="fe-thememode__label">{{ t('theme.mode.label') }}</span>
73
+ <div class="fe-thememode__seg">
74
+ <button
75
+ v-for="m in MODES"
76
+ :key="m.id"
77
+ type="button"
78
+ class="fe-thememode__opt"
79
+ :class="{ 'is-active': shownMode === m.id }"
80
+ :aria-pressed="shownMode === m.id"
81
+ @click="emit('mode', m.id)"
82
+ >
83
+ <svg
84
+ class="fe-ficon"
85
+ viewBox="0 0 24 24"
86
+ fill="none"
87
+ stroke="currentColor"
88
+ stroke-width="1.8"
89
+ stroke-linecap="round"
90
+ stroke-linejoin="round"
91
+ aria-hidden="true"
92
+ focusable="false"
93
+ >
94
+ <template v-if="m.id === 'light'">
95
+ <circle cx="12" cy="12" r="4" />
96
+ <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
97
+ </template>
98
+ <template v-else-if="m.id === 'dark'">
99
+ <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
100
+ </template>
101
+ <template v-else>
102
+ <!-- Half-filled disc: the mode that is neither, decided elsewhere. -->
103
+ <circle cx="12" cy="12" r="9" />
104
+ <path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none" />
105
+ </template>
106
+ </svg>
107
+ <span>{{ t(m.labelKey) }}</span>
108
+ </button>
109
+ </div>
110
+ </div>
48
111
  <p class="fe-themes__hint">{{ t('theme.hint') }}</p>
49
112
  <div class="fe-themes" role="listbox" :aria-label="t('theme.title')">
50
113
  <button
@@ -259,11 +259,27 @@ export function useFileApi(config: ExplorerConfig) {
259
259
  const endpoints = resolveEndpoints(config);
260
260
  const authConf = normalizeAuth(config.auth);
261
261
 
262
+ /**
263
+ * Last bearer value a function-token actually produced.
264
+ *
265
+ * ⚠ Exists for `authHeadersSync` only. A function token is resolved
266
+ * asynchronously (the desktop app fetches it from the main process per
267
+ * call), and a synchronous caller cannot wait for that — before this cache
268
+ * it simply emitted NO Authorization header, so the request went out
269
+ * anonymous and came back 401. Remembering the last value turns "no
270
+ * credential at all" into "the credential we last held", which is the
271
+ * difference between a dead feature and a stale-token retry.
272
+ */
273
+ let lastBearer: string | null = null;
274
+
262
275
  async function authHeaders(extra: Record<string, string> = {}): Promise<Record<string, string>> {
263
276
  const h: Record<string, string> = { Accept: 'application/json', ...extra };
264
277
  if (authConf.kind === 'bearer') {
265
278
  const token = await resolveToken(authConf.token);
266
- if (token) h.Authorization = `Bearer ${token}`;
279
+ if (token) {
280
+ h.Authorization = `Bearer ${token}`;
281
+ lastBearer = token;
282
+ }
267
283
  } else if (authConf.kind === 'csrf') {
268
284
  h['X-CSRF-TOKEN'] = authConf.csrf;
269
285
  h['X-Requested-With'] = 'XMLHttpRequest';
@@ -275,17 +291,21 @@ export function useFileApi(config: ExplorerConfig) {
275
291
  }
276
292
 
277
293
  /**
278
- * Sync auth-header builder for callers that need headers in a
279
- * non-async context (XMLHttpRequest, OnlyOffice config endpoint
280
- * post-mounted PreviewModal). Function-token bearers are *resolved
281
- * lazily* via the async path; here we just include the cached value
282
- * if any. Component code should prefer the async `authHeaders()`
283
- * this is a defensive escape hatch.
294
+ * Sync auth-header builder for the few callers that genuinely cannot await
295
+ * (XMLHttpRequest's `setRequestHeader` loop).
296
+ *
297
+ * Prefer `authHeaders()`. A function token can only be *resolved*
298
+ * asynchronously, so this returns the last value one produced which is
299
+ * nothing at all until the first async call has run. Measured on 2026-08-10
300
+ * in the desktop app: the OnlyOffice config POST, the starred list and the
301
+ * recently-opened POST all went out with no Authorization header and came
302
+ * back 401, because every one of them reached the API through this function.
284
303
  */
285
304
  function authHeadersSync(extra: Record<string, string> = {}): Record<string, string> {
286
305
  const h: Record<string, string> = { Accept: 'application/json', ...extra };
287
- if (authConf.kind === 'bearer' && typeof authConf.token === 'string') {
288
- h.Authorization = `Bearer ${authConf.token}`;
306
+ if (authConf.kind === 'bearer') {
307
+ const token = typeof authConf.token === 'string' ? authConf.token : lastBearer;
308
+ if (token) h.Authorization = `Bearer ${token}`;
289
309
  } else if (authConf.kind === 'csrf') {
290
310
  h['X-CSRF-TOKEN'] = authConf.csrf;
291
311
  h['X-Requested-With'] = 'XMLHttpRequest';
@@ -45,7 +45,7 @@ export function preloadEditor(): void {
45
45
  });
46
46
  }
47
47
  if (!highlightPromise) {
48
- highlightPromise = import(/* @vite-ignore */ 'highlight.js')
48
+ highlightPromise = import(/* @vite-ignore */ 'highlight.js/lib/common')
49
49
  .then((mod) => {
50
50
  highlightCached = (mod as { default?: unknown }).default ?? mod;
51
51
  return highlightCached;
package/src/lib/themes.ts CHANGED
@@ -403,6 +403,73 @@ export function useThemeState(): { themeId: Ref<string>; setTheme: (id: string)
403
403
  return { themeId, setTheme };
404
404
  }
405
405
 
406
+ /* ------------------------------------------------------------------ */
407
+ /* Light / dark MODE preference */
408
+ /* ------------------------------------------------------------------ */
409
+
410
+ /**
411
+ * The user's own light/dark choice — a different question from WHICH theme
412
+ * paints (that is `themeId` above).
413
+ *
414
+ * `'host'` is the default and means "whatever the embedder passed in
415
+ * `config.theme`". That is what keeps every existing embed unchanged: work and
416
+ * the fishapp hand the explorer the mode their own page is in, and an explorer
417
+ * that silently overrode it would sit as a dark rectangle inside a light page.
418
+ * The other three are an explicit choice by the person looking at it and
419
+ * outrank the host: `'auto'` follows the operating system, `'light'`/`'dark'`
420
+ * are fixed.
421
+ */
422
+ export type ThemeModePref = 'host' | 'auto' | 'light' | 'dark';
423
+
424
+ export const THEME_MODE_LS_KEY = 'filex.thememode';
425
+
426
+ const MODE_VALUES: ThemeModePref[] = ['host', 'auto', 'light', 'dark'];
427
+
428
+ function readStoredThemeMode(): ThemeModePref {
429
+ try {
430
+ const v = localStorage.getItem(THEME_MODE_LS_KEY) as ThemeModePref | null;
431
+ return v && MODE_VALUES.includes(v) ? v : 'host';
432
+ } catch {
433
+ return 'host';
434
+ }
435
+ }
436
+
437
+ const themeMode: Ref<ThemeModePref> = ref(
438
+ typeof window === 'undefined' ? 'host' : readStoredThemeMode(),
439
+ );
440
+
441
+ export function setThemeMode(mode: ThemeModePref): void {
442
+ const valid = MODE_VALUES.includes(mode) ? mode : 'host';
443
+ themeMode.value = valid;
444
+ try {
445
+ if (valid === 'host') localStorage.removeItem(THEME_MODE_LS_KEY);
446
+ else localStorage.setItem(THEME_MODE_LS_KEY, valid);
447
+ } catch {
448
+ /* quota / private mode */
449
+ }
450
+ }
451
+
452
+ /** Reactive handle for components: `{ themeMode, setThemeMode }`. */
453
+ export function useThemeModeState(): {
454
+ themeMode: Ref<ThemeModePref>;
455
+ setThemeMode: (mode: ThemeModePref) => void;
456
+ } {
457
+ return { themeMode, setThemeMode };
458
+ }
459
+
460
+ // Cross-tab sync, same contract as the theme id above.
461
+ if (typeof window !== 'undefined') {
462
+ try {
463
+ window.addEventListener('storage', (e) => {
464
+ if (e.key !== THEME_MODE_LS_KEY) return;
465
+ const v = e.newValue as ThemeModePref | null;
466
+ themeMode.value = v && MODE_VALUES.includes(v) ? v : 'host';
467
+ });
468
+ } catch {
469
+ /* non-browser env */
470
+ }
471
+ }
472
+
406
473
  // Cross-tab sync — another tab changing the preference updates this one live.
407
474
  if (typeof window !== 'undefined') {
408
475
  try {
package/src/locales/en.ts CHANGED
@@ -248,7 +248,11 @@ export const en: Record<string, string> = {
248
248
  /* wiring:c1 — theme gallery */
249
249
  'theme.menu': 'Theme',
250
250
  'theme.title': 'Theme Gallery',
251
- 'theme.hint': 'Themes only change the color palette; light/dark mode still applies separately.',
251
+ 'theme.hint': 'A theme only changes the colour palette day or night is the choice above.',
252
+ 'theme.mode.label': 'Appearance',
253
+ 'theme.mode.light': 'Day',
254
+ 'theme.mode.dark': 'Night',
255
+ 'theme.mode.auto': 'Automatic',
252
256
  'theme.reset': 'Reset to default',
253
257
  'theme.close': 'Close',
254
258
  'theme.selected': 'Selected',
package/src/locales/tr.ts CHANGED
@@ -248,7 +248,11 @@ export const tr: Record<string, string> = {
248
248
  /* wiring:c1 — tema galerisi */
249
249
  'theme.menu': 'Tema',
250
250
  'theme.title': 'Tema Galerisi',
251
- 'theme.hint': 'Tema yalnız renk paletini değiştirir; açık/koyu mod ayrı çalışmaya devam eder.',
251
+ 'theme.hint': 'Tema yalnız renk paletini değiştirir; gündüz gece mi olduğu yukarıdaki seçim.',
252
+ 'theme.mode.label': 'Görünüm',
253
+ 'theme.mode.light': 'Gündüz',
254
+ 'theme.mode.dark': 'Gece',
255
+ 'theme.mode.auto': 'Otomatik',
252
256
  'theme.reset': 'Varsayılana dön',
253
257
  'theme.close': 'Kapat',
254
258
  'theme.selected': 'Seçili',
@@ -38,7 +38,7 @@ const props = defineProps<{
38
38
  onlyOfficeConfigEndpoint?: string | null;
39
39
  saveTextEndpoint?: string | null;
40
40
  openMode?: 'edit' | 'view';
41
- authHeaders?: () => Record<string, string>;
41
+ authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
42
42
  authCredentials?: RequestCredentials;
43
43
  /** New rich-viewer config (forwarded by FileExplorer from ExplorerConfig). */
44
44
  drawioUrl?: string | null;
@@ -351,7 +351,7 @@ async function fetchText(url: string): Promise<void> {
351
351
  tooLarge.value = false;
352
352
  try {
353
353
  const headers: Record<string, string> = {};
354
- if (props.authHeaders) Object.assign(headers, props.authHeaders());
354
+ if (props.authHeaders) Object.assign(headers, await props.authHeaders());
355
355
  const res = await fetch(url, {
356
356
  credentials: props.authCredentials || 'same-origin',
357
357
  headers,
@@ -390,14 +390,25 @@ async function fetchText(url: string): Promise<void> {
390
390
 
391
391
  const markdownHtml = ref<string>('');
392
392
  const markdownEl = ref<HTMLDivElement | null>(null);
393
+ /**
394
+ * The renderer could not be loaded at all.
395
+ *
396
+ * ⚠ Tracked separately from "nothing to render". In view mode a missing
397
+ * renderer falls through to the raw source, which is honest; in the split
398
+ * editor the preview pane simply stayed BLANK — a white half-window with no
399
+ * explanation, measured in the desktop app on 2026-08-10.
400
+ */
401
+ const markdownUnavailable = ref(false);
393
402
 
394
403
  async function renderMarkdown(text: string): Promise<void> {
395
404
  try {
396
405
  const mod = (await import(/* @vite-ignore */ 'markdown-it').catch(() => null)) as any;
397
406
  if (!mod) {
398
407
  markdownHtml.value = '';
408
+ markdownUnavailable.value = true;
399
409
  return;
400
410
  }
411
+ markdownUnavailable.value = false;
401
412
  const Md = (mod as { default: any }).default ?? (mod as any);
402
413
  // html: true so inline HTML in README.md / docs renders (the
403
414
  // GitHub / GitLab contract — operators expect `<img>`, tables,
@@ -552,6 +563,8 @@ const monacoEl = ref<HTMLDivElement | null>(null);
552
563
  let monacoEditor: any = null;
553
564
  let codeAutosaveTimer: ReturnType<typeof setTimeout> | undefined;
554
565
  const monacoReady = ref(false);
566
+ /** Monaco resolved to nothing — the peer is not in this build at all. */
567
+ const monacoUnavailable = ref(false);
555
568
  const saving = ref(false);
556
569
  const saveOk = ref(false);
557
570
  const saveError = ref<string | null>(null);
@@ -596,6 +609,12 @@ function monacoLanguageFor(extension: string): string {
596
609
 
597
610
  async function tryMountMonaco(text: string, extension: string): Promise<boolean> {
598
611
  const monaco = (await ensureMonaco()) as any;
612
+ // ⚠ "Not installed" is not "still loading". Monaco is an optional peer that
613
+ // most embeds do not ship, and the toolbar label was gated only on "not ready
614
+ // yet" — so a bundle without it showed "Editor loading…" forever, over a
615
+ // perfectly good read-only view. Measured in the desktop app once syntax
616
+ // highlighting started working and that label finally had something to sit on.
617
+ if (!monaco) monacoUnavailable.value = true;
599
618
  if (!monaco || !monacoEl.value) return false;
600
619
  try {
601
620
  disposeMonaco();
@@ -653,7 +672,7 @@ async function saveCode(): Promise<void> {
653
672
  try {
654
673
  const text = monacoEditor ? monacoEditor.getValue() : '';
655
674
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
656
- if (props.authHeaders) Object.assign(headers, props.authHeaders());
675
+ if (props.authHeaders) Object.assign(headers, await props.authHeaders());
657
676
  const res = await fetch(props.saveTextEndpoint, {
658
677
  method: 'POST',
659
678
  headers,
@@ -718,7 +737,10 @@ async function runOrchestration(open: boolean, url: string, k: string): Promise<
718
737
  // kick off the Monaco load and swap it in once ready.
719
738
  if (k === 'code') await highlightCode(rawText.value, lang);
720
739
  ensureMonaco().then(async (m) => {
721
- if (!m) return;
740
+ // Record the "never coming" case here too. This branch returns early
741
+ // without ever reaching tryMountMonaco, so a build with no Monaco left
742
+ // the toolbar saying "Editor loading…" indefinitely.
743
+ if (!m) { monacoUnavailable.value = true; return; }
722
744
  if (!props.open) return;
723
745
  // Wait one tick so the placeholder DOM exists before swapping.
724
746
  await new Promise<void>((r) => setTimeout(r, 0));
@@ -770,7 +792,7 @@ async function mountOnlyOfficeEditor(): Promise<void> {
770
792
 
771
793
  try {
772
794
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
773
- if (props.authHeaders) Object.assign(headers, props.authHeaders());
795
+ if (props.authHeaders) Object.assign(headers, await props.authHeaders());
774
796
  const res = await fetch(props.onlyOfficeConfigEndpoint, {
775
797
  method: 'POST',
776
798
  headers,
@@ -932,6 +954,11 @@ function loadOnlyOfficeScript(base: string): Promise<void> {
932
954
  placeholder="# Markdown buraya…"
933
955
  />
934
956
  <div
957
+ v-if="markdownUnavailable"
958
+ class="fe-preview__md-split-output fe-preview__md"
959
+ >{{ t('viewer.peer_not_installed') }}</div>
960
+ <div
961
+ v-else
935
962
  ref="markdownEl"
936
963
  class="fe-preview__md-split-output fe-preview__md"
937
964
  v-html="markdownHtml"
@@ -978,7 +1005,7 @@ function loadOnlyOfficeScript(base: string): Promise<void> {
978
1005
  <div class="fe-preview__code-toolbar">
979
1006
  <span class="fe-preview__code-lang">{{ codeLanguage }}</span>
980
1007
  <span v-if="!monacoReady && codeHtml" class="fe-preview__code-status">
981
- {{ saveTextEndpoint ? t('viewer.editor_loading') : t('viewer.read_only') }}
1008
+ {{ saveTextEndpoint && !monacoUnavailable ? t('viewer.editor_loading') : t('viewer.read_only') }}
982
1009
  </span>
983
1010
  <span v-if="saveOk" class="fe-preview__code-status fe-preview__code-status--ok">✓ {{ t('viewer.saved') }}</span>
984
1011
  <span v-if="saveError" class="fe-preview__code-status fe-preview__code-status--err" :title="saveError">✗ {{ t('viewer.save_error') }}</span>