@dirwell/theme-crosswave 0.0.1-alpha → 0.0.1

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",
@@ -255,6 +255,247 @@ function entryCategories(entry, type, categories) {
255
255
  return categories.filter((category) => category.match === "all" || folder && category.match === "folders" || !folder && category.match === "other" && !specific.length || specific.includes(category));
256
256
  }
257
257
  //#endregion
258
+ //#region ../shared-theme/src/customization.ts
259
+ /** Small serializable inputs shared by official themes; layout stays theme-owned. */
260
+ const defaultLabels = {
261
+ other: "other",
262
+ crosswaveKeyboardHelp: "Arrow keys navigate · Enter confirms · Backspace returns",
263
+ brokenLink: "broken link",
264
+ unavailableLink: "unavailable link",
265
+ cycleLink: "cycle link",
266
+ azure: "azure",
267
+ violet: "violet",
268
+ amber: "amber",
269
+ rose: "rose",
270
+ jade: "jade",
271
+ graphite: "graphite",
272
+ allFiles: "All files",
273
+ categoryFolders: "Folders",
274
+ categoryPhotos: "Photos",
275
+ categoryMusic: "Music",
276
+ categoryVideos: "Videos",
277
+ categoryDocuments: "Documents",
278
+ categoryOther: "Other",
279
+ indexOf: "Index of {path}",
280
+ home: "Home",
281
+ breadcrumb: "Breadcrumb",
282
+ archive: "File archive",
283
+ searchDirectory: "Search this directory",
284
+ searchFiles: "Search files",
285
+ search: "Search",
286
+ types: "Types",
287
+ directory: "directory",
288
+ file: "file",
289
+ link: "link",
290
+ folder: "Folder",
291
+ sort: "Sort",
292
+ name: "Name",
293
+ size: "Size",
294
+ modified: "Modified",
295
+ names: "Names",
296
+ natural: "Natural",
297
+ locale: "Locale",
298
+ unicode: "Unicode",
299
+ order: "Order",
300
+ ascending: "Ascending",
301
+ descending: "Descending",
302
+ directoriesFirst: "Folders first",
303
+ appearance: "Appearance",
304
+ system: "System",
305
+ light: "Light",
306
+ dark: "Dark",
307
+ searchArchive: "Search archive",
308
+ archiveSearchInput: "Search archive files",
309
+ entries: "{count} entries",
310
+ pageEntries: "{total} entries · {count} on this page",
311
+ loading: "Loading…",
312
+ noMatches: "No matching entries.",
313
+ empty: "This directory is empty.",
314
+ pagination: "Pagination",
315
+ previous: "Previous",
316
+ next: "Next",
317
+ page: "Page {page} of {pages}",
318
+ keyboardHelp: "Arrow keys select files · Home / End jump · / searches · Backspace opens parent",
319
+ modifiedTime: "Modified time",
320
+ close: "Close",
321
+ unknown: "Unknown",
322
+ unavailable: "Target unavailable",
323
+ beginArchive: "Enter a search to begin.",
324
+ searchingArchive: "Searching archive…",
325
+ archiveResults: "{count} results",
326
+ truncatedResults: "Showing {shown} of {count} results",
327
+ archiveError: "Could not load archive search. Try again.",
328
+ listingError: "Could not load complete directory data. Native page links remain available.",
329
+ folderError: "Could not open the folder. Its native link remains available.",
330
+ updatesError: "Live updates disconnected. Refresh to retry.",
331
+ retry: "Retry",
332
+ openNative: "Open folder directly",
333
+ categories: "File categories",
334
+ items: "{count} items",
335
+ filesIn: "Files in {path}",
336
+ controller: "Controller · Confirm / Back",
337
+ color: "Color",
338
+ pauseMotion: "Pause motion",
339
+ resumeMotion: "Resume motion",
340
+ copyLink: "Copy link",
341
+ copyPath: "Copy path",
342
+ download: "Download",
343
+ copied: "Copied.",
344
+ copyError: "Could not copy. Use the native link or select the path.",
345
+ repository: "Repository",
346
+ author: "by",
347
+ target: "Target",
348
+ newTab: "opens in a new tab"
349
+ };
350
+ function resolveCustomization(options = {}) {
351
+ 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;
352
+ for (const [key, value] of Object.entries({
353
+ language,
354
+ locale,
355
+ timeZone
356
+ })) if (typeof value !== "string" || !value) throw new TypeError(`${key} must be a non-empty string`);
357
+ new Intl.DateTimeFormat(locale, { timeZone });
358
+ const direction = options.direction ?? "ltr";
359
+ if (direction !== "ltr" && direction !== "rtl") throw new TypeError("direction must be ltr or rtl");
360
+ const labels = { ...defaultLabels };
361
+ if (options.labels !== void 0) {
362
+ if (typeof options.labels !== "object" || options.labels === null || Array.isArray(options.labels)) throw new TypeError("labels must be an object");
363
+ for (const [key, value] of Object.entries(options.labels)) {
364
+ if (!Object.hasOwn(labels, key) || typeof value !== "string") throw new TypeError(`Unsupported label or value: ${key}`);
365
+ Object.assign(labels, { [key]: value });
366
+ }
367
+ }
368
+ const brand = options.brand ?? {};
369
+ if (typeof brand !== "object" || brand === null || Array.isArray(brand)) throw new TypeError("brand must be an object");
370
+ for (const [key, value] of Object.entries(brand)) {
371
+ if (![
372
+ "name",
373
+ "logoUrl",
374
+ "logoAlt",
375
+ "footerText",
376
+ "footerUrl",
377
+ "faviconUrl"
378
+ ].includes(key) || typeof value !== "string") throw new TypeError(`Unsupported brand option or value: ${key}`);
379
+ 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`);
380
+ }
381
+ const columns = options.columns ?? [
382
+ "name",
383
+ "size",
384
+ "modified"
385
+ ];
386
+ if (!Array.isArray(columns) || !columns.includes("name")) throw new TypeError("columns must contain name and unique supported columns");
387
+ const columnIds = /* @__PURE__ */ new Set();
388
+ for (const column of columns) {
389
+ let id;
390
+ if (typeof column === "string") {
391
+ if (![
392
+ "name",
393
+ "size",
394
+ "modified"
395
+ ].includes(column)) throw new TypeError("Unsupported column");
396
+ id = column;
397
+ } else {
398
+ 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");
399
+ id = "data:" + column.dataKey;
400
+ }
401
+ if (columnIds.has(id)) throw new TypeError("columns must contain unique supported columns");
402
+ columnIds.add(id);
403
+ }
404
+ const actions = options.actions ?? [];
405
+ if (!Array.isArray(actions) || new Set(actions).size !== actions.length || actions.some((key) => ![
406
+ "copy-link",
407
+ "copy-path",
408
+ "download"
409
+ ].includes(key))) throw new TypeError("actions must contain unique supported actions");
410
+ const cssVariables = options.cssVariables ?? {};
411
+ if (typeof cssVariables !== "object" || cssVariables === null || Array.isArray(cssVariables)) throw new TypeError("cssVariables must be an object");
412
+ 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}`);
413
+ return {
414
+ language,
415
+ dateOptions: {
416
+ ...options.locale === void 0 ? {} : { locale: options.locale },
417
+ ...options.timeZone === void 0 ? {} : { timeZone: options.timeZone }
418
+ },
419
+ direction,
420
+ locale,
421
+ timeZone,
422
+ labels,
423
+ brand: { ...brand },
424
+ columns: columns.map((column) => typeof column === "string" ? column : { ...column }),
425
+ actions: [...actions],
426
+ cssVariables: { ...cssVariables }
427
+ };
428
+ }
429
+ resolveCustomization();
430
+ function label(ui, key, values = {}) {
431
+ return ui.labels[key].replace(/\{([a-zA-Z]+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
432
+ }
433
+ const dateFormats = /* @__PURE__ */ new Map();
434
+ function formattedDate(value, ui, local = false) {
435
+ const date = new Date(value);
436
+ if (Number.isNaN(date.getTime())) return ui.labels.unknown;
437
+ if (ui.dateOptions.locale === void 0 && ui.dateOptions.timeZone === void 0) return local ? date.toLocaleString(void 0, {
438
+ dateStyle: "short",
439
+ timeStyle: "short"
440
+ }) : date.toISOString().slice(0, 16).replace("T", " ") + " UTC";
441
+ const locale = ui.dateOptions.locale, timeZone = ui.dateOptions.timeZone ?? (local ? void 0 : "UTC");
442
+ const key = String(locale) + "\0" + String(timeZone);
443
+ let formatter = dateFormats.get(key);
444
+ if (formatter === void 0) {
445
+ formatter = new Intl.DateTimeFormat(locale, {
446
+ timeZone,
447
+ dateStyle: "short",
448
+ timeStyle: "short"
449
+ });
450
+ if (dateFormats.size >= 32) dateFormats.delete(dateFormats.keys().next().value);
451
+ dateFormats.set(key, formatter);
452
+ }
453
+ return formatter.format(date);
454
+ }
455
+ function customizationCss(ui) {
456
+ const declarations = Object.entries(ui.cssVariables).map(([key, value]) => `${key}:${value}`).join(";");
457
+ return declarations ? `:root:root{${declarations}}${ui.cssVariables["--dw-font-family"] ? "body{font-family:var(--dw-font-family)}" : ""}` : "";
458
+ }
459
+ /** Publish only supplied customization instead of repeating defaults in every page. */
460
+ function pickCustomization(options) {
461
+ const result = {};
462
+ for (const key of [
463
+ "language",
464
+ "direction",
465
+ "locale",
466
+ "timeZone",
467
+ "labels",
468
+ "brand",
469
+ "columns",
470
+ "actions",
471
+ "cssVariables"
472
+ ]) if (options[key] !== void 0) {
473
+ const value = options[key];
474
+ Object.assign(result, { [key]: Array.isArray(value) ? value.map((item) => typeof item === "object" ? { ...item } : item) : typeof value === "object" && value !== null ? { ...value } : value });
475
+ }
476
+ return Object.keys(result).length ? result : void 0;
477
+ }
478
+ /** Built-in factories fail on typos instead of silently ignoring configuration. */
479
+ function assertThemeOptions(value, extraKeys) {
480
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("Theme options must be an object");
481
+ const allowed = [
482
+ "language",
483
+ "direction",
484
+ "locale",
485
+ "timeZone",
486
+ "labels",
487
+ "brand",
488
+ "columns",
489
+ "actions",
490
+ "cssVariables",
491
+ ...extraKeys
492
+ ];
493
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new TypeError(`Unsupported theme option: ${key}`);
494
+ }
495
+ function publicDataValue(value) {
496
+ return value === void 0 || value === null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
497
+ }
498
+ //#endregion
258
499
  //#region ../shared-theme/src/navigation.ts
259
500
  /** Folder snapshots resolve relative URLs before History changes the document URL. */
260
501
  async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false, attempt = 0) {
@@ -270,6 +511,25 @@ async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false,
270
511
  const data = value;
271
512
  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
513
  if (data.metadataHead !== void 0 && typeof data.metadataHead !== "string") throw new ListingDataError("Invalid folder metadata");
514
+ const sort = data.sort;
515
+ if (!sort || typeof sort !== "object" || ![
516
+ "name",
517
+ "size",
518
+ "modified"
519
+ ].includes(String(sort.field)) || !["asc", "desc"].includes(String(sort.direction)) || ![
520
+ "natural",
521
+ "locale",
522
+ "unicode"
523
+ ].includes(String(sort.nameMode)) || typeof sort.directoriesFirst !== "boolean") throw new ListingDataError("Invalid folder order");
524
+ if (theme === "crosswave") {
525
+ const categories = data.categories;
526
+ const ids = /* @__PURE__ */ new Set();
527
+ if (!Array.isArray(categories) || categories.length === 0 || categories.some((category) => {
528
+ 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;
529
+ ids.add(category.id);
530
+ return false;
531
+ })) throw new ListingDataError("Invalid folder categories");
532
+ }
273
533
  const base = document.querySelector("base")?.href ?? pageHref;
274
534
  const link = (raw) => {
275
535
  if (raw === null) return null;
@@ -286,6 +546,17 @@ async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false,
286
546
  } } : {}
287
547
  }));
288
548
  if (entries.length > Number(data.total)) throw new ListingDataError("Invalid folder count");
549
+ if (data.page !== null) {
550
+ const page = data.page;
551
+ 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");
552
+ data.page = {
553
+ ...page,
554
+ href: link(page.href),
555
+ previousHref: link(page.previousHref),
556
+ nextHref: link(page.nextHref)
557
+ };
558
+ }
559
+ if (data.ui !== void 0) resolveCustomization(data.ui);
289
560
  const result = {
290
561
  ...data,
291
562
  updatesHref: link(data.updatesHref ?? null),
@@ -305,10 +576,10 @@ async function fetchThemePage(dataHref, pageHref, theme, signal, fresh = false,
305
576
  };
306
577
  })
307
578
  };
308
- if (fresh && result.manifestHref) {
579
+ if (result.manifestHref) {
309
580
  const loaded = await loadListing(result.manifestHref, {
310
581
  signal,
311
- cache: "no-store"
582
+ cache: fresh ? "no-store" : "default"
312
583
  });
313
584
  if (loaded.manifest.total !== data.total || data.listingRevision !== void 0 && loaded.manifest.revision !== data.listingRevision) {
314
585
  if (attempt < 2) return fetchThemePage(dataHref, pageHref, theme, signal, true, attempt + 1);
@@ -376,6 +647,139 @@ function applyThemeMetadata(page, pageHref) {
376
647
  }
377
648
  }
378
649
  //#endregion
650
+ //#region ../shared-theme/src/view-state.ts
651
+ const supportedTypes = [
652
+ "directory",
653
+ "file",
654
+ "link"
655
+ ];
656
+ function validSort(value) {
657
+ if (typeof value !== "object" || value === null) return false;
658
+ const sort = value;
659
+ return [
660
+ "name",
661
+ "size",
662
+ "modified"
663
+ ].includes(String(sort.field)) && ["asc", "desc"].includes(String(sort.direction)) && [
664
+ "natural",
665
+ "locale",
666
+ "unicode"
667
+ ].includes(String(sort.nameMode)) && typeof sort.directoriesFirst === "boolean";
668
+ }
669
+ function position(value) {
670
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(value, 1e9)) : 0;
671
+ }
672
+ /** URL controls are shareable; selection and scroll belong only to one history entry. */
673
+ function readView(href, theme, path, defaults, categories = [], historyState = null) {
674
+ const params = new URL(href).searchParams;
675
+ const result = {
676
+ ...defaults,
677
+ query: (params.get("dw.q") ?? "").slice(0, 4096),
678
+ selected: null,
679
+ windowY: 0,
680
+ listTop: 0
681
+ };
682
+ if (theme === "ledger") {
683
+ const parts = params.get("dw.sort")?.split(":");
684
+ if (parts?.length === 4) {
685
+ const sort = {
686
+ field: parts[0],
687
+ direction: parts[1],
688
+ nameMode: parts[2],
689
+ directoriesFirst: parts[3] === "folders"
690
+ };
691
+ if (["folders", "mixed"].includes(parts[3]) && validSort(sort)) result.sort = sort;
692
+ }
693
+ if (params.has("dw.types")) {
694
+ const types = params.get("dw.types").split(",").filter(Boolean);
695
+ if (types.every((type) => supportedTypes.includes(type))) result.types = [...new Set(types)];
696
+ }
697
+ } else {
698
+ const category = params.get("dw.category");
699
+ if (category !== null && categories.includes(category)) result.category = category;
700
+ }
701
+ if (typeof historyState === "object" && historyState !== null && "dirwell" in historyState) {
702
+ const state = historyState.dirwell;
703
+ 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) {
704
+ const view = state.view;
705
+ result.selected = typeof view.selected === "string" && view.selected.length <= 4096 ? view.selected : null;
706
+ result.windowY = position(view.windowY);
707
+ result.listTop = position(view.listTop);
708
+ }
709
+ }
710
+ return result;
711
+ }
712
+ function viewHref(href, theme, view, defaults) {
713
+ const url = new URL(href);
714
+ for (const key of [
715
+ "dw.q",
716
+ "dw.sort",
717
+ "dw.types",
718
+ "dw.category"
719
+ ]) url.searchParams.delete(key);
720
+ if (view.query) url.searchParams.set("dw.q", view.query.slice(0, 4096));
721
+ if (theme === "ledger") {
722
+ if (view.sort && [
723
+ "field",
724
+ "direction",
725
+ "nameMode",
726
+ "directoriesFirst"
727
+ ].some((key) => view.sort[key] !== defaults.sort?.[key])) {
728
+ const sort = view.sort;
729
+ url.searchParams.set("dw.sort", `${sort.field}:${sort.direction}:${sort.nameMode}:${sort.directoriesFirst ? "folders" : "mixed"}`);
730
+ }
731
+ if (view.types && supportedTypes.some((type) => view.types.includes(type) !== (defaults.types ?? supportedTypes).includes(type))) url.searchParams.set("dw.types", view.types.join(","));
732
+ } else if (view.category && view.category !== defaults.category) url.searchParams.set("dw.category", view.category);
733
+ return url.href;
734
+ }
735
+ /** History failures never require discarding a successfully loaded view. */
736
+ function createViewHistory(theme) {
737
+ let committedHref = location.href;
738
+ let committedState = history.state;
739
+ const stateFor = (dataHref, path, view) => ({
740
+ ...typeof history.state === "object" && history.state !== null ? history.state : {},
741
+ dataHref,
742
+ dirwell: {
743
+ version: 1,
744
+ theme,
745
+ path,
746
+ ...view === void 0 ? {} : { view: {
747
+ query: view.query,
748
+ ...view.sort === void 0 ? {} : { sort: { ...view.sort } },
749
+ ...view.types === void 0 ? {} : { types: [...view.types] },
750
+ category: view.category ?? null,
751
+ selected: view.selected ?? null,
752
+ windowY: view.windowY ?? 0,
753
+ listTop: view.listTop ?? 0
754
+ } }
755
+ }
756
+ });
757
+ return {
758
+ save(dataHref, path, view, defaults) {
759
+ if (location.href !== committedHref) return;
760
+ const href = viewHref(committedHref, theme, view, defaults);
761
+ const state = stateFor(dataHref, path, view);
762
+ try {
763
+ history.replaceState(state, "", href);
764
+ committedHref = href;
765
+ committedState = state;
766
+ } catch {}
767
+ },
768
+ commit(dataHref, path, href, push) {
769
+ const state = stateFor(dataHref, path);
770
+ if (push) history.pushState(state, "", href);
771
+ else history.replaceState(state, "", href);
772
+ committedHref = href;
773
+ committedState = state;
774
+ },
775
+ rollback() {
776
+ if (location.href !== committedHref) try {
777
+ history.replaceState(committedState, "", committedHref);
778
+ } catch {}
779
+ }
780
+ };
781
+ }
782
+ //#endregion
379
783
  //#region ../shared-theme/src/icons.ts
380
784
  const paths = {
381
785
  directory: "<path d=\"M3 7V5h6l2 3h10v12H3Z\"/><path d=\"M3 10h18\"/>",
@@ -404,14 +808,6 @@ function size(bytes) {
404
808
  }
405
809
  return `${value.toFixed(index === 0 || value >= 100 ? 0 : 1)} ${units[index]}`;
406
810
  }
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
811
  //#endregion
416
812
  //#region .generated/wave.mjs
417
813
  function createWave(canvas, colors, root, moving, onReady) {
@@ -557,11 +953,89 @@ var App_server_default = {
557
953
  required: true
558
954
  } },
559
955
  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();
956
+ const props = __props, model = shallowRef(restoreThemePage(props.model, Boolean(import.meta.hot))), entries = shallowRef(model.value.entries), ui = computed(() => resolveCustomization(model.value.ui));
957
+ 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());
562
958
  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");
959
+ 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
960
  const nodes = /* @__PURE__ */ new Map();
961
+ const virtual = computed(() => ready.value && rows.value.length > 500);
962
+ const offsets = computed(() => {
963
+ const result = [0];
964
+ for (const entry of rows.value) result.push(result.at(-1) + (heights.value.get(entry.id) ?? 76));
965
+ return result;
966
+ });
967
+ const start = computed(() => {
968
+ if (!virtual.value) return 0;
969
+ const position = Math.max(0, scrollTop.value - (model.value.parentHref ? 76 : 0));
970
+ let low = 0, high = rows.value.length;
971
+ while (low < high) {
972
+ const middle = low + high >> 1;
973
+ if (offsets.value[middle + 1] <= position) low = middle + 1;
974
+ else high = middle;
975
+ }
976
+ return Math.max(0, low - 5);
977
+ });
978
+ const end = computed(() => {
979
+ if (!virtual.value) return rows.value.length;
980
+ let low = 0, high = rows.value.length;
981
+ while (low < high) {
982
+ const middle = low + high >> 1;
983
+ if (offsets.value[middle + 1] <= scrollTop.value + viewportHeight.value) low = middle + 1;
984
+ else high = middle;
985
+ }
986
+ return Math.min(rows.value.length, Math.max(start.value + 35, low + 5));
987
+ });
988
+ const visibleRows = computed(() => rows.value.slice(start.value, end.value));
989
+ const heightCache = /* @__PURE__ */ new Map();
990
+ let rowObserver, layoutObserver, viewHistory, measuredWidth;
991
+ function measureLayout() {
992
+ if (!listScroll.value) return;
993
+ const width = listScroll.value.clientWidth;
994
+ if (measuredWidth !== void 0 && width !== measuredWidth) {
995
+ heightCache.clear();
996
+ heights.value = new Map([...nodes].map(([id, node]) => [id, node.getBoundingClientRect().height]));
997
+ }
998
+ measuredWidth = width;
999
+ const footerHeight = footerElement.value?.getBoundingClientRect().height ?? 82;
1000
+ document.documentElement.style.setProperty("--cw-footer-height", footerHeight + "px");
1001
+ const available = Math.max(160, innerHeight - listScroll.value.getBoundingClientRect().top - footerHeight - 24);
1002
+ viewportHeight.value = available;
1003
+ listScroll.value.style.setProperty("--cw-list-height", available + "px");
1004
+ }
1005
+ watch([virtual, () => model.value.path], () => nextTick(measureLayout));
1006
+ const viewDefaults = (page) => ({
1007
+ query: "",
1008
+ category: page.categories[0]?.id ?? null
1009
+ });
1010
+ function saveView() {
1011
+ if (!viewHistory) return;
1012
+ viewHistory.save(new URL(model.value.dataHref, document.baseURI).href, model.value.path, {
1013
+ query: query.value,
1014
+ category: category.value,
1015
+ selected: selected.value,
1016
+ windowY: window.scrollY,
1017
+ listTop: listScroll.value?.scrollTop ?? 0
1018
+ }, viewDefaults(model.value));
1019
+ }
1020
+ watchEffect((onCleanup) => {
1021
+ if (!ready.value || busy.value) return;
1022
+ const view = {
1023
+ query: query.value,
1024
+ category: category.value,
1025
+ selected: selected.value,
1026
+ listTop: scrollTop.value
1027
+ };
1028
+ const dataHref = model.value.dataHref, path = model.value.path, defaults = viewDefaults(model.value);
1029
+ const frame = requestAnimationFrame(() => viewHistory?.save(new URL(dataHref, document.baseURI).href, path, {
1030
+ ...view,
1031
+ windowY: window.scrollY
1032
+ }, defaults));
1033
+ onCleanup(() => cancelAnimationFrame(frame));
1034
+ });
1035
+ watch([query, category], () => {
1036
+ scrollTop.value = 0;
1037
+ if (listScroll.value) listScroll.value.scrollTop = 0;
1038
+ });
565
1039
  let wave, gamepad, abort, navigation = 0, timer, lastPad = 0, pressed = /* @__PURE__ */ new Set(), disposed = false, padIdentity = null, motionQuery;
566
1040
  function safeHref(entry) {
567
1041
  return typeof location !== "undefined" && location.protocol === "file:" && directoryLike(entry) && !entry.exitsExplorer && entry.href?.endsWith("/") ? entry.href + "index.html" : entry.href;
@@ -577,67 +1051,113 @@ var App_server_default = {
577
1051
  });
578
1052
  if (token === navigation && !local.signal.aborted && !disposed) entries.value = data.entries;
579
1053
  } catch (cause) {
580
- if (token === navigation && !local.signal.aborted) error.value = "Could not load complete directory data. Native page links remain available.";
1054
+ if (token === navigation && !local.signal.aborted) {
1055
+ error.value = ui.value.labels.listingError;
1056
+ failure.value = "listing";
1057
+ }
581
1058
  } finally {
582
1059
  if (token === navigation) busy.value = false;
583
1060
  }
584
1061
  }
585
- async function openFolder(dataHref, url, push, refresh = false) {
1062
+ async function openFolder(dataHref, url, push, refresh = false, restoreState = null, fresh = false) {
1063
+ if (push) saveView();
586
1064
  const token = ++navigation;
587
1065
  abort?.abort();
588
1066
  const local = abort = new AbortController();
589
1067
  busy.value = true;
590
1068
  error.value = "";
1069
+ failure.value = null;
591
1070
  try {
592
- const next = await fetchThemePage(dataHref, url, "crosswave", local.signal, refresh);
1071
+ const next = await fetchThemePage(dataHref, url, "crosswave", local.signal, refresh || fresh);
593
1072
  let nextEntries = next.entries;
594
1073
  if (refresh && next.manifestHref && nextEntries.length !== next.total) nextEntries = (await loadListing(next.manifestHref, {
595
1074
  signal: local.signal,
596
1075
  cache: "no-store"
597
1076
  })).entries;
598
1077
  if (token !== navigation || local.signal.aborted || disposed) return;
1078
+ if (!refresh) viewHistory.commit(next.dataHref, next.path, url, push);
1079
+ if (next.path !== model.value.path) {
1080
+ heightCache.set(model.value.path, heights.value);
1081
+ if (heightCache.size > 8) heightCache.delete(heightCache.keys().next().value);
1082
+ heights.value = heightCache.get(next.path) ?? /* @__PURE__ */ new Map();
1083
+ }
599
1084
  model.value = next;
1085
+ retryRequest.value = null;
600
1086
  entries.value = nextEntries;
601
1087
  if (!refresh) {
602
- query.value = "";
603
- selected.value = null;
604
- category.value = "all";
1088
+ const view = readView(url, "crosswave", next.path, viewDefaults(next), next.categories.map((item) => item.id), restoreState);
1089
+ query.value = view.query;
1090
+ selected.value = view.selected;
1091
+ category.value = view.category;
605
1092
  }
606
- if (push) history.pushState({ dataHref: next.dataHref }, "", url);
607
1093
  applyThemeMetadata(next, url);
608
1094
  await loadComplete();
1095
+ if (!refresh) {
1096
+ const view = readView(url, "crosswave", next.path, viewDefaults(next), next.categories.map((item) => item.id), restoreState);
1097
+ await nextTick();
1098
+ if (selected.value && !rows.value.some((entry) => entry.id === selected.value)) selected.value = null;
1099
+ scrollTop.value = view.listTop;
1100
+ if (listScroll.value) listScroll.value.scrollTop = view.listTop;
1101
+ await nextTick();
1102
+ window.scrollTo({ top: view.windowY });
1103
+ if (restoreState && selected.value) nodes.get(selected.value)?.querySelector("a")?.focus({ preventScroll: true });
1104
+ }
609
1105
  } catch (cause) {
610
- if (token === navigation && !local.signal.aborted) error.value = "Could not open the folder. Its native link remains available.";
1106
+ if (token === navigation && !local.signal.aborted) {
1107
+ error.value = ui.value.labels.folderError;
1108
+ failure.value = "folder";
1109
+ retryRequest.value = [
1110
+ dataHref,
1111
+ url,
1112
+ push,
1113
+ refresh,
1114
+ restoreState
1115
+ ];
1116
+ viewHistory?.rollback();
1117
+ }
611
1118
  } finally {
612
1119
  if (token === navigation) busy.value = false;
613
1120
  }
614
1121
  }
615
1122
  async function move(index) {
616
- const entry = rows.value[Math.max(0, Math.min(rows.value.length - 1, index))];
1123
+ const target = Math.max(0, Math.min(rows.value.length - 1, index));
1124
+ const entry = rows.value[target];
617
1125
  if (!entry) return;
618
1126
  selected.value = entry.id;
1127
+ if (virtual.value) {
1128
+ scrollTop.value = Math.max(0, offsets.value[target] - 100 + (model.value.parentHref ? 76 : 0));
1129
+ if (listScroll.value) listScroll.value.scrollTop = scrollTop.value;
1130
+ }
619
1131
  await nextTick();
620
1132
  const node = nodes.get(entry.id);
621
1133
  node?.querySelector("a")?.focus({ preventScroll: true });
622
1134
  node?.scrollIntoView({ block: "nearest" });
623
1135
  }
624
- function stepCategory(delta) {
1136
+ async function stepCategory(delta) {
1137
+ if (ui.value.direction === "rtl") delta = -delta;
1138
+ const moveFocus = document.activeElement?.getAttribute("role") === "tab";
625
1139
  const items = model.value.categories, index = items.findIndex((item) => item.id === category.value);
626
1140
  category.value = items[(index + delta + items.length) % items.length].id;
627
1141
  selected.value = null;
1142
+ if (moveFocus) {
1143
+ await nextTick();
1144
+ document.getElementById("cw-tab-" + category.value)?.focus({ preventScroll: true });
1145
+ }
628
1146
  }
629
- function confirm() {
1147
+ async function confirm() {
630
1148
  if (!active.value || busy.value) return;
1149
+ await move(rows.value.findIndex((entry) => entry.id === active.value.id));
631
1150
  (nodes.get(active.value.id)?.querySelector("a"))?.click();
632
1151
  }
633
1152
  function parent() {
634
1153
  if (model.value.parentHref) {
635
- if (location.protocol === "file:") location.href = model.value.parentHref;
1154
+ if (location.protocol === "file:" || typeof model.value.parentDataHref !== "string") location.href = model.value.parentHref;
636
1155
  else openFolder(model.value.parentDataHref, new URL(model.value.parentHref, document.baseURI).href, true);
637
1156
  }
638
1157
  }
639
1158
  function keyboard(event) {
640
1159
  if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey) return;
1160
+ if (event.target?.closest?.("[data-dirwell-actions]")) return;
641
1161
  if (/INPUT|TEXTAREA|SELECT/.test(event.target?.tagName ?? "")) {
642
1162
  if (event.key === "Escape") query.value = "";
643
1163
  return;
@@ -736,7 +1256,7 @@ var App_server_default = {
736
1256
  .38
737
1257
  ]
738
1258
  };
739
- const onPop = (event) => event.state?.dataHref ? openFolder(event.state.dataHref, location.href, false) : location.reload();
1259
+ const onPop = (event) => event.state?.dataHref ? openFolder(event.state.dataHref, location.href, false, false, event.state) : location.reload();
740
1260
  const suspend = () => {
741
1261
  gamepad?.pause();
742
1262
  controller(null);
@@ -757,7 +1277,36 @@ var App_server_default = {
757
1277
  ready.value = true;
758
1278
  document.body.setAttribute("data-cw-enhanced", "");
759
1279
  abort = new AbortController();
760
- history.replaceState({ dataHref: new URL(model.value.dataHref, document.baseURI).href }, "");
1280
+ if (typeof ResizeObserver !== "undefined") {
1281
+ rowObserver = new ResizeObserver((observations) => {
1282
+ const next = new Map(heights.value);
1283
+ let changed = false;
1284
+ for (const observation of observations) {
1285
+ const id = observation.target.dataset.id;
1286
+ const height = observation.target.getBoundingClientRect().height;
1287
+ if (height > 0 && next.get(id) !== height) {
1288
+ next.set(id, height);
1289
+ changed = true;
1290
+ }
1291
+ }
1292
+ if (changed) heights.value = next;
1293
+ });
1294
+ for (const node of nodes.values()) rowObserver.observe(node);
1295
+ layoutObserver = new ResizeObserver(measureLayout);
1296
+ if (footerElement.value) layoutObserver.observe(footerElement.value);
1297
+ const header = document.querySelector("[data-dirwell-theme='crosswave'] .cw-top");
1298
+ if (header) layoutObserver.observe(header);
1299
+ }
1300
+ measureLayout();
1301
+ window.addEventListener("resize", measureLayout);
1302
+ viewHistory = createViewHistory("crosswave");
1303
+ const view = readView(location.href, "crosswave", model.value.path, viewDefaults(model.value), model.value.categories.map((item) => item.id), history.state);
1304
+ query.value = view.query;
1305
+ category.value = view.category;
1306
+ selected.value = view.selected;
1307
+ scrollTop.value = view.listTop;
1308
+ if (listScroll.value) listScroll.value.scrollTop = view.listTop;
1309
+ saveView();
761
1310
  try {
762
1311
  const stored = localStorage.getItem("dirwell-crosswave-color");
763
1312
  if (Object.hasOwn(colors, stored)) color.value = stored;
@@ -785,7 +1334,8 @@ var App_server_default = {
785
1334
  gamepad.resume();
786
1335
  watchEffect(() => controller(gamepad.gamepads.value.find((value) => value.connected && value.mapping === "standard") ?? null), { flush: "sync" });
787
1336
  }
788
- const updateClock = () => clock.value = (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
1337
+ const updateClock = () => clock.value = (/* @__PURE__ */ new Date()).toLocaleTimeString(ui.value.dateOptions.locale, {
1338
+ timeZone: ui.value.dateOptions.timeZone,
789
1339
  hour: "2-digit",
790
1340
  minute: "2-digit"
791
1341
  });
@@ -804,9 +1354,15 @@ var App_server_default = {
804
1354
  if (!href || typeof EventSource === "undefined") return;
805
1355
  const updates = new EventSource(href);
806
1356
  updates.onmessage = () => pendingUpdates.value = href;
807
- updates.onerror = () => error.value = "Live updates disconnected. Refresh to retry.";
1357
+ updates.onerror = () => {
1358
+ error.value = ui.value.labels.updatesError;
1359
+ failure.value = "updates";
1360
+ };
808
1361
  updates.onopen = () => {
809
- if (error.value.startsWith("Live updates")) error.value = "";
1362
+ if (failure.value === "updates") {
1363
+ error.value = "";
1364
+ failure.value = null;
1365
+ }
810
1366
  };
811
1367
  onCleanup(() => updates.close());
812
1368
  });
@@ -831,6 +1387,9 @@ var App_server_default = {
831
1387
  disposed = true;
832
1388
  document.body.removeAttribute("data-cw-enhanced");
833
1389
  abort?.abort();
1390
+ rowObserver?.disconnect();
1391
+ layoutObserver?.disconnect();
1392
+ window.removeEventListener("resize", measureLayout);
834
1393
  gamepad?.pause();
835
1394
  wave?.dispose();
836
1395
  clearInterval(timer);
@@ -844,25 +1403,29 @@ var App_server_default = {
844
1403
  return (_ctx, _push, _parent, _attrs) => {
845
1404
  _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
1405
  ssrRenderSlot(_ctx.$slots, "header", { model: model.value }, () => {
847
- _push(`<a${ssrRenderAttr("href", model.value.rootHref)}>${ssrInterpolate(model.value.title)}</a>`);
1406
+ if (ui.value.brand.logoUrl) _push(`<img${ssrRenderAttr("src", ui.value.brand.logoUrl)}${ssrRenderAttr("alt", ui.value.brand.logoAlt ?? "")} width="40" height="40">`);
1407
+ else _push(`<!---->`);
1408
+ _push(`<a${ssrRenderAttr("href", model.value.rootHref)}>${ssrInterpolate(ui.value.brand.name ?? model.value.title)}</a>`);
848
1409
  }, _push, _parent);
849
- _push(`</div><nav class="cw-location" aria-label="Breadcrumb"><a${ssrRenderAttr("href", model.value.rootHref)}>Home</a><!--[-->`);
1410
+ _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
1411
  ssrRenderList(model.value.breadcrumbs, (crumb) => {
851
1412
  _push(`<!--[--> / <a${ssrRenderAttr("href", crumb.href)}>${ssrInterpolate(crumb.name)}</a><!--]-->`);
852
1413
  });
853
1414
  _push(`<!--]--></nav>`);
854
1415
  if (ready.value) _push(`<div class="cw-clock">${ssrInterpolate(clock.value)}</div>`);
855
1416
  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"><!--[-->`);
1417
+ _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
1418
  ssrRenderList(model.value.categories, (item) => {
858
1419
  _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
1420
  });
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">`);
1421
+ _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></div><div class="cw-list-scroll"${ssrRenderAttr("data-virtual", virtual.value)}><ul class="cw-files">`);
861
1422
  if (model.value.parentHref) _push(`<li class="cw-row"><a${ssrRenderAttr("href", model.value.parentHref)} class="cw-entry">../</a></li>`);
862
1423
  else _push(`<!---->`);
1424
+ if (start.value) _push(`<li aria-hidden="true" style="${ssrRenderStyle({ height: offsets.value[start.value] + "px" })}"></li>`);
1425
+ else _push(`<!---->`);
863
1426
  _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)}>`);
1427
+ ssrRenderList(visibleRows.value, (entry, visibleIndex) => {
1428
+ _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
1429
  if (entry.href) {
867
1430
  _push(`<a class="cw-entry"${ssrRenderAttr("href", safeHref(entry))}${ssrRenderAttr("target", entry.exitsExplorer ? "_blank" : void 0)}${ssrRenderAttr("rel", entry.exitsExplorer ? "noopener" : void 0)}>`);
868
1431
  ssrRenderSlot(_ctx.$slots, "row", {
@@ -870,10 +1433,22 @@ var App_server_default = {
870
1433
  entry,
871
1434
  selected: selected.value === entry.id
872
1435
  }, () => {
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>`);
1436
+ _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"><!--[-->`);
1437
+ ssrRenderList(ui.value.columns, (column) => {
1438
+ _push(`<!--[-->`);
1439
+ if (column === "name") _push(`<span class="cw-name">${ssrInterpolate(entry.name)}${ssrInterpolate(unref(directoryLike)(entry) ? "/" : "")}</span>`);
1440
+ else _push(`<!---->`);
1441
+ if (entry.link && column === "name") _push(`<span class="cw-target">${ssrInterpolate(entry.link.target)}</span>`);
1442
+ else _push(`<!---->`);
1443
+ 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>`);
1444
+ else _push(`<!---->`);
1445
+ if (column === "modified") _push(`<span class="cw-row-date">${ssrInterpolate(ui.value.labels.modified)} ${ssrInterpolate(unref(formattedDate)(entry.modifiedAt, ui.value, ready.value))}</span>`);
1446
+ else _push(`<!---->`);
1447
+ if (typeof column === "object") _push(`<span class="cw-data-value">${ssrInterpolate(column.label)}: ${ssrInterpolate(unref(publicDataValue)(entry.data?.[column.dataKey]))}</span>`);
1448
+ else _push(`<!---->`);
1449
+ _push(`<!--]-->`);
1450
+ });
1451
+ _push(`<!--]--></span>`);
877
1452
  }, _push, _parent);
878
1453
  _push(`</a>`);
879
1454
  } else {
@@ -887,40 +1462,76 @@ var App_server_default = {
887
1462
  }, _push, _parent);
888
1463
  _push(`</span>`);
889
1464
  }
1465
+ if (ui.value.actions.length || _ctx.$slots.actions) {
1466
+ _push(`<span data-dirwell-actions class="cw-row-actions">`);
1467
+ ssrRenderSlot(_ctx.$slots, "actions", {
1468
+ model: model.value,
1469
+ entry,
1470
+ selected: selected.value === entry.id
1471
+ }, null, _push, _parent);
1472
+ _push(`<!--[-->`);
1473
+ ssrRenderList(ui.value.actions, (action) => {
1474
+ _push(`<!--[-->`);
1475
+ if (action === "download" && entry.href && !unref(directoryLike)(entry) && !entry.link?.unavailable) _push(`<a${ssrRenderAttr("href", safeHref(entry))} download>${ssrInterpolate(ui.value.labels.download)}</a>`);
1476
+ 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>`);
1477
+ else _push(`<!---->`);
1478
+ _push(`<!--]-->`);
1479
+ });
1480
+ _push(`<!--]--></span>`);
1481
+ } else _push(`<!---->`);
890
1482
  _push(`</li>`);
891
1483
  });
892
- _push(`<!--]--></ul>`);
893
- if (!rows.value.length) _push(`<p class="cw-empty">No matching files.</p>`);
1484
+ _push(`<!--]-->`);
1485
+ 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>`);
1486
+ else _push(`<!---->`);
1487
+ _push(`</ul>`);
1488
+ 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
1489
  else _push(`<!---->`);
895
1490
  _push(`</div></section>`);
896
1491
  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>`);
1492
+ _push(`<aside class="cw-detail"><h2>${ssrInterpolate(active.value.name)}</h2>`);
1493
+ 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>`);
1494
+ else _push(`<!---->`);
1495
+ if (ui.value.columns.includes("modified")) _push(`<p>${ssrInterpolate(unref(formattedDate)(active.value.modifiedAt, ui.value, ready.value))}</p>`);
1496
+ else _push(`<!---->`);
898
1497
  if (active.value.link) _push(`<p>${ssrInterpolate(active.value.link.target)}</p>`);
899
1498
  else _push(`<!---->`);
900
1499
  _push(`</aside>`);
901
1500
  } else _push(`<!---->`);
902
- _push(`<p role="status" aria-live="polite">${ssrInterpolate(busy.value ? "Loading…" : error.value)}</p>`);
1501
+ _push(`<p role="status" aria-live="polite">${ssrInterpolate(busy.value ? ui.value.labels.loading : error.value)}</p>`);
1502
+ if (failure.value && failure.value !== "copy") _push(`<button${ssrIncludeBooleanAttr(busy.value) ? " disabled" : ""}>${ssrInterpolate(ui.value.labels.retry)}</button>`);
1503
+ else _push(`<!---->`);
1504
+ if (failure.value === "folder" && retryRequest.value) _push(`<a${ssrRenderAttr("href", retryRequest.value[1])}>${ssrInterpolate(ui.value.labels.openNative)}</a>`);
1505
+ else _push(`<!---->`);
903
1506
  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>`);
1507
+ _push(`<nav${ssrRenderAttr("aria-label", ui.value.labels.pagination)}>`);
1508
+ if (model.value.page.previousHref) _push(`<a${ssrRenderAttr("href", model.value.page.previousHref)} rel="prev">${ssrInterpolate(ui.value.labels.previous)}</a>`);
906
1509
  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>`);
1510
+ _push(` ${ssrInterpolate(unref(label)(ui.value, "page", {
1511
+ page: model.value.page.number,
1512
+ pages: model.value.page.count
1513
+ }))} `);
1514
+ if (model.value.page.nextHref) _push(`<a${ssrRenderAttr("href", model.value.page.nextHref)} rel="next">${ssrInterpolate(ui.value.labels.next)}</a>`);
909
1515
  else _push(`<!---->`);
910
1516
  _push(`</nav>`);
911
1517
  } else _push(`<!---->`);
912
- _push(`</main><footer class="cw-bottom"><div class="cw-key-help">`);
1518
+ _push(`</main><footer class="cw-bottom"><div class="${ssrRenderClass(ui.value.brand.footerText || _ctx.$slots.footer ? "cw-custom-footer" : "cw-key-help")}">`);
913
1519
  ssrRenderSlot(_ctx.$slots, "footer", { model: model.value }, () => {
914
- _push(`Arrow keys navigate · Enter confirms · Backspace returns`);
1520
+ if (ui.value.brand.footerText) {
1521
+ _push(`<!--[-->`);
1522
+ if (ui.value.brand.footerUrl) _push(`<a${ssrRenderAttr("href", ui.value.brand.footerUrl)}>${ssrInterpolate(ui.value.brand.footerText)}</a>`);
1523
+ else _push(`<span>${ssrInterpolate(ui.value.brand.footerText)}</span>`);
1524
+ _push(`<!--]-->`);
1525
+ } else _push(`<!--[-->${ssrInterpolate(ui.value.labels.crosswaveKeyboardHelp)}<!--]-->`);
915
1526
  }, _push, _parent);
916
1527
  _push(`</div>`);
917
- if (pad.value) _push(`<div class="cw-controller">Controller · Confirm / Back</div>`);
1528
+ if (pad.value) _push(`<div class="cw-controller">${ssrInterpolate(ui.value.labels.controller)}</div>`);
918
1529
  else _push(`<!---->`);
919
- _push(`<label class="cw-appearance">Color<select${ssrIncludeBooleanAttr(!ready.value) ? " disabled" : ""}><!--[-->`);
1530
+ _push(`<label class="cw-appearance">${ssrInterpolate(ui.value.labels.color)}<select${ssrIncludeBooleanAttr(!ready.value) ? " disabled" : ""}><!--[-->`);
920
1531
  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>`);
1532
+ _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
1533
  });
923
- _push(`<!--]--></select></label><button class="cw-motion"${ssrIncludeBooleanAttr(!ready.value) ? " disabled" : ""}>${ssrInterpolate(paused.value ? "Resume motion" : "Pause motion")}</button></footer><!--]-->`);
1534
+ _push(`<!--]--></select></label><button class="cw-motion"${ssrIncludeBooleanAttr(!ready.value) ? " disabled" : ""}>${ssrInterpolate(paused.value ? ui.value.labels.resumeMotion : ui.value.labels.pauseMotion)}</button></footer><!--]-->`);
924
1535
  };
925
1536
  }
926
1537
  };
@@ -930,7 +1541,44 @@ const renderBody = (model) => renderToString(createSSRApp(App_server_default, {
930
1541
  //#endregion
931
1542
  //#region src/index.ts
932
1543
  function createCrosswaveTheme(options = {}, renderer) {
933
- const categories = resolveCrosswaveCategories(options.categories);
1544
+ assertThemeOptions(options, [
1545
+ "title",
1546
+ "categories",
1547
+ "color",
1548
+ "gamepad",
1549
+ "backgroundMotion"
1550
+ ]);
1551
+ if (options.title !== void 0 && (typeof options.title !== "string" || !options.title)) throw new TypeError("title must be a non-empty string");
1552
+ if (options.color !== void 0 && ![
1553
+ "azure",
1554
+ "violet",
1555
+ "amber",
1556
+ "rose",
1557
+ "jade",
1558
+ "graphite"
1559
+ ].includes(options.color)) throw new TypeError("Unsupported Crosswave color");
1560
+ for (const key of ["gamepad", "backgroundMotion"]) if (options[key] !== void 0 && typeof options[key] !== "boolean") throw new TypeError(`${key} must be a boolean`);
1561
+ const ui = resolveCustomization(options);
1562
+ const publishedUi = pickCustomization(options);
1563
+ const categoryLabels = {
1564
+ all: ui.labels.allFiles,
1565
+ folder: ui.labels.categoryFolders,
1566
+ image: ui.labels.categoryPhotos,
1567
+ audio: ui.labels.categoryMusic,
1568
+ video: ui.labels.categoryVideos,
1569
+ document: ui.labels.categoryDocuments,
1570
+ other: ui.labels.categoryOther
1571
+ };
1572
+ const categories = resolveCrosswaveCategories(options.categories ?? defaultCrosswaveCategories.map((category) => ({
1573
+ ...category,
1574
+ label: categoryLabels[category.id] ?? category.label
1575
+ })));
1576
+ let resources;
1577
+ 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]) => ({
1578
+ script,
1579
+ styles,
1580
+ scriptName: `crosswave-client-${createHash("sha256").update(script).digest("hex").slice(0, 16)}.js`
1581
+ }));
934
1582
  const theme = {
935
1583
  name: "crosswave",
936
1584
  searchIndex: false,
@@ -942,7 +1590,8 @@ function createCrosswaveTheme(options = {}, renderer) {
942
1590
  }),
943
1591
  async render(context) {
944
1592
  const model = {
945
- ...createThemePage(context, theme, options.title ?? "Dirwell"),
1593
+ ...createThemePage(context, theme, options.title ?? ui.brand.name ?? "Dirwell"),
1594
+ ...publishedUi === void 0 ? {} : { ui: publishedUi },
946
1595
  categories: categories.map(({ id, label, svg }) => ({
947
1596
  id,
948
1597
  label,
@@ -952,11 +1601,10 @@ function createCrosswaveTheme(options = {}, renderer) {
952
1601
  gamepad: options.gamepad ?? true,
953
1602
  backgroundMotion: options.backgroundMotion ?? true
954
1603
  };
955
- const script = renderer?.clientScript ?? await readFile(new URL("./client.js", import.meta.url), "utf8"), styles = await readFile(new URL("./styles.css", import.meta.url), "utf8");
956
- const scriptName = `crosswave-client-${createHash("sha256").update(script).digest("hex").slice(0, 16)}.js`;
1604
+ const { script, scriptName, styles } = await loadResources();
957
1605
  const json = JSON.stringify(model).replaceAll("<", "\\u003c");
958
1606
  return {
959
- 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>`,
1607
+ 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>`,
960
1608
  assets: {
961
1609
  [scriptName]: script,
962
1610
  [pageAsset("crosswave", model.path, model.page?.number ?? 1)]: JSON.stringify(model)