@dirwell/theme-crosswave 0.0.1-alpha.1 → 0.0.2

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.
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ import { copyJsonObject, escapeHtml, projectListingEntry } from "@dirwell/core";
4
4
  import path from "node:path";
5
5
  import mime from "mime";
6
6
  import { computed, createSSRApp, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch, watchEffect } from "vue";
7
- import { renderToString, ssrIncludeBooleanAttr, ssrInterpolate, ssrLooseContain, ssrLooseEqual, ssrRenderAttr, ssrRenderClass, ssrRenderDynamicAttr, ssrRenderList, ssrRenderSlot } from "vue/server-renderer";
7
+ import { renderToString, ssrIncludeBooleanAttr, ssrInterpolate, ssrLooseContain, ssrLooseEqual, ssrRenderAttr, ssrRenderClass, ssrRenderDynamicAttr, ssrRenderList, ssrRenderSlot, ssrRenderStyle } from "vue/server-renderer";
8
8
  import { useGamepad } from "@vueuse/core";
9
9
  import { ListingDataError, loadListing, parseListingEntries } from "@dirwell/core/client";
10
10
  //#region ../shared-theme/src/page.ts
@@ -37,7 +37,7 @@ function createThemePage(context, theme, title) {
37
37
  documentBaseHref: context.documentBaseHref,
38
38
  directoryPath
39
39
  };
40
- const dataForDirectory = (logicalPath) => folderDataHref({
40
+ const dataForDirectory = (logicalPath) => context.hasPageForDirectory?.(logicalPath) === false ? null : folderDataHref({
41
41
  id: logicalPath,
42
42
  name: logicalPath,
43
43
  kind: "directory",
@@ -68,6 +68,10 @@ function createThemePage(context, theme, title) {
68
68
  manifestHref: context.listing?.manifestHref ?? null,
69
69
  ...context.listing ? { listingRevision: context.listing.revision } : {},
70
70
  updatesHref: context.updatesHref ?? null,
71
+ ...context.searchQueryHref ? {
72
+ searchQueryHref: context.searchQueryHref,
73
+ searchRefreshIntervalMs: context.searchRefreshIntervalMs
74
+ } : {},
71
75
  total: context.listing?.page.total ?? entries.length,
72
76
  entries,
73
77
  sort: context.sort,
@@ -255,6 +259,275 @@ function entryCategories(entry, type, categories) {
255
259
  return categories.filter((category) => category.match === "all" || folder && category.match === "folders" || !folder && category.match === "other" && !specific.length || specific.includes(category));
256
260
  }
257
261
  //#endregion
262
+ //#region ../shared-theme/src/live-search.ts
263
+ async function queryLiveArchive(href, query, signal) {
264
+ const url = new URL(href, document.baseURI);
265
+ if (url.origin !== location.origin) throw new Error("Unsafe search endpoint");
266
+ url.searchParams.set("q", query);
267
+ const response = await fetch(url, {
268
+ signal,
269
+ cache: "no-store"
270
+ });
271
+ if (!response.ok) throw new Error("Archive search unavailable");
272
+ const data = await response.json();
273
+ if (typeof data !== "object" || data === null || !("version" in data) || data.version !== 1 || !("results" in data) || !Array.isArray(data.results) || data.results.length > 1e4 || !("truncated" in data) || typeof data.truncated !== "boolean") throw new Error("Invalid archive results");
274
+ return {
275
+ records: data.results.map((entry) => {
276
+ if (typeof entry !== "object" || entry === null || !("name" in entry) || typeof entry.name !== "string" || !("path" in entry) || typeof entry.path !== "string" || !("kind" in entry) || !["file", "directory"].includes(String(entry.kind)) || !("href" in entry) || entry.href !== null && typeof entry.href !== "string") throw new Error("Invalid archive record");
277
+ const target = entry.href === null ? null : new URL(entry.href, url);
278
+ if (target && !["http:", "https:"].includes(target.protocol)) throw new Error("Unsafe archive link");
279
+ return {
280
+ name: entry.name,
281
+ path: entry.path,
282
+ href: target?.href ?? null,
283
+ exitsExplorer: entry.kind !== "directory"
284
+ };
285
+ }),
286
+ truncated: data.truncated
287
+ };
288
+ }
289
+ //#endregion
290
+ //#region ../shared-theme/src/customization.ts
291
+ /** Small serializable inputs shared by official themes; layout stays theme-owned. */
292
+ const defaultLabels = {
293
+ other: "other",
294
+ crosswaveKeyboardHelp: "Arrow keys navigate · Enter confirms · Backspace returns",
295
+ brokenLink: "broken link",
296
+ unavailableLink: "unavailable link",
297
+ cycleLink: "cycle link",
298
+ azure: "azure",
299
+ violet: "violet",
300
+ amber: "amber",
301
+ rose: "rose",
302
+ jade: "jade",
303
+ graphite: "graphite",
304
+ allFiles: "All files",
305
+ categoryFolders: "Folders",
306
+ categoryPhotos: "Photos",
307
+ categoryMusic: "Music",
308
+ categoryVideos: "Videos",
309
+ categoryDocuments: "Documents",
310
+ categoryOther: "Other",
311
+ indexOf: "Index of {path}",
312
+ home: "Home",
313
+ breadcrumb: "Breadcrumb",
314
+ archive: "File archive",
315
+ searchDirectory: "Search this directory",
316
+ searchFiles: "Search files",
317
+ search: "Search",
318
+ types: "Types",
319
+ directory: "directory",
320
+ file: "file",
321
+ link: "link",
322
+ folder: "Folder",
323
+ sort: "Sort",
324
+ name: "Name",
325
+ size: "Size",
326
+ modified: "Modified",
327
+ names: "Names",
328
+ natural: "Natural",
329
+ locale: "Locale",
330
+ unicode: "Unicode",
331
+ order: "Order",
332
+ ascending: "Ascending",
333
+ descending: "Descending",
334
+ directoriesFirst: "Folders first",
335
+ appearance: "Appearance",
336
+ system: "System",
337
+ light: "Light",
338
+ dark: "Dark",
339
+ searchArchive: "Search archive",
340
+ archiveSearchInput: "Search archive files",
341
+ entries: "{count} entries",
342
+ pageEntries: "{total} entries · {count} on this page",
343
+ loading: "Loading…",
344
+ noMatches: "No matching entries.",
345
+ empty: "This directory is empty.",
346
+ pagination: "Pagination",
347
+ previous: "Previous",
348
+ next: "Next",
349
+ page: "Page {page} of {pages}",
350
+ keyboardHelp: "Arrow keys select files · Home / End jump · / searches · Backspace opens parent",
351
+ modifiedTime: "Modified time",
352
+ close: "Close",
353
+ unknown: "Unknown",
354
+ unavailable: "Target unavailable",
355
+ beginArchive: "Enter a search to begin.",
356
+ searchingArchive: "Searching archive…",
357
+ archiveResults: "{count} results",
358
+ truncatedResults: "Showing {shown} of {count} results",
359
+ archiveError: "Could not load archive search. Try again.",
360
+ listingError: "Could not load complete directory data. Native page links remain available.",
361
+ folderError: "Could not open the folder. Its native link remains available.",
362
+ updatesError: "Live updates disconnected. Refresh to retry.",
363
+ retry: "Retry",
364
+ openNative: "Open folder directly",
365
+ categories: "File categories",
366
+ items: "{count} items",
367
+ filesIn: "Files in {path}",
368
+ controller: "Controller · Confirm / Back",
369
+ color: "Color",
370
+ pauseMotion: "Pause motion",
371
+ resumeMotion: "Resume motion",
372
+ copyLink: "Copy link",
373
+ copyPath: "Copy path",
374
+ download: "Download",
375
+ copied: "Copied.",
376
+ copyError: "Could not copy. Use the native link or select the path.",
377
+ repository: "Repository",
378
+ author: "by",
379
+ target: "Target",
380
+ newTab: "opens in a new tab"
381
+ };
382
+ function resolveCustomization(options = {}) {
383
+ const language = options.language === void 0 ? "en" : options.language, locale = options.locale === void 0 ? "en-US" : options.locale, timeZone = options.timeZone === void 0 ? "UTC" : options.timeZone;
384
+ for (const [key, value] of Object.entries({
385
+ language,
386
+ locale,
387
+ timeZone
388
+ })) if (typeof value !== "string" || !value) throw new TypeError(`${key} must be a non-empty string`);
389
+ new Intl.DateTimeFormat(locale, { timeZone });
390
+ const direction = options.direction ?? "ltr";
391
+ if (direction !== "ltr" && direction !== "rtl") throw new TypeError("direction must be ltr or rtl");
392
+ const labels = { ...defaultLabels };
393
+ if (options.labels !== void 0) {
394
+ if (typeof options.labels !== "object" || options.labels === null || Array.isArray(options.labels)) throw new TypeError("labels must be an object");
395
+ for (const [key, value] of Object.entries(options.labels)) {
396
+ if (!Object.hasOwn(labels, key) || typeof value !== "string") throw new TypeError(`Unsupported label or value: ${key}`);
397
+ Object.assign(labels, { [key]: value });
398
+ }
399
+ }
400
+ const brand = options.brand ?? {};
401
+ if (typeof brand !== "object" || brand === null || Array.isArray(brand)) throw new TypeError("brand must be an object");
402
+ for (const [key, value] of Object.entries(brand)) {
403
+ if (![
404
+ "name",
405
+ "logoUrl",
406
+ "logoAlt",
407
+ "footerText",
408
+ "footerUrl",
409
+ "faviconUrl"
410
+ ].includes(key) || typeof value !== "string") throw new TypeError(`Unsupported brand option or value: ${key}`);
411
+ if (key.endsWith("Url") && !/^https?:\/\//i.test(value) && !(value.startsWith("/") && !value.startsWith("//"))) throw new TypeError(`brand.${key} must be an HTTP(S) or root-relative URL`);
412
+ }
413
+ const columns = options.columns ?? [
414
+ "name",
415
+ "size",
416
+ "modified"
417
+ ];
418
+ if (!Array.isArray(columns) || !columns.includes("name")) throw new TypeError("columns must contain name and unique supported columns");
419
+ const columnIds = /* @__PURE__ */ new Set();
420
+ for (const column of columns) {
421
+ let id;
422
+ if (typeof column === "string") {
423
+ if (![
424
+ "name",
425
+ "size",
426
+ "modified"
427
+ ].includes(column)) throw new TypeError("Unsupported column");
428
+ id = column;
429
+ } else {
430
+ if (typeof column !== "object" || column === null || Object.keys(column).some((key) => !["dataKey", "label"].includes(key)) || typeof column.dataKey !== "string" || !column.dataKey || typeof column.label !== "string" || !column.label) throw new TypeError("Data columns require dataKey and label");
431
+ id = "data:" + column.dataKey;
432
+ }
433
+ if (columnIds.has(id)) throw new TypeError("columns must contain unique supported columns");
434
+ columnIds.add(id);
435
+ }
436
+ const actions = options.actions ?? [];
437
+ if (!Array.isArray(actions) || new Set(actions).size !== actions.length || actions.some((key) => ![
438
+ "copy-link",
439
+ "copy-path",
440
+ "download"
441
+ ].includes(key))) throw new TypeError("actions must contain unique supported actions");
442
+ const cssVariables = options.cssVariables ?? {};
443
+ if (typeof cssVariables !== "object" || cssVariables === null || Array.isArray(cssVariables)) throw new TypeError("cssVariables must be an object");
444
+ for (const [key, value] of Object.entries(cssVariables)) if (!/^--[a-zA-Z][a-zA-Z0-9_-]*$/.test(key) || typeof value !== "string" || /[{};<>]/.test(value)) throw new TypeError(`Invalid CSS variable: ${key}`);
445
+ return {
446
+ language,
447
+ dateOptions: {
448
+ ...options.locale === void 0 ? {} : { locale: options.locale },
449
+ ...options.timeZone === void 0 ? {} : { timeZone: options.timeZone }
450
+ },
451
+ direction,
452
+ locale,
453
+ timeZone,
454
+ labels,
455
+ brand: { ...brand },
456
+ columns: columns.map((column) => typeof column === "string" ? column : { ...column }),
457
+ actions: [...actions],
458
+ cssVariables: { ...cssVariables }
459
+ };
460
+ }
461
+ resolveCustomization();
462
+ function label(ui, key, values = {}) {
463
+ return ui.labels[key].replace(/\{([a-zA-Z]+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
464
+ }
465
+ const dateFormats = /* @__PURE__ */ new Map();
466
+ function formattedDate(value, ui, local = false) {
467
+ const date = new Date(value);
468
+ if (Number.isNaN(date.getTime())) return ui.labels.unknown;
469
+ if (ui.dateOptions.locale === void 0 && ui.dateOptions.timeZone === void 0) return local ? date.toLocaleString(void 0, {
470
+ dateStyle: "short",
471
+ timeStyle: "short"
472
+ }) : date.toISOString().slice(0, 16).replace("T", " ") + " UTC";
473
+ const locale = ui.dateOptions.locale, timeZone = ui.dateOptions.timeZone ?? (local ? void 0 : "UTC");
474
+ const key = String(locale) + "\0" + String(timeZone);
475
+ let formatter = dateFormats.get(key);
476
+ if (formatter === void 0) {
477
+ formatter = new Intl.DateTimeFormat(locale, {
478
+ timeZone,
479
+ dateStyle: "short",
480
+ timeStyle: "short"
481
+ });
482
+ if (dateFormats.size >= 32) dateFormats.delete(dateFormats.keys().next().value);
483
+ dateFormats.set(key, formatter);
484
+ }
485
+ return formatter.format(date);
486
+ }
487
+ function customizationCss(ui) {
488
+ const declarations = Object.entries(ui.cssVariables).map(([key, value]) => `${key}:${value}`).join(";");
489
+ return declarations ? `:root:root{${declarations}}${ui.cssVariables["--dw-font-family"] ? "body{font-family:var(--dw-font-family)}" : ""}` : "";
490
+ }
491
+ /** Publish only supplied customization instead of repeating defaults in every page. */
492
+ function pickCustomization(options) {
493
+ const result = {};
494
+ for (const key of [
495
+ "language",
496
+ "direction",
497
+ "locale",
498
+ "timeZone",
499
+ "labels",
500
+ "brand",
501
+ "columns",
502
+ "actions",
503
+ "cssVariables"
504
+ ]) if (options[key] !== void 0) {
505
+ const value = options[key];
506
+ Object.assign(result, { [key]: Array.isArray(value) ? value.map((item) => typeof item === "object" ? { ...item } : item) : typeof value === "object" && value !== null ? { ...value } : value });
507
+ }
508
+ return Object.keys(result).length ? result : void 0;
509
+ }
510
+ /** Built-in factories fail on typos instead of silently ignoring configuration. */
511
+ function assertThemeOptions(value, extraKeys) {
512
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("Theme options must be an object");
513
+ const allowed = [
514
+ "language",
515
+ "direction",
516
+ "locale",
517
+ "timeZone",
518
+ "labels",
519
+ "brand",
520
+ "columns",
521
+ "actions",
522
+ "cssVariables",
523
+ ...extraKeys
524
+ ];
525
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new TypeError(`Unsupported theme option: ${key}`);
526
+ }
527
+ function publicDataValue(value) {
528
+ return value === void 0 || value === null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
529
+ }
530
+ //#endregion
258
531
  //#region ../shared-theme/src/navigation.ts
259
532
  /** Folder snapshots resolve relative URLs before History changes the document URL. */
260
533
  async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false, attempt = 0) {
@@ -270,6 +543,25 @@ async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false,
270
543
  const data = value;
271
544
  if (data.version !== 1 || data.theme !== theme || typeof data.title !== "string" || typeof data.path !== "string" || !Number.isSafeInteger(data.total) || Number(data.total) < 0 || !Array.isArray(data.breadcrumbs)) throw new ListingDataError("Unsupported folder snapshot");
272
545
  if (data.metadataHead !== void 0 && typeof data.metadataHead !== "string") throw new ListingDataError("Invalid folder metadata");
546
+ const sort = data.sort;
547
+ if (!sort || typeof sort !== "object" || ![
548
+ "name",
549
+ "size",
550
+ "modified"
551
+ ].includes(String(sort.field)) || !["asc", "desc"].includes(String(sort.direction)) || ![
552
+ "natural",
553
+ "locale",
554
+ "unicode"
555
+ ].includes(String(sort.nameMode)) || typeof sort.directoriesFirst !== "boolean") throw new ListingDataError("Invalid folder order");
556
+ if (theme === "crosswave") {
557
+ const categories = data.categories;
558
+ const ids = /* @__PURE__ */ new Set();
559
+ if (!Array.isArray(categories) || categories.length === 0 || categories.some((category) => {
560
+ if (typeof category !== "object" || category === null || typeof category.id !== "string" || !category.id || ids.has(category.id) || typeof category.label !== "string" || typeof category.svg !== "string") return true;
561
+ ids.add(category.id);
562
+ return false;
563
+ })) throw new ListingDataError("Invalid folder categories");
564
+ }
273
565
  const base = document.querySelector("base")?.href ?? pageHref;
274
566
  const link = (raw) => {
275
567
  if (raw === null) return null;
@@ -286,8 +578,20 @@ async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false,
286
578
  } } : {}
287
579
  }));
288
580
  if (entries.length > Number(data.total)) throw new ListingDataError("Invalid folder count");
581
+ if (data.page !== null) {
582
+ const page = data.page;
583
+ if (!page || typeof page !== "object" || !Number.isSafeInteger(page.number) || Number(page.number) < 1 || !Number.isSafeInteger(page.count) || Number(page.count) < Number(page.number) || !Number.isSafeInteger(page.offset) || Number(page.offset) < 0 || Number(page.offset) > Number(data.total) || page.total !== data.total) throw new ListingDataError("Invalid folder pagination");
584
+ data.page = {
585
+ ...page,
586
+ href: link(page.href),
587
+ previousHref: link(page.previousHref),
588
+ nextHref: link(page.nextHref)
589
+ };
590
+ }
591
+ if (data.ui !== void 0) resolveCustomization(data.ui);
289
592
  const result = {
290
593
  ...data,
594
+ searchQueryHref: link(data.searchQueryHref ?? null),
291
595
  updatesHref: link(data.updatesHref ?? null),
292
596
  entries,
293
597
  dataHref: source.href,
@@ -305,10 +609,10 @@ async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false,
305
609
  };
306
610
  })
307
611
  };
308
- if (fresh && result.manifestHref) {
612
+ if (result.manifestHref) {
309
613
  const loaded = await loadListing(result.manifestHref, {
310
614
  signal,
311
- cache: "no-store"
615
+ cache: fresh ? "no-store" : "default"
312
616
  });
313
617
  if (loaded.manifest.total !== data.total || data.listingRevision !== void 0 && loaded.manifest.revision !== data.listingRevision) {
314
618
  if (attempt < 2) return fetchThemePage(dataHref, pageHref, theme, signal, true, attempt + 1);
@@ -376,6 +680,139 @@ function applyThemeMetadata(page, pageHref) {
376
680
  }
377
681
  }
378
682
  //#endregion
683
+ //#region ../shared-theme/src/view-state.ts
684
+ const supportedTypes = [
685
+ "directory",
686
+ "file",
687
+ "link"
688
+ ];
689
+ function validSort(value) {
690
+ if (typeof value !== "object" || value === null) return false;
691
+ const sort = value;
692
+ return [
693
+ "name",
694
+ "size",
695
+ "modified"
696
+ ].includes(String(sort.field)) && ["asc", "desc"].includes(String(sort.direction)) && [
697
+ "natural",
698
+ "locale",
699
+ "unicode"
700
+ ].includes(String(sort.nameMode)) && typeof sort.directoriesFirst === "boolean";
701
+ }
702
+ function position(value) {
703
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(value, 1e9)) : 0;
704
+ }
705
+ /** URL controls are shareable; selection and scroll belong only to one history entry. */
706
+ function readView(href, theme, path, defaults, categories = [], historyState = null) {
707
+ const params = new URL(href).searchParams;
708
+ const result = {
709
+ ...defaults,
710
+ query: (params.get("dw.q") ?? "").slice(0, 4096),
711
+ selected: null,
712
+ windowY: 0,
713
+ listTop: 0
714
+ };
715
+ if (theme === "ledger") {
716
+ const parts = params.get("dw.sort")?.split(":");
717
+ if (parts?.length === 4) {
718
+ const sort = {
719
+ field: parts[0],
720
+ direction: parts[1],
721
+ nameMode: parts[2],
722
+ directoriesFirst: parts[3] === "folders"
723
+ };
724
+ if (["folders", "mixed"].includes(parts[3]) && validSort(sort)) result.sort = sort;
725
+ }
726
+ if (params.has("dw.types")) {
727
+ const types = params.get("dw.types").split(",").filter(Boolean);
728
+ if (types.every((type) => supportedTypes.includes(type))) result.types = [...new Set(types)];
729
+ }
730
+ } else {
731
+ const category = params.get("dw.category");
732
+ if (category !== null && categories.includes(category)) result.category = category;
733
+ }
734
+ if (typeof historyState === "object" && historyState !== null && "dirwell" in historyState) {
735
+ const state = historyState.dirwell;
736
+ if (typeof state === "object" && state !== null && "version" in state && state.version === 1 && "theme" in state && state.theme === theme && "path" in state && state.path === path && "view" in state && typeof state.view === "object" && state.view !== null) {
737
+ const view = state.view;
738
+ result.selected = typeof view.selected === "string" && view.selected.length <= 4096 ? view.selected : null;
739
+ result.windowY = position(view.windowY);
740
+ result.listTop = position(view.listTop);
741
+ }
742
+ }
743
+ return result;
744
+ }
745
+ function viewHref(href, theme, view, defaults) {
746
+ const url = new URL(href);
747
+ for (const key of [
748
+ "dw.q",
749
+ "dw.sort",
750
+ "dw.types",
751
+ "dw.category"
752
+ ]) url.searchParams.delete(key);
753
+ if (view.query) url.searchParams.set("dw.q", view.query.slice(0, 4096));
754
+ if (theme === "ledger") {
755
+ if (view.sort && [
756
+ "field",
757
+ "direction",
758
+ "nameMode",
759
+ "directoriesFirst"
760
+ ].some((key) => view.sort[key] !== defaults.sort?.[key])) {
761
+ const sort = view.sort;
762
+ url.searchParams.set("dw.sort", `${sort.field}:${sort.direction}:${sort.nameMode}:${sort.directoriesFirst ? "folders" : "mixed"}`);
763
+ }
764
+ if (view.types && supportedTypes.some((type) => view.types.includes(type) !== (defaults.types ?? supportedTypes).includes(type))) url.searchParams.set("dw.types", view.types.join(","));
765
+ } else if (view.category && view.category !== defaults.category) url.searchParams.set("dw.category", view.category);
766
+ return url.href;
767
+ }
768
+ /** History failures never require discarding a successfully loaded view. */
769
+ function createViewHistory(theme) {
770
+ let committedHref = location.href;
771
+ let committedState = history.state;
772
+ const stateFor = (dataHref, path, view) => ({
773
+ ...typeof history.state === "object" && history.state !== null ? history.state : {},
774
+ dataHref,
775
+ dirwell: {
776
+ version: 1,
777
+ theme,
778
+ path,
779
+ ...view === void 0 ? {} : { view: {
780
+ query: view.query,
781
+ ...view.sort === void 0 ? {} : { sort: { ...view.sort } },
782
+ ...view.types === void 0 ? {} : { types: [...view.types] },
783
+ category: view.category ?? null,
784
+ selected: view.selected ?? null,
785
+ windowY: view.windowY ?? 0,
786
+ listTop: view.listTop ?? 0
787
+ } }
788
+ }
789
+ });
790
+ return {
791
+ save(dataHref, path, view, defaults) {
792
+ if (location.href !== committedHref) return;
793
+ const href = viewHref(committedHref, theme, view, defaults);
794
+ const state = stateFor(dataHref, path, view);
795
+ try {
796
+ history.replaceState(state, "", href);
797
+ committedHref = href;
798
+ committedState = state;
799
+ } catch {}
800
+ },
801
+ commit(dataHref, path, href, push) {
802
+ const state = stateFor(dataHref, path);
803
+ if (push) history.pushState(state, "", href);
804
+ else history.replaceState(state, "", href);
805
+ committedHref = href;
806
+ committedState = state;
807
+ },
808
+ rollback() {
809
+ if (location.href !== committedHref) try {
810
+ history.replaceState(committedState, "", committedHref);
811
+ } catch {}
812
+ }
813
+ };
814
+ }
815
+ //#endregion
379
816
  //#region ../shared-theme/src/icons.ts
380
817
  const paths = {
381
818
  directory: "<path d=\"M3 7V5h6l2 3h10v12H3Z\"/><path d=\"M3 10h18\"/>",
@@ -404,14 +841,6 @@ function size(bytes) {
404
841
  }
405
842
  return `${value.toFixed(index === 0 || value >= 100 ? 0 : 1)} ${units[index]}`;
406
843
  }
407
- function modified(value, local = false) {
408
- const date = new Date(value);
409
- if (Number.isNaN(date.getTime())) return "Unknown";
410
- return local ? date.toLocaleString(void 0, {
411
- dateStyle: "short",
412
- timeStyle: "short"
413
- }) : `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
414
- }
415
844
  //#endregion
416
845
  //#region .generated/wave.mjs
417
846
  function createWave(canvas, colors, root, moving, onReady) {
@@ -557,11 +986,154 @@ var App_server_default = {
557
986
  required: true
558
987
  } },
559
988
  setup(__props) {
560
- const props = __props, model = shallowRef(restoreThemePage(props.model, Boolean(import.meta.hot))), entries = shallowRef(model.value.entries);
561
- const query = ref(""), category = ref("all"), selected = ref(null), ready = ref(false), busy = ref(false), error = ref(""), color = ref(props.model.color), paused = ref(!props.model.backgroundMotion), clock = ref(""), pad = ref(false), waveReady = ref(false), pendingUpdates = ref(null), canvas = ref(), search = ref();
989
+ const props = __props, model = shallowRef(restoreThemePage(props.model, Boolean(import.meta.hot))), entries = shallowRef(model.value.entries), ui = computed(() => resolveCustomization(model.value.ui));
990
+ const query = ref(""), category = ref(model.value.categories[0]?.id ?? null), selected = ref(null), ready = ref(false), busy = ref(false), error = ref(""), failure = ref(null), color = ref(props.model.color), paused = ref(!props.model.backgroundMotion), clock = ref(""), pad = ref(false), waveReady = ref(false), pendingUpdates = ref(null), canvas = ref(), search = ref(), listScroll = ref(), footerElement = ref(), scrollTop = ref(0), viewportHeight = ref(400), retryRequest = shallowRef(null), heights = shallowRef(/* @__PURE__ */ new Map());
991
+ const archiveOpen = ref(false), archiveQuery = ref(""), archiveRows = shallowRef([]), archiveStatus = ref(""), archiveFailed = ref(false), archiveTrigger = ref(), archiveDialog = ref(), archiveInput = ref();
992
+ let archiveAbort, archiveRequest = 0, archivePending = false, archiveDebouncing = false;
993
+ async function searchArchive() {
994
+ const token = ++archiveRequest;
995
+ archiveAbort?.abort();
996
+ const local = archiveAbort = new AbortController();
997
+ archiveFailed.value = false;
998
+ if (!archiveQuery.value.trim()) {
999
+ archiveRows.value = [];
1000
+ archiveStatus.value = ui.value.labels.beginArchive;
1001
+ return;
1002
+ }
1003
+ archiveStatus.value = ui.value.labels.searchingArchive;
1004
+ archivePending = true;
1005
+ try {
1006
+ const result = await queryLiveArchive(model.value.searchQueryHref, archiveQuery.value.trim(), local.signal);
1007
+ if (local.signal.aborted || token !== archiveRequest) return;
1008
+ archiveRows.value = result.records;
1009
+ archiveStatus.value = result.truncated ? label(ui.value, "truncatedResults", {
1010
+ shown: result.records.length,
1011
+ count: result.records.length + "+"
1012
+ }) : label(ui.value, "archiveResults", { count: result.records.length });
1013
+ } catch {
1014
+ if (local.signal.aborted || token !== archiveRequest) return;
1015
+ archiveRows.value = [];
1016
+ archiveStatus.value = ui.value.labels.archiveError;
1017
+ archiveFailed.value = true;
1018
+ } finally {
1019
+ if (token === archiveRequest) archivePending = false;
1020
+ }
1021
+ }
1022
+ watch([archiveOpen, archiveQuery], (_, __, cleanup) => {
1023
+ archiveAbort?.abort();
1024
+ archiveRequest++;
1025
+ archivePending = false;
1026
+ if (!archiveOpen.value) return;
1027
+ archiveDebouncing = true;
1028
+ const debounce = setTimeout(() => {
1029
+ archiveDebouncing = false;
1030
+ searchArchive();
1031
+ }, 300);
1032
+ cleanup(() => {
1033
+ clearTimeout(debounce);
1034
+ archiveDebouncing = false;
1035
+ });
1036
+ });
1037
+ watch(archiveOpen, async (open, _, cleanup) => {
1038
+ if (!open) {
1039
+ await nextTick();
1040
+ archiveTrigger.value?.focus();
1041
+ return;
1042
+ }
1043
+ await nextTick();
1044
+ if (!archiveOpen.value) return;
1045
+ archiveDialog.value?.showModal();
1046
+ archiveInput.value?.focus();
1047
+ const timer = setInterval(() => {
1048
+ if (document.visibilityState === "visible" && archiveQuery.value.trim() && !archivePending && !archiveDebouncing) searchArchive();
1049
+ }, model.value.searchRefreshIntervalMs ?? 2e3);
1050
+ cleanup(() => {
1051
+ clearInterval(timer);
1052
+ archiveAbort?.abort();
1053
+ archiveRequest++;
1054
+ });
1055
+ });
562
1056
  const complete = computed(() => entries.value.length === model.value.total), membership = (entry) => Array.isArray(entry.presentation?.categories) ? entry.presentation.categories : [];
563
- const rows = computed(() => entries.value.filter((entry) => (category.value === "all" || membership(entry).includes(category.value)) && `${entry.name} ${entry.link?.target ?? ""}`.toLocaleLowerCase().includes(query.value.trim().toLocaleLowerCase()))), active = computed(() => rows.value.find((entry) => entry.id === selected.value) ?? rows.value[0]), heading = computed(() => model.value.categories.find((item) => item.id === category.value)?.label ?? "All files");
1057
+ const rows = computed(() => entries.value.filter((entry) => membership(entry).includes(category.value) && `${entry.name} ${entry.link?.target ?? ""}`.toLocaleLowerCase().includes(query.value.trim().toLocaleLowerCase()))), active = computed(() => rows.value.find((entry) => entry.id === selected.value) ?? rows.value[0]), heading = computed(() => model.value.categories.find((item) => item.id === category.value)?.label ?? ui.value.labels.allFiles);
564
1058
  const nodes = /* @__PURE__ */ new Map();
1059
+ const virtual = computed(() => ready.value && rows.value.length > 500);
1060
+ const offsets = computed(() => {
1061
+ const result = [0];
1062
+ for (const entry of rows.value) result.push(result.at(-1) + (heights.value.get(entry.id) ?? 76));
1063
+ return result;
1064
+ });
1065
+ const start = computed(() => {
1066
+ if (!virtual.value) return 0;
1067
+ const position = Math.max(0, scrollTop.value - (model.value.parentHref ? 76 : 0));
1068
+ let low = 0, high = rows.value.length;
1069
+ while (low < high) {
1070
+ const middle = low + high >> 1;
1071
+ if (offsets.value[middle + 1] <= position) low = middle + 1;
1072
+ else high = middle;
1073
+ }
1074
+ return Math.max(0, low - 5);
1075
+ });
1076
+ const end = computed(() => {
1077
+ if (!virtual.value) return rows.value.length;
1078
+ let low = 0, high = rows.value.length;
1079
+ while (low < high) {
1080
+ const middle = low + high >> 1;
1081
+ if (offsets.value[middle + 1] <= scrollTop.value + viewportHeight.value) low = middle + 1;
1082
+ else high = middle;
1083
+ }
1084
+ return Math.min(rows.value.length, Math.max(start.value + 35, low + 5));
1085
+ });
1086
+ const visibleRows = computed(() => rows.value.slice(start.value, end.value));
1087
+ const heightCache = /* @__PURE__ */ new Map();
1088
+ let rowObserver, layoutObserver, viewHistory, measuredWidth;
1089
+ function measureLayout() {
1090
+ if (!listScroll.value) return;
1091
+ const width = listScroll.value.clientWidth;
1092
+ if (measuredWidth !== void 0 && width !== measuredWidth) {
1093
+ heightCache.clear();
1094
+ heights.value = new Map([...nodes].map(([id, node]) => [id, node.getBoundingClientRect().height]));
1095
+ }
1096
+ measuredWidth = width;
1097
+ const footerHeight = footerElement.value?.getBoundingClientRect().height ?? 82;
1098
+ document.documentElement.style.setProperty("--cw-footer-height", footerHeight + "px");
1099
+ const available = Math.max(160, innerHeight - listScroll.value.getBoundingClientRect().top - footerHeight - 24);
1100
+ viewportHeight.value = available;
1101
+ listScroll.value.style.setProperty("--cw-list-height", available + "px");
1102
+ }
1103
+ watch([virtual, () => model.value.path], () => nextTick(measureLayout));
1104
+ const viewDefaults = (page) => ({
1105
+ query: "",
1106
+ category: page.categories[0]?.id ?? null
1107
+ });
1108
+ function saveView() {
1109
+ if (!viewHistory) return;
1110
+ viewHistory.save(new URL(model.value.dataHref, document.baseURI).href, model.value.path, {
1111
+ query: query.value,
1112
+ category: category.value,
1113
+ selected: selected.value,
1114
+ windowY: window.scrollY,
1115
+ listTop: listScroll.value?.scrollTop ?? 0
1116
+ }, viewDefaults(model.value));
1117
+ }
1118
+ watchEffect((onCleanup) => {
1119
+ if (!ready.value || busy.value) return;
1120
+ const view = {
1121
+ query: query.value,
1122
+ category: category.value,
1123
+ selected: selected.value,
1124
+ listTop: scrollTop.value
1125
+ };
1126
+ const dataHref = model.value.dataHref, path = model.value.path, defaults = viewDefaults(model.value);
1127
+ const frame = requestAnimationFrame(() => viewHistory?.save(new URL(dataHref, document.baseURI).href, path, {
1128
+ ...view,
1129
+ windowY: window.scrollY
1130
+ }, defaults));
1131
+ onCleanup(() => cancelAnimationFrame(frame));
1132
+ });
1133
+ watch([query, category], () => {
1134
+ scrollTop.value = 0;
1135
+ if (listScroll.value) listScroll.value.scrollTop = 0;
1136
+ });
565
1137
  let wave, gamepad, abort, navigation = 0, timer, lastPad = 0, pressed = /* @__PURE__ */ new Set(), disposed = false, padIdentity = null, motionQuery;
566
1138
  function safeHref(entry) {
567
1139
  return typeof location !== "undefined" && location.protocol === "file:" && directoryLike(entry) && !entry.exitsExplorer && entry.href?.endsWith("/") ? entry.href + "index.html" : entry.href;
@@ -577,67 +1149,114 @@ var App_server_default = {
577
1149
  });
578
1150
  if (token === navigation && !local.signal.aborted && !disposed) entries.value = data.entries;
579
1151
  } catch (cause) {
580
- if (token === navigation && !local.signal.aborted) error.value = "Could not load complete directory data. Native page links remain available.";
1152
+ if (token === navigation && !local.signal.aborted) {
1153
+ error.value = ui.value.labels.listingError;
1154
+ failure.value = "listing";
1155
+ }
581
1156
  } finally {
582
1157
  if (token === navigation) busy.value = false;
583
1158
  }
584
1159
  }
585
- async function openFolder(dataHref, url, push, refresh = false) {
1160
+ async function openFolder(dataHref, url, push, refresh = false, restoreState = null, fresh = false) {
1161
+ if (push) saveView();
586
1162
  const token = ++navigation;
587
1163
  abort?.abort();
588
1164
  const local = abort = new AbortController();
589
1165
  busy.value = true;
590
1166
  error.value = "";
1167
+ failure.value = null;
591
1168
  try {
592
- const next = await fetchThemePage(dataHref, url, "crosswave", local.signal, refresh);
1169
+ const next = await fetchThemePage(dataHref, url, "crosswave", local.signal, refresh || fresh);
593
1170
  let nextEntries = next.entries;
594
1171
  if (refresh && next.manifestHref && nextEntries.length !== next.total) nextEntries = (await loadListing(next.manifestHref, {
595
1172
  signal: local.signal,
596
1173
  cache: "no-store"
597
1174
  })).entries;
598
1175
  if (token !== navigation || local.signal.aborted || disposed) return;
1176
+ if (!refresh) viewHistory.commit(next.dataHref, next.path, url, push);
1177
+ if (next.path !== model.value.path) {
1178
+ heightCache.set(model.value.path, heights.value);
1179
+ if (heightCache.size > 8) heightCache.delete(heightCache.keys().next().value);
1180
+ heights.value = heightCache.get(next.path) ?? /* @__PURE__ */ new Map();
1181
+ }
599
1182
  model.value = next;
1183
+ retryRequest.value = null;
600
1184
  entries.value = nextEntries;
601
1185
  if (!refresh) {
602
- query.value = "";
603
- selected.value = null;
604
- category.value = "all";
1186
+ const view = readView(url, "crosswave", next.path, viewDefaults(next), next.categories.map((item) => item.id), restoreState);
1187
+ query.value = view.query;
1188
+ selected.value = view.selected;
1189
+ category.value = view.category;
605
1190
  }
606
- if (push) history.pushState({ dataHref: next.dataHref }, "", url);
607
1191
  applyThemeMetadata(next, url);
608
1192
  await loadComplete();
1193
+ if (!refresh) {
1194
+ const view = readView(url, "crosswave", next.path, viewDefaults(next), next.categories.map((item) => item.id), restoreState);
1195
+ await nextTick();
1196
+ if (selected.value && !rows.value.some((entry) => entry.id === selected.value)) selected.value = null;
1197
+ scrollTop.value = view.listTop;
1198
+ if (listScroll.value) listScroll.value.scrollTop = view.listTop;
1199
+ await nextTick();
1200
+ window.scrollTo({ top: view.windowY });
1201
+ if (restoreState && selected.value) nodes.get(selected.value)?.querySelector("a")?.focus({ preventScroll: true });
1202
+ }
609
1203
  } catch (cause) {
610
- if (token === navigation && !local.signal.aborted) error.value = "Could not open the folder. Its native link remains available.";
1204
+ if (token === navigation && !local.signal.aborted) {
1205
+ error.value = ui.value.labels.folderError;
1206
+ failure.value = "folder";
1207
+ retryRequest.value = [
1208
+ dataHref,
1209
+ url,
1210
+ push,
1211
+ refresh,
1212
+ restoreState
1213
+ ];
1214
+ viewHistory?.rollback();
1215
+ }
611
1216
  } finally {
612
1217
  if (token === navigation) busy.value = false;
613
1218
  }
614
1219
  }
615
1220
  async function move(index) {
616
- const entry = rows.value[Math.max(0, Math.min(rows.value.length - 1, index))];
1221
+ const target = Math.max(0, Math.min(rows.value.length - 1, index));
1222
+ const entry = rows.value[target];
617
1223
  if (!entry) return;
618
1224
  selected.value = entry.id;
1225
+ if (virtual.value) {
1226
+ scrollTop.value = Math.max(0, offsets.value[target] - 100 + (model.value.parentHref ? 76 : 0));
1227
+ if (listScroll.value) listScroll.value.scrollTop = scrollTop.value;
1228
+ }
619
1229
  await nextTick();
620
1230
  const node = nodes.get(entry.id);
621
1231
  node?.querySelector("a")?.focus({ preventScroll: true });
622
1232
  node?.scrollIntoView({ block: "nearest" });
623
1233
  }
624
- function stepCategory(delta) {
1234
+ async function stepCategory(delta) {
1235
+ if (ui.value.direction === "rtl") delta = -delta;
1236
+ const moveFocus = document.activeElement?.getAttribute("role") === "tab";
625
1237
  const items = model.value.categories, index = items.findIndex((item) => item.id === category.value);
626
1238
  category.value = items[(index + delta + items.length) % items.length].id;
627
1239
  selected.value = null;
1240
+ if (moveFocus) {
1241
+ await nextTick();
1242
+ document.getElementById("cw-tab-" + category.value)?.focus({ preventScroll: true });
1243
+ }
628
1244
  }
629
- function confirm() {
1245
+ async function confirm() {
630
1246
  if (!active.value || busy.value) return;
1247
+ await move(rows.value.findIndex((entry) => entry.id === active.value.id));
631
1248
  (nodes.get(active.value.id)?.querySelector("a"))?.click();
632
1249
  }
633
1250
  function parent() {
634
1251
  if (model.value.parentHref) {
635
- if (location.protocol === "file:") location.href = model.value.parentHref;
1252
+ if (location.protocol === "file:" || typeof model.value.parentDataHref !== "string") location.href = model.value.parentHref;
636
1253
  else openFolder(model.value.parentDataHref, new URL(model.value.parentHref, document.baseURI).href, true);
637
1254
  }
638
1255
  }
639
1256
  function keyboard(event) {
1257
+ if (archiveOpen.value) return;
640
1258
  if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey) return;
1259
+ if (event.target?.closest?.("[data-dirwell-actions]")) return;
641
1260
  if (/INPUT|TEXTAREA|SELECT/.test(event.target?.tagName ?? "")) {
642
1261
  if (event.key === "Escape") query.value = "";
643
1262
  return;
@@ -666,6 +1285,7 @@ var App_server_default = {
666
1285
  }
667
1286
  }
668
1287
  function controller(value) {
1288
+ if (archiveOpen.value) return;
669
1289
  pad.value = !!value;
670
1290
  if (!value) {
671
1291
  pressed.clear();
@@ -736,7 +1356,7 @@ var App_server_default = {
736
1356
  .38
737
1357
  ]
738
1358
  };
739
- const onPop = (event) => event.state?.dataHref ? openFolder(event.state.dataHref, location.href, false) : location.reload();
1359
+ const onPop = (event) => event.state?.dataHref ? openFolder(event.state.dataHref, location.href, false, false, event.state) : location.reload();
740
1360
  const suspend = () => {
741
1361
  gamepad?.pause();
742
1362
  controller(null);
@@ -757,7 +1377,36 @@ var App_server_default = {
757
1377
  ready.value = true;
758
1378
  document.body.setAttribute("data-cw-enhanced", "");
759
1379
  abort = new AbortController();
760
- history.replaceState({ dataHref: new URL(model.value.dataHref, document.baseURI).href }, "");
1380
+ if (typeof ResizeObserver !== "undefined") {
1381
+ rowObserver = new ResizeObserver((observations) => {
1382
+ const next = new Map(heights.value);
1383
+ let changed = false;
1384
+ for (const observation of observations) {
1385
+ const id = observation.target.dataset.id;
1386
+ const height = observation.target.getBoundingClientRect().height;
1387
+ if (height > 0 && next.get(id) !== height) {
1388
+ next.set(id, height);
1389
+ changed = true;
1390
+ }
1391
+ }
1392
+ if (changed) heights.value = next;
1393
+ });
1394
+ for (const node of nodes.values()) rowObserver.observe(node);
1395
+ layoutObserver = new ResizeObserver(measureLayout);
1396
+ if (footerElement.value) layoutObserver.observe(footerElement.value);
1397
+ const header = document.querySelector("[data-dirwell-theme='crosswave'] .cw-top");
1398
+ if (header) layoutObserver.observe(header);
1399
+ }
1400
+ measureLayout();
1401
+ window.addEventListener("resize", measureLayout);
1402
+ viewHistory = createViewHistory("crosswave");
1403
+ const view = readView(location.href, "crosswave", model.value.path, viewDefaults(model.value), model.value.categories.map((item) => item.id), history.state);
1404
+ query.value = view.query;
1405
+ category.value = view.category;
1406
+ selected.value = view.selected;
1407
+ scrollTop.value = view.listTop;
1408
+ if (listScroll.value) listScroll.value.scrollTop = view.listTop;
1409
+ saveView();
761
1410
  try {
762
1411
  const stored = localStorage.getItem("dirwell-crosswave-color");
763
1412
  if (Object.hasOwn(colors, stored)) color.value = stored;
@@ -785,7 +1434,8 @@ var App_server_default = {
785
1434
  gamepad.resume();
786
1435
  watchEffect(() => controller(gamepad.gamepads.value.find((value) => value.connected && value.mapping === "standard") ?? null), { flush: "sync" });
787
1436
  }
788
- const updateClock = () => clock.value = (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
1437
+ const updateClock = () => clock.value = (/* @__PURE__ */ new Date()).toLocaleTimeString(ui.value.dateOptions.locale, {
1438
+ timeZone: ui.value.dateOptions.timeZone,
789
1439
  hour: "2-digit",
790
1440
  minute: "2-digit"
791
1441
  });
@@ -804,9 +1454,15 @@ var App_server_default = {
804
1454
  if (!href || typeof EventSource === "undefined") return;
805
1455
  const updates = new EventSource(href);
806
1456
  updates.onmessage = () => pendingUpdates.value = href;
807
- updates.onerror = () => error.value = "Live updates disconnected. Refresh to retry.";
1457
+ updates.onerror = () => {
1458
+ error.value = ui.value.labels.updatesError;
1459
+ failure.value = "updates";
1460
+ };
808
1461
  updates.onopen = () => {
809
- if (error.value.startsWith("Live updates")) error.value = "";
1462
+ if (failure.value === "updates") {
1463
+ error.value = "";
1464
+ failure.value = null;
1465
+ }
810
1466
  };
811
1467
  onCleanup(() => updates.close());
812
1468
  });
@@ -828,9 +1484,13 @@ var App_server_default = {
828
1484
  }
829
1485
  });
830
1486
  onBeforeUnmount(() => {
1487
+ archiveAbort?.abort();
831
1488
  disposed = true;
832
1489
  document.body.removeAttribute("data-cw-enhanced");
833
1490
  abort?.abort();
1491
+ rowObserver?.disconnect();
1492
+ layoutObserver?.disconnect();
1493
+ window.removeEventListener("resize", measureLayout);
834
1494
  gamepad?.pause();
835
1495
  wave?.dispose();
836
1496
  clearInterval(timer);
@@ -844,25 +1504,32 @@ var App_server_default = {
844
1504
  return (_ctx, _push, _parent, _attrs) => {
845
1505
  _push(`<!--[--><div class="cw-background"${ssrRenderAttr("data-ready", waveReady.value ? true : void 0)}><canvas aria-hidden="true"></canvas><svg class="cw-fallback-wave" aria-hidden="true" viewBox="0 0 1200 600"><path d="M-100 400 Q400 150 1300 340"></path></svg></div><header class="cw-top"><div class="cw-brand">`);
846
1506
  ssrRenderSlot(_ctx.$slots, "header", { model: model.value }, () => {
847
- _push(`<a${ssrRenderAttr("href", model.value.rootHref)}>${ssrInterpolate(model.value.title)}</a>`);
1507
+ if (ui.value.brand.logoUrl) _push(`<img${ssrRenderAttr("src", ui.value.brand.logoUrl)}${ssrRenderAttr("alt", ui.value.brand.logoAlt ?? "")} width="40" height="40">`);
1508
+ else _push(`<!---->`);
1509
+ _push(`<a${ssrRenderAttr("href", model.value.rootHref)}>${ssrInterpolate(ui.value.brand.name ?? model.value.title)}</a>`);
848
1510
  }, _push, _parent);
849
- _push(`</div><nav class="cw-location" aria-label="Breadcrumb"><a${ssrRenderAttr("href", model.value.rootHref)}>Home</a><!--[-->`);
1511
+ _push(`</div><nav class="cw-location"${ssrRenderAttr("aria-label", ui.value.labels.breadcrumb)}><a${ssrRenderAttr("href", model.value.rootHref)}>${ssrInterpolate(ui.value.labels.home)}</a><!--[-->`);
850
1512
  ssrRenderList(model.value.breadcrumbs, (crumb) => {
851
1513
  _push(`<!--[--> / <a${ssrRenderAttr("href", crumb.href)}>${ssrInterpolate(crumb.name)}</a><!--]-->`);
852
1514
  });
853
1515
  _push(`<!--]--></nav>`);
854
1516
  if (ready.value) _push(`<div class="cw-clock">${ssrInterpolate(clock.value)}</div>`);
855
1517
  else _push(`<!---->`);
856
- _push(`</header><main class="cw-stage" data-framework="vue"${ssrRenderAttr("aria-busy", busy.value)}><h1 class="cw-sr">Files in ${ssrInterpolate(model.value.path || "/")}</h1><nav class="cw-rail" aria-label="File categories"${ssrRenderDynamicAttr("hidden", !ready.value)}><div class="cw-categories" role="tablist"><!--[-->`);
1518
+ _push(`</header><main class="cw-stage" data-framework="vue"${ssrRenderAttr("aria-busy", busy.value)}><h1 class="cw-sr">${ssrInterpolate(unref(label)(ui.value, "filesIn", { path: model.value.path || "/" }))}</h1><nav class="cw-rail"${ssrRenderAttr("aria-label", ui.value.labels.categories)}${ssrRenderDynamicAttr("hidden", !ready.value)}><div class="cw-categories" role="tablist"><!--[-->`);
857
1519
  ssrRenderList(model.value.categories, (item) => {
858
1520
  _push(`<button role="tab"${ssrRenderAttr("id", "cw-tab-" + item.id)} aria-controls="cw-panel"${ssrRenderAttr("aria-selected", category.value === item.id)}${ssrRenderAttr("tabindex", category.value === item.id ? 0 : -1)} class="${ssrRenderClass(["cw-category", { "cw-active": category.value === item.id }])}"${ssrIncludeBooleanAttr(!complete.value) ? " disabled" : ""}><span>${item.svg ?? ""}</span><span>${ssrInterpolate(item.label)}</span><small>${ssrInterpolate(entries.value.filter((entry) => membership(entry).includes(item.id)).length)}</small></button>`);
859
1521
  });
860
- _push(`<!--]--></div></nav><section class="cw-browser" id="cw-panel" role="tabpanel"${ssrRenderAttr("aria-labelledby", "cw-tab-" + category.value)}><div class="cw-list-head"><div class="cw-directory"><h2>${ssrInterpolate(heading.value)}</h2><span data-cw-count>${ssrInterpolate(complete.value ? rows.value.length : entries.value.length)} items</span></div><label class="cw-search">Search<input type="search"${ssrRenderAttr("value", query.value)}${ssrIncludeBooleanAttr(!ready.value || !complete.value) ? " disabled" : ""}></label></div><div class="cw-list-scroll"><ul class="cw-files">`);
1522
+ _push(`<!--]--></div></nav><section class="cw-browser" id="cw-panel" role="tabpanel"${ssrRenderAttr("aria-labelledby", "cw-tab-" + category.value)}><div class="cw-list-head"><div class="cw-directory"><h2>${ssrInterpolate(heading.value)}</h2><span data-cw-count>${ssrInterpolate(unref(label)(ui.value, "items", { count: complete.value ? rows.value.length : entries.value.length }))}</span></div><label class="cw-search"><span>${ssrInterpolate(ui.value.labels.search)}</span><input type="search"${ssrRenderAttr("value", query.value)}${ssrIncludeBooleanAttr(!ready.value || !complete.value) ? " disabled" : ""}></label>`);
1523
+ if (model.value.searchQueryHref) _push(`<button class="cw-archive-trigger"${ssrIncludeBooleanAttr(!ready.value) ? " disabled" : ""}>${ssrInterpolate(ui.value.labels.searchArchive)}</button>`);
1524
+ else _push(`<!---->`);
1525
+ _push(`</div><div class="cw-list-scroll"${ssrRenderAttr("data-virtual", virtual.value)}><ul class="cw-files">`);
861
1526
  if (model.value.parentHref) _push(`<li class="cw-row"><a${ssrRenderAttr("href", model.value.parentHref)} class="cw-entry">../</a></li>`);
862
1527
  else _push(`<!---->`);
1528
+ if (start.value) _push(`<li aria-hidden="true" style="${ssrRenderStyle({ height: offsets.value[start.value] + "px" })}"></li>`);
1529
+ else _push(`<!---->`);
863
1530
  _push(`<!--[-->`);
864
- ssrRenderList(rows.value, (entry) => {
865
- _push(`<li class="${ssrRenderClass(["cw-row", { "cw-selected": active.value?.id === entry.id }])}" data-cw-entry${ssrRenderAttr("data-id", entry.id)}>`);
1531
+ ssrRenderList(visibleRows.value, (entry, visibleIndex) => {
1532
+ _push(`<li${ssrRenderAttr("aria-posinset", start.value + visibleIndex + 1)}${ssrRenderAttr("aria-setsize", rows.value.length)} class="${ssrRenderClass(["cw-row", { "cw-selected": active.value?.id === entry.id }])}" data-cw-entry${ssrRenderAttr("data-custom-columns", Boolean(model.value.ui?.columns))}${ssrRenderAttr("data-id", entry.id)}>`);
866
1533
  if (entry.href) {
867
1534
  _push(`<a class="cw-entry"${ssrRenderAttr("href", safeHref(entry))}${ssrRenderAttr("target", entry.exitsExplorer ? "_blank" : void 0)}${ssrRenderAttr("rel", entry.exitsExplorer ? "noopener" : void 0)}>`);
868
1535
  ssrRenderSlot(_ctx.$slots, "row", {
@@ -870,10 +1537,22 @@ var App_server_default = {
870
1537
  entry,
871
1538
  selected: selected.value === entry.id
872
1539
  }, () => {
873
- _push(`<span class="cw-entry-icon" aria-hidden="true">${unref(entryIcon)(entry.kind === "symlink" ? "link" : unref(directoryLike)(entry) ? "directory" : "file") ?? ""}</span><span class="cw-entry-label"><span class="cw-name">${ssrInterpolate(entry.name)}${ssrInterpolate(unref(directoryLike)(entry) ? "/" : "")}</span>`);
874
- if (entry.link) _push(`<span class="cw-target">${ssrInterpolate(entry.link.target)}</span>`);
875
- else _push(`<!---->`);
876
- _push(`<span class="cw-row-meta">${ssrInterpolate(entry.link?.unavailable ? "Target unavailable" : entry.kind)} · ${ssrInterpolate(unref(directoryLike)(entry) ? "Folder" : unref(size)(entry.size))}</span><span class="cw-row-date">Modified ${ssrInterpolate(unref(modified)(entry.modifiedAt))}</span></span>`);
1540
+ _push(`<span class="cw-entry-icon" aria-hidden="true">${unref(entryIcon)(entry.kind === "symlink" ? "link" : unref(directoryLike)(entry) ? "directory" : "file") ?? ""}</span><span class="cw-entry-label"><!--[-->`);
1541
+ ssrRenderList(ui.value.columns, (column) => {
1542
+ _push(`<!--[-->`);
1543
+ if (column === "name") _push(`<span class="cw-name">${ssrInterpolate(entry.name)}${ssrInterpolate(unref(directoryLike)(entry) ? "/" : "")}</span>`);
1544
+ else _push(`<!---->`);
1545
+ if (entry.link && column === "name") _push(`<span class="cw-target">${ssrInterpolate(entry.link.target)}</span>`);
1546
+ else _push(`<!---->`);
1547
+ if (column === "size") _push(`<span class="cw-row-meta">${ssrInterpolate(entry.link?.unavailable ? ui.value.labels.unavailable : ui.value.labels[entry.kind === "symlink" ? "link" : entry.kind])} · ${ssrInterpolate(unref(directoryLike)(entry) ? ui.value.labels.folder : unref(size)(entry.size))}</span>`);
1548
+ else _push(`<!---->`);
1549
+ if (column === "modified") _push(`<span class="cw-row-date">${ssrInterpolate(ui.value.labels.modified)} ${ssrInterpolate(unref(formattedDate)(entry.modifiedAt, ui.value, ready.value))}</span>`);
1550
+ else _push(`<!---->`);
1551
+ if (typeof column === "object") _push(`<span class="cw-data-value">${ssrInterpolate(column.label)}: ${ssrInterpolate(unref(publicDataValue)(entry.data?.[column.dataKey]))}</span>`);
1552
+ else _push(`<!---->`);
1553
+ _push(`<!--]-->`);
1554
+ });
1555
+ _push(`<!--]--></span>`);
877
1556
  }, _push, _parent);
878
1557
  _push(`</a>`);
879
1558
  } else {
@@ -887,40 +1566,90 @@ var App_server_default = {
887
1566
  }, _push, _parent);
888
1567
  _push(`</span>`);
889
1568
  }
1569
+ if (ui.value.actions.length || _ctx.$slots.actions) {
1570
+ _push(`<span data-dirwell-actions class="cw-row-actions">`);
1571
+ ssrRenderSlot(_ctx.$slots, "actions", {
1572
+ model: model.value,
1573
+ entry,
1574
+ selected: selected.value === entry.id
1575
+ }, null, _push, _parent);
1576
+ _push(`<!--[-->`);
1577
+ ssrRenderList(ui.value.actions, (action) => {
1578
+ _push(`<!--[-->`);
1579
+ if (action === "download" && entry.href && !unref(directoryLike)(entry) && !entry.link?.unavailable) _push(`<a${ssrRenderAttr("href", safeHref(entry))} download>${ssrInterpolate(ui.value.labels.download)}</a>`);
1580
+ else if (action !== "download") _push(`<button${ssrIncludeBooleanAttr(!ready.value || action === "copy-link" && !entry.href) ? " disabled" : ""}${ssrRenderAttr("aria-label", (action === "copy-link" ? ui.value.labels.copyLink : ui.value.labels.copyPath) + ": " + entry.name)}>${ssrInterpolate(action === "copy-link" ? ui.value.labels.copyLink : ui.value.labels.copyPath)}</button>`);
1581
+ else _push(`<!---->`);
1582
+ _push(`<!--]-->`);
1583
+ });
1584
+ _push(`<!--]--></span>`);
1585
+ } else _push(`<!---->`);
890
1586
  _push(`</li>`);
891
1587
  });
892
- _push(`<!--]--></ul>`);
893
- if (!rows.value.length) _push(`<p class="cw-empty">No matching files.</p>`);
1588
+ _push(`<!--]-->`);
1589
+ if (end.value < rows.value.length) _push(`<li aria-hidden="true" style="${ssrRenderStyle({ height: offsets.value[rows.value.length] - offsets.value[end.value] + "px" })}"></li>`);
1590
+ else _push(`<!---->`);
1591
+ _push(`</ul>`);
1592
+ if (!rows.value.length && !busy.value) _push(`<p class="cw-empty">${ssrInterpolate(model.value.total === 0 ? ui.value.labels.empty : ui.value.labels.noMatches)}</p>`);
894
1593
  else _push(`<!---->`);
895
1594
  _push(`</div></section>`);
896
1595
  if (ready.value && active.value) {
897
- _push(`<aside class="cw-detail"><h2>${ssrInterpolate(active.value.name)}</h2><p>${ssrInterpolate(active.value.kind)} · ${ssrInterpolate(unref(directoryLike)(active.value) ? "Folder" : unref(size)(active.value.size))}</p><p>${ssrInterpolate(unref(modified)(active.value.modifiedAt))}</p>`);
1596
+ _push(`<aside class="cw-detail"><h2>${ssrInterpolate(active.value.name)}</h2>`);
1597
+ if (ui.value.columns.includes("size")) _push(`<p>${ssrInterpolate(ui.value.labels[active.value.kind === "symlink" ? "link" : active.value.kind])} · ${ssrInterpolate(unref(directoryLike)(active.value) ? ui.value.labels.folder : unref(size)(active.value.size))}</p>`);
1598
+ else _push(`<!---->`);
1599
+ if (ui.value.columns.includes("modified")) _push(`<p>${ssrInterpolate(unref(formattedDate)(active.value.modifiedAt, ui.value, ready.value))}</p>`);
1600
+ else _push(`<!---->`);
898
1601
  if (active.value.link) _push(`<p>${ssrInterpolate(active.value.link.target)}</p>`);
899
1602
  else _push(`<!---->`);
900
1603
  _push(`</aside>`);
901
1604
  } else _push(`<!---->`);
902
- _push(`<p role="status" aria-live="polite">${ssrInterpolate(busy.value ? "Loading…" : error.value)}</p>`);
1605
+ _push(`<p role="status" aria-live="polite">${ssrInterpolate(busy.value ? ui.value.labels.loading : error.value)}</p>`);
1606
+ if (failure.value && failure.value !== "copy") _push(`<button${ssrIncludeBooleanAttr(busy.value) ? " disabled" : ""}>${ssrInterpolate(ui.value.labels.retry)}</button>`);
1607
+ else _push(`<!---->`);
1608
+ if (failure.value === "folder" && retryRequest.value) _push(`<a${ssrRenderAttr("href", retryRequest.value[1])}>${ssrInterpolate(ui.value.labels.openNative)}</a>`);
1609
+ else _push(`<!---->`);
903
1610
  if (model.value.page?.count > 1 && !complete.value) {
904
- _push(`<nav aria-label="Pagination">`);
905
- if (model.value.page.previousHref) _push(`<a${ssrRenderAttr("href", model.value.page.previousHref)} rel="prev">Previous</a>`);
1611
+ _push(`<nav${ssrRenderAttr("aria-label", ui.value.labels.pagination)}>`);
1612
+ if (model.value.page.previousHref) _push(`<a${ssrRenderAttr("href", model.value.page.previousHref)} rel="prev">${ssrInterpolate(ui.value.labels.previous)}</a>`);
906
1613
  else _push(`<!---->`);
907
- _push(` Page ${ssrInterpolate(model.value.page.number)} of ${ssrInterpolate(model.value.page.count)} `);
908
- if (model.value.page.nextHref) _push(`<a${ssrRenderAttr("href", model.value.page.nextHref)} rel="next">Next</a>`);
1614
+ _push(` ${ssrInterpolate(unref(label)(ui.value, "page", {
1615
+ page: model.value.page.number,
1616
+ pages: model.value.page.count
1617
+ }))} `);
1618
+ if (model.value.page.nextHref) _push(`<a${ssrRenderAttr("href", model.value.page.nextHref)} rel="next">${ssrInterpolate(ui.value.labels.next)}</a>`);
909
1619
  else _push(`<!---->`);
910
1620
  _push(`</nav>`);
911
1621
  } else _push(`<!---->`);
912
- _push(`</main><footer class="cw-bottom"><div class="cw-key-help">`);
1622
+ _push(`</main><footer class="cw-bottom"><div class="${ssrRenderClass(ui.value.brand.footerText || _ctx.$slots.footer ? "cw-custom-footer" : "cw-key-help")}">`);
913
1623
  ssrRenderSlot(_ctx.$slots, "footer", { model: model.value }, () => {
914
- _push(`Arrow keys navigate · Enter confirms · Backspace returns`);
1624
+ if (ui.value.brand.footerText) {
1625
+ _push(`<!--[-->`);
1626
+ if (ui.value.brand.footerUrl) _push(`<a${ssrRenderAttr("href", ui.value.brand.footerUrl)}>${ssrInterpolate(ui.value.brand.footerText)}</a>`);
1627
+ else _push(`<span>${ssrInterpolate(ui.value.brand.footerText)}</span>`);
1628
+ _push(`<!--]-->`);
1629
+ } else _push(`<!--[-->${ssrInterpolate(ui.value.labels.crosswaveKeyboardHelp)}<!--]-->`);
915
1630
  }, _push, _parent);
916
1631
  _push(`</div>`);
917
- if (pad.value) _push(`<div class="cw-controller">Controller · Confirm / Back</div>`);
1632
+ if (pad.value) _push(`<div class="cw-controller">${ssrInterpolate(ui.value.labels.controller)}</div>`);
918
1633
  else _push(`<!---->`);
919
- _push(`<label class="cw-appearance">Color<select${ssrIncludeBooleanAttr(!ready.value) ? " disabled" : ""}><!--[-->`);
1634
+ _push(`<label class="cw-appearance">${ssrInterpolate(ui.value.labels.color)}<select${ssrIncludeBooleanAttr(!ready.value) ? " disabled" : ""}><!--[-->`);
920
1635
  ssrRenderList(Object.keys(colors), (name) => {
921
- _push(`<option${ssrIncludeBooleanAttr(Array.isArray(color.value) ? ssrLooseContain(color.value, null) : ssrLooseEqual(color.value, null)) ? " selected" : ""}>${ssrInterpolate(name)}</option>`);
1636
+ _push(`<option${ssrRenderAttr("value", name)}${ssrIncludeBooleanAttr(Array.isArray(color.value) ? ssrLooseContain(color.value, name) : ssrLooseEqual(color.value, name)) ? " selected" : ""}>${ssrInterpolate(ui.value.labels[name])}</option>`);
922
1637
  });
923
- _push(`<!--]--></select></label><button class="cw-motion"${ssrIncludeBooleanAttr(!ready.value) ? " disabled" : ""}>${ssrInterpolate(paused.value ? "Resume motion" : "Pause motion")}</button></footer><!--]-->`);
1638
+ _push(`<!--]--></select></label><button class="cw-motion"${ssrIncludeBooleanAttr(!ready.value) ? " disabled" : ""}>${ssrInterpolate(paused.value ? ui.value.labels.resumeMotion : ui.value.labels.pauseMotion)}</button></footer>`);
1639
+ if (archiveOpen.value) {
1640
+ _push(`<dialog class="cw-archive"${ssrRenderAttr("aria-label", ui.value.labels.searchArchive)}><h2>${ssrInterpolate(ui.value.labels.searchArchive)}</h2><input type="search"${ssrRenderAttr("value", archiveQuery.value)}${ssrRenderAttr("aria-label", ui.value.labels.archiveSearchInput)}><p role="status">${ssrInterpolate(archiveStatus.value)}</p>`);
1641
+ if (archiveFailed.value) _push(`<button>${ssrInterpolate(ui.value.labels.retry)}</button>`);
1642
+ else _push(`<!---->`);
1643
+ _push(`<ul><!--[-->`);
1644
+ ssrRenderList(archiveRows.value, (record) => {
1645
+ _push(`<li>`);
1646
+ if (record.href) _push(`<a${ssrRenderAttr("href", record.href)}${ssrRenderAttr("target", record.exitsExplorer ? "_blank" : void 0)}${ssrRenderAttr("rel", record.exitsExplorer ? "noopener" : void 0)}>${ssrInterpolate(record.path)}</a>`);
1647
+ else _push(`<span>${ssrInterpolate(record.path)}</span>`);
1648
+ _push(`</li>`);
1649
+ });
1650
+ _push(`<!--]--></ul><button>${ssrInterpolate(ui.value.labels.close)}</button></dialog>`);
1651
+ } else _push(`<!---->`);
1652
+ _push(`<!--]-->`);
924
1653
  };
925
1654
  }
926
1655
  };
@@ -930,7 +1659,43 @@ const renderBody = (model) => renderToString(createSSRApp(App_server_default, {
930
1659
  //#endregion
931
1660
  //#region src/index.ts
932
1661
  function createCrosswaveTheme(options = {}, renderer) {
933
- const categories = resolveCrosswaveCategories(options.categories);
1662
+ assertThemeOptions(options, [
1663
+ "title",
1664
+ "categories",
1665
+ "color",
1666
+ "gamepad",
1667
+ "backgroundMotion",
1668
+ "archiveSearch"
1669
+ ]);
1670
+ if (options.title !== void 0 && (typeof options.title !== "string" || !options.title)) throw new TypeError("title must be a non-empty string");
1671
+ if (options.color !== void 0 && ![
1672
+ "azure",
1673
+ "violet",
1674
+ "amber",
1675
+ "rose",
1676
+ "jade",
1677
+ "graphite"
1678
+ ].includes(options.color)) throw new TypeError("Unsupported Crosswave color");
1679
+ for (const key of [
1680
+ "gamepad",
1681
+ "backgroundMotion",
1682
+ "archiveSearch"
1683
+ ]) if (options[key] !== void 0 && typeof options[key] !== "boolean") throw new TypeError(`${key} must be a boolean`);
1684
+ const ui = resolveCustomization(options);
1685
+ const publishedUi = pickCustomization(options);
1686
+ const categoryLabels = {
1687
+ all: ui.labels.allFiles,
1688
+ folder: ui.labels.categoryFolders,
1689
+ image: ui.labels.categoryPhotos,
1690
+ audio: ui.labels.categoryMusic,
1691
+ video: ui.labels.categoryVideos,
1692
+ document: ui.labels.categoryDocuments,
1693
+ other: ui.labels.categoryOther
1694
+ };
1695
+ const categories = resolveCrosswaveCategories(options.categories ?? defaultCrosswaveCategories.map((category) => ({
1696
+ ...category,
1697
+ label: categoryLabels[category.id] ?? category.label
1698
+ })));
934
1699
  let resources;
935
1700
  const loadResources = () => resources ??= Promise.all([renderer?.clientScript ?? readFile(new URL("./client.js", import.meta.url), "utf8"), readFile(new URL("./styles.css", import.meta.url), "utf8")]).then(([script, styles]) => ({
936
1701
  script,
@@ -948,7 +1713,9 @@ function createCrosswaveTheme(options = {}, renderer) {
948
1713
  }),
949
1714
  async render(context) {
950
1715
  const model = {
951
- ...createThemePage(context, theme, options.title ?? "Dirwell"),
1716
+ ...createThemePage(context, theme, options.title ?? ui.brand.name ?? "Dirwell"),
1717
+ ...publishedUi === void 0 ? {} : { ui: publishedUi },
1718
+ searchQueryHref: options.archiveSearch === false ? null : context.searchQueryHref ?? null,
952
1719
  categories: categories.map(({ id, label, svg }) => ({
953
1720
  id,
954
1721
  label,
@@ -961,7 +1728,7 @@ function createCrosswaveTheme(options = {}, renderer) {
961
1728
  const { script, scriptName, styles } = await loadResources();
962
1729
  const json = JSON.stringify(model).replaceAll("<", "\\u003c");
963
1730
  return {
964
- html: `<!doctype html><html lang="en" data-cw-color="${escapeHtml(model.color)}" data-cw-motion="${model.backgroundMotion}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${context.documentBaseHref === null ? "" : `<base href="${escapeHtml(context.documentBaseHref)}">`}${context.metadata?.head ?? `<title>${escapeHtml(model.title)}</title>`}<style>${styles}${renderer?.styles ?? ""}</style></head><body><div data-dirwell-theme="crosswave">${await (renderer?.renderBody ?? renderBody)(model)}</div><script type="application/json" id="dirwell-page-data">${json}<\/script><script ${renderer?.clientType === "module" ? "type=\"module\"" : "defer"} src="${escapeHtml(context.assetHref(scriptName))}"><\/script></body></html>`,
1731
+ html: `<!doctype html><html lang="${escapeHtml(ui.language)}" dir="${ui.direction}" data-cw-color="${escapeHtml(model.color)}" data-cw-motion="${model.backgroundMotion}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${context.documentBaseHref === null ? "" : `<base href="${escapeHtml(context.documentBaseHref)}">`}${context.metadata?.head ?? `<title>${escapeHtml(model.title)}</title>`}${ui.brand.faviconUrl ? `<link rel="icon" href="${escapeHtml(ui.brand.faviconUrl)}">` : ""}<style>${styles}${renderer?.styles ?? ""}${customizationCss(ui)}</style></head><body><div data-dirwell-theme="crosswave">${await (renderer?.renderBody ?? renderBody)(model)}</div><script type="application/json" id="dirwell-page-data">${json}<\/script><script ${renderer?.clientType === "module" ? "type=\"module\"" : "defer"} src="${escapeHtml(context.assetHref(scriptName))}"><\/script></body></html>`,
965
1732
  assets: {
966
1733
  [scriptName]: script,
967
1734
  [pageAsset("crosswave", model.path, model.page?.number ?? 1)]: JSON.stringify(model)