vitepress-plugin-filelist 0.3.0 → 0.4.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.
@@ -25,12 +25,14 @@ interface FileListLabels {
25
25
  download: string;
26
26
  loaded: string;
27
27
  selected: string;
28
+ /** @deprecated No longer shown; the footer only shows loaded files and size. */
28
29
  unloaded: string;
29
30
  folderFailed: string;
30
31
  demoDownload: string;
31
32
  demoPreview: string;
32
33
  demoBatch: string;
33
34
  downloadFailed: string;
35
+ /** @deprecated Preview errors are shown in the dialog (`previewUnavailable`). */
34
36
  previewFailed: string;
35
37
  batchFailed: string;
36
38
  defaultZip: string;
@@ -51,6 +53,23 @@ interface FileListLabels {
51
53
  expand: string;
52
54
  /** Accessible name of the collapse button, `{name}`. */
53
55
  collapse: string;
56
+ /** Preview dialog: close button. */
57
+ close: string;
58
+ /** Preview dialog: previous / next previewable file. */
59
+ previous: string;
60
+ next: string;
61
+ /** Preview dialog: rotate image by 90°. */
62
+ rotate: string;
63
+ /** Preview dialog: toggle the image between fit-to-window and actual size. */
64
+ actualSize: string;
65
+ /** Preview dialog: file type that is not previewed (Office and others). */
66
+ previewUnsupported: string;
67
+ /** Preview dialog: preview failed (network, CORS, unsupported codec). */
68
+ previewUnavailable: string;
69
+ /** Preview dialog: PDF larger than the in-page limit, `{size}`. */
70
+ previewTooLarge: string;
71
+ /** Preview dialog: only the first `{size}` of a text file is shown. */
72
+ textTruncated: string;
54
73
  }
55
74
  interface FileListSource {
56
75
  /** OpenList path, directory or single file. */
@@ -212,6 +231,32 @@ export declare function totalSize(items: Array<{
212
231
  size?: number;
213
232
  }>): number;
214
233
  export declare function compareNodes(a: SortableNode, b: SortableNode, key: SortKey, ascending?: boolean): number;
234
+ export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'pdf' | 'none';
235
+ /**
236
+ * How a file is shown in the preview dialog. Office documents and anything
237
+ * else the browser cannot render are `none` (info + download).
238
+ */
239
+ export declare function previewKind(name: string): PreviewKind;
240
+ export interface IframePreview {
241
+ label: string;
242
+ scheme: string;
243
+ }
244
+ /**
245
+ * Viewers from OpenList's public `iframe_previews` setting that match a file,
246
+ * in the order OpenList lists them. Keys are comma-separated extensions or a
247
+ * `/regex/flags` tested against the extension, as in OpenList's frontend.
248
+ */
249
+ export declare function iframePreviewsFor(name: string, setting: string | undefined): IframePreview[];
250
+ /**
251
+ * Fill an OpenList preview scheme: `$name`, `$url` (raw URL) and `$durl`
252
+ * (signed download URL), each optionally prefixed with `e` (URI-encode) or
253
+ * `b` (base64), e.g. `$e_url`, `$b_url`, `$e_durl`.
254
+ */
255
+ export declare function fillPreviewUrl(scheme: string, args: {
256
+ name: string;
257
+ rawUrl: string;
258
+ downloadUrl: string;
259
+ }): string;
215
260
  /**
216
261
  * Register `<FileList>` and `<VPFileList>` globally.
217
262
  *
@@ -1,5 +1,5 @@
1
- import { computed, defineComponent, mergeProps, onBeforeUnmount, onMounted, ref, unref, useSSRContext } from "vue";
2
- import { ssrIncludeBooleanAttr, ssrInterpolate, ssrLooseContain, ssrLooseEqual, ssrRenderAttr, ssrRenderAttrs, ssrRenderClass, ssrRenderList, ssrRenderStyle } from "vue/server-renderer";
1
+ import { computed, defineComponent, mergeProps, onBeforeUnmount, onMounted, ref, unref, useSSRContext, watch } from "vue";
2
+ import { ssrIncludeBooleanAttr, ssrInterpolate, ssrLooseContain, ssrLooseEqual, ssrRenderAttr, ssrRenderAttrs, ssrRenderClass, ssrRenderComponent, ssrRenderList, ssrRenderStyle } from "vue/server-renderer";
3
3
  // src/client/locales.ts
4
4
  const fileListLocales = {
5
5
  "zh-CN": {
@@ -43,7 +43,16 @@ const fileListLocales = {
43
43
  selectAll: "全选",
44
44
  selectItem: "选择 {name}",
45
45
  expand: "展开 {name}",
46
- collapse: "收起 {name}"
46
+ collapse: "收起 {name}",
47
+ close: "关闭",
48
+ previous: "上一个",
49
+ next: "下一个",
50
+ rotate: "旋转",
51
+ actualSize: "原始大小",
52
+ previewUnsupported: "该类型暂不支持在线预览,可下载后查看",
53
+ previewUnavailable: "该文件无法在页面内预览,可下载后查看",
54
+ previewTooLarge: "文件较大({size}),请下载后查看",
55
+ textTruncated: "仅显示前 {size},完整内容请下载"
47
56
  },
48
57
  "en-US": {
49
58
  allFiles: "All files",
@@ -86,7 +95,16 @@ const fileListLocales = {
86
95
  selectAll: "Select all",
87
96
  selectItem: "Select {name}",
88
97
  expand: "Expand {name}",
89
- collapse: "Collapse {name}"
98
+ collapse: "Collapse {name}",
99
+ close: "Close",
100
+ previous: "Previous",
101
+ next: "Next",
102
+ rotate: "Rotate",
103
+ actualSize: "Actual size",
104
+ previewUnsupported: "This file type cannot be previewed here. Download it to view.",
105
+ previewUnavailable: "This file cannot be previewed in the page. Download it to view.",
106
+ previewTooLarge: "This file is large ({size}). Download it to view.",
107
+ textTruncated: "Showing the first {size} only. Download for the full file."
90
108
  }
91
109
  };
92
110
  function formatLabel(template, values = {}) {
@@ -268,6 +286,376 @@ function compareNodes(a, b, key, ascending = true) {
268
286
  else result = a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true });
269
287
  return ascending ? result : -result;
270
288
  }
289
+ const PREVIEW_EXTS = {
290
+ image: [
291
+ "png",
292
+ "jpg",
293
+ "jpeg",
294
+ "gif",
295
+ "webp",
296
+ "svg",
297
+ "bmp",
298
+ "ico",
299
+ "avif"
300
+ ],
301
+ video: [
302
+ "mp4",
303
+ "webm",
304
+ "m4v",
305
+ "mov",
306
+ "ogv"
307
+ ],
308
+ audio: [
309
+ "mp3",
310
+ "wav",
311
+ "ogg",
312
+ "oga",
313
+ "m4a",
314
+ "aac",
315
+ "flac",
316
+ "opus"
317
+ ],
318
+ text: [
319
+ "txt",
320
+ "md",
321
+ "markdown",
322
+ "log",
323
+ "json",
324
+ "csv",
325
+ "tsv",
326
+ "xml",
327
+ "yml",
328
+ "yaml",
329
+ "toml",
330
+ "ini",
331
+ "conf",
332
+ "properties",
333
+ "js",
334
+ "mjs",
335
+ "cjs",
336
+ "ts",
337
+ "tsx",
338
+ "jsx",
339
+ "vue",
340
+ "css",
341
+ "scss",
342
+ "less",
343
+ "html",
344
+ "htm",
345
+ "py",
346
+ "java",
347
+ "kt",
348
+ "c",
349
+ "h",
350
+ "cpp",
351
+ "hpp",
352
+ "cs",
353
+ "go",
354
+ "rs",
355
+ "rb",
356
+ "php",
357
+ "sh",
358
+ "bat",
359
+ "ps1",
360
+ "sql",
361
+ "srt",
362
+ "vtt",
363
+ "ass",
364
+ "lrc",
365
+ "gitignore",
366
+ "env"
367
+ ],
368
+ pdf: ["pdf"]
369
+ };
370
+ const PREVIEW_KINDS = {};
371
+ for (const [kind, exts] of Object.entries(PREVIEW_EXTS)) for (const ext of exts) PREVIEW_KINDS[ext] = kind;
372
+ function lowerExt(name) {
373
+ const dot = name.lastIndexOf(".");
374
+ return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
375
+ }
376
+ /**
377
+ * How a file is shown in the preview dialog. Office documents and anything
378
+ * else the browser cannot render are `none` (info + download).
379
+ */
380
+ function previewKind(name) {
381
+ return PREVIEW_KINDS[lowerExt(name)] ?? "none";
382
+ }
383
+ /**
384
+ * Viewers from OpenList's public `iframe_previews` setting that match a file,
385
+ * in the order OpenList lists them. Keys are comma-separated extensions or a
386
+ * `/regex/flags` tested against the extension, as in OpenList's frontend.
387
+ */
388
+ function iframePreviewsFor(name, setting) {
389
+ if (!setting) return [];
390
+ let parsed;
391
+ try {
392
+ parsed = JSON.parse(setting);
393
+ } catch {
394
+ return [];
395
+ }
396
+ if (!parsed || typeof parsed !== "object") return [];
397
+ const ext = lowerExt(name);
398
+ const out = [];
399
+ for (const [key, viewers] of Object.entries(parsed)) {
400
+ let hit = false;
401
+ if (key.startsWith("/")) {
402
+ const end = key.lastIndexOf("/");
403
+ try {
404
+ hit = end > 0 && new RegExp(key.slice(1, end), key.slice(end + 1)).test(ext);
405
+ } catch {
406
+ hit = false;
407
+ }
408
+ } else hit = key.split(",").map((item) => item.trim().toLowerCase()).includes(ext);
409
+ if (!hit || !viewers || typeof viewers !== "object") continue;
410
+ for (const [label, scheme] of Object.entries(viewers)) if (typeof scheme === "string" && /^https?:\/\//i.test(scheme)) out.push({
411
+ label,
412
+ scheme
413
+ });
414
+ }
415
+ return out;
416
+ }
417
+ function applyUrlOps(placeholder, url) {
418
+ const ops = placeholder.slice(1, placeholder.lastIndexOf("url")).replace(/d$/, "").match(/[eb]/g) ?? [];
419
+ let value = url;
420
+ for (const op of ops.reverse()) value = op === "e" ? encodeURIComponent(value) : btoa(String.fromCharCode(...new TextEncoder().encode(value)));
421
+ return value;
422
+ }
423
+ /**
424
+ * Fill an OpenList preview scheme: `$name`, `$url` (raw URL) and `$durl`
425
+ * (signed download URL), each optionally prefixed with `e` (URI-encode) or
426
+ * `b` (base64), e.g. `$e_url`, `$b_url`, `$e_durl`.
427
+ */
428
+ function fillPreviewUrl(scheme, args) {
429
+ return scheme.replace(/\$name/g, args.name).replace(/\$[eb_]*durl/g, (placeholder) => applyUrlOps(placeholder, args.downloadUrl)).replace(/\$[eb_]*url/g, (placeholder) => applyUrlOps(placeholder, args.rawUrl));
430
+ }
431
+ // src/client/PreviewDialog.vue
432
+ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
433
+ __name: "PreviewDialog",
434
+ __ssrInlineRender: true,
435
+ props: {
436
+ open: { type: Boolean },
437
+ file: {},
438
+ labels: {},
439
+ src: { default: "" },
440
+ viewer: { default: "" },
441
+ resolving: {
442
+ type: Boolean,
443
+ default: false
444
+ },
445
+ error: { default: "" },
446
+ demo: {
447
+ type: Boolean,
448
+ default: false
449
+ },
450
+ position: { default: 0 },
451
+ total: { default: 0 },
452
+ maxTextBytes: { default: 1048576 },
453
+ maxPdfBytes: { default: 52428800 }
454
+ },
455
+ emits: [
456
+ "close",
457
+ "prev",
458
+ "next",
459
+ "download"
460
+ ],
461
+ setup(__props, { emit: __emit }) {
462
+ /**
463
+ * PreviewDialog.vue —— 文件预览弹窗(原生 <dialog>,不跳转页面)
464
+ *
465
+ * image / video / audio:原生标签;text:Range 读取前 maxTextBytes;
466
+ * pdf:OpenList `iframe_previews` 中的查看器,未配置时用浏览器自带阅读器(Blob);
467
+ * 其它类型(含 Office)、失败、过大、演示模式:文件信息 + 下载。
468
+ */
469
+ const props = __props;
470
+ function t(key, values) {
471
+ return formatLabel(props.labels[key], values);
472
+ }
473
+ const dialog = ref();
474
+ const kind = computed(() => props.file ? previewKind(props.file.name) : "none");
475
+ const loadingBody = ref(false);
476
+ const bodyFailed = ref(false);
477
+ const textContent = ref("");
478
+ const truncated = ref(false);
479
+ const blobUrl = ref("");
480
+ const rotation = ref(0);
481
+ const actualSize = ref(false);
482
+ let controller;
483
+ let loadSeq = 0;
484
+ const tooLarge = computed(() => kind.value === "pdf" && !props.viewer && (props.file?.size ?? 0) > props.maxPdfBytes);
485
+ const mode = computed(() => {
486
+ if (props.demo) return "demo";
487
+ if (props.error || bodyFailed.value) return "failed";
488
+ if (kind.value === "none") return "unsupported";
489
+ if (tooLarge.value) return "tooLarge";
490
+ return kind.value;
491
+ });
492
+ const busy = computed(() => !props.demo && kind.value !== "none" && !props.error && !bodyFailed.value && (props.resolving || loadingBody.value || !props.src && !tooLarge.value));
493
+ const infoMessage = computed(() => {
494
+ const file = props.file;
495
+ if (!file) return "";
496
+ if (mode.value === "demo") return t("demoPreview", { name: file.name });
497
+ if (mode.value === "tooLarge") return t("previewTooLarge", { size: formatSize(file.size) });
498
+ if (mode.value === "failed") return t("previewUnavailable");
499
+ return t("previewUnsupported");
500
+ });
501
+ function revokeBlob() {
502
+ if (blobUrl.value) URL.revokeObjectURL(blobUrl.value);
503
+ blobUrl.value = "";
504
+ }
505
+ function resetBody() {
506
+ controller?.abort();
507
+ controller = void 0;
508
+ loadSeq += 1;
509
+ revokeBlob();
510
+ loadingBody.value = false;
511
+ bodyFailed.value = false;
512
+ textContent.value = "";
513
+ truncated.value = false;
514
+ rotation.value = 0;
515
+ actualSize.value = false;
516
+ }
517
+ async function readPrefix(response, limit) {
518
+ const reader = response.body?.getReader();
519
+ if (!reader) {
520
+ const all = new Uint8Array(await response.arrayBuffer());
521
+ return {
522
+ bytes: all.slice(0, limit),
523
+ more: all.length > limit
524
+ };
525
+ }
526
+ const chunks = [];
527
+ let length = 0;
528
+ let more = false;
529
+ while (true) {
530
+ const { done, value } = await reader.read();
531
+ if (done) break;
532
+ chunks.push(value);
533
+ length += value.length;
534
+ if (length >= limit) {
535
+ more = length > limit;
536
+ await reader.cancel().catch(() => {});
537
+ break;
538
+ }
539
+ }
540
+ const bytes = new Uint8Array(Math.min(length, limit));
541
+ let offset = 0;
542
+ for (const chunk of chunks) {
543
+ const part = chunk.subarray(0, bytes.length - offset);
544
+ bytes.set(part, offset);
545
+ offset += part.length;
546
+ if (offset >= bytes.length) break;
547
+ }
548
+ return {
549
+ bytes,
550
+ more
551
+ };
552
+ }
553
+ async function loadBody() {
554
+ resetBody();
555
+ const file = props.file;
556
+ if (!props.open || !file || props.demo || !props.src || props.error) return;
557
+ if (kind.value !== "text" && !(kind.value === "pdf" && !props.viewer && !tooLarge.value)) return;
558
+ const seq = loadSeq;
559
+ controller = new AbortController();
560
+ loadingBody.value = true;
561
+ try {
562
+ if (kind.value === "text") {
563
+ const limit = props.maxTextBytes;
564
+ const response = await fetch(props.src, {
565
+ headers: { Range: `bytes=0-${limit - 1}` },
566
+ signal: controller.signal
567
+ });
568
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
569
+ const { bytes, more } = await readPrefix(response, limit);
570
+ if (seq !== loadSeq) return;
571
+ textContent.value = new TextDecoder("utf-8").decode(bytes);
572
+ truncated.value = more || file.size > limit;
573
+ } else {
574
+ const response = await fetch(props.src, { signal: controller.signal });
575
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
576
+ const buffer = await response.arrayBuffer();
577
+ if (seq !== loadSeq) return;
578
+ blobUrl.value = URL.createObjectURL(new Blob([buffer], { type: "application/pdf" }));
579
+ }
580
+ } catch {
581
+ if (seq === loadSeq && !controller?.signal.aborted) bodyFailed.value = true;
582
+ } finally {
583
+ if (seq === loadSeq) loadingBody.value = false;
584
+ }
585
+ }
586
+ watch(() => [
587
+ props.open,
588
+ props.file?.name,
589
+ props.src,
590
+ props.viewer,
591
+ props.error,
592
+ props.demo
593
+ ], () => void loadBody(), { immediate: true });
594
+ watch(() => props.open, (open) => {
595
+ const el = dialog.value;
596
+ if (!el) return;
597
+ if (open && !el.open) {
598
+ if (typeof el.showModal === "function") el.showModal();
599
+ else el.setAttribute("open", "");
600
+ } else if (!open && el.open) {
601
+ if (typeof el.close === "function") el.close();
602
+ else el.removeAttribute("open");
603
+ }
604
+ }, { flush: "post" });
605
+ onBeforeUnmount(resetBody);
606
+ return (_ctx, _push, _parent, _attrs) => {
607
+ _push(`<dialog${ssrRenderAttrs(mergeProps({
608
+ ref_key: "dialog",
609
+ ref: dialog,
610
+ class: "fl-preview",
611
+ "aria-label": __props.file?.name
612
+ }, _attrs))}>`);
613
+ if (__props.open && __props.file) {
614
+ _push(`<div class="fl-preview-panel"><header class="fl-preview-head"><span class="fl-type"${ssrRenderAttr("data-kind", unref(fileKind)(__props.file.name))} aria-hidden="true">${ssrInterpolate(unref(extOf)(__props.file.name) || "FILE")}</span><div class="fl-preview-title"><span class="fl-preview-name">${ssrInterpolate(__props.file.name)}</span><span class="fl-preview-meta">${ssrInterpolate(unref(formatSize)(__props.file.size))} · ${ssrInterpolate(unref(formatDate)(__props.file.modified))}</span></div>`);
615
+ if (__props.total > 1 || mode.value === "image" && !busy.value) {
616
+ _push(`<div class="fl-preview-tools fl-preview-nav">`);
617
+ if (__props.total > 1) _push(`<!--[--><button class="fl-preview-tool" type="button"${ssrRenderAttr("aria-label", t("previous"))}${ssrRenderAttr("title", t("previous"))}><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 6-6 6 6 6"></path></svg></button><span class="fl-preview-count">${ssrInterpolate(__props.position)} / ${ssrInterpolate(__props.total)}</span><button class="fl-preview-tool" type="button"${ssrRenderAttr("aria-label", t("next"))}${ssrRenderAttr("title", t("next"))}><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"></path></svg></button><!--]-->`);
618
+ else _push(`<!---->`);
619
+ if (mode.value === "image" && !busy.value) {
620
+ _push(`<!--[--><button class="fl-preview-tool fl-preview-rotate" type="button"${ssrRenderAttr("aria-label", t("rotate"))}${ssrRenderAttr("title", t("rotate"))}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"></path><path d="M21 4v5h-5"></path></svg></button><button class="fl-preview-tool fl-preview-zoom" type="button"${ssrRenderAttr("aria-pressed", actualSize.value)}${ssrRenderAttr("aria-label", t("actualSize"))}${ssrRenderAttr("title", t("actualSize"))}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path>`);
621
+ if (!actualSize.value) _push(`<path d="M11 8v6M8 11h6"></path>`);
622
+ else _push(`<path d="M8 11h6"></path>`);
623
+ _push(`</svg></button><!--]-->`);
624
+ } else _push(`<!---->`);
625
+ _push(`</div>`);
626
+ } else _push(`<!---->`);
627
+ _push(`<div class="fl-preview-tools fl-preview-actions">`);
628
+ if (!__props.demo) _push(`<button class="fl-preview-tool fl-preview-download" type="button"${ssrRenderAttr("aria-label", t("download"))}${ssrRenderAttr("title", t("download"))}><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"></path><path d="m7 10 5 5 5-5"></path><path d="M5 21h14"></path></svg></button>`);
629
+ else _push(`<!---->`);
630
+ _push(`<button class="fl-preview-tool fl-preview-close" type="button" autofocus${ssrRenderAttr("aria-label", t("close"))}${ssrRenderAttr("title", t("close"))}><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12"></path><path d="M18 6 6 18"></path></svg></button></div></header><div class="fl-preview-body"${ssrRenderAttr("data-mode", busy.value ? "loading" : mode.value)}>`);
631
+ if (busy.value) _push(`<div class="fl-preview-status" role="status">${ssrInterpolate(t("loading"))}</div>`);
632
+ else if (mode.value === "image") _push(`<img class="${ssrRenderClass([{ "fl-preview-actual": actualSize.value }, "fl-preview-image"])}"${ssrRenderAttr("src", __props.src)}${ssrRenderAttr("alt", __props.file.name)} style="${ssrRenderStyle({ transform: rotation.value ? `rotate(${rotation.value}deg)` : void 0 })}">`);
633
+ else if (mode.value === "video") _push(`<video class="fl-preview-video"${ssrRenderAttr("src", __props.src)} controls playsinline preload="metadata"></video>`);
634
+ else if (mode.value === "audio") _push(`<audio class="fl-preview-audio"${ssrRenderAttr("src", __props.src)} controls preload="metadata"></audio>`);
635
+ else if (mode.value === "text") {
636
+ _push(`<div class="fl-preview-text">`);
637
+ if (truncated.value) _push(`<p class="fl-preview-note">${ssrInterpolate(t("textTruncated", { size: unref(formatSize)(__props.maxTextBytes) }))}</p>`);
638
+ else _push(`<!---->`);
639
+ _push(`<pre>${ssrInterpolate(textContent.value)}</pre></div>`);
640
+ } else if (mode.value === "pdf") _push(`<iframe class="fl-preview-frame"${ssrRenderAttr("src", __props.viewer || blobUrl.value)}${ssrRenderAttr("title", __props.file.name)}></iframe>`);
641
+ else {
642
+ _push(`<div class="fl-preview-info"><span class="fl-type fl-preview-badge"${ssrRenderAttr("data-kind", unref(fileKind)(__props.file.name))} aria-hidden="true">${ssrInterpolate(unref(extOf)(__props.file.name) || "FILE")}</span><p class="fl-preview-message">${ssrInterpolate(infoMessage.value)}</p>`);
643
+ if (!__props.demo) _push(`<button class="fl-btn fl-btn-primary" type="button"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"></path><path d="m7 10 5 5 5-5"></path><path d="M5 21h14"></path></svg> ${ssrInterpolate(t("download"))}</button>`);
644
+ else _push(`<!---->`);
645
+ _push(`</div>`);
646
+ }
647
+ _push(`</div></div>`);
648
+ } else _push(`<!---->`);
649
+ _push(`</dialog>`);
650
+ };
651
+ }
652
+ });
653
+ const _sfc_setup$1 = _sfc_main$1.setup;
654
+ _sfc_main$1.setup = (props, ctx) => {
655
+ const ssrContext = useSSRContext();
656
+ (ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/client/PreviewDialog.vue");
657
+ return _sfc_setup$1 ? _sfc_setup$1(props, ctx) : void 0;
658
+ };
271
659
  // src/client/FileList.vue
272
660
  const _sfc_main = /*@__PURE__*/ defineComponent({
273
661
  __name: "FileList",
@@ -313,6 +701,8 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
313
701
  const sortKey = ref("name");
314
702
  const sortAsc = ref(true);
315
703
  const siteTitle = ref("");
704
+ /** OpenList's public `iframe_previews` setting (JSON text). */
705
+ const iframePreviews = ref("");
316
706
  const batchBusy = ref(false);
317
707
  const batchProgress = ref(0);
318
708
  const downloadCache = /* @__PURE__ */ new Map();
@@ -498,13 +888,47 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
498
888
  refresh: false
499
889
  }))?.content ?? [];
500
890
  }
891
+ function absoluteUrl(raw) {
892
+ if (!raw) return "";
893
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return raw;
894
+ const root = baseUrl.value || (typeof window !== "undefined" ? window.location.origin : "");
895
+ try {
896
+ return new URL(raw, `${root}/`).href;
897
+ } catch {
898
+ return raw;
899
+ }
900
+ }
901
+ async function resolveUrl(node) {
902
+ if (demo.value) return "";
903
+ if (node.raw_url) return absoluteUrl(node.raw_url);
904
+ const cached = downloadCache.get(node.path);
905
+ if (cached) return cached;
906
+ let url = "";
907
+ try {
908
+ url = absoluteUrl((await request("/api/fs/get", {
909
+ path: node.path,
910
+ password: node.password || activePassword.value
911
+ }))?.raw_url ?? "");
912
+ } catch {
913
+ url = "";
914
+ }
915
+ if (!url) url = directUrl(node);
916
+ downloadCache.set(node.path, url);
917
+ return url;
918
+ }
919
+ /** OpenList's signed `/d/` download link for a file. */
920
+ function directUrl(node) {
921
+ return `${baseUrl.value}/d${encodePath(node.path)}${node.sign ? `?sign=${encodeURIComponent(node.sign)}` : ""}`;
922
+ }
501
923
  async function loadSiteTitle() {
502
924
  if (demo.value) return;
503
925
  try {
504
926
  const data = await request("/api/public/settings");
505
927
  siteTitle.value = data?.site_title || data?.title || "";
928
+ iframePreviews.value = data?.iframe_previews || "";
506
929
  } catch {
507
930
  siteTitle.value = "";
931
+ iframePreviews.value = "";
508
932
  }
509
933
  }
510
934
  function sortTree(nodes) {
@@ -675,20 +1099,75 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
675
1099
  resortAll();
676
1100
  }
677
1101
  });
678
- const unloadedFolders = computed(() => {
679
- let count = 0;
680
- const walk = (nodes) => {
681
- for (const node of nodes) {
682
- if (node.is_dir && !node.loaded) count += 1;
683
- if (node.children.length) walk(node.children);
684
- }
685
- };
686
- walk(roots.value);
687
- return count;
688
- });
689
- const previewable = /\.(?:pdf|png|jpe?g|gif|webp|svg|txt|md|json|mp3|wav|mp4|webm|mov)$/i;
1102
+ function triggerUrl(url) {
1103
+ const anchor = document.createElement("a");
1104
+ anchor.href = url;
1105
+ anchor.target = "_blank";
1106
+ anchor.rel = "noopener";
1107
+ document.body.appendChild(anchor);
1108
+ anchor.click();
1109
+ anchor.remove();
1110
+ }
1111
+ async function download(node) {
1112
+ if (node.is_dir) return;
1113
+ if (demo.value) {
1114
+ showToast(t("demoDownload", { name: node.name }));
1115
+ return;
1116
+ }
1117
+ try {
1118
+ triggerUrl(await resolveUrl(node));
1119
+ } catch (error) {
1120
+ showToast(t("downloadFailed", { message: errorText(error) }));
1121
+ }
1122
+ }
1123
+ /** Only types the dialog can actually render get a preview button. */
690
1124
  function canPreview(node) {
691
- return !node.is_dir && previewable.test(node.name);
1125
+ return !node.is_dir && previewKind(node.name) !== "none";
1126
+ }
1127
+ const previewNode = ref(null);
1128
+ const previewSrc = ref("");
1129
+ const previewViewer = ref("");
1130
+ const previewResolving = ref(false);
1131
+ const previewError = ref("");
1132
+ let previewSeq = 0;
1133
+ /** Previewable files in the order they are listed, for ← / →. */
1134
+ const previewList = computed(() => visible.value.filter(canPreview));
1135
+ const previewPosition = computed(() => previewNode.value ? previewList.value.indexOf(previewNode.value) + 1 : 0);
1136
+ async function preview(node) {
1137
+ const seq = ++previewSeq;
1138
+ previewNode.value = node;
1139
+ previewSrc.value = "";
1140
+ previewViewer.value = "";
1141
+ previewError.value = "";
1142
+ previewResolving.value = false;
1143
+ if (demo.value || !canPreview(node)) return;
1144
+ previewResolving.value = true;
1145
+ try {
1146
+ const url = await resolveUrl(node);
1147
+ if (seq !== previewSeq) return;
1148
+ previewSrc.value = url;
1149
+ if (previewKind(node.name) === "pdf") {
1150
+ const viewer = iframePreviewsFor(node.name, iframePreviews.value)[0];
1151
+ if (viewer) previewViewer.value = fillPreviewUrl(viewer.scheme, {
1152
+ name: node.name,
1153
+ rawUrl: url,
1154
+ downloadUrl: directUrl(node)
1155
+ });
1156
+ }
1157
+ } catch (error) {
1158
+ if (seq === previewSeq) previewError.value = errorText(error);
1159
+ } finally {
1160
+ if (seq === previewSeq) previewResolving.value = false;
1161
+ }
1162
+ }
1163
+ function stepPreview(delta) {
1164
+ const list = previewList.value;
1165
+ if (!list.length) return;
1166
+ preview(list[((previewNode.value ? list.indexOf(previewNode.value) : -1) + delta + list.length) % list.length]);
1167
+ }
1168
+ function closePreview() {
1169
+ previewSeq += 1;
1170
+ previewNode.value = null;
692
1171
  }
693
1172
  const displayTitle = computed(() => props.title || siteTitle.value || (props.path === "/" ? t("allFiles") : props.path));
694
1173
  const stickyTop = ref(0);
@@ -780,12 +1259,25 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
780
1259
  }
781
1260
  _push(`</span></div>`);
782
1261
  });
783
- _push(`<!--]--><div class="fl-foot"><span>${ssrInterpolate(t("loaded", { count: allFiles.value.length }))} · ${ssrInterpolate(t("totalSize", { size: unref(formatSize)(loadedBytes.value) }))}</span>`);
784
- if (unloadedFolders.value) _push(`<span class="fl-foot-hint">${ssrInterpolate(t("unloaded", { count: unloadedFolders.value }))}</span>`);
785
- else _push(`<!---->`);
786
- _push(`</div>`);
1262
+ _push(`<!--]--><div class="fl-foot"><span>${ssrInterpolate(t("loaded", { count: allFiles.value.length }))} · ${ssrInterpolate(t("totalSize", { size: unref(formatSize)(loadedBytes.value) }))}</span></div>`);
787
1263
  if (toast.value) _push(`<div class="fl-toast" role="status">${ssrInterpolate(toast.value)}</div>`);
788
1264
  else _push(`<!---->`);
1265
+ _push(ssrRenderComponent(_sfc_main$1, {
1266
+ open: !!previewNode.value,
1267
+ file: previewNode.value,
1268
+ labels: text.value,
1269
+ src: previewSrc.value,
1270
+ viewer: previewViewer.value,
1271
+ resolving: previewResolving.value,
1272
+ error: previewError.value,
1273
+ demo: demo.value,
1274
+ position: previewPosition.value,
1275
+ total: previewPosition.value ? previewList.value.length : 0,
1276
+ onClose: closePreview,
1277
+ onPrev: ($event) => stepPreview(-1),
1278
+ onNext: ($event) => stepPreview(1),
1279
+ onDownload: ($event) => previewNode.value && download(previewNode.value)
1280
+ }, null, _parent));
789
1281
  _push(`</div>`);
790
1282
  };
791
1283
  }
@@ -817,4 +1309,4 @@ function enhanceAppWithFileList({ app }) {
817
1309
  if (!app._context.components.FileList) app.component("FileList", _sfc_main);
818
1310
  if (!app._context.components.VPFileList) app.component("VPFileList", _sfc_main);
819
1311
  }
820
- export { _sfc_main as FileList, _sfc_main as VPFileList, compareNodes, encodePath, enhanceAppWithFileList, extOf, fileKind, fileListLocales, formatDate, formatLabel, formatSize, joinPath, resolveLabels, totalSize, uniqueEntryName, zipEntryName, zipSegment };
1312
+ export { _sfc_main as FileList, _sfc_main as VPFileList, compareNodes, encodePath, enhanceAppWithFileList, extOf, fileKind, fileListLocales, fillPreviewUrl, formatDate, formatLabel, formatSize, iframePreviewsFor, joinPath, previewKind, resolveLabels, totalSize, uniqueEntryName, zipEntryName, zipSegment };