@brftech/filex-core 0.1.56

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/ArchiveViewer-CJdaw5x3.js +99 -0
  4. package/dist/ArchiveViewer-CJdaw5x3.js.map +1 -0
  5. package/dist/CsvViewer-QYx_tePQ.js +132 -0
  6. package/dist/CsvViewer-QYx_tePQ.js.map +1 -0
  7. package/dist/DrawioViewer-GWt0TyB7.js +141 -0
  8. package/dist/DrawioViewer-GWt0TyB7.js.map +1 -0
  9. package/dist/EpubViewer-Je0ywklQ.js +146 -0
  10. package/dist/EpubViewer-Je0ywklQ.js.map +1 -0
  11. package/dist/IpynbViewer-TsB4gqP-.js +175 -0
  12. package/dist/IpynbViewer-TsB4gqP-.js.map +1 -0
  13. package/dist/MermaidViewer-CXfeDS-7.js +128 -0
  14. package/dist/MermaidViewer-CXfeDS-7.js.map +1 -0
  15. package/dist/PsdViewer-VtPpxKVe.js +113 -0
  16. package/dist/PsdViewer-VtPpxKVe.js.map +1 -0
  17. package/dist/TiffViewer-OINn9bbb.js +133 -0
  18. package/dist/TiffViewer-OINn9bbb.js.map +1 -0
  19. package/dist/UTIF-Cjsrlm0l.js +3104 -0
  20. package/dist/UTIF-Cjsrlm0l.js.map +1 -0
  21. package/dist/Viewer3D-xvvyiWHd.js +61 -0
  22. package/dist/Viewer3D-xvvyiWHd.js.map +1 -0
  23. package/dist/_commonjsHelpers-DaMA6jEr.js +9 -0
  24. package/dist/_commonjsHelpers-DaMA6jEr.js.map +1 -0
  25. package/dist/filex-core.js +26 -0
  26. package/dist/filex-core.js.map +1 -0
  27. package/dist/filex-core.umd.cjs +285 -0
  28. package/dist/filex-core.umd.cjs.map +1 -0
  29. package/dist/index-CmSYzt6L.js +5404 -0
  30. package/dist/index-CmSYzt6L.js.map +1 -0
  31. package/dist/index-UFULWo35.js +10736 -0
  32. package/dist/index-UFULWo35.js.map +1 -0
  33. package/dist/index.d.ts +1089 -0
  34. package/dist/katex-yuB6V-q6.js +11616 -0
  35. package/dist/katex-yuB6V-q6.js.map +1 -0
  36. package/dist/papaparse.min-VB1HBwYX.js +441 -0
  37. package/dist/papaparse.min-VB1HBwYX.js.map +1 -0
  38. package/dist/style.css +1 -0
  39. package/dist/useViewerFetch-czqbd2Lj.js +25 -0
  40. package/dist/useViewerFetch-czqbd2Lj.js.map +1 -0
  41. package/package.json +113 -0
  42. package/src/FileExplorer.vue +1872 -0
  43. package/src/components/Breadcrumb.vue +282 -0
  44. package/src/components/ContextMenu.vue +152 -0
  45. package/src/components/GridView.vue +166 -0
  46. package/src/components/ListView.vue +212 -0
  47. package/src/components/PendingOpsTray.vue +91 -0
  48. package/src/components/RecentlyOpened.vue +155 -0
  49. package/src/components/StarButton.vue +101 -0
  50. package/src/components/TagPicker.vue +184 -0
  51. package/src/components/Toolbar.vue +220 -0
  52. package/src/components/UploadProgress.vue +78 -0
  53. package/src/composables/useFileApi.ts +692 -0
  54. package/src/composables/useKeyboardShortcuts.ts +144 -0
  55. package/src/composables/useLocale.ts +60 -0
  56. package/src/composables/useMonacoLoader.ts +98 -0
  57. package/src/composables/usePendingOps.ts +200 -0
  58. package/src/composables/useSelection.ts +81 -0
  59. package/src/composables/useUploadChunked.ts +250 -0
  60. package/src/composables/useViewerFetch.ts +58 -0
  61. package/src/index.ts +80 -0
  62. package/src/locales/en.ts +122 -0
  63. package/src/locales/index.ts +12 -0
  64. package/src/locales/tr.ts +122 -0
  65. package/src/modals/ConvertModal.vue +254 -0
  66. package/src/modals/DeleteConfirmModal.vue +32 -0
  67. package/src/modals/Modal.vue +104 -0
  68. package/src/modals/NewFolderModal.vue +64 -0
  69. package/src/modals/PermissionsModal.vue +815 -0
  70. package/src/modals/PreviewModal.vue +1070 -0
  71. package/src/modals/RenameModal.vue +58 -0
  72. package/src/modals/ShareModal.vue +135 -0
  73. package/src/styles/base.css +1224 -0
  74. package/src/styles/variables.css +90 -0
  75. package/src/types/ExplorerConfig.ts +282 -0
  76. package/src/types/FileNode.ts +136 -0
  77. package/src/types/index.ts +27 -0
  78. package/src/types/peers.d.ts +45 -0
  79. package/src/viewers/ArchiveViewer.vue +186 -0
  80. package/src/viewers/CsvViewer.vue +319 -0
  81. package/src/viewers/DrawioViewer.vue +243 -0
  82. package/src/viewers/EpubViewer.vue +307 -0
  83. package/src/viewers/IpynbViewer.vue +365 -0
  84. package/src/viewers/MermaidViewer.vue +259 -0
  85. package/src/viewers/PdfViewer.vue +562 -0
  86. package/src/viewers/PsdViewer.vue +264 -0
  87. package/src/viewers/TiffViewer.vue +273 -0
  88. package/src/viewers/Viewer3D.vue +120 -0
@@ -0,0 +1,1070 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PreviewModal — inline file preview.
4
+ *
5
+ * Strategy by extension:
6
+ * image / video / audio / pdf → native browser elements
7
+ * md / markdown → markdown-it (lazy) → rendered HTML
8
+ * code (js/ts/php/py/json/...) → Monaco when ready, highlight.js
9
+ * fallback while it's still loading,
10
+ * plain `<pre>` when neither is
11
+ * installed
12
+ * office (docx/xlsx/pptx) → OnlyOffice iframe (config.onlyOfficeBase)
13
+ * plain text (txt/log/conf) → CodeMirror when saveText is set,
14
+ * `<pre>` otherwise
15
+ * anything else → "indir" fallback
16
+ *
17
+ * Monaco is dynamic-imported at FileExplorer onMounted; by the time the
18
+ * user clicks an editable code file the chunk is usually already cached.
19
+ * We probe `getMonaco()` synchronously when the modal opens — if it's
20
+ * not yet loaded we render the highlight.js read-only view immediately,
21
+ * then upgrade to Monaco once the import resolves.
22
+ */
23
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
24
+ import type { Component } from 'vue';
25
+ import type { FileNode } from '../types/FileNode';
26
+ import type { LocaleCode } from '../types/ExplorerConfig';
27
+ import Modal from './Modal.vue';
28
+ import { ensureMonaco, getMonaco, ensureHighlight } from '../composables/useMonacoLoader';
29
+ import { useLocale } from '../composables/useLocale';
30
+
31
+ const props = defineProps<{
32
+ open: boolean;
33
+ locale: LocaleCode;
34
+ file: FileNode | null;
35
+ previewUrl: (path: string) => string;
36
+ downloadUrl: (path: string) => string;
37
+ onlyOfficeBase?: string | null;
38
+ onlyOfficeConfigEndpoint?: string | null;
39
+ saveTextEndpoint?: string | null;
40
+ openMode?: 'edit' | 'view';
41
+ authHeaders?: () => Record<string, string>;
42
+ authCredentials?: RequestCredentials;
43
+ /** New rich-viewer config (forwarded by FileExplorer from ExplorerConfig). */
44
+ drawioUrl?: string | null;
45
+ pdfWorkerUrl?: string | null;
46
+ pdfSaveUrl?: string | null;
47
+ /** Standalone full-screen viewer route — `?path=…&storage=…&type=…`. */
48
+ viewerBaseUrl?: string | null;
49
+ /** Drop the dialog chrome (backdrop tint, header bar, footer actions)
50
+ * so the viewer fills the full viewport. Used by the standalone
51
+ * /files/edit route where the browser tab IS the container — a
52
+ * modal frame on top of the editor just steals real estate. */
53
+ chromeless?: boolean;
54
+ /** When the dynamic-viewer chunk fails to load, fall back to the
55
+ * legacy native renderer (e.g. native `<object>` for PDFs). */
56
+ /** Explicit theme. Forwarded to the underlying Modal (which tags
57
+ * its backdrop with `.fe--theme-{light,dark}` so the CSS variable
58
+ * override matches the host admin shell) and to Monaco (vs vs
59
+ * vs-dark). When unset Monaco falls back to `prefers-color-scheme`
60
+ * and the modal cascade follows whatever `.fe` parent it gets. */
61
+ theme?: 'light' | 'dark' | 'auto';
62
+ }>();
63
+
64
+ const emit = defineEmits<{
65
+ (e: 'close'): void;
66
+ }>();
67
+
68
+ const { t } = useLocale(() => props.locale);
69
+
70
+ function ext(f: FileNode | null): string {
71
+ return (f?.extension || '').toLowerCase();
72
+ }
73
+
74
+ const IMAGE = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'avif', 'svg', 'heic'];
75
+ const VIDEO = ['mp4', 'webm', 'mov', 'mkv', 'm4v', 'ogv'];
76
+ const AUDIO = ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus'];
77
+
78
+ const CODE_LANGS: Record<string, string> = {
79
+ md: 'markdown', markdown: 'markdown',
80
+ txt: 'plaintext', log: 'plaintext',
81
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript',
82
+ ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
83
+ vue: 'xml', svelte: 'xml', html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml',
84
+ css: 'css', scss: 'scss', sass: 'scss', less: 'less',
85
+ json: 'json', jsonc: 'json',
86
+ yml: 'yaml', yaml: 'yaml',
87
+ php: 'php', py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
88
+ java: 'java', kt: 'kotlin', swift: 'swift', cpp: 'cpp', c: 'c',
89
+ h: 'cpp', hpp: 'cpp', cs: 'csharp', dart: 'dart',
90
+ sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
91
+ sql: 'sql',
92
+ toml: 'ini', ini: 'ini', conf: 'ini', env: 'bash', cfg: 'ini',
93
+ dockerfile: 'dockerfile',
94
+ graphql: 'graphql', gql: 'graphql',
95
+ diff: 'diff', patch: 'diff',
96
+ };
97
+ // CSV/TSV land in the rich viewer (`csv` kind below) instead of the
98
+ // legacy plain-text path so the user gets a proper table preview.
99
+ const TEXT_PLAIN = ['txt', 'log'];
100
+ const OFFICE = ['docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt', 'odt', 'ods', 'odp', 'rtf'];
101
+
102
+ /**
103
+ * Lazy viewer map — extension → component loader. Each loader is a
104
+ * dynamic import so the viewer chunk only ships when the file type
105
+ * is actually opened. The PreviewModal mounts the resolved component
106
+ * via `<component :is="ViewerCmp" />` once the dynamic import settles.
107
+ */
108
+ const VIEWER_MAP: Record<string, () => Promise<Component>> = {
109
+ glb: () => import('../viewers/Viewer3D.vue'),
110
+ gltf: () => import('../viewers/Viewer3D.vue'),
111
+ obj: () => import('../viewers/Viewer3D.vue'),
112
+ stl: () => import('../viewers/Viewer3D.vue'),
113
+ fbx: () => import('../viewers/Viewer3D.vue'),
114
+ '3ds': () => import('../viewers/Viewer3D.vue'),
115
+
116
+ epub: () => import('../viewers/EpubViewer.vue'),
117
+
118
+ mmd: () => import('../viewers/MermaidViewer.vue'),
119
+ mermaid: () => import('../viewers/MermaidViewer.vue'),
120
+
121
+ drawio: () => import('../viewers/DrawioViewer.vue'),
122
+ dio: () => import('../viewers/DrawioViewer.vue'),
123
+
124
+ tif: () => import('../viewers/TiffViewer.vue'),
125
+ tiff: () => import('../viewers/TiffViewer.vue'),
126
+
127
+ psd: () => import('../viewers/PsdViewer.vue'),
128
+
129
+ // PDF deliberately uses the native browser viewer (`kind === 'pdf'`
130
+ // branch in the template). PdfViewer.vue still exists for callers
131
+ // that want the rich custom UI but the SFC default keeps things
132
+ // minimal — the browser already paints search/zoom/page UI for free.
133
+
134
+ ipynb: () => import('../viewers/IpynbViewer.vue'),
135
+
136
+ csv: () => import('../viewers/CsvViewer.vue'),
137
+ tsv: () => import('../viewers/CsvViewer.vue'),
138
+
139
+ zip: () => import('../viewers/ArchiveViewer.vue'),
140
+ };
141
+
142
+ type PreviewKind =
143
+ | 'image' | 'video' | 'audio' | 'pdf' | 'markdown' | 'code'
144
+ | 'office' | 'text' | 'viewer' | 'other';
145
+
146
+ const kind = computed<PreviewKind>(() => {
147
+ const e = ext(props.file);
148
+ if (!e) return 'other';
149
+ if (IMAGE.includes(e)) return 'image';
150
+ if (VIDEO.includes(e)) return 'video';
151
+ if (AUDIO.includes(e)) return 'audio';
152
+ // PDF always uses the native browser <object> renderer. The
153
+ // PdfViewer SFC is no longer wired into the default map — the
154
+ // browser-bundled toolbar is enough and removes a 600 KB pdfjs
155
+ // worker chunk + a custom toolbar layer for parity with the rest
156
+ // of the read-only viewers.
157
+ if (e === 'pdf') return 'pdf';
158
+ // Markdown stays in its own branch (gets the split view in edit mode);
159
+ // plain text → code editor in edit mode, raw <pre> in view mode.
160
+ const wantEdit = props.openMode !== 'view';
161
+ if (e === 'md' || e === 'markdown') return 'markdown';
162
+ if (e in CODE_LANGS) return 'code';
163
+ if (e in VIEWER_MAP) return 'viewer';
164
+ if (OFFICE.includes(e)) return 'office';
165
+ if (TEXT_PLAIN.includes(e)) return wantEdit ? 'code' : 'text';
166
+ return 'other';
167
+ });
168
+
169
+ // --- Rich viewer plumbing ---
170
+ //
171
+ // `viewerCmp` holds the dynamically-imported component once the load
172
+ // resolves. Stored in a `shallowRef` because the component itself
173
+ // shouldn't be deep-watched.
174
+ const viewerCmp = shallowRef<Component | null>(null);
175
+ const viewerLoadError = ref<string | null>(null);
176
+ const pdfFallbackToNative = ref(false);
177
+
178
+ async function loadViewerFor(extension: string): Promise<void> {
179
+ viewerCmp.value = null;
180
+ viewerLoadError.value = null;
181
+ const loader = VIEWER_MAP[extension];
182
+ if (!loader) return;
183
+ try {
184
+ const mod = (await loader()) as { default?: Component };
185
+ viewerCmp.value = mod.default ?? (mod as unknown as Component);
186
+ } catch (err) {
187
+ viewerLoadError.value = err instanceof Error ? err.message : 'viewer load failed';
188
+ if (extension === 'pdf') pdfFallbackToNative.value = true;
189
+ }
190
+ }
191
+
192
+ function onPdfFallback(): void {
193
+ pdfFallbackToNative.value = true;
194
+ }
195
+
196
+ /** Build the props bundle pushed into the active viewer component. */
197
+ const viewerProps = computed(() => {
198
+ const e = ext(props.file);
199
+ const base: Record<string, unknown> = {
200
+ url: src.value,
201
+ ext: e,
202
+ mime: props.file?.mime_type,
203
+ t,
204
+ authHeaders: props.authHeaders,
205
+ authCredentials: props.authCredentials,
206
+ };
207
+ if (props.file) {
208
+ base.filePath = stripAdapter(props.file.path);
209
+ // Archive viewer needs the adapter-qualified path because the
210
+ // /api/files/archive/list handler falls back to storages[0] when
211
+ // no adapter prefix is present — on multi-storage instances that
212
+ // 500s for every non-default storage (sample.zip on fm s3-test).
213
+ if (e === 'zip' || e === 'rar' || e === '7z' || e === 'tar' || e === 'gz' || e === 'tgz') {
214
+ base.filePath = props.file.path;
215
+ }
216
+ }
217
+ if (e === 'drawio' || e === 'dio') {
218
+ base.drawioUrl = props.drawioUrl ?? undefined;
219
+ base.saveUrl = props.saveTextEndpoint ?? undefined;
220
+ base.readOnly = props.openMode === 'view';
221
+ }
222
+ if (e === 'pdf') {
223
+ base.pdfWorkerUrl = props.pdfWorkerUrl ?? undefined;
224
+ base.pdfSaveUrl = props.pdfSaveUrl ?? undefined;
225
+ }
226
+ return base;
227
+ });
228
+
229
+ function openInNewTab(): void {
230
+ buildAndOpenStandalone(props.openMode || 'edit');
231
+ }
232
+
233
+ function openEditInNewTab(): void {
234
+ buildAndOpenStandalone('edit');
235
+ }
236
+
237
+ function buildAndOpenStandalone(mode: 'view' | 'edit'): void {
238
+ if (!props.file) return;
239
+ const e = ext(props.file);
240
+ // Keep the adapter-qualified path intact so the editor route
241
+ // resolves the storage from the URL (stripping it falls back to
242
+ // storages[0] and 404s for any non-default adapter). Default base
243
+ // is the SFC's standalone /files/edit route; embedders override via
244
+ // viewerBaseUrl when they mount us elsewhere.
245
+ const base = props.viewerBaseUrl || '/files/edit';
246
+ const sep = base.includes('?') ? '&' : '?';
247
+ const url =
248
+ `${base}${sep}path=${encodeURIComponent(props.file.path)}` +
249
+ `&type=${encodeURIComponent(e)}` +
250
+ `&mode=${encodeURIComponent(mode)}`;
251
+ window.open(url, '_blank', 'noopener');
252
+ }
253
+
254
+ /**
255
+ * Extensions that have a meaningful "edit" surface. Read-only kinds
256
+ * (image/video/audio/3D/archive) don't surface a "Düzenle" button.
257
+ */
258
+ const EDITABLE_EXTS = new Set([
259
+ // OnlyOffice — open the in-page office editor (or new tab) with
260
+ // edit permissions.
261
+ 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt',
262
+ 'odt', 'ods', 'odp', 'rtf',
263
+ // Drawio / mermaid round-trip via the new-tab route.
264
+ 'drawio', 'dio', 'mmd', 'mermaid',
265
+ // Code / text / markdown — Monaco / split editor.
266
+ 'md', 'markdown', 'txt', 'log',
267
+ 'json', 'jsonc', 'yaml', 'yml', 'xml', 'svg', 'html', 'htm',
268
+ 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'vue', 'svelte',
269
+ 'css', 'scss', 'sass', 'less',
270
+ 'php', 'py', 'rb', 'go', 'rs', 'java', 'kt', 'swift',
271
+ 'cpp', 'c', 'h', 'hpp', 'cs', 'dart',
272
+ 'sh', 'bash', 'sql',
273
+ 'toml', 'ini', 'conf', 'cfg', 'env',
274
+ 'dockerfile', 'graphql', 'gql',
275
+ ]);
276
+
277
+ const canEditKind = computed<boolean>(() =>
278
+ !!props.file && EDITABLE_EXTS.has(ext(props.file)),
279
+ );
280
+
281
+ // Keep adapter prefix so backend resolves the right storage — stripping
282
+ // it defaults to storages[0] and 404s on any non-default adapter.
283
+ const src = computed(() => (props.file ? props.previewUrl(props.file.path) : ''));
284
+ const download = computed(() => (props.file ? props.downloadUrl(props.file.path) : ''));
285
+
286
+ function stripAdapter(p: string): string {
287
+ const idx = p.indexOf('://');
288
+ return idx === -1 ? p : p.slice(idx + 3);
289
+ }
290
+
291
+ const loading = ref(false);
292
+ const fetchError = ref<string | null>(null);
293
+ const rawText = ref<string>('');
294
+ const MAX_TEXT_BYTES = 1_000_000;
295
+ const tooLarge = ref(false);
296
+
297
+ // Markdown split-edit state — drives the side-by-side textarea+preview
298
+ // layout inside the `kind === 'markdown'` template when openMode='edit'.
299
+ const mdDirty = ref(false);
300
+ const mdSaving = ref(false);
301
+ let mdReRenderTimer: ReturnType<typeof setTimeout> | undefined;
302
+ let mdAutosaveTimer: ReturnType<typeof setTimeout> | undefined;
303
+ function onMdInput() {
304
+ mdDirty.value = true;
305
+ if (mdReRenderTimer) clearTimeout(mdReRenderTimer);
306
+ mdReRenderTimer = setTimeout(() => {
307
+ renderMarkdown(rawText.value);
308
+ }, 250);
309
+ // Autosave 1.5s after last keystroke — manual Kaydet button + Ctrl+S
310
+ // still work; saveMarkdown() guards against overlap.
311
+ if (mdAutosaveTimer) clearTimeout(mdAutosaveTimer);
312
+ mdAutosaveTimer = setTimeout(() => {
313
+ void saveMarkdown();
314
+ }, 1500);
315
+ }
316
+ async function saveMarkdown() {
317
+ if (!props.saveTextEndpoint || !props.file || mdSaving.value) return;
318
+ mdSaving.value = true;
319
+ fetchError.value = null;
320
+ try {
321
+ const headers = {
322
+ 'Content-Type': 'application/json',
323
+ ...(await (props.authHeaders ?? (() => ({})))()),
324
+ };
325
+ const res = await fetch(props.saveTextEndpoint, {
326
+ method: 'POST',
327
+ headers,
328
+ credentials: props.authCredentials || 'same-origin',
329
+ body: JSON.stringify({ path: stripAdapter(props.file.path), content: rawText.value }),
330
+ });
331
+ if (!res.ok) {
332
+ throw new Error(`save failed: ${res.status} ${await res.text()}`);
333
+ }
334
+ mdDirty.value = false;
335
+ } catch (err) {
336
+ fetchError.value = err instanceof Error ? err.message : String(err);
337
+ } finally {
338
+ mdSaving.value = false;
339
+ }
340
+ }
341
+
342
+ async function fetchText(url: string): Promise<void> {
343
+ loading.value = true;
344
+ fetchError.value = null;
345
+ rawText.value = '';
346
+ tooLarge.value = false;
347
+ try {
348
+ const headers: Record<string, string> = {};
349
+ if (props.authHeaders) Object.assign(headers, props.authHeaders());
350
+ const res = await fetch(url, {
351
+ credentials: props.authCredentials || 'include',
352
+ headers,
353
+ });
354
+ if (!res.ok) {
355
+ throw new Error(`${res.status} ${res.statusText}`);
356
+ }
357
+ const len = Number(res.headers.get('content-length') || '0');
358
+ if (len > MAX_TEXT_BYTES) {
359
+ tooLarge.value = true;
360
+ return;
361
+ }
362
+ const text = await res.text();
363
+ if (text.length > MAX_TEXT_BYTES) {
364
+ tooLarge.value = true;
365
+ rawText.value = text.slice(0, MAX_TEXT_BYTES);
366
+ } else {
367
+ rawText.value = text;
368
+ }
369
+ } catch (err) {
370
+ fetchError.value = err instanceof Error ? err.message : String(err);
371
+ } finally {
372
+ loading.value = false;
373
+ }
374
+ }
375
+
376
+ // --- Markdown rendering (lazy markdown-it) ---
377
+ //
378
+ // Beyond the base markdown render we walk the resulting DOM for two
379
+ // enrichments:
380
+ // - ```mermaid``` fences → swap the `<pre>` for a rendered SVG via
381
+ // the `mermaid` peer (when installed).
382
+ // - ```math``` / `$$ … $$` blocks → render with `katex` (peer).
383
+ // Both enrichments degrade gracefully when the peer isn't available
384
+ // (the original `<pre>` stays in place).
385
+
386
+ const markdownHtml = ref<string>('');
387
+ const markdownEl = ref<HTMLDivElement | null>(null);
388
+
389
+ async function renderMarkdown(text: string): Promise<void> {
390
+ try {
391
+ const mod = (await import(/* @vite-ignore */ 'markdown-it').catch(() => null)) as any;
392
+ if (!mod) {
393
+ markdownHtml.value = '';
394
+ return;
395
+ }
396
+ const Md = (mod as { default: any }).default ?? (mod as any);
397
+ // html: true so inline HTML in README.md / docs renders (the
398
+ // GitHub / GitLab contract — operators expect `<img>`, tables,
399
+ // `<details>`, etc. to work). We sanitize the output below to
400
+ // strip scripts / event handlers before injecting via v-html.
401
+ const md = new Md({
402
+ html: true,
403
+ linkify: true,
404
+ breaks: true,
405
+ typographer: true,
406
+ });
407
+ const raw = md.render(text);
408
+ markdownHtml.value = sanitizeHtml(raw);
409
+ // Wait for Vue to flush the v-html into the DOM before walking it.
410
+ await new Promise<void>((r) => setTimeout(r, 0));
411
+ await enrichMarkdown();
412
+ } catch (err) {
413
+ markdownHtml.value = '';
414
+ fetchError.value = err instanceof Error ? err.message : String(err);
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Sanitize the markdown-it output before v-html injection.
420
+ *
421
+ * GitHub / GitLab let README authors embed inline HTML (img, table,
422
+ * details, kbd …) so the viewer ships with `html: true`. This filter
423
+ * strips the executable surface: `<script>`, `<iframe>`, `<object>`,
424
+ * `<embed>`, any `on*` event-handler attribute, and `javascript:`
425
+ * URLs. Conservative — README content with inline handlers is
426
+ * exceptional and silent over-removal beats silent XSS.
427
+ *
428
+ * No external dep — keeping the package install-free is the trade
429
+ * we want. Integrators who need a heavier sanitizer can run their
430
+ * own DOMPurify pass on the rendered output before showing it.
431
+ */
432
+ function sanitizeHtml(html: string): string {
433
+ return html
434
+ .replace(/<\s*script\b[^<]*(?:(?!<\s*\/\s*script\s*>)<[^<]*)*<\s*\/\s*script\s*>/gi, '')
435
+ .replace(/<\s*iframe\b[\s\S]*?<\s*\/\s*iframe\s*>/gi, '')
436
+ .replace(/<\s*object\b[\s\S]*?<\s*\/\s*object\s*>/gi, '')
437
+ .replace(/<\s*embed\b[^>]*>/gi, '')
438
+ .replace(/\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
439
+ .replace(/(href|src|action|formaction|xlink:href)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*')/gi, '$1="#"');
440
+ }
441
+
442
+ async function enrichMarkdown(): Promise<void> {
443
+ if (!markdownEl.value) return;
444
+ const root = markdownEl.value;
445
+ const blocks = Array.from(root.querySelectorAll('pre > code')) as HTMLElement[];
446
+ if (blocks.length === 0) return;
447
+
448
+ let mermaid: any = undefined;
449
+ let katex: any = undefined;
450
+
451
+ for (const block of blocks) {
452
+ const cls = block.className || '';
453
+ const isMermaid =
454
+ /\blanguage-mermaid\b/.test(cls) || /\blanguage-mmd\b/.test(cls);
455
+ const isMath =
456
+ /\blanguage-math\b/.test(cls) ||
457
+ /\blanguage-latex\b/.test(cls) ||
458
+ /\blanguage-tex\b/.test(cls);
459
+
460
+ if (isMermaid) {
461
+ if (mermaid === undefined) {
462
+ try {
463
+ const m = await import(/* @vite-ignore */ 'mermaid');
464
+ mermaid = m.default ?? m;
465
+ mermaid.initialize?.({
466
+ startOnLoad: false,
467
+ securityLevel: 'strict',
468
+ });
469
+ } catch {
470
+ mermaid = null;
471
+ }
472
+ }
473
+ if (!mermaid) continue;
474
+ try {
475
+ const id = `filex-md-mermaid-${Math.random().toString(36).slice(2)}`;
476
+ const { svg } = await mermaid.render(id, block.textContent || '');
477
+ const wrap = document.createElement('div');
478
+ wrap.className = 'fe-preview__md-mermaid';
479
+ wrap.innerHTML = svg;
480
+ block.parentElement?.replaceWith(wrap);
481
+ } catch {
482
+ /* leave fenced block */
483
+ }
484
+ } else if (isMath) {
485
+ if (katex === undefined) {
486
+ try {
487
+ const m = await import(/* @vite-ignore */ 'katex');
488
+ katex = m.default ?? m;
489
+ } catch {
490
+ katex = null;
491
+ }
492
+ }
493
+ if (!katex) continue;
494
+ try {
495
+ const html = katex.renderToString(block.textContent || '', {
496
+ displayMode: true,
497
+ throwOnError: false,
498
+ });
499
+ const wrap = document.createElement('div');
500
+ wrap.className = 'fe-preview__md-math';
501
+ wrap.innerHTML = html;
502
+ block.parentElement?.replaceWith(wrap);
503
+ } catch {
504
+ /* leave fenced block */
505
+ }
506
+ }
507
+ }
508
+ }
509
+
510
+ // --- Code highlight (highlight.js fallback) ---
511
+ //
512
+ // Used as the placeholder render until Monaco resolves, AND as the
513
+ // permanent renderer when Monaco isn't installed at all (peer missing).
514
+
515
+ const codeHtml = ref<string>('');
516
+
517
+ async function highlightCode(text: string, language: string): Promise<void> {
518
+ try {
519
+ const mod = (await ensureHighlight()) as any;
520
+ if (!mod) {
521
+ codeHtml.value = '';
522
+ return;
523
+ }
524
+ const hljs = mod.default ?? mod;
525
+ if (hljs.getLanguage(language)) {
526
+ const result = hljs.highlight(text, { language, ignoreIllegals: true });
527
+ codeHtml.value = result.value;
528
+ } else {
529
+ const result = hljs.highlightAuto(text);
530
+ codeHtml.value = result.value;
531
+ }
532
+ } catch (err) {
533
+ codeHtml.value = '';
534
+ fetchError.value = err instanceof Error ? err.message : String(err);
535
+ }
536
+ }
537
+
538
+ // --- Monaco editor (lazy) ---
539
+ //
540
+ // We attempt to instantiate Monaco when the modal opens for a code/text
541
+ // file. If the module isn't loaded yet, the highlight.js placeholder
542
+ // renders first, and the editor mounts in-place once Monaco resolves.
543
+ // Save endpoint required for editing — read-only Monaco still loads if
544
+ // the user just wants the IDE-grade syntax/colour view.
545
+
546
+ const monacoEl = ref<HTMLDivElement | null>(null);
547
+ let monacoEditor: any = null;
548
+ let codeAutosaveTimer: ReturnType<typeof setTimeout> | undefined;
549
+ const monacoReady = ref(false);
550
+ const saving = ref(false);
551
+ const saveOk = ref(false);
552
+ const saveError = ref<string | null>(null);
553
+
554
+ function disposeMonaco(): void {
555
+ if (monacoEditor) {
556
+ try {
557
+ monacoEditor.dispose();
558
+ } catch {
559
+ /* ignore */
560
+ }
561
+ monacoEditor = null;
562
+ }
563
+ monacoReady.value = false;
564
+ saveOk.value = false;
565
+ saveError.value = null;
566
+ }
567
+
568
+ /**
569
+ * Map an extension to a Monaco language id. Anything not in the map
570
+ * just becomes 'plaintext' — Monaco still gives line numbers + Ctrl+S.
571
+ */
572
+ function monacoLanguageFor(extension: string): string {
573
+ const map: Record<string, string> = {
574
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript',
575
+ ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
576
+ json: 'json', jsonc: 'json',
577
+ css: 'css', scss: 'scss', sass: 'scss', less: 'less',
578
+ html: 'html', htm: 'html', vue: 'html', svelte: 'html',
579
+ xml: 'xml', svg: 'xml',
580
+ md: 'markdown', markdown: 'markdown',
581
+ php: 'php', py: 'python',
582
+ yml: 'yaml', yaml: 'yaml',
583
+ go: 'go', rs: 'rust', sql: 'sql',
584
+ cpp: 'cpp', c: 'c', h: 'cpp', hpp: 'cpp',
585
+ sh: 'shell', bash: 'shell',
586
+ dockerfile: 'dockerfile',
587
+ toml: 'ini', ini: 'ini', conf: 'ini', cfg: 'ini',
588
+ };
589
+ return map[extension] ?? 'plaintext';
590
+ }
591
+
592
+ async function tryMountMonaco(text: string, extension: string): Promise<boolean> {
593
+ const monaco = (await ensureMonaco()) as any;
594
+ if (!monaco || !monacoEl.value) return false;
595
+ try {
596
+ disposeMonaco();
597
+ const editable = !!props.saveTextEndpoint && props.openMode !== 'view';
598
+ // Honour the explicit theme prop when set; fall back to the OS
599
+ // preference otherwise. Without this the Monaco editor stays on
600
+ // vs-dark on OS-dark systems even when the host admin shell is
601
+ // light, producing a jarring light-card + dark-code combo inside
602
+ // the in-page preview modal.
603
+ const dark = props.theme === 'dark'
604
+ ? true
605
+ : props.theme === 'light'
606
+ ? false
607
+ : window.matchMedia?.('(prefers-color-scheme: dark)').matches;
608
+ monacoEditor = monaco.editor.create(monacoEl.value, {
609
+ value: text,
610
+ language: monacoLanguageFor(extension),
611
+ readOnly: !editable,
612
+ theme: dark ? 'vs-dark' : 'vs',
613
+ automaticLayout: true,
614
+ minimap: { enabled: false },
615
+ fontSize: 13,
616
+ });
617
+ if (editable) {
618
+ monacoEditor.addCommand(
619
+ monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
620
+ () => void saveCode(),
621
+ );
622
+ // Autosave: 1.5s after the last keystroke we POST the buffer.
623
+ // Ctrl+S still works for the impatient — saveCode() guards against
624
+ // overlap, so a manual save during the debounce window just wins
625
+ // and the queued autosave becomes a no-op.
626
+ monacoEditor.onDidChangeModelContent(() => {
627
+ if (codeAutosaveTimer) clearTimeout(codeAutosaveTimer);
628
+ codeAutosaveTimer = setTimeout(() => {
629
+ void saveCode();
630
+ }, 1500);
631
+ });
632
+ }
633
+ monacoReady.value = true;
634
+ return true;
635
+ } catch (err) {
636
+ fetchError.value = `Monaco mount fail: ${(err as Error).message}`;
637
+ return false;
638
+ }
639
+ }
640
+
641
+ async function saveCode(): Promise<void> {
642
+ if (!props.saveTextEndpoint || !props.file) return;
643
+ if (props.openMode === 'view') return;
644
+ if (saving.value) return;
645
+ saving.value = true;
646
+ saveOk.value = false;
647
+ saveError.value = null;
648
+ try {
649
+ const text = monacoEditor ? monacoEditor.getValue() : '';
650
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
651
+ if (props.authHeaders) Object.assign(headers, props.authHeaders());
652
+ const res = await fetch(props.saveTextEndpoint, {
653
+ method: 'POST',
654
+ headers,
655
+ credentials: props.authCredentials || 'same-origin',
656
+ body: JSON.stringify({
657
+ path: stripAdapter(props.file.path),
658
+ content: text,
659
+ }),
660
+ });
661
+ if (!res.ok) {
662
+ const txt = await res.text().catch(() => '');
663
+ throw new Error(`${res.status} ${res.statusText}${txt ? ' — ' + txt.slice(0, 150) : ''}`);
664
+ }
665
+ saveOk.value = true;
666
+ setTimeout(() => {
667
+ saveOk.value = false;
668
+ }, 2500);
669
+ } catch (err) {
670
+ saveError.value = (err as Error).message;
671
+ } finally {
672
+ saving.value = false;
673
+ }
674
+ }
675
+
676
+ // --- Orchestration: fetch + render when modal opens ---
677
+
678
+ async function runOrchestration(open: boolean, url: string, k: string): Promise<void> {
679
+ rawText.value = '';
680
+ markdownHtml.value = '';
681
+ codeHtml.value = '';
682
+ fetchError.value = null;
683
+ officeError.value = null;
684
+ viewerCmp.value = null;
685
+ viewerLoadError.value = null;
686
+ pdfFallbackToNative.value = false;
687
+ disposeOnlyOfficeEditor();
688
+ disposeMonaco();
689
+ if (!open || !url) return;
690
+ if (k === 'viewer') {
691
+ await loadViewerFor(ext(props.file));
692
+ return;
693
+ }
694
+ if (k === 'markdown' || k === 'code' || k === 'text') {
695
+ await fetchText(url);
696
+ if (tooLarge.value) return;
697
+ if (k === 'markdown') {
698
+ await renderMarkdown(rawText.value);
699
+ return;
700
+ }
701
+ // For code/text — try Monaco first when the cached module is
702
+ // already in memory. If not, render the highlight.js placeholder
703
+ // immediately, then attempt to upgrade to Monaco when the import
704
+ // settles.
705
+ const lang = CODE_LANGS[ext(props.file)] || '';
706
+ const monacoCached = getMonaco();
707
+ if (monacoCached) {
708
+ await new Promise<void>((r) => setTimeout(r, 0));
709
+ const ok = await tryMountMonaco(rawText.value, ext(props.file));
710
+ if (!ok) await highlightCode(rawText.value, lang);
711
+ } else {
712
+ // Render highlight.js immediately for read-only colour. Then
713
+ // kick off the Monaco load and swap it in once ready.
714
+ if (k === 'code') await highlightCode(rawText.value, lang);
715
+ ensureMonaco().then(async (m) => {
716
+ if (!m) return;
717
+ if (!props.open) return;
718
+ // Wait one tick so the placeholder DOM exists before swapping.
719
+ await new Promise<void>((r) => setTimeout(r, 0));
720
+ await tryMountMonaco(rawText.value, ext(props.file));
721
+ });
722
+ }
723
+ } else if (k === 'office') {
724
+ await new Promise<void>((r) => setTimeout(r, 0));
725
+ await mountOnlyOfficeEditor();
726
+ }
727
+ }
728
+
729
+ watch(
730
+ () => [props.open, src.value, kind.value, props.openMode] as const,
731
+ ([open, url, k]) => {
732
+ void runOrchestration(open, url, k);
733
+ },
734
+ );
735
+
736
+ // Hand-fire on mount so the standalone Editor.vue route (which mounts
737
+ // us with `open` already true) actually runs the orchestration. The
738
+ // watcher itself is non-immediate because `immediate: true` would fire
739
+ // before the rest of <script setup> finishes, hitting the TDZ on
740
+ // `officeEditor`/Monaco state below. Doing this in onMounted+nextTick
741
+ // guarantees every `let`/`function` in the file has been hoisted.
742
+ onMounted(() => {
743
+ void nextTick(() => {
744
+ if (!props.open) return;
745
+ void runOrchestration(props.open, src.value, kind.value);
746
+ });
747
+ });
748
+
749
+ const codeLanguage = computed(() => CODE_LANGS[ext(props.file)] || 'plaintext');
750
+
751
+ // --- OnlyOffice DocEditor (real, JWT-signed) ---
752
+
753
+ const officeEl = ref<HTMLDivElement | null>(null);
754
+ const officeError = ref<string | null>(null);
755
+ let officeEditor: any = null;
756
+
757
+ async function mountOnlyOfficeEditor(): Promise<void> {
758
+ officeError.value = null;
759
+ if (!props.file || kind.value !== 'office') return;
760
+ if (!props.onlyOfficeConfigEndpoint) {
761
+ officeError.value = 'OnlyOffice yapılandırması yok';
762
+ return;
763
+ }
764
+ if (!officeEl.value) return;
765
+
766
+ try {
767
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
768
+ if (props.authHeaders) Object.assign(headers, props.authHeaders());
769
+ const res = await fetch(props.onlyOfficeConfigEndpoint, {
770
+ method: 'POST',
771
+ headers,
772
+ credentials: props.authCredentials || 'same-origin',
773
+ body: JSON.stringify({
774
+ // Send the FULL adapter-qualified path. The backend resolves
775
+ // `<adapter>://<rel>` against ListEnabledStorages; passing the
776
+ // bare relative path falls back to storages[0] which 404s for
777
+ // anything sitting on a non-primary storage (e.g. s3-test).
778
+ path: props.file.path,
779
+ mode: props.openMode || 'edit',
780
+ }),
781
+ });
782
+ if (!res.ok) {
783
+ throw new Error(`Config fetch ${res.status}: ${(await res.text()).slice(0, 200)}`);
784
+ }
785
+ const { config, documentServerUrl } = (await res.json()) as {
786
+ config: any;
787
+ documentServerUrl: string;
788
+ };
789
+
790
+ await loadOnlyOfficeScript(documentServerUrl);
791
+ disposeOnlyOfficeEditor();
792
+
793
+ const mountId = 'fe-onlyoffice-mount';
794
+ officeEl.value.id = mountId;
795
+
796
+ config.events = {
797
+ onError: (err: any) => {
798
+ officeError.value = formatOnlyOfficeError(err);
799
+ },
800
+ };
801
+
802
+ const W = window as any;
803
+ if (!W.DocsAPI || !W.DocsAPI.DocEditor) {
804
+ throw new Error('DocsAPI not available after script load');
805
+ }
806
+ officeEditor = new W.DocsAPI.DocEditor(mountId, config);
807
+ } catch (err) {
808
+ officeError.value = err instanceof Error ? err.message : String(err);
809
+ }
810
+ }
811
+
812
+ function disposeOnlyOfficeEditor(): void {
813
+ try {
814
+ officeEditor?.destroyEditor?.();
815
+ } catch {
816
+ /* ignore */
817
+ }
818
+ officeEditor = null;
819
+ }
820
+
821
+ onBeforeUnmount(() => {
822
+ disposeOnlyOfficeEditor();
823
+ disposeMonaco();
824
+ });
825
+
826
+ /**
827
+ * OnlyOffice fires onError with an event-like object:
828
+ * { type:'error', data:{ errorCode, errorDescription, ... } }
829
+ * The legacy stringification produced "[object Object]" for objects
830
+ * whose `data` was itself an object. Walk one level deeper so the
831
+ * user sees the actual error description instead of a useless cast.
832
+ */
833
+ function formatOnlyOfficeError(err: unknown): string {
834
+ if (typeof err === 'string') return err;
835
+ const e = err as { data?: unknown; message?: unknown; errorDescription?: unknown };
836
+ if (e?.data && typeof e.data === 'object') {
837
+ const d = e.data as { errorDescription?: unknown; errorCode?: unknown; message?: unknown };
838
+ if (typeof d.errorDescription === 'string') return d.errorDescription;
839
+ if (typeof d.message === 'string') return d.message;
840
+ if (d.errorCode !== undefined) return `OnlyOffice error ${d.errorCode}`;
841
+ }
842
+ if (typeof e?.data === 'string') return e.data;
843
+ if (typeof e?.errorDescription === 'string') return e.errorDescription;
844
+ if (typeof e?.message === 'string') return e.message;
845
+ try {
846
+ return JSON.stringify(err);
847
+ } catch {
848
+ return 'OnlyOffice error';
849
+ }
850
+ }
851
+
852
+ const ONLYOFFICE_SCRIPT_ID = 'fe-onlyoffice-api-js';
853
+ function loadOnlyOfficeScript(base: string): Promise<void> {
854
+ return new Promise((resolve, reject) => {
855
+ if ((window as any).DocsAPI?.DocEditor) {
856
+ resolve();
857
+ return;
858
+ }
859
+ const existing = document.getElementById(ONLYOFFICE_SCRIPT_ID) as HTMLScriptElement | null;
860
+ if (existing) {
861
+ existing.addEventListener('load', () => resolve());
862
+ existing.addEventListener('error', () => reject(new Error('OnlyOffice api.js load failed')));
863
+ return;
864
+ }
865
+ const script = document.createElement('script');
866
+ script.id = ONLYOFFICE_SCRIPT_ID;
867
+ script.src = `${base.replace(/\/$/, '')}/web-apps/apps/api/documents/api.js`;
868
+ script.async = true;
869
+ script.onload = () => resolve();
870
+ script.onerror = () => reject(new Error('OnlyOffice api.js load failed'));
871
+ document.head.appendChild(script);
872
+ });
873
+ }
874
+ </script>
875
+
876
+ <template>
877
+ <Modal :open="open" size="xl" :title="file?.basename || ''" :chromeless="chromeless" :theme="theme" @close="emit('close')">
878
+ <div v-if="file" class="fe-preview">
879
+ <template v-if="kind === 'image'">
880
+ <img :src="src" :alt="file.basename" class="fe-preview__image" />
881
+ </template>
882
+ <template v-else-if="kind === 'video'">
883
+ <video :src="src" controls preload="metadata" class="fe-preview__video" />
884
+ </template>
885
+ <template v-else-if="kind === 'audio'">
886
+ <audio :src="src" controls class="fe-preview__audio" />
887
+ </template>
888
+ <template v-else-if="kind === 'pdf'">
889
+ <object :data="src" type="application/pdf" class="fe-preview__iframe">
890
+ <div class="fe-preview__fallback">
891
+ <span class="fe-preview__fallback-icon">📕</span>
892
+ <p>Tarayıcı PDF'i inline açamadı.</p>
893
+ <a :href="download" class="fe-btn fe-btn--primary" target="_blank" rel="noopener">PDF'i Yeni Sekmede Aç</a>
894
+ </div>
895
+ </object>
896
+ </template>
897
+
898
+ <template v-else-if="kind === 'markdown'">
899
+ <div v-if="loading" class="fe-preview__fallback">{{ t('viewer.loading') }}</div>
900
+ <div v-else-if="tooLarge" class="fe-preview__fallback">
901
+ Dosya çok büyük (>1 MB). <a :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
902
+ </div>
903
+ <div
904
+ v-else-if="openMode === 'edit' && saveTextEndpoint"
905
+ class="fe-preview__md-split"
906
+ >
907
+ <div class="fe-preview__md-split-bar">
908
+ <span class="fe-preview__md-split-label">MARKDOWN</span>
909
+ <span v-if="fetchError" class="fe-preview__md-split-error">{{ fetchError }}</span>
910
+ <button
911
+ type="button"
912
+ class="fe-btn fe-btn--primary"
913
+ :disabled="!mdDirty || mdSaving"
914
+ @click="saveMarkdown"
915
+ >
916
+ {{ mdSaving ? 'Kaydediliyor…' : (mdDirty ? 'Kaydet (Ctrl+S)' : 'Kaydedildi') }}
917
+ </button>
918
+ </div>
919
+ <div class="fe-preview__md-split-body">
920
+ <textarea
921
+ class="fe-preview__md-split-input"
922
+ v-model="rawText"
923
+ @input="onMdInput"
924
+ @keydown.ctrl.s.prevent="saveMarkdown"
925
+ @keydown.meta.s.prevent="saveMarkdown"
926
+ spellcheck="false"
927
+ placeholder="# Markdown buraya…"
928
+ />
929
+ <div
930
+ ref="markdownEl"
931
+ class="fe-preview__md-split-output fe-preview__md"
932
+ v-html="markdownHtml"
933
+ ></div>
934
+ </div>
935
+ </div>
936
+ <div v-else-if="fetchError" class="fe-preview__fallback">
937
+ <p>{{ fetchError }}</p>
938
+ <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
939
+ </div>
940
+ <div v-else-if="markdownHtml" ref="markdownEl" class="fe-preview__md" v-html="markdownHtml"></div>
941
+ <pre v-else class="fe-preview__pre">{{ rawText }}</pre>
942
+ </template>
943
+
944
+ <template v-else-if="kind === 'viewer'">
945
+ <div v-if="viewerLoadError && !pdfFallbackToNative" class="fe-preview__fallback">
946
+ <span class="fe-preview__fallback-icon">⚠️</span>
947
+ <p>{{ viewerLoadError }}</p>
948
+ <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
949
+ </div>
950
+ <div v-else-if="!viewerCmp" class="fe-preview__fallback">
951
+ <span class="fe-preview__fallback-icon">⏳</span>
952
+ <p>{{ t('viewer.loading') }}</p>
953
+ </div>
954
+ <component
955
+ v-else
956
+ :is="viewerCmp"
957
+ v-bind="viewerProps"
958
+ class="fe-preview__viewer"
959
+ @fallback="onPdfFallback"
960
+ />
961
+ </template>
962
+
963
+ <template v-else-if="kind === 'code' || kind === 'text'">
964
+ <div v-if="loading" class="fe-preview__fallback">{{ t('viewer.loading') }}</div>
965
+ <div v-else-if="tooLarge" class="fe-preview__fallback">
966
+ Dosya çok büyük (>1 MB). <a :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
967
+ </div>
968
+ <div v-else-if="fetchError" class="fe-preview__fallback">
969
+ <p>{{ fetchError }}</p>
970
+ <a :href="download" class="fe-btn fe-btn--primary">{{ t('viewer.download') }}</a>
971
+ </div>
972
+ <div v-else class="fe-preview__code-wrap">
973
+ <div class="fe-preview__code-toolbar">
974
+ <span class="fe-preview__code-lang">{{ codeLanguage }}</span>
975
+ <span v-if="!monacoReady && codeHtml" class="fe-preview__code-status">
976
+ {{ saveTextEndpoint ? 'Editör yükleniyor…' : 'Salt okunur' }}
977
+ </span>
978
+ <span v-if="saveOk" class="fe-preview__code-status fe-preview__code-status--ok">✓ Kaydedildi</span>
979
+ <span v-if="saveError" class="fe-preview__code-status fe-preview__code-status--err" :title="saveError">✗ Hata</span>
980
+ <span v-if="openMode === 'view'" class="fe-preview__code-status">Salt okunur</span>
981
+ <button
982
+ v-else-if="saveTextEndpoint && monacoReady"
983
+ type="button"
984
+ class="fe-btn fe-btn--primary"
985
+ :disabled="saving"
986
+ @click="saveCode"
987
+ >{{ saving ? 'Kaydediliyor…' : 'Kaydet (Ctrl+S)' }}</button>
988
+ </div>
989
+ <!-- Monaco target — hidden until ready, then occupies the slot. -->
990
+ <div ref="monacoEl" class="fe-preview__code-editor" :class="{ 'is-hidden': !monacoReady }" />
991
+ <!-- Highlight.js read-only fallback — visible until Monaco mounts. -->
992
+ <pre
993
+ v-if="!monacoReady && codeHtml"
994
+ class="fe-preview__pre fe-preview__code hljs"
995
+ ><code :class="`language-${codeLanguage}`" v-html="codeHtml"></code></pre>
996
+ <pre
997
+ v-else-if="!monacoReady && !codeHtml"
998
+ class="fe-preview__pre"
999
+ :data-lang="codeLanguage"
1000
+ >{{ rawText }}</pre>
1001
+ </div>
1002
+ </template>
1003
+
1004
+ <template v-else-if="kind === 'office'">
1005
+ <div v-if="officeError" class="fe-preview__fallback">
1006
+ <span class="fe-preview__fallback-icon">📄</span>
1007
+ <p>{{ officeError }}</p>
1008
+ <a :href="download" class="fe-btn fe-btn--primary">İndir</a>
1009
+ </div>
1010
+ <div v-else ref="officeEl" class="fe-preview__office" />
1011
+ </template>
1012
+
1013
+ <template v-else>
1014
+ <div class="fe-preview__fallback">
1015
+ <span class="fe-preview__fallback-icon">📎</span>
1016
+ <p>Bu dosya tipi için önizleme henüz yok.</p>
1017
+ <a :href="download" class="fe-btn fe-btn--primary">İndir</a>
1018
+ </div>
1019
+ </template>
1020
+ </div>
1021
+ <template #actions>
1022
+ <button
1023
+ v-if="file && openMode === 'view' && canEditKind"
1024
+ type="button"
1025
+ class="fe-btn fe-btn--primary"
1026
+ @click="openEditInNewTab"
1027
+ >✏ Düzenle</button>
1028
+ <button
1029
+ v-if="file"
1030
+ type="button"
1031
+ class="fe-btn"
1032
+ @click="openInNewTab"
1033
+ >↗ {{ t('viewer.open_in_new_tab') }}</button>
1034
+ <a v-if="file" :href="download" class="fe-btn">{{ t('viewer.download') }}</a>
1035
+ <button type="button" class="fe-btn" @click="emit('close')">Kapat</button>
1036
+ </template>
1037
+ </Modal>
1038
+ </template>
1039
+
1040
+ <style>
1041
+ .fe-preview__code-editor.is-hidden {
1042
+ display: none;
1043
+ }
1044
+ /* Wrapper for the dynamic viewers — they render their own toolbars
1045
+ * inside the wrapper, so we just give them the full pane height. */
1046
+ .fe-preview__viewer {
1047
+ width: 100%;
1048
+ height: 100%;
1049
+ min-height: 70vh;
1050
+ display: flex;
1051
+ flex-direction: column;
1052
+ }
1053
+ .fe-preview .fe-preview__viewer {
1054
+ align-self: stretch;
1055
+ }
1056
+ .fe-preview__md-mermaid {
1057
+ display: flex;
1058
+ justify-content: center;
1059
+ margin: 16px 0;
1060
+ }
1061
+ .fe-preview__md-mermaid svg {
1062
+ max-width: 100%;
1063
+ height: auto;
1064
+ }
1065
+ .fe-preview__md-math {
1066
+ margin: 16px 0;
1067
+ text-align: center;
1068
+ overflow-x: auto;
1069
+ }
1070
+ </style>