@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.
@@ -9,6 +9,7 @@ import {
9
9
  watch,
10
10
  nextTick,
11
11
  } from "vue";
12
+ import { queryLiveArchive } from "./shared/live-search.mjs";
12
13
  import { useGamepad } from "@vueuse/core";
13
14
  import { loadListing } from "@dirwell/core/client";
14
15
  import {
@@ -16,18 +17,28 @@ import {
16
17
  applyThemeMetadata,
17
18
  restoreThemePage,
18
19
  } from "./shared/navigation.mjs";
20
+ import { readView, createViewHistory } from "./shared/view-state.mjs";
19
21
  import { entryIcon } from "./shared/icons.mjs";
20
- import { directoryLike, size, modified } from "./shared/records.mjs";
22
+ import { directoryLike, size } from "./shared/records.mjs";
23
+ import {
24
+ resolveCustomization,
25
+ label,
26
+ formattedDate,
27
+ copyEntryAction,
28
+ publicDataValue,
29
+ } from "./shared/customization.mjs";
21
30
  import { createWave } from "./wave.mjs";
22
31
  const props = defineProps({ model: { type: Object, required: true } }),
23
32
  model = shallowRef(restoreThemePage(props.model, Boolean(import.meta.hot))),
24
- entries = shallowRef(model.value.entries);
33
+ entries = shallowRef(model.value.entries),
34
+ ui = computed(() => resolveCustomization(model.value.ui));
25
35
  const query = ref(""),
26
- category = ref("all"),
36
+ category = ref(model.value.categories[0]?.id ?? null),
27
37
  selected = ref(null),
28
38
  ready = ref(false),
29
39
  busy = ref(false),
30
40
  error = ref(""),
41
+ failure = ref(null),
31
42
  color = ref(props.model.color),
32
43
  paused = ref(!props.model.backgroundMotion),
33
44
  clock = ref(""),
@@ -35,14 +46,107 @@ const query = ref(""),
35
46
  waveReady = ref(false),
36
47
  pendingUpdates = ref(null),
37
48
  canvas = ref(),
38
- search = ref();
49
+ search = ref(),
50
+ listScroll = ref(),
51
+ footerElement = ref(),
52
+ scrollTop = ref(0),
53
+ viewportHeight = ref(400),
54
+ retryRequest = shallowRef(null),
55
+ heights = shallowRef(new Map());
56
+ const archiveOpen = ref(false),
57
+ archiveQuery = ref(""),
58
+ archiveRows = shallowRef([]),
59
+ archiveStatus = ref(""),
60
+ archiveFailed = ref(false),
61
+ archiveTrigger = ref(),
62
+ archiveDialog = ref(),
63
+ archiveInput = ref();
64
+ let archiveAbort,
65
+ archiveRequest = 0,
66
+ archivePending = false,
67
+ archiveDebouncing = false;
68
+ async function searchArchive() {
69
+ const token = ++archiveRequest;
70
+ archiveAbort?.abort();
71
+ const local = (archiveAbort = new AbortController());
72
+ archiveFailed.value = false;
73
+ if (!archiveQuery.value.trim()) {
74
+ archiveRows.value = [];
75
+ archiveStatus.value = ui.value.labels.beginArchive;
76
+ return;
77
+ }
78
+ archiveStatus.value = ui.value.labels.searchingArchive;
79
+ archivePending = true;
80
+ try {
81
+ const result = await queryLiveArchive(
82
+ model.value.searchQueryHref,
83
+ archiveQuery.value.trim(),
84
+ local.signal,
85
+ );
86
+ if (local.signal.aborted || token !== archiveRequest) return;
87
+ archiveRows.value = result.records;
88
+ archiveStatus.value = result.truncated
89
+ ? label(ui.value, "truncatedResults", {
90
+ shown: result.records.length,
91
+ count: result.records.length + "+",
92
+ })
93
+ : label(ui.value, "archiveResults", { count: result.records.length });
94
+ } catch {
95
+ if (local.signal.aborted || token !== archiveRequest) return;
96
+ archiveRows.value = [];
97
+ archiveStatus.value = ui.value.labels.archiveError;
98
+ archiveFailed.value = true;
99
+ } finally {
100
+ if (token === archiveRequest) archivePending = false;
101
+ }
102
+ }
103
+ watch([archiveOpen, archiveQuery], (_, __, cleanup) => {
104
+ archiveAbort?.abort();
105
+ archiveRequest++;
106
+ archivePending = false;
107
+ if (!archiveOpen.value) return;
108
+ archiveDebouncing = true;
109
+ const debounce = setTimeout(() => {
110
+ archiveDebouncing = false;
111
+ searchArchive();
112
+ }, 300);
113
+ cleanup(() => {
114
+ clearTimeout(debounce);
115
+ archiveDebouncing = false;
116
+ });
117
+ });
118
+ watch(archiveOpen, async (open, _, cleanup) => {
119
+ if (!open) {
120
+ await nextTick();
121
+ archiveTrigger.value?.focus();
122
+ return;
123
+ }
124
+ await nextTick();
125
+ if (!archiveOpen.value) return;
126
+ archiveDialog.value?.showModal();
127
+ archiveInput.value?.focus();
128
+ const timer = setInterval(() => {
129
+ if (
130
+ document.visibilityState === "visible" &&
131
+ archiveQuery.value.trim() &&
132
+ !archivePending &&
133
+ !archiveDebouncing
134
+ )
135
+ searchArchive();
136
+ }, model.value.searchRefreshIntervalMs ?? 2000);
137
+ cleanup(() => {
138
+ clearInterval(timer);
139
+ archiveAbort?.abort();
140
+ archiveRequest++;
141
+ });
142
+ });
39
143
  const complete = computed(() => entries.value.length === model.value.total),
40
144
  membership = (entry) =>
41
145
  Array.isArray(entry.presentation?.categories) ? entry.presentation.categories : [];
42
146
  const rows = computed(() =>
43
147
  entries.value.filter(
44
148
  (entry) =>
45
- (category.value === "all" || membership(entry).includes(category.value)) &&
149
+ membership(entry).includes(category.value) &&
46
150
  `${entry.name} ${entry.link?.target ?? ""}`
47
151
  .toLocaleLowerCase()
48
152
  .includes(query.value.trim().toLocaleLowerCase()),
@@ -50,9 +154,116 @@ const rows = computed(() =>
50
154
  ),
51
155
  active = computed(() => rows.value.find((entry) => entry.id === selected.value) ?? rows.value[0]),
52
156
  heading = computed(
53
- () => model.value.categories.find((item) => item.id === category.value)?.label ?? "All files",
157
+ () =>
158
+ model.value.categories.find((item) => item.id === category.value)?.label ??
159
+ ui.value.labels.allFiles,
54
160
  );
55
161
  const nodes = new Map();
162
+ const virtual = computed(() => ready.value && rows.value.length > 500);
163
+ const offsets = computed(() => {
164
+ const result = [0];
165
+ for (const entry of rows.value) result.push(result.at(-1) + (heights.value.get(entry.id) ?? 76));
166
+ return result;
167
+ });
168
+ const start = computed(() => {
169
+ if (!virtual.value) return 0;
170
+ const position = Math.max(0, scrollTop.value - (model.value.parentHref ? 76 : 0));
171
+ let low = 0,
172
+ high = rows.value.length;
173
+ while (low < high) {
174
+ const middle = (low + high) >> 1;
175
+ if (offsets.value[middle + 1] <= position) low = middle + 1;
176
+ else high = middle;
177
+ }
178
+ return Math.max(0, low - 5);
179
+ });
180
+ const end = computed(() => {
181
+ if (!virtual.value) return rows.value.length;
182
+ let low = 0,
183
+ high = rows.value.length;
184
+ while (low < high) {
185
+ const middle = (low + high) >> 1;
186
+ if (offsets.value[middle + 1] <= scrollTop.value + viewportHeight.value) low = middle + 1;
187
+ else high = middle;
188
+ }
189
+ return Math.min(rows.value.length, Math.max(start.value + 35, low + 5));
190
+ });
191
+ const visibleRows = computed(() => rows.value.slice(start.value, end.value));
192
+ const heightCache = new Map();
193
+ let rowObserver, layoutObserver, viewHistory, measuredWidth;
194
+ function measureLayout() {
195
+ if (!listScroll.value) return;
196
+ const width = listScroll.value.clientWidth;
197
+ if (measuredWidth !== undefined && width !== measuredWidth) {
198
+ heightCache.clear();
199
+ heights.value = new Map(
200
+ [...nodes].map(([id, node]) => [id, node.getBoundingClientRect().height]),
201
+ );
202
+ }
203
+ measuredWidth = width;
204
+ const footerHeight = footerElement.value?.getBoundingClientRect().height ?? 82;
205
+ document.documentElement.style.setProperty("--cw-footer-height", footerHeight + "px");
206
+ const available = Math.max(
207
+ 160,
208
+ innerHeight - listScroll.value.getBoundingClientRect().top - footerHeight - 24,
209
+ );
210
+ viewportHeight.value = available;
211
+ listScroll.value.style.setProperty("--cw-list-height", available + "px");
212
+ }
213
+ watch([virtual, () => model.value.path], () => nextTick(measureLayout));
214
+ const viewDefaults = (page) => ({ query: "", category: page.categories[0]?.id ?? null });
215
+ function saveView() {
216
+ if (!viewHistory) return;
217
+ viewHistory.save(
218
+ new URL(model.value.dataHref, document.baseURI).href,
219
+ model.value.path,
220
+ {
221
+ query: query.value,
222
+ category: category.value,
223
+ selected: selected.value,
224
+ windowY: window.scrollY,
225
+ listTop: listScroll.value?.scrollTop ?? 0,
226
+ },
227
+ viewDefaults(model.value),
228
+ );
229
+ }
230
+ function retry() {
231
+ if (failure.value === "folder" && retryRequest.value) openFolder(...retryRequest.value, true);
232
+ else openFolder(model.value.dataHref, location.href, false, true);
233
+ }
234
+ watchEffect((onCleanup) => {
235
+ if (!ready.value || busy.value) return;
236
+ const view = {
237
+ query: query.value,
238
+ category: category.value,
239
+ selected: selected.value,
240
+ listTop: scrollTop.value,
241
+ };
242
+ const dataHref = model.value.dataHref,
243
+ path = model.value.path,
244
+ defaults = viewDefaults(model.value);
245
+ const frame = requestAnimationFrame(() =>
246
+ viewHistory?.save(
247
+ new URL(dataHref, document.baseURI).href,
248
+ path,
249
+ { ...view, windowY: window.scrollY },
250
+ defaults,
251
+ ),
252
+ );
253
+ onCleanup(() => cancelAnimationFrame(frame));
254
+ });
255
+ function setNode(id, node) {
256
+ const old = nodes.get(id);
257
+ if (old && old !== node) rowObserver?.unobserve(old);
258
+ if (node) {
259
+ nodes.set(id, node);
260
+ rowObserver?.observe(node);
261
+ } else nodes.delete(id);
262
+ }
263
+ watch([query, category], () => {
264
+ scrollTop.value = 0;
265
+ if (listScroll.value) listScroll.value.scrollTop = 0;
266
+ });
56
267
  let wave,
57
268
  gamepad,
58
269
  abort,
@@ -84,45 +295,95 @@ async function loadComplete() {
84
295
  });
85
296
  if (token === navigation && !local.signal.aborted && !disposed) entries.value = data.entries;
86
297
  } catch (cause) {
87
- if (token === navigation && !local.signal.aborted)
88
- error.value = "Could not load complete directory data. Native page links remain available.";
298
+ if (token === navigation && !local.signal.aborted) {
299
+ error.value = ui.value.labels.listingError;
300
+ failure.value = "listing";
301
+ }
89
302
  } finally {
90
303
  if (token === navigation) busy.value = false;
91
304
  }
92
305
  }
93
- async function openFolder(dataHref, url, push, refresh = false) {
306
+ async function openFolder(
307
+ dataHref,
308
+ url,
309
+ push,
310
+ refresh = false,
311
+ restoreState = null,
312
+ fresh = false,
313
+ ) {
314
+ if (push) saveView();
94
315
  const token = ++navigation;
95
316
  abort?.abort();
96
317
  const local = (abort = new AbortController());
97
318
  busy.value = true;
98
319
  error.value = "";
320
+ failure.value = null;
99
321
  try {
100
- const next = await fetchThemePage(dataHref, url, "crosswave", local.signal, refresh);
322
+ const next = await fetchThemePage(dataHref, url, "crosswave", local.signal, refresh || fresh);
101
323
  let nextEntries = next.entries;
102
324
  if (refresh && next.manifestHref && nextEntries.length !== next.total)
103
325
  nextEntries = (
104
326
  await loadListing(next.manifestHref, { signal: local.signal, cache: "no-store" })
105
327
  ).entries;
106
328
  if (token !== navigation || local.signal.aborted || disposed) return;
329
+ if (!refresh) viewHistory.commit(next.dataHref, next.path, url, push);
330
+ if (next.path !== model.value.path) {
331
+ heightCache.set(model.value.path, heights.value);
332
+ if (heightCache.size > 8) heightCache.delete(heightCache.keys().next().value);
333
+ heights.value = heightCache.get(next.path) ?? new Map();
334
+ }
107
335
  model.value = next;
336
+ retryRequest.value = null;
108
337
  entries.value = nextEntries;
109
338
  if (!refresh) {
110
- query.value = "";
111
- selected.value = null;
112
- category.value = "all";
339
+ const view = readView(
340
+ url,
341
+ "crosswave",
342
+ next.path,
343
+ viewDefaults(next),
344
+ next.categories.map((item) => item.id),
345
+ restoreState,
346
+ );
347
+ query.value = view.query;
348
+ selected.value = view.selected;
349
+ category.value = view.category;
113
350
  }
114
- if (push) history.pushState({ dataHref: next.dataHref }, "", url);
115
351
  applyThemeMetadata(next, url);
116
352
  await loadComplete();
353
+ if (!refresh) {
354
+ const view = readView(
355
+ url,
356
+ "crosswave",
357
+ next.path,
358
+ viewDefaults(next),
359
+ next.categories.map((item) => item.id),
360
+ restoreState,
361
+ );
362
+ await nextTick();
363
+ if (selected.value && !rows.value.some((entry) => entry.id === selected.value))
364
+ selected.value = null;
365
+ scrollTop.value = view.listTop;
366
+ if (listScroll.value) listScroll.value.scrollTop = view.listTop;
367
+ await nextTick();
368
+ window.scrollTo({ top: view.windowY });
369
+ if (restoreState && selected.value)
370
+ nodes.get(selected.value)?.querySelector("a")?.focus({ preventScroll: true });
371
+ }
117
372
  } catch (cause) {
118
- if (token === navigation && !local.signal.aborted)
119
- error.value = "Could not open the folder. Its native link remains available.";
373
+ if (token === navigation && !local.signal.aborted) {
374
+ error.value = ui.value.labels.folderError;
375
+ failure.value = "folder";
376
+ retryRequest.value = [dataHref, url, push, refresh, restoreState];
377
+ viewHistory?.rollback();
378
+ }
120
379
  } finally {
121
380
  if (token === navigation) busy.value = false;
122
381
  }
123
382
  }
124
383
  function navigateLink(event, href, dataHref) {
125
384
  if (
385
+ typeof href !== "string" ||
386
+ typeof dataHref !== "string" ||
126
387
  event?.altKey ||
127
388
  event?.ctrlKey ||
128
389
  event?.metaKey ||
@@ -135,6 +396,7 @@ function navigateLink(event, href, dataHref) {
135
396
  openFolder(dataHref, new URL(href, document.baseURI).href, true);
136
397
  }
137
398
  async function openEntry(event, entry) {
399
+ saveView();
138
400
  const href = entry.presentation?.folderDataHref;
139
401
  if (
140
402
  entry.exitsExplorer ||
@@ -151,28 +413,41 @@ async function openEntry(event, entry) {
151
413
  await openFolder(href, new URL(entry.href, document.baseURI).href, true);
152
414
  }
153
415
  async function move(index) {
154
- const entry = rows.value[Math.max(0, Math.min(rows.value.length - 1, index))];
416
+ const target = Math.max(0, Math.min(rows.value.length - 1, index));
417
+ const entry = rows.value[target];
155
418
  if (!entry) return;
156
419
  selected.value = entry.id;
420
+ if (virtual.value) {
421
+ scrollTop.value = Math.max(0, offsets.value[target] - 100 + (model.value.parentHref ? 76 : 0));
422
+ if (listScroll.value) listScroll.value.scrollTop = scrollTop.value;
423
+ }
157
424
  await nextTick();
158
425
  const node = nodes.get(entry.id);
159
426
  node?.querySelector("a")?.focus({ preventScroll: true });
160
427
  node?.scrollIntoView({ block: "nearest" });
161
428
  }
162
- function stepCategory(delta) {
429
+ async function stepCategory(delta) {
430
+ if (ui.value.direction === "rtl") delta = -delta;
431
+ const moveFocus = document.activeElement?.getAttribute("role") === "tab";
163
432
  const items = model.value.categories,
164
433
  index = items.findIndex((item) => item.id === category.value);
165
434
  category.value = items[(index + delta + items.length) % items.length].id;
166
435
  selected.value = null;
436
+ if (moveFocus) {
437
+ await nextTick();
438
+ document.getElementById("cw-tab-" + category.value)?.focus({ preventScroll: true });
439
+ }
167
440
  }
168
- function confirm() {
441
+ async function confirm() {
169
442
  if (!active.value || busy.value) return;
443
+ await move(rows.value.findIndex((entry) => entry.id === active.value.id));
170
444
  const anchor = nodes.get(active.value.id)?.querySelector("a");
171
445
  anchor?.click();
172
446
  }
173
447
  function parent() {
174
448
  if (model.value.parentHref) {
175
- if (location.protocol === "file:") location.href = model.value.parentHref;
449
+ if (location.protocol === "file:" || typeof model.value.parentDataHref !== "string")
450
+ location.href = model.value.parentHref;
176
451
  else
177
452
  openFolder(
178
453
  model.value.parentDataHref,
@@ -182,7 +457,9 @@ function parent() {
182
457
  }
183
458
  }
184
459
  function keyboard(event) {
460
+ if (archiveOpen.value) return;
185
461
  if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey) return;
462
+ if (event.target?.closest?.("[data-dirwell-actions]")) return;
186
463
  const editing = /INPUT|TEXTAREA|SELECT/.test(event.target?.tagName ?? "");
187
464
  if (editing) {
188
465
  if (event.key === "Escape") query.value = "";
@@ -213,6 +490,7 @@ function keyboard(event) {
213
490
  }
214
491
  }
215
492
  function controller(value) {
493
+ if (archiveOpen.value) return;
216
494
  pad.value = !!value;
217
495
  if (!value) {
218
496
  pressed.clear();
@@ -276,7 +554,7 @@ const colors = {
276
554
  };
277
555
  const onPop = (event) =>
278
556
  event.state?.dataHref
279
- ? openFolder(event.state.dataHref, location.href, false)
557
+ ? openFolder(event.state.dataHref, location.href, false, false, event.state)
280
558
  : location.reload();
281
559
  const suspend = () => {
282
560
  gamepad?.pause();
@@ -298,7 +576,43 @@ onMounted(() => {
298
576
  ready.value = true;
299
577
  document.body.setAttribute("data-cw-enhanced", "");
300
578
  abort = new AbortController();
301
- history.replaceState({ dataHref: new URL(model.value.dataHref, document.baseURI).href }, "");
579
+ if (typeof ResizeObserver !== "undefined") {
580
+ rowObserver = new ResizeObserver((observations) => {
581
+ const next = new Map(heights.value);
582
+ let changed = false;
583
+ for (const observation of observations) {
584
+ const id = observation.target.dataset.id;
585
+ const height = observation.target.getBoundingClientRect().height;
586
+ if (height > 0 && next.get(id) !== height) {
587
+ next.set(id, height);
588
+ changed = true;
589
+ }
590
+ }
591
+ if (changed) heights.value = next;
592
+ });
593
+ for (const node of nodes.values()) rowObserver.observe(node);
594
+ layoutObserver = new ResizeObserver(measureLayout);
595
+ if (footerElement.value) layoutObserver.observe(footerElement.value);
596
+ const header = document.querySelector("[data-dirwell-theme='crosswave'] .cw-top");
597
+ if (header) layoutObserver.observe(header);
598
+ }
599
+ measureLayout();
600
+ window.addEventListener("resize", measureLayout);
601
+ viewHistory = createViewHistory("crosswave");
602
+ const view = readView(
603
+ location.href,
604
+ "crosswave",
605
+ model.value.path,
606
+ viewDefaults(model.value),
607
+ model.value.categories.map((item) => item.id),
608
+ history.state,
609
+ );
610
+ query.value = view.query;
611
+ category.value = view.category;
612
+ selected.value = view.selected;
613
+ scrollTop.value = view.listTop;
614
+ if (listScroll.value) listScroll.value.scrollTop = view.listTop;
615
+ saveView();
302
616
  try {
303
617
  const stored = localStorage.getItem("dirwell-crosswave-color");
304
618
  if (Object.hasOwn(colors, stored)) color.value = stored;
@@ -347,7 +661,11 @@ onMounted(() => {
347
661
  );
348
662
  }
349
663
  const updateClock = () =>
350
- (clock.value = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
664
+ (clock.value = new Date().toLocaleTimeString(ui.value.dateOptions.locale, {
665
+ timeZone: ui.value.dateOptions.timeZone,
666
+ hour: "2-digit",
667
+ minute: "2-digit",
668
+ }));
351
669
  updateClock();
352
670
  timer = setInterval(updateClock, 1000);
353
671
  window.addEventListener("keydown", keyboard);
@@ -368,9 +686,15 @@ watch(
368
686
  if (!href || typeof EventSource === "undefined") return;
369
687
  const updates = new EventSource(href);
370
688
  updates.onmessage = () => (pendingUpdates.value = href);
371
- updates.onerror = () => (error.value = "Live updates disconnected. Refresh to retry.");
689
+ updates.onerror = () => {
690
+ error.value = ui.value.labels.updatesError;
691
+ failure.value = "updates";
692
+ };
372
693
  updates.onopen = () => {
373
- if (error.value.startsWith("Live updates")) error.value = "";
694
+ if (failure.value === "updates") {
695
+ error.value = "";
696
+ failure.value = null;
697
+ }
374
698
  };
375
699
  onCleanup(() => updates.close());
376
700
  },
@@ -394,9 +718,13 @@ watchEffect(() => {
394
718
  }
395
719
  });
396
720
  onBeforeUnmount(() => {
721
+ archiveAbort?.abort();
397
722
  disposed = true;
398
723
  document.body.removeAttribute("data-cw-enhanced");
399
724
  abort?.abort();
725
+ rowObserver?.disconnect();
726
+ layoutObserver?.disconnect();
727
+ window.removeEventListener("resize", measureLayout);
400
728
  gamepad?.pause();
401
729
  wave?.dispose();
402
730
  clearInterval(timer);
@@ -418,16 +746,24 @@ onBeforeUnmount(() => {
418
746
  <header class="cw-top">
419
747
  <div class="cw-brand">
420
748
  <slot name="header" :model="model">
749
+ <img
750
+ v-if="ui.brand.logoUrl"
751
+ :src="ui.brand.logoUrl"
752
+ :alt="ui.brand.logoAlt ?? ''"
753
+ width="40"
754
+ height="40"
755
+ />
421
756
  <a
422
757
  :href="model.rootHref"
423
758
  @click="navigateLink($event, model.rootHref, model.rootDataHref)"
424
- >{{ model.title }}</a
759
+ >{{ ui.brand.name ?? model.title }}</a
425
760
  ></slot
426
761
  >
427
762
  </div>
428
- <nav class="cw-location" aria-label="Breadcrumb">
429
- <a :href="model.rootHref" @click="navigateLink($event, model.rootHref, model.rootDataHref)"
430
- >Home</a
763
+ <nav class="cw-location" :aria-label="ui.labels.breadcrumb">
764
+ <a :href="model.rootHref" @click="navigateLink($event, model.rootHref, model.rootDataHref)">{{
765
+ ui.labels.home
766
+ }}</a
431
767
  ><template v-for="crumb in model.breadcrumbs" :key="crumb.href">
432
768
  /
433
769
  <a :href="crumb.href" @click="navigateLink($event, crumb.href, crumb.dataHref)">{{
@@ -438,8 +774,8 @@ onBeforeUnmount(() => {
438
774
  <div class="cw-clock" v-if="ready">{{ clock }}</div>
439
775
  </header>
440
776
  <main class="cw-stage" data-framework="vue" :aria-busy="busy">
441
- <h1 class="cw-sr">Files in {{ model.path || "/" }}</h1>
442
- <nav class="cw-rail" aria-label="File categories" :hidden="!ready">
777
+ <h1 class="cw-sr">{{ label(ui, "filesIn", { path: model.path || "/" }) }}</h1>
778
+ <nav class="cw-rail" :aria-label="ui.labels.categories" :hidden="!ready">
443
779
  <div class="cw-categories" role="tablist">
444
780
  <button
445
781
  v-for="item in model.categories"
@@ -472,13 +808,30 @@ onBeforeUnmount(() => {
472
808
  <div class="cw-list-head">
473
809
  <div class="cw-directory">
474
810
  <h2>{{ heading }}</h2>
475
- <span data-cw-count>{{ complete ? rows.length : entries.length }} items</span>
811
+ <span data-cw-count>{{
812
+ label(ui, "items", { count: complete ? rows.length : entries.length })
813
+ }}</span>
476
814
  </div>
477
815
  <label class="cw-search"
478
- >Search<input ref="search" type="search" v-model="query" :disabled="!ready || !complete"
816
+ ><span>{{ ui.labels.search }}</span
817
+ ><input ref="search" type="search" v-model="query" :disabled="!ready || !complete"
479
818
  /></label>
819
+ <button
820
+ class="cw-archive-trigger"
821
+ ref="archiveTrigger"
822
+ v-if="model.searchQueryHref"
823
+ :disabled="!ready"
824
+ @click="archiveOpen = true"
825
+ >
826
+ {{ ui.labels.searchArchive }}
827
+ </button>
480
828
  </div>
481
- <div class="cw-list-scroll">
829
+ <div
830
+ class="cw-list-scroll"
831
+ :data-virtual="virtual"
832
+ ref="listScroll"
833
+ @scroll="scrollTop = $event.currentTarget.scrollTop"
834
+ >
482
835
  <ul class="cw-files">
483
836
  <li v-if="model.parentHref" class="cw-row">
484
837
  <a
@@ -488,12 +841,16 @@ onBeforeUnmount(() => {
488
841
  >../</a
489
842
  >
490
843
  </li>
844
+ <li v-if="start" aria-hidden="true" :style="{ height: offsets[start] + 'px' }"></li>
491
845
  <li
492
- v-for="entry in rows"
846
+ v-for="(entry, visibleIndex) in visibleRows"
493
847
  :key="entry.id"
494
- :ref="(node) => (node ? nodes.set(entry.id, node) : nodes.delete(entry.id))"
848
+ :ref="(node) => setNode(entry.id, node)"
849
+ :aria-posinset="start + visibleIndex + 1"
850
+ :aria-setsize="rows.length"
495
851
  :class="['cw-row', { 'cw-selected': active?.id === entry.id }]"
496
852
  data-cw-entry
853
+ :data-custom-columns="Boolean(model.ui?.columns)"
497
854
  :data-id="entry.id"
498
855
  >
499
856
  <a
@@ -519,13 +876,30 @@ onBeforeUnmount(() => {
519
876
  "
520
877
  ></span>
521
878
  <span class="cw-entry-label">
522
- <span class="cw-name">{{ entry.name }}{{ directoryLike(entry) ? "/" : "" }}</span>
523
- <span v-if="entry.link" class="cw-target">{{ entry.link.target }}</span>
524
- <span class="cw-row-meta"
525
- >{{ entry.link?.unavailable ? "Target unavailable" : entry.kind }} ·
526
- {{ directoryLike(entry) ? "Folder" : size(entry.size) }}</span
879
+ <template
880
+ v-for="column in ui.columns"
881
+ :key="typeof column === 'string' ? column : column.dataKey"
882
+ ><span v-if="column === 'name'" class="cw-name"
883
+ >{{ entry.name }}{{ directoryLike(entry) ? "/" : "" }}</span
884
+ >
885
+ <span v-if="entry.link && column === 'name'" class="cw-target">{{
886
+ entry.link.target
887
+ }}</span>
888
+ <span v-if="column === 'size'" class="cw-row-meta"
889
+ >{{
890
+ entry.link?.unavailable
891
+ ? ui.labels.unavailable
892
+ : ui.labels[entry.kind === "symlink" ? "link" : entry.kind]
893
+ }}
894
+ · {{ directoryLike(entry) ? ui.labels.folder : size(entry.size) }}</span
895
+ >
896
+ <span v-if="column === 'modified'" class="cw-row-date"
897
+ >{{ ui.labels.modified }}
898
+ {{ formattedDate(entry.modifiedAt, ui, ready) }}</span
899
+ ><span v-if="typeof column === 'object'" class="cw-data-value"
900
+ >{{ column.label }}: {{ publicDataValue(entry.data?.[column.dataKey]) }}</span
901
+ ></template
527
902
  >
528
- <span class="cw-row-date">Modified {{ modified(entry.modifiedAt) }}</span>
529
903
  </span></slot
530
904
  ></a
531
905
  ><span v-else class="cw-entry cw-unavailable"
@@ -533,37 +907,142 @@ onBeforeUnmount(() => {
533
907
  entry.name
534
908
  }}</slot></span
535
909
  >
910
+ <span
911
+ v-if="ui.actions.length || $slots.actions"
912
+ data-dirwell-actions
913
+ class="cw-row-actions"
914
+ >
915
+ <slot
916
+ name="actions"
917
+ :model="model"
918
+ :entry="entry"
919
+ :selected="selected === entry.id"
920
+ />
921
+ <template v-for="action in ui.actions" :key="action">
922
+ <a
923
+ v-if="
924
+ action === 'download' &&
925
+ entry.href &&
926
+ !directoryLike(entry) &&
927
+ !entry.link?.unavailable
928
+ "
929
+ :href="safeHref(entry)"
930
+ download
931
+ >{{ ui.labels.download }}</a
932
+ >
933
+ <button
934
+ v-else-if="action !== 'download'"
935
+ :disabled="!ready || (action === 'copy-link' && !entry.href)"
936
+ :aria-label="
937
+ (action === 'copy-link' ? ui.labels.copyLink : ui.labels.copyPath) +
938
+ ': ' +
939
+ entry.name
940
+ "
941
+ @click="
942
+ async () => {
943
+ try {
944
+ await copyEntryAction(action, entry);
945
+ error = ui.labels.copied;
946
+ failure = null;
947
+ } catch {
948
+ error = ui.labels.copyError;
949
+ failure = 'copy';
950
+ }
951
+ }
952
+ "
953
+ >
954
+ {{ action === "copy-link" ? ui.labels.copyLink : ui.labels.copyPath }}
955
+ </button>
956
+ </template>
957
+ </span>
536
958
  </li>
959
+ <li
960
+ v-if="end < rows.length"
961
+ aria-hidden="true"
962
+ :style="{ height: offsets[rows.length] - offsets[end] + 'px' }"
963
+ ></li>
537
964
  </ul>
538
- <p class="cw-empty" v-if="!rows.length">No matching files.</p>
965
+ <p class="cw-empty" v-if="!rows.length && !busy">
966
+ {{ model.total === 0 ? ui.labels.empty : ui.labels.noMatches }}
967
+ </p>
539
968
  </div>
540
969
  </section>
541
970
  <aside class="cw-detail" v-if="ready && active">
542
971
  <h2>{{ active.name }}</h2>
543
- <p>{{ active.kind }} · {{ directoryLike(active) ? "Folder" : size(active.size) }}</p>
544
- <p>{{ modified(active.modifiedAt) }}</p>
972
+ <p v-if="ui.columns.includes('size')">
973
+ {{ ui.labels[active.kind === "symlink" ? "link" : active.kind] }} ·
974
+ {{ directoryLike(active) ? ui.labels.folder : size(active.size) }}
975
+ </p>
976
+ <p v-if="ui.columns.includes('modified')">
977
+ {{ formattedDate(active.modifiedAt, ui, ready) }}
978
+ </p>
545
979
  <p v-if="active.link">{{ active.link.target }}</p>
546
980
  </aside>
547
- <p role="status" aria-live="polite">{{ busy ? "Loading…" : error }}</p>
548
- <nav v-if="model.page?.count > 1 && !complete" aria-label="Pagination">
549
- <a v-if="model.page.previousHref" :href="model.page.previousHref" rel="prev">Previous</a> Page
550
- {{ model.page.number }} of {{ model.page.count }}
551
- <a v-if="model.page.nextHref" :href="model.page.nextHref" rel="next">Next</a>
981
+ <p role="status" aria-live="polite">{{ busy ? ui.labels.loading : error }}</p>
982
+ <button v-if="failure && failure !== 'copy'" :disabled="busy" @click="retry">
983
+ {{ ui.labels.retry }}
984
+ </button>
985
+ <a v-if="failure === 'folder' && retryRequest" :href="retryRequest[1]">{{
986
+ ui.labels.openNative
987
+ }}</a>
988
+ <nav v-if="model.page?.count > 1 && !complete" :aria-label="ui.labels.pagination">
989
+ <a v-if="model.page.previousHref" :href="model.page.previousHref" rel="prev">{{
990
+ ui.labels.previous
991
+ }}</a>
992
+ {{ label(ui, "page", { page: model.page.number, pages: model.page.count }) }}
993
+ <a v-if="model.page.nextHref" :href="model.page.nextHref" rel="next">{{ ui.labels.next }}</a>
552
994
  </nav>
553
995
  </main>
554
- <footer class="cw-bottom">
555
- <div class="cw-key-help">
996
+ <footer class="cw-bottom" ref="footerElement">
997
+ <div :class="ui.brand.footerText || $slots.footer ? 'cw-custom-footer' : 'cw-key-help'">
556
998
  <slot name="footer" :model="model"
557
- >Arrow keys navigate · Enter confirms · Backspace returns</slot
999
+ ><template v-if="ui.brand.footerText"
1000
+ ><a v-if="ui.brand.footerUrl" :href="ui.brand.footerUrl">{{ ui.brand.footerText }}</a
1001
+ ><span v-else>{{ ui.brand.footerText }}</span></template
1002
+ ><template v-else>{{ ui.labels.crosswaveKeyboardHelp }}</template></slot
558
1003
  >
559
1004
  </div>
560
- <div class="cw-controller" v-if="pad">Controller · Confirm / Back</div>
1005
+ <div class="cw-controller" v-if="pad">{{ ui.labels.controller }}</div>
561
1006
  <label class="cw-appearance"
562
- >Color<select v-model="color" :disabled="!ready">
563
- <option v-for="name in Object.keys(colors)" :key="name">{{ name }}</option>
1007
+ >{{ ui.labels.color
1008
+ }}<select v-model="color" :disabled="!ready">
1009
+ <option v-for="name in Object.keys(colors)" :key="name" :value="name">
1010
+ {{ ui.labels[name] }}
1011
+ </option>
564
1012
  </select></label
565
1013
  ><button class="cw-motion" :disabled="!ready" @click="paused = !paused">
566
- {{ paused ? "Resume motion" : "Pause motion" }}
1014
+ {{ paused ? ui.labels.resumeMotion : ui.labels.pauseMotion }}
567
1015
  </button>
568
1016
  </footer>
1017
+ <dialog
1018
+ v-if="archiveOpen"
1019
+ ref="archiveDialog"
1020
+ class="cw-archive"
1021
+ :aria-label="ui.labels.searchArchive"
1022
+ @close="archiveOpen = false"
1023
+ @keydown.esc.prevent.stop="archiveOpen = false"
1024
+ >
1025
+ <h2>{{ ui.labels.searchArchive }}</h2>
1026
+ <input
1027
+ ref="archiveInput"
1028
+ type="search"
1029
+ v-model="archiveQuery"
1030
+ :aria-label="ui.labels.archiveSearchInput"
1031
+ />
1032
+ <p role="status">{{ archiveStatus }}</p>
1033
+ <button v-if="archiveFailed" @click="searchArchive">{{ ui.labels.retry }}</button>
1034
+ <ul>
1035
+ <li v-for="record in archiveRows" :key="record.path">
1036
+ <a
1037
+ v-if="record.href"
1038
+ :href="record.href"
1039
+ :target="record.exitsExplorer ? '_blank' : undefined"
1040
+ :rel="record.exitsExplorer ? 'noopener' : undefined"
1041
+ >{{ record.path }}</a
1042
+ >
1043
+ <span v-else>{{ record.path }}</span>
1044
+ </li>
1045
+ </ul>
1046
+ <button @click="archiveOpen = false">{{ ui.labels.close }}</button>
1047
+ </dialog>
569
1048
  </template>