@dirwell/theme-crosswave 0.0.1-alpha.1 → 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.
@@ -16,18 +16,28 @@ import {
16
16
  applyThemeMetadata,
17
17
  restoreThemePage,
18
18
  } from "./shared/navigation.mjs";
19
+ import { readView, createViewHistory } from "./shared/view-state.mjs";
19
20
  import { entryIcon } from "./shared/icons.mjs";
20
- import { directoryLike, size, modified } from "./shared/records.mjs";
21
+ import { directoryLike, size } from "./shared/records.mjs";
22
+ import {
23
+ resolveCustomization,
24
+ label,
25
+ formattedDate,
26
+ copyEntryAction,
27
+ publicDataValue,
28
+ } from "./shared/customization.mjs";
21
29
  import { createWave } from "./wave.mjs";
22
30
  const props = defineProps({ model: { type: Object, required: true } }),
23
31
  model = shallowRef(restoreThemePage(props.model, Boolean(import.meta.hot))),
24
- entries = shallowRef(model.value.entries);
32
+ entries = shallowRef(model.value.entries),
33
+ ui = computed(() => resolveCustomization(model.value.ui));
25
34
  const query = ref(""),
26
- category = ref("all"),
35
+ category = ref(model.value.categories[0]?.id ?? null),
27
36
  selected = ref(null),
28
37
  ready = ref(false),
29
38
  busy = ref(false),
30
39
  error = ref(""),
40
+ failure = ref(null),
31
41
  color = ref(props.model.color),
32
42
  paused = ref(!props.model.backgroundMotion),
33
43
  clock = ref(""),
@@ -35,14 +45,20 @@ const query = ref(""),
35
45
  waveReady = ref(false),
36
46
  pendingUpdates = ref(null),
37
47
  canvas = ref(),
38
- search = ref();
48
+ search = ref(),
49
+ listScroll = ref(),
50
+ footerElement = ref(),
51
+ scrollTop = ref(0),
52
+ viewportHeight = ref(400),
53
+ retryRequest = shallowRef(null),
54
+ heights = shallowRef(new Map());
39
55
  const complete = computed(() => entries.value.length === model.value.total),
40
56
  membership = (entry) =>
41
57
  Array.isArray(entry.presentation?.categories) ? entry.presentation.categories : [];
42
58
  const rows = computed(() =>
43
59
  entries.value.filter(
44
60
  (entry) =>
45
- (category.value === "all" || membership(entry).includes(category.value)) &&
61
+ membership(entry).includes(category.value) &&
46
62
  `${entry.name} ${entry.link?.target ?? ""}`
47
63
  .toLocaleLowerCase()
48
64
  .includes(query.value.trim().toLocaleLowerCase()),
@@ -50,9 +66,116 @@ const rows = computed(() =>
50
66
  ),
51
67
  active = computed(() => rows.value.find((entry) => entry.id === selected.value) ?? rows.value[0]),
52
68
  heading = computed(
53
- () => model.value.categories.find((item) => item.id === category.value)?.label ?? "All files",
69
+ () =>
70
+ model.value.categories.find((item) => item.id === category.value)?.label ??
71
+ ui.value.labels.allFiles,
54
72
  );
55
73
  const nodes = new Map();
74
+ const virtual = computed(() => ready.value && rows.value.length > 500);
75
+ const offsets = computed(() => {
76
+ const result = [0];
77
+ for (const entry of rows.value) result.push(result.at(-1) + (heights.value.get(entry.id) ?? 76));
78
+ return result;
79
+ });
80
+ const start = computed(() => {
81
+ if (!virtual.value) return 0;
82
+ const position = Math.max(0, scrollTop.value - (model.value.parentHref ? 76 : 0));
83
+ let low = 0,
84
+ high = rows.value.length;
85
+ while (low < high) {
86
+ const middle = (low + high) >> 1;
87
+ if (offsets.value[middle + 1] <= position) low = middle + 1;
88
+ else high = middle;
89
+ }
90
+ return Math.max(0, low - 5);
91
+ });
92
+ const end = computed(() => {
93
+ if (!virtual.value) return rows.value.length;
94
+ let low = 0,
95
+ high = rows.value.length;
96
+ while (low < high) {
97
+ const middle = (low + high) >> 1;
98
+ if (offsets.value[middle + 1] <= scrollTop.value + viewportHeight.value) low = middle + 1;
99
+ else high = middle;
100
+ }
101
+ return Math.min(rows.value.length, Math.max(start.value + 35, low + 5));
102
+ });
103
+ const visibleRows = computed(() => rows.value.slice(start.value, end.value));
104
+ const heightCache = new Map();
105
+ let rowObserver, layoutObserver, viewHistory, measuredWidth;
106
+ function measureLayout() {
107
+ if (!listScroll.value) return;
108
+ const width = listScroll.value.clientWidth;
109
+ if (measuredWidth !== undefined && width !== measuredWidth) {
110
+ heightCache.clear();
111
+ heights.value = new Map(
112
+ [...nodes].map(([id, node]) => [id, node.getBoundingClientRect().height]),
113
+ );
114
+ }
115
+ measuredWidth = width;
116
+ const footerHeight = footerElement.value?.getBoundingClientRect().height ?? 82;
117
+ document.documentElement.style.setProperty("--cw-footer-height", footerHeight + "px");
118
+ const available = Math.max(
119
+ 160,
120
+ innerHeight - listScroll.value.getBoundingClientRect().top - footerHeight - 24,
121
+ );
122
+ viewportHeight.value = available;
123
+ listScroll.value.style.setProperty("--cw-list-height", available + "px");
124
+ }
125
+ watch([virtual, () => model.value.path], () => nextTick(measureLayout));
126
+ const viewDefaults = (page) => ({ query: "", category: page.categories[0]?.id ?? null });
127
+ function saveView() {
128
+ if (!viewHistory) return;
129
+ viewHistory.save(
130
+ new URL(model.value.dataHref, document.baseURI).href,
131
+ model.value.path,
132
+ {
133
+ query: query.value,
134
+ category: category.value,
135
+ selected: selected.value,
136
+ windowY: window.scrollY,
137
+ listTop: listScroll.value?.scrollTop ?? 0,
138
+ },
139
+ viewDefaults(model.value),
140
+ );
141
+ }
142
+ function retry() {
143
+ if (failure.value === "folder" && retryRequest.value) openFolder(...retryRequest.value, true);
144
+ else openFolder(model.value.dataHref, location.href, false, true);
145
+ }
146
+ watchEffect((onCleanup) => {
147
+ if (!ready.value || busy.value) return;
148
+ const view = {
149
+ query: query.value,
150
+ category: category.value,
151
+ selected: selected.value,
152
+ listTop: scrollTop.value,
153
+ };
154
+ const dataHref = model.value.dataHref,
155
+ path = model.value.path,
156
+ defaults = viewDefaults(model.value);
157
+ const frame = requestAnimationFrame(() =>
158
+ viewHistory?.save(
159
+ new URL(dataHref, document.baseURI).href,
160
+ path,
161
+ { ...view, windowY: window.scrollY },
162
+ defaults,
163
+ ),
164
+ );
165
+ onCleanup(() => cancelAnimationFrame(frame));
166
+ });
167
+ function setNode(id, node) {
168
+ const old = nodes.get(id);
169
+ if (old && old !== node) rowObserver?.unobserve(old);
170
+ if (node) {
171
+ nodes.set(id, node);
172
+ rowObserver?.observe(node);
173
+ } else nodes.delete(id);
174
+ }
175
+ watch([query, category], () => {
176
+ scrollTop.value = 0;
177
+ if (listScroll.value) listScroll.value.scrollTop = 0;
178
+ });
56
179
  let wave,
57
180
  gamepad,
58
181
  abort,
@@ -84,45 +207,95 @@ async function loadComplete() {
84
207
  });
85
208
  if (token === navigation && !local.signal.aborted && !disposed) entries.value = data.entries;
86
209
  } catch (cause) {
87
- if (token === navigation && !local.signal.aborted)
88
- error.value = "Could not load complete directory data. Native page links remain available.";
210
+ if (token === navigation && !local.signal.aborted) {
211
+ error.value = ui.value.labels.listingError;
212
+ failure.value = "listing";
213
+ }
89
214
  } finally {
90
215
  if (token === navigation) busy.value = false;
91
216
  }
92
217
  }
93
- async function openFolder(dataHref, url, push, refresh = false) {
218
+ async function openFolder(
219
+ dataHref,
220
+ url,
221
+ push,
222
+ refresh = false,
223
+ restoreState = null,
224
+ fresh = false,
225
+ ) {
226
+ if (push) saveView();
94
227
  const token = ++navigation;
95
228
  abort?.abort();
96
229
  const local = (abort = new AbortController());
97
230
  busy.value = true;
98
231
  error.value = "";
232
+ failure.value = null;
99
233
  try {
100
- const next = await fetchThemePage(dataHref, url, "crosswave", local.signal, refresh);
234
+ const next = await fetchThemePage(dataHref, url, "crosswave", local.signal, refresh || fresh);
101
235
  let nextEntries = next.entries;
102
236
  if (refresh && next.manifestHref && nextEntries.length !== next.total)
103
237
  nextEntries = (
104
238
  await loadListing(next.manifestHref, { signal: local.signal, cache: "no-store" })
105
239
  ).entries;
106
240
  if (token !== navigation || local.signal.aborted || disposed) return;
241
+ if (!refresh) viewHistory.commit(next.dataHref, next.path, url, push);
242
+ if (next.path !== model.value.path) {
243
+ heightCache.set(model.value.path, heights.value);
244
+ if (heightCache.size > 8) heightCache.delete(heightCache.keys().next().value);
245
+ heights.value = heightCache.get(next.path) ?? new Map();
246
+ }
107
247
  model.value = next;
248
+ retryRequest.value = null;
108
249
  entries.value = nextEntries;
109
250
  if (!refresh) {
110
- query.value = "";
111
- selected.value = null;
112
- category.value = "all";
251
+ const view = readView(
252
+ url,
253
+ "crosswave",
254
+ next.path,
255
+ viewDefaults(next),
256
+ next.categories.map((item) => item.id),
257
+ restoreState,
258
+ );
259
+ query.value = view.query;
260
+ selected.value = view.selected;
261
+ category.value = view.category;
113
262
  }
114
- if (push) history.pushState({ dataHref: next.dataHref }, "", url);
115
263
  applyThemeMetadata(next, url);
116
264
  await loadComplete();
265
+ if (!refresh) {
266
+ const view = readView(
267
+ url,
268
+ "crosswave",
269
+ next.path,
270
+ viewDefaults(next),
271
+ next.categories.map((item) => item.id),
272
+ restoreState,
273
+ );
274
+ await nextTick();
275
+ if (selected.value && !rows.value.some((entry) => entry.id === selected.value))
276
+ selected.value = null;
277
+ scrollTop.value = view.listTop;
278
+ if (listScroll.value) listScroll.value.scrollTop = view.listTop;
279
+ await nextTick();
280
+ window.scrollTo({ top: view.windowY });
281
+ if (restoreState && selected.value)
282
+ nodes.get(selected.value)?.querySelector("a")?.focus({ preventScroll: true });
283
+ }
117
284
  } catch (cause) {
118
- if (token === navigation && !local.signal.aborted)
119
- error.value = "Could not open the folder. Its native link remains available.";
285
+ if (token === navigation && !local.signal.aborted) {
286
+ error.value = ui.value.labels.folderError;
287
+ failure.value = "folder";
288
+ retryRequest.value = [dataHref, url, push, refresh, restoreState];
289
+ viewHistory?.rollback();
290
+ }
120
291
  } finally {
121
292
  if (token === navigation) busy.value = false;
122
293
  }
123
294
  }
124
295
  function navigateLink(event, href, dataHref) {
125
296
  if (
297
+ typeof href !== "string" ||
298
+ typeof dataHref !== "string" ||
126
299
  event?.altKey ||
127
300
  event?.ctrlKey ||
128
301
  event?.metaKey ||
@@ -135,6 +308,7 @@ function navigateLink(event, href, dataHref) {
135
308
  openFolder(dataHref, new URL(href, document.baseURI).href, true);
136
309
  }
137
310
  async function openEntry(event, entry) {
311
+ saveView();
138
312
  const href = entry.presentation?.folderDataHref;
139
313
  if (
140
314
  entry.exitsExplorer ||
@@ -151,28 +325,41 @@ async function openEntry(event, entry) {
151
325
  await openFolder(href, new URL(entry.href, document.baseURI).href, true);
152
326
  }
153
327
  async function move(index) {
154
- const entry = rows.value[Math.max(0, Math.min(rows.value.length - 1, index))];
328
+ const target = Math.max(0, Math.min(rows.value.length - 1, index));
329
+ const entry = rows.value[target];
155
330
  if (!entry) return;
156
331
  selected.value = entry.id;
332
+ if (virtual.value) {
333
+ scrollTop.value = Math.max(0, offsets.value[target] - 100 + (model.value.parentHref ? 76 : 0));
334
+ if (listScroll.value) listScroll.value.scrollTop = scrollTop.value;
335
+ }
157
336
  await nextTick();
158
337
  const node = nodes.get(entry.id);
159
338
  node?.querySelector("a")?.focus({ preventScroll: true });
160
339
  node?.scrollIntoView({ block: "nearest" });
161
340
  }
162
- function stepCategory(delta) {
341
+ async function stepCategory(delta) {
342
+ if (ui.value.direction === "rtl") delta = -delta;
343
+ const moveFocus = document.activeElement?.getAttribute("role") === "tab";
163
344
  const items = model.value.categories,
164
345
  index = items.findIndex((item) => item.id === category.value);
165
346
  category.value = items[(index + delta + items.length) % items.length].id;
166
347
  selected.value = null;
348
+ if (moveFocus) {
349
+ await nextTick();
350
+ document.getElementById("cw-tab-" + category.value)?.focus({ preventScroll: true });
351
+ }
167
352
  }
168
- function confirm() {
353
+ async function confirm() {
169
354
  if (!active.value || busy.value) return;
355
+ await move(rows.value.findIndex((entry) => entry.id === active.value.id));
170
356
  const anchor = nodes.get(active.value.id)?.querySelector("a");
171
357
  anchor?.click();
172
358
  }
173
359
  function parent() {
174
360
  if (model.value.parentHref) {
175
- if (location.protocol === "file:") location.href = model.value.parentHref;
361
+ if (location.protocol === "file:" || typeof model.value.parentDataHref !== "string")
362
+ location.href = model.value.parentHref;
176
363
  else
177
364
  openFolder(
178
365
  model.value.parentDataHref,
@@ -183,6 +370,7 @@ function parent() {
183
370
  }
184
371
  function keyboard(event) {
185
372
  if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey) return;
373
+ if (event.target?.closest?.("[data-dirwell-actions]")) return;
186
374
  const editing = /INPUT|TEXTAREA|SELECT/.test(event.target?.tagName ?? "");
187
375
  if (editing) {
188
376
  if (event.key === "Escape") query.value = "";
@@ -276,7 +464,7 @@ const colors = {
276
464
  };
277
465
  const onPop = (event) =>
278
466
  event.state?.dataHref
279
- ? openFolder(event.state.dataHref, location.href, false)
467
+ ? openFolder(event.state.dataHref, location.href, false, false, event.state)
280
468
  : location.reload();
281
469
  const suspend = () => {
282
470
  gamepad?.pause();
@@ -298,7 +486,43 @@ onMounted(() => {
298
486
  ready.value = true;
299
487
  document.body.setAttribute("data-cw-enhanced", "");
300
488
  abort = new AbortController();
301
- history.replaceState({ dataHref: new URL(model.value.dataHref, document.baseURI).href }, "");
489
+ if (typeof ResizeObserver !== "undefined") {
490
+ rowObserver = new ResizeObserver((observations) => {
491
+ const next = new Map(heights.value);
492
+ let changed = false;
493
+ for (const observation of observations) {
494
+ const id = observation.target.dataset.id;
495
+ const height = observation.target.getBoundingClientRect().height;
496
+ if (height > 0 && next.get(id) !== height) {
497
+ next.set(id, height);
498
+ changed = true;
499
+ }
500
+ }
501
+ if (changed) heights.value = next;
502
+ });
503
+ for (const node of nodes.values()) rowObserver.observe(node);
504
+ layoutObserver = new ResizeObserver(measureLayout);
505
+ if (footerElement.value) layoutObserver.observe(footerElement.value);
506
+ const header = document.querySelector("[data-dirwell-theme='crosswave'] .cw-top");
507
+ if (header) layoutObserver.observe(header);
508
+ }
509
+ measureLayout();
510
+ window.addEventListener("resize", measureLayout);
511
+ viewHistory = createViewHistory("crosswave");
512
+ const view = readView(
513
+ location.href,
514
+ "crosswave",
515
+ model.value.path,
516
+ viewDefaults(model.value),
517
+ model.value.categories.map((item) => item.id),
518
+ history.state,
519
+ );
520
+ query.value = view.query;
521
+ category.value = view.category;
522
+ selected.value = view.selected;
523
+ scrollTop.value = view.listTop;
524
+ if (listScroll.value) listScroll.value.scrollTop = view.listTop;
525
+ saveView();
302
526
  try {
303
527
  const stored = localStorage.getItem("dirwell-crosswave-color");
304
528
  if (Object.hasOwn(colors, stored)) color.value = stored;
@@ -347,7 +571,11 @@ onMounted(() => {
347
571
  );
348
572
  }
349
573
  const updateClock = () =>
350
- (clock.value = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
574
+ (clock.value = new Date().toLocaleTimeString(ui.value.dateOptions.locale, {
575
+ timeZone: ui.value.dateOptions.timeZone,
576
+ hour: "2-digit",
577
+ minute: "2-digit",
578
+ }));
351
579
  updateClock();
352
580
  timer = setInterval(updateClock, 1000);
353
581
  window.addEventListener("keydown", keyboard);
@@ -368,9 +596,15 @@ watch(
368
596
  if (!href || typeof EventSource === "undefined") return;
369
597
  const updates = new EventSource(href);
370
598
  updates.onmessage = () => (pendingUpdates.value = href);
371
- updates.onerror = () => (error.value = "Live updates disconnected. Refresh to retry.");
599
+ updates.onerror = () => {
600
+ error.value = ui.value.labels.updatesError;
601
+ failure.value = "updates";
602
+ };
372
603
  updates.onopen = () => {
373
- if (error.value.startsWith("Live updates")) error.value = "";
604
+ if (failure.value === "updates") {
605
+ error.value = "";
606
+ failure.value = null;
607
+ }
374
608
  };
375
609
  onCleanup(() => updates.close());
376
610
  },
@@ -397,6 +631,9 @@ onBeforeUnmount(() => {
397
631
  disposed = true;
398
632
  document.body.removeAttribute("data-cw-enhanced");
399
633
  abort?.abort();
634
+ rowObserver?.disconnect();
635
+ layoutObserver?.disconnect();
636
+ window.removeEventListener("resize", measureLayout);
400
637
  gamepad?.pause();
401
638
  wave?.dispose();
402
639
  clearInterval(timer);
@@ -418,16 +655,24 @@ onBeforeUnmount(() => {
418
655
  <header class="cw-top">
419
656
  <div class="cw-brand">
420
657
  <slot name="header" :model="model">
658
+ <img
659
+ v-if="ui.brand.logoUrl"
660
+ :src="ui.brand.logoUrl"
661
+ :alt="ui.brand.logoAlt ?? ''"
662
+ width="40"
663
+ height="40"
664
+ />
421
665
  <a
422
666
  :href="model.rootHref"
423
667
  @click="navigateLink($event, model.rootHref, model.rootDataHref)"
424
- >{{ model.title }}</a
668
+ >{{ ui.brand.name ?? model.title }}</a
425
669
  ></slot
426
670
  >
427
671
  </div>
428
- <nav class="cw-location" aria-label="Breadcrumb">
429
- <a :href="model.rootHref" @click="navigateLink($event, model.rootHref, model.rootDataHref)"
430
- >Home</a
672
+ <nav class="cw-location" :aria-label="ui.labels.breadcrumb">
673
+ <a :href="model.rootHref" @click="navigateLink($event, model.rootHref, model.rootDataHref)">{{
674
+ ui.labels.home
675
+ }}</a
431
676
  ><template v-for="crumb in model.breadcrumbs" :key="crumb.href">
432
677
  /
433
678
  <a :href="crumb.href" @click="navigateLink($event, crumb.href, crumb.dataHref)">{{
@@ -438,8 +683,8 @@ onBeforeUnmount(() => {
438
683
  <div class="cw-clock" v-if="ready">{{ clock }}</div>
439
684
  </header>
440
685
  <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">
686
+ <h1 class="cw-sr">{{ label(ui, "filesIn", { path: model.path || "/" }) }}</h1>
687
+ <nav class="cw-rail" :aria-label="ui.labels.categories" :hidden="!ready">
443
688
  <div class="cw-categories" role="tablist">
444
689
  <button
445
690
  v-for="item in model.categories"
@@ -472,13 +717,21 @@ onBeforeUnmount(() => {
472
717
  <div class="cw-list-head">
473
718
  <div class="cw-directory">
474
719
  <h2>{{ heading }}</h2>
475
- <span data-cw-count>{{ complete ? rows.length : entries.length }} items</span>
720
+ <span data-cw-count>{{
721
+ label(ui, "items", { count: complete ? rows.length : entries.length })
722
+ }}</span>
476
723
  </div>
477
724
  <label class="cw-search"
478
- >Search<input ref="search" type="search" v-model="query" :disabled="!ready || !complete"
725
+ ><span>{{ ui.labels.search }}</span
726
+ ><input ref="search" type="search" v-model="query" :disabled="!ready || !complete"
479
727
  /></label>
480
728
  </div>
481
- <div class="cw-list-scroll">
729
+ <div
730
+ class="cw-list-scroll"
731
+ :data-virtual="virtual"
732
+ ref="listScroll"
733
+ @scroll="scrollTop = $event.currentTarget.scrollTop"
734
+ >
482
735
  <ul class="cw-files">
483
736
  <li v-if="model.parentHref" class="cw-row">
484
737
  <a
@@ -488,12 +741,16 @@ onBeforeUnmount(() => {
488
741
  >../</a
489
742
  >
490
743
  </li>
744
+ <li v-if="start" aria-hidden="true" :style="{ height: offsets[start] + 'px' }"></li>
491
745
  <li
492
- v-for="entry in rows"
746
+ v-for="(entry, visibleIndex) in visibleRows"
493
747
  :key="entry.id"
494
- :ref="(node) => (node ? nodes.set(entry.id, node) : nodes.delete(entry.id))"
748
+ :ref="(node) => setNode(entry.id, node)"
749
+ :aria-posinset="start + visibleIndex + 1"
750
+ :aria-setsize="rows.length"
495
751
  :class="['cw-row', { 'cw-selected': active?.id === entry.id }]"
496
752
  data-cw-entry
753
+ :data-custom-columns="Boolean(model.ui?.columns)"
497
754
  :data-id="entry.id"
498
755
  >
499
756
  <a
@@ -519,13 +776,30 @@ onBeforeUnmount(() => {
519
776
  "
520
777
  ></span>
521
778
  <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
779
+ <template
780
+ v-for="column in ui.columns"
781
+ :key="typeof column === 'string' ? column : column.dataKey"
782
+ ><span v-if="column === 'name'" class="cw-name"
783
+ >{{ entry.name }}{{ directoryLike(entry) ? "/" : "" }}</span
784
+ >
785
+ <span v-if="entry.link && column === 'name'" class="cw-target">{{
786
+ entry.link.target
787
+ }}</span>
788
+ <span v-if="column === 'size'" class="cw-row-meta"
789
+ >{{
790
+ entry.link?.unavailable
791
+ ? ui.labels.unavailable
792
+ : ui.labels[entry.kind === "symlink" ? "link" : entry.kind]
793
+ }}
794
+ · {{ directoryLike(entry) ? ui.labels.folder : size(entry.size) }}</span
795
+ >
796
+ <span v-if="column === 'modified'" class="cw-row-date"
797
+ >{{ ui.labels.modified }}
798
+ {{ formattedDate(entry.modifiedAt, ui, ready) }}</span
799
+ ><span v-if="typeof column === 'object'" class="cw-data-value"
800
+ >{{ column.label }}: {{ publicDataValue(entry.data?.[column.dataKey]) }}</span
801
+ ></template
527
802
  >
528
- <span class="cw-row-date">Modified {{ modified(entry.modifiedAt) }}</span>
529
803
  </span></slot
530
804
  ></a
531
805
  ><span v-else class="cw-entry cw-unavailable"
@@ -533,37 +807,111 @@ onBeforeUnmount(() => {
533
807
  entry.name
534
808
  }}</slot></span
535
809
  >
810
+ <span
811
+ v-if="ui.actions.length || $slots.actions"
812
+ data-dirwell-actions
813
+ class="cw-row-actions"
814
+ >
815
+ <slot
816
+ name="actions"
817
+ :model="model"
818
+ :entry="entry"
819
+ :selected="selected === entry.id"
820
+ />
821
+ <template v-for="action in ui.actions" :key="action">
822
+ <a
823
+ v-if="
824
+ action === 'download' &&
825
+ entry.href &&
826
+ !directoryLike(entry) &&
827
+ !entry.link?.unavailable
828
+ "
829
+ :href="safeHref(entry)"
830
+ download
831
+ >{{ ui.labels.download }}</a
832
+ >
833
+ <button
834
+ v-else-if="action !== 'download'"
835
+ :disabled="!ready || (action === 'copy-link' && !entry.href)"
836
+ :aria-label="
837
+ (action === 'copy-link' ? ui.labels.copyLink : ui.labels.copyPath) +
838
+ ': ' +
839
+ entry.name
840
+ "
841
+ @click="
842
+ async () => {
843
+ try {
844
+ await copyEntryAction(action, entry);
845
+ error = ui.labels.copied;
846
+ failure = null;
847
+ } catch {
848
+ error = ui.labels.copyError;
849
+ failure = 'copy';
850
+ }
851
+ }
852
+ "
853
+ >
854
+ {{ action === "copy-link" ? ui.labels.copyLink : ui.labels.copyPath }}
855
+ </button>
856
+ </template>
857
+ </span>
536
858
  </li>
859
+ <li
860
+ v-if="end < rows.length"
861
+ aria-hidden="true"
862
+ :style="{ height: offsets[rows.length] - offsets[end] + 'px' }"
863
+ ></li>
537
864
  </ul>
538
- <p class="cw-empty" v-if="!rows.length">No matching files.</p>
865
+ <p class="cw-empty" v-if="!rows.length && !busy">
866
+ {{ model.total === 0 ? ui.labels.empty : ui.labels.noMatches }}
867
+ </p>
539
868
  </div>
540
869
  </section>
541
870
  <aside class="cw-detail" v-if="ready && active">
542
871
  <h2>{{ active.name }}</h2>
543
- <p>{{ active.kind }} · {{ directoryLike(active) ? "Folder" : size(active.size) }}</p>
544
- <p>{{ modified(active.modifiedAt) }}</p>
872
+ <p v-if="ui.columns.includes('size')">
873
+ {{ ui.labels[active.kind === "symlink" ? "link" : active.kind] }} ·
874
+ {{ directoryLike(active) ? ui.labels.folder : size(active.size) }}
875
+ </p>
876
+ <p v-if="ui.columns.includes('modified')">
877
+ {{ formattedDate(active.modifiedAt, ui, ready) }}
878
+ </p>
545
879
  <p v-if="active.link">{{ active.link.target }}</p>
546
880
  </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>
881
+ <p role="status" aria-live="polite">{{ busy ? ui.labels.loading : error }}</p>
882
+ <button v-if="failure && failure !== 'copy'" :disabled="busy" @click="retry">
883
+ {{ ui.labels.retry }}
884
+ </button>
885
+ <a v-if="failure === 'folder' && retryRequest" :href="retryRequest[1]">{{
886
+ ui.labels.openNative
887
+ }}</a>
888
+ <nav v-if="model.page?.count > 1 && !complete" :aria-label="ui.labels.pagination">
889
+ <a v-if="model.page.previousHref" :href="model.page.previousHref" rel="prev">{{
890
+ ui.labels.previous
891
+ }}</a>
892
+ {{ label(ui, "page", { page: model.page.number, pages: model.page.count }) }}
893
+ <a v-if="model.page.nextHref" :href="model.page.nextHref" rel="next">{{ ui.labels.next }}</a>
552
894
  </nav>
553
895
  </main>
554
- <footer class="cw-bottom">
555
- <div class="cw-key-help">
896
+ <footer class="cw-bottom" ref="footerElement">
897
+ <div :class="ui.brand.footerText || $slots.footer ? 'cw-custom-footer' : 'cw-key-help'">
556
898
  <slot name="footer" :model="model"
557
- >Arrow keys navigate · Enter confirms · Backspace returns</slot
899
+ ><template v-if="ui.brand.footerText"
900
+ ><a v-if="ui.brand.footerUrl" :href="ui.brand.footerUrl">{{ ui.brand.footerText }}</a
901
+ ><span v-else>{{ ui.brand.footerText }}</span></template
902
+ ><template v-else>{{ ui.labels.crosswaveKeyboardHelp }}</template></slot
558
903
  >
559
904
  </div>
560
- <div class="cw-controller" v-if="pad">Controller · Confirm / Back</div>
905
+ <div class="cw-controller" v-if="pad">{{ ui.labels.controller }}</div>
561
906
  <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>
907
+ >{{ ui.labels.color
908
+ }}<select v-model="color" :disabled="!ready">
909
+ <option v-for="name in Object.keys(colors)" :key="name" :value="name">
910
+ {{ ui.labels[name] }}
911
+ </option>
564
912
  </select></label
565
913
  ><button class="cw-motion" :disabled="!ready" @click="paused = !paused">
566
- {{ paused ? "Resume motion" : "Pause motion" }}
914
+ {{ paused ? ui.labels.resumeMotion : ui.labels.pauseMotion }}
567
915
  </button>
568
916
  </footer>
569
917
  </template>