@dirwell/theme-ledger 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.
@@ -1,5 +1,9 @@
1
1
  <script>
2
2
  import { onMount, tick, untrack } from "svelte";
3
+ import {
4
+ readView,
5
+ createViewHistory,
6
+ } from "./shared/view-state.mjs";
3
7
  import { entryIcon } from "./shared/icons.mjs";
4
8
  import { loadListing } from "@dirwell/core/client";
5
9
  import {
@@ -12,10 +16,17 @@
12
16
  directoryLike,
13
17
  entryType,
14
18
  size,
15
- modified,
16
19
  fuzzy,
17
20
  } from "./shared/records.mjs";
18
- let { model: initialModel, header, row, footer } = $props();
21
+ import {
22
+ resolveCustomization,
23
+ label,
24
+ formattedDate,
25
+ copyEntryAction,
26
+ columnLabel,
27
+ publicDataValue,
28
+ } from "./shared/customization.mjs";
29
+ let { model: initialModel, header, row, actions, footer } = $props();
19
30
  const snapshot = untrack(() =>
20
31
  restoreThemePage(initialModel, Boolean(import.meta.hot)),
21
32
  );
@@ -29,14 +40,26 @@
29
40
  ready = $state(false),
30
41
  busy = $state(false),
31
42
  error = $state(""),
43
+ failure = $state(null),
32
44
  timestamp = $state(null),
33
45
  composing = $state(false),
34
46
  scrollTop = $state(0),
47
+ viewportHeight = $state(800),
35
48
  globalOpen = $state(false),
36
49
  globalQuery = $state(""),
37
50
  globalRows = $state.raw([]),
38
- globalStatus = $state("Enter a search to begin."),
51
+ globalStatus = $state(
52
+ resolveCustomization(snapshot.ui).labels.beginArchive,
53
+ ),
39
54
  pendingUpdates = $state(null);
55
+ let ui = $derived(resolveCustomization(model.ui));
56
+ let archiveFailed = $state(false);
57
+ let countParts = $derived(
58
+ label(ui, complete ? "entries" : "pageEntries", {
59
+ count: "__COUNT__",
60
+ total: model.total,
61
+ }).split("__COUNT__"),
62
+ );
40
63
  let heights = $state.raw(new Map());
41
64
  let updatesHref = $derived(
42
65
  ready && model.updatesHref
@@ -65,7 +88,17 @@
65
88
  }
66
89
  return Math.max(0, low - 5);
67
90
  }),
68
- end = $derived(virtual ? Math.min(rows.length, start + 35) : rows.length);
91
+ end = $derived.by(() => {
92
+ if (!virtual) return rows.length;
93
+ let low = 0,
94
+ high = rows.length;
95
+ while (low < high) {
96
+ const middle = (low + high) >> 1;
97
+ if (offsets[middle + 1] <= scrollTop + viewportHeight) low = middle + 1;
98
+ else high = middle;
99
+ }
100
+ return Math.min(rows.length, Math.max(start + 35, low + 5));
101
+ });
69
102
  function measure(node, id) {
70
103
  if (typeof ResizeObserver === "undefined") return;
71
104
  const observer = new ResizeObserver(() => {
@@ -87,7 +120,53 @@
87
120
  let navigation = 0,
88
121
  abort,
89
122
  archiveAbort,
90
- archiveRequest = 0;
123
+ archiveRequest = 0,
124
+ archiveCache = null,
125
+ archiveCacheHref = "",
126
+ archiveCacheAt = 0,
127
+ viewHistory,
128
+ retryRequest = null;
129
+ const viewDefaults = (page) => ({
130
+ query: "",
131
+ sort: page.sort,
132
+ types: ["directory", "file", "link"],
133
+ });
134
+ function saveView() {
135
+ if (!viewHistory) return;
136
+ viewHistory.save(
137
+ new URL(model.dataHref, document.baseURI).href,
138
+ model.path,
139
+ {
140
+ query,
141
+ types,
142
+ sort,
143
+ selected,
144
+ windowY: window.scrollY,
145
+ listTop: scrollTop,
146
+ },
147
+ viewDefaults(model),
148
+ );
149
+ }
150
+ function retry() {
151
+ if (failure === "folder" && retryRequest) openFolder(...retryRequest, true);
152
+ else openFolder(model.dataHref, location.href, false, true);
153
+ }
154
+ $effect(() => {
155
+ if (!ready || busy) return;
156
+ const view = { query, types, sort, selected, listTop: scrollTop };
157
+ const dataHref = model.dataHref,
158
+ path = model.path,
159
+ defaults = viewDefaults(model);
160
+ const frame = requestAnimationFrame(() =>
161
+ viewHistory?.save(
162
+ new URL(dataHref, document.baseURI).href,
163
+ path,
164
+ { ...view, windowY: window.scrollY },
165
+ defaults,
166
+ ),
167
+ );
168
+ return () => cancelAnimationFrame(frame);
169
+ });
91
170
  const isDirectory = directoryLike;
92
171
  function safeHref(record) {
93
172
  if (
@@ -117,14 +196,16 @@
117
196
  if (token === navigation && !local.signal.aborted)
118
197
  entries = loaded.entries;
119
198
  } catch (cause) {
120
- if (token === navigation && !local.signal.aborted)
121
- error =
122
- "Could not load complete directory data. Native page links remain available.";
199
+ if (token === navigation && !local.signal.aborted) {
200
+ error = ui.labels.listingError;
201
+ failure = "listing";
202
+ }
123
203
  } finally {
124
204
  if (token === navigation) busy = false;
125
205
  }
126
206
  }
127
207
  async function navigate(event, record) {
208
+ saveView();
128
209
  const dataHref = record.presentation?.folderDataHref;
129
210
  if (
130
211
  record.exitsExplorer ||
@@ -144,19 +225,28 @@
144
225
  true,
145
226
  );
146
227
  }
147
- async function openFolder(dataHref, url, push, refresh = false) {
228
+ async function openFolder(
229
+ dataHref,
230
+ url,
231
+ push,
232
+ refresh = false,
233
+ restoreState = null,
234
+ fresh = false,
235
+ ) {
236
+ if (push) saveView();
148
237
  const token = ++navigation;
149
238
  abort?.abort();
150
239
  const local = (abort = new AbortController());
151
240
  busy = true;
152
241
  error = "";
242
+ failure = null;
153
243
  try {
154
244
  const next = await fetchThemePage(
155
245
  dataHref,
156
246
  url,
157
247
  "ledger",
158
248
  local.signal,
159
- refresh,
249
+ refresh || fresh,
160
250
  );
161
251
  let nextEntries = next.entries;
162
252
  if (refresh && next.manifestHref && nextEntries.length !== next.total) {
@@ -168,27 +258,60 @@
168
258
  ).entries;
169
259
  }
170
260
  if (token !== navigation || local.signal.aborted) return;
261
+ if (!refresh) viewHistory.commit(next.dataHref, next.path, url, push);
171
262
  model = next;
172
263
  entries = nextEntries;
264
+ retryRequest = null;
173
265
  if (!refresh) {
174
- query = "";
175
- selected = null;
176
- scrollTop = 0;
266
+ const view = readView(
267
+ url,
268
+ "ledger",
269
+ next.path,
270
+ viewDefaults(next),
271
+ [],
272
+ restoreState,
273
+ );
274
+ query = view.query;
275
+ types = view.types;
276
+ sort = view.sort;
277
+ selected = view.selected;
278
+ scrollTop = view.listTop;
177
279
  }
178
- if (push) history.pushState({ dataHref: next.dataHref }, "", url);
179
280
  applyThemeMetadata(next, url);
180
281
  await hydrateData();
181
- if (!refresh) window.scrollTo({ top: 0 });
282
+ if (!refresh) {
283
+ const view = readView(
284
+ url,
285
+ "ledger",
286
+ next.path,
287
+ viewDefaults(next),
288
+ [],
289
+ restoreState,
290
+ );
291
+ await tick();
292
+ if (selected && !rows.some((entry) => entry.id === selected))
293
+ selected = null;
294
+ window.scrollTo({ top: view.windowY });
295
+ if (restoreState && selected)
296
+ list
297
+ ?.querySelector(`[data-id="${CSS.escape(selected)}"] a`)
298
+ ?.focus({ preventScroll: true });
299
+ }
182
300
  } catch (cause) {
183
- if (token === navigation && !local.signal.aborted)
184
- error =
185
- "Could not open this folder. Open its native link or try again.";
301
+ if (token === navigation && !local.signal.aborted) {
302
+ error = ui.labels.folderError;
303
+ failure = "folder";
304
+ retryRequest = [dataHref, url, push, refresh, restoreState];
305
+ viewHistory?.rollback();
306
+ }
186
307
  } finally {
187
308
  if (token === navigation) busy = false;
188
309
  }
189
310
  }
190
311
  function navigateLink(event, href, dataHref) {
191
312
  if (
313
+ typeof href !== "string" ||
314
+ typeof dataHref !== "string" ||
192
315
  event?.altKey ||
193
316
  event?.ctrlKey ||
194
317
  event?.metaKey ||
@@ -201,11 +324,12 @@
201
324
  openFolder(dataHref, new URL(href, document.baseURI).href, true);
202
325
  }
203
326
  async function move(index) {
204
- const row = rows[Math.max(0, Math.min(rows.length - 1, index))];
327
+ const target = Math.max(0, Math.min(rows.length - 1, index));
328
+ const row = rows[target];
205
329
  if (!row) return;
206
330
  selected = row.id;
207
331
  if (virtual) {
208
- scrollTop = Math.max(0, offsets[index] - 100);
332
+ scrollTop = Math.max(0, offsets[target] - 100);
209
333
  if (list)
210
334
  window.scrollTo({
211
335
  top: list.getBoundingClientRect().top + window.scrollY + scrollTop,
@@ -230,6 +354,7 @@
230
354
  if (event.key === "Escape") timestamp = null;
231
355
  return;
232
356
  }
357
+ if (event.target?.closest?.("[data-dirwell-actions]")) return;
233
358
  const editable = /INPUT|TEXTAREA|SELECT/.test(event.target?.tagName ?? "");
234
359
  if (event.key === "/" && !editable) {
235
360
  event.preventDefault();
@@ -245,7 +370,11 @@
245
370
  }
246
371
  if (event.key === "Backspace" && model.parentHref) {
247
372
  event.preventDefault();
248
- if (location.protocol === "file:") location.href = model.parentHref;
373
+ if (
374
+ location.protocol === "file:" ||
375
+ typeof model.parentDataHref !== "string"
376
+ )
377
+ location.href = model.parentHref;
249
378
  else
250
379
  openFolder(
251
380
  model.parentDataHref,
@@ -277,54 +406,66 @@
277
406
  archiveAbort?.abort();
278
407
  const local = (archiveAbort = new AbortController());
279
408
  globalRows = [];
409
+ archiveFailed = false;
280
410
  const query = globalQuery.trim();
281
411
  if (!query) {
282
- globalStatus = "Enter a search to begin.";
412
+ globalStatus = ui.labels.beginArchive;
283
413
  return;
284
414
  }
285
- globalStatus = "Searching archive…";
415
+ globalStatus = ui.labels.searchingArchive;
286
416
  try {
287
417
  const url = new URL(model.searchIndexHref, document.baseURI);
288
- const response = await fetch(url, { signal: local.signal });
289
- if (!response.ok) throw new Error("Unavailable archive");
290
- const index = await response.json();
291
- if (
292
- index.version !== 3 ||
293
- !Array.isArray(index.shards) ||
294
- !Number.isSafeInteger(index.count) ||
295
- index.count > 100000
296
- )
297
- throw new Error("Unsupported archive");
298
- const all = [];
299
- for (const shard of index.shards) {
300
- if (typeof shard !== "string") throw new Error("Invalid shard");
301
- const shardUrl = new URL(shard, url);
302
- if (shardUrl.origin !== url.origin) throw new Error("Unsafe shard");
303
- const chunk = await fetch(shardUrl, { signal: local.signal });
304
- if (!chunk.ok) throw new Error("Unavailable shard");
305
- const data = await chunk.json();
418
+ let all =
419
+ archiveCacheHref === url.href && Date.now() - archiveCacheAt < 60000
420
+ ? archiveCache
421
+ : null;
422
+ if (all === null) {
423
+ const response = await fetch(url, { signal: local.signal });
424
+ if (!response.ok) throw new Error("Unavailable archive");
425
+ const index = await response.json();
306
426
  if (
307
- !Array.isArray(data.entries) ||
308
- all.length + data.entries.length > 100000
427
+ index.version !== 3 ||
428
+ !Array.isArray(index.shards) ||
429
+ !Number.isSafeInteger(index.count) ||
430
+ index.count > 100000
309
431
  )
310
- throw new Error("Invalid archive");
311
- for (const record of data.entries) {
432
+ throw new Error("Unsupported archive");
433
+ all = [];
434
+ for (const shard of index.shards) {
435
+ if (typeof shard !== "string") throw new Error("Invalid shard");
436
+ const shardUrl = new URL(shard, url);
437
+ if (shardUrl.origin !== url.origin) throw new Error("Unsafe shard");
438
+ const chunk = await fetch(shardUrl, { signal: local.signal });
439
+ if (!chunk.ok) throw new Error("Unavailable shard");
440
+ const data = await chunk.json();
312
441
  if (
313
- typeof record.name !== "string" ||
314
- typeof record.path !== "string" ||
315
- typeof record.exitsExplorer !== "boolean"
442
+ !Array.isArray(data.entries) ||
443
+ all.length + data.entries.length > 100000
316
444
  )
317
- throw new Error("Invalid archive record");
318
- const href = record.href === null ? null : new URL(record.href, url);
319
- if (href && !["http:", "https:"].includes(href.protocol))
320
- throw new Error("Unsupported archive link");
321
- all.push({
322
- name: record.name,
323
- path: record.path,
324
- href: href?.href ?? null,
325
- exitsExplorer: record.exitsExplorer,
326
- });
445
+ throw new Error("Invalid archive");
446
+ for (const record of data.entries) {
447
+ if (
448
+ typeof record.name !== "string" ||
449
+ typeof record.path !== "string" ||
450
+ typeof record.exitsExplorer !== "boolean"
451
+ )
452
+ throw new Error("Invalid archive record");
453
+ const href =
454
+ record.href === null ? null : new URL(record.href, url);
455
+ if (href && !["http:", "https:"].includes(href.protocol))
456
+ throw new Error("Unsupported archive link");
457
+ all.push({
458
+ name: record.name,
459
+ path: record.path,
460
+ href: href?.href ?? null,
461
+ exitsExplorer: record.exitsExplorer,
462
+ });
463
+ }
327
464
  }
465
+ if (token !== archiveRequest || local.signal.aborted) return;
466
+ archiveCache = all;
467
+ archiveCacheHref = url.href;
468
+ archiveCacheAt = Date.now();
328
469
  }
329
470
  const matches = all
330
471
  .map((record) => ({
@@ -340,27 +481,47 @@
340
481
  globalRows = matches.slice(0, 100).map((item) => item.record);
341
482
  globalStatus =
342
483
  matches.length > 100
343
- ? "Showing 100 of " + matches.length + " results"
344
- : matches.length + " results";
484
+ ? label(ui, "truncatedResults", { shown: 100, count: matches.length })
485
+ : label(ui, "archiveResults", { count: matches.length });
345
486
  } catch {
346
- if (token === archiveRequest && !local.signal.aborted)
347
- globalStatus = "Could not load archive search. Try again.";
487
+ if (token === archiveRequest && !local.signal.aborted) {
488
+ globalStatus = ui.labels.archiveError;
489
+ archiveFailed = true;
490
+ }
348
491
  }
349
492
  }
350
493
  onMount(() => {
494
+ viewportHeight = innerHeight;
351
495
  ready = true;
352
496
  abort = new AbortController();
353
497
  try {
354
498
  const stored = localStorage.getItem("dirwell-color-scheme");
355
499
  if (["system", "light", "dark"].includes(stored)) scheme = stored;
356
500
  } catch {}
357
- history.replaceState(
358
- { dataHref: new URL(model.dataHref, document.baseURI).href },
359
- "",
501
+ viewHistory = createViewHistory("ledger");
502
+ const view = readView(
503
+ location.href,
504
+ "ledger",
505
+ model.path,
506
+ viewDefaults(model),
507
+ [],
508
+ history.state,
360
509
  );
510
+ query = view.query;
511
+ types = view.types;
512
+ sort = view.sort;
513
+ selected = view.selected;
514
+ scrollTop = view.listTop;
515
+ saveView();
361
516
  const onPop = (event) => {
362
517
  if (event.state?.dataHref)
363
- openFolder(event.state.dataHref, location.href, false);
518
+ openFolder(
519
+ event.state.dataHref,
520
+ location.href,
521
+ false,
522
+ false,
523
+ event.state,
524
+ );
364
525
  else location.reload();
365
526
  };
366
527
  window.addEventListener("popstate", onPop);
@@ -376,10 +537,15 @@
376
537
  if (!href || typeof EventSource === "undefined") return;
377
538
  const updates = new EventSource(href);
378
539
  updates.onmessage = () => (pendingUpdates = href);
379
- updates.onerror = () =>
380
- (error = "Live updates disconnected. Refresh to retry.");
540
+ updates.onerror = () => {
541
+ error = ui.labels.updatesError;
542
+ failure = "updates";
543
+ };
381
544
  updates.onopen = () => {
382
- if (error.startsWith("Live updates")) error = "";
545
+ if (failure === "updates") {
546
+ error = "";
547
+ failure = null;
548
+ }
383
549
  };
384
550
  return () => updates.close();
385
551
  });
@@ -406,6 +572,7 @@
406
572
  </script>
407
573
 
408
574
  <svelte:window
575
+ onresize={() => (viewportHeight = innerHeight)}
409
576
  onkeydown={keyboard}
410
577
  onscroll={() => {
411
578
  if (list) scrollTop = Math.max(0, window.scrollY - list.offsetTop);
@@ -413,14 +580,21 @@
413
580
  />
414
581
  <header class="page-header">
415
582
  <div class="wordmark">
416
- {#if header}{@render header({ model })}{:else}<h1>{model.title}</h1>
417
- <span>File archive</span>{/if}
583
+ {#if header}{@render header({ model })}{:else}{#if ui.brand.logoUrl}<img
584
+ src={ui.brand.logoUrl}
585
+ alt={ui.brand.logoAlt ?? ""}
586
+ width="40"
587
+ height="40"
588
+ />{/if}
589
+ <h1>{ui.brand.name ?? model.title}</h1>
590
+ <span>{ui.labels.archive}</span>{/if}
418
591
  </div>
419
- <nav aria-label="Breadcrumb">
592
+ <nav aria-label={ui.labels.breadcrumb}>
420
593
  <a
421
594
  href={model.rootHref}
422
595
  onclick={(event) =>
423
- navigateLink(event, model.rootHref, model.rootDataHref)}>Home</a
596
+ navigateLink(event, model.rootHref, model.rootDataHref)}
597
+ >{ui.labels.home}</a
424
598
  >{#each model.breadcrumbs as crumb}
425
599
  / <a
426
600
  href={crumb.href}
@@ -432,10 +606,10 @@
432
606
  <main data-explorer data-framework="svelte" aria-busy={busy}>
433
607
  <div class="toolbar">
434
608
  <label class="search-field"
435
- >Search this directory<input
609
+ >{ui.labels.searchDirectory}<input
436
610
  bind:this={searchInput}
437
611
  type="search"
438
- placeholder="Search files"
612
+ placeholder={ui.labels.searchFiles}
439
613
  bind:value={query}
440
614
  disabled={!ready || !complete}
441
615
  oncompositionstart={() => (composing = true)}
@@ -443,7 +617,8 @@
443
617
  /></label
444
618
  >
445
619
  <fieldset disabled={!ready || !complete}>
446
- <legend>Types</legend>{#each ["directory", "file", "link"] as type}<label
620
+ <legend>{ui.labels.types}</legend
621
+ >{#each ["directory", "file", "link"] as type}<label
447
622
  ><input
448
623
  type="checkbox"
449
624
  checked={types.includes(type)}
@@ -451,38 +626,39 @@
451
626
  (types = event.currentTarget.checked
452
627
  ? [...types, type]
453
628
  : types.filter((value) => value !== type))}
454
- />{type}</label
629
+ />{ui.labels[type]}</label
455
630
  >{/each}
456
631
  </fieldset>
457
632
  <label
458
- >Sort<select
633
+ >{ui.labels.sort}<select
459
634
  value={sort.field}
460
635
  disabled={!ready || !complete}
461
636
  onchange={(event) =>
462
637
  (sort = { ...sort, field: event.currentTarget.value })}
463
- ><option value="name">Name</option><option value="size">Size</option
464
- ><option value="modified">Modified</option></select
638
+ ><option value="name">{ui.labels.name}</option><option value="size"
639
+ >{ui.labels.size}</option
640
+ ><option value="modified">{ui.labels.modified}</option></select
465
641
  ></label
466
642
  >
467
643
  <label
468
- >Names<select
644
+ >{ui.labels.names}<select
469
645
  value={sort.nameMode}
470
646
  disabled={!ready || !complete}
471
647
  onchange={(event) =>
472
648
  (sort = { ...sort, nameMode: event.currentTarget.value })}
473
- ><option value="natural">Natural</option><option value="locale"
474
- >Locale</option
475
- ><option value="unicode">Unicode</option></select
649
+ ><option value="natural">{ui.labels.natural}</option><option
650
+ value="locale">{ui.labels.locale}</option
651
+ ><option value="unicode">{ui.labels.unicode}</option></select
476
652
  ></label
477
653
  >
478
654
  <label
479
- >Order<select
655
+ >{ui.labels.order}<select
480
656
  value={sort.direction}
481
657
  disabled={!ready || !complete}
482
658
  onchange={(event) =>
483
659
  (sort = { ...sort, direction: event.currentTarget.value })}
484
- ><option value="asc">Ascending</option><option value="desc"
485
- >Descending</option
660
+ ><option value="asc">{ui.labels.ascending}</option><option value="desc"
661
+ >{ui.labels.descending}</option
486
662
  ></select
487
663
  ></label
488
664
  >
@@ -493,34 +669,56 @@
493
669
  disabled={!ready || !complete}
494
670
  onchange={(event) =>
495
671
  (sort = { ...sort, directoriesFirst: event.currentTarget.checked })}
496
- />Folders first</label
672
+ />{ui.labels.directoriesFirst}</label
497
673
  >
498
674
  <label
499
- >Appearance<select
675
+ >{ui.labels.appearance}<select
500
676
  value={scheme}
501
677
  disabled={!ready}
502
678
  onchange={(event) => updateScheme(event.currentTarget.value)}
503
- ><option value="system">System</option><option value="light"
504
- >Light</option
505
- ><option value="dark">Dark</option></select
679
+ ><option value="system">{ui.labels.system}</option><option value="light"
680
+ >{ui.labels.light}</option
681
+ ><option value="dark">{ui.labels.dark}</option></select
506
682
  ></label
507
683
  >
508
- <button
509
- disabled={!ready || !model.searchIndexHref}
510
- onclick={() => (globalOpen = true)}>Search archive</button
511
- >
684
+ {#if model.searchIndexHref}
685
+ <button
686
+ disabled={!ready || !model.searchIndexHref}
687
+ onclick={() => (globalOpen = true)}>{ui.labels.searchArchive}</button
688
+ >
689
+ {/if}
512
690
  </div>
513
691
  <section class="listing-card">
514
692
  <div class="listing-summary">
515
- <span data-visible-count>{complete ? rows.length : entries.length}</span>
516
- entries{#if !complete}
517
- on this page · {model.total} in directory{/if}<span
693
+ {countParts[0]}<span data-visible-count
694
+ >{complete ? rows.length : entries.length}</span
695
+ >{countParts.slice(1).join("__COUNT__")}<span
518
696
  role="status"
519
- aria-live="polite">{busy ? "Loading…" : error}</span
697
+ aria-live="polite">{busy ? ui.labels.loading : error}</span
520
698
  >
521
699
  </div>
522
- <div class="entry-head">
523
- <span>Name</span><span>Size</span><span>Modified</span>
700
+ {#if failure && failure !== "copy"}<button disabled={busy} onclick={retry}
701
+ >{ui.labels.retry}</button
702
+ >{/if}
703
+ {#if failure === "folder" && retryRequest}<a href={retryRequest[1]}
704
+ >{ui.labels.openNative}</a
705
+ >{/if}
706
+ <div
707
+ class="entry-head"
708
+ data-custom-columns={Boolean(model.ui?.columns)}
709
+ style:grid-template-columns={model.ui?.columns
710
+ ? ui.columns
711
+ .map((column) =>
712
+ column === "name"
713
+ ? "minmax(0,1fr)"
714
+ : typeof column === "object"
715
+ ? "minmax(0,12rem)"
716
+ : "minmax(6rem,max-content)",
717
+ )
718
+ .join(" ")
719
+ : undefined}
720
+ >
721
+ {#each ui.columns as column}<span>{columnLabel(column, ui)}</span>{/each}
524
722
  </div>
525
723
  <ul class="entry-list" bind:this={list}>
526
724
  {#if model.parentHref}<li class="entry">
@@ -540,48 +738,102 @@
540
738
  class="entry"
541
739
  use:measure={entry.id}
542
740
  data-entry
741
+ data-custom-columns={Boolean(model.ui?.columns)}
543
742
  data-id={entry.id}
544
743
  data-order={start + index}
545
744
  data-active={selected === entry.id}
745
+ style:grid-template-columns={model.ui?.columns
746
+ ? ui.columns
747
+ .map((column) =>
748
+ column === "name"
749
+ ? "minmax(0,1fr)"
750
+ : "minmax(6rem,max-content)",
751
+ )
752
+ .join(" ")
753
+ : undefined}
546
754
  >
547
- <span class="identity"
548
- >{#if entry.href}<a
549
- class="name"
550
- href={safeHref(entry)}
551
- target={entry.exitsExplorer ? "_blank" : undefined}
552
- rel={entry.exitsExplorer ? "noopener" : undefined}
553
- onclick={(event) => navigate(event, entry)}
554
- onfocus={() => (selected = entry.id)}
555
- >{#if row}{@render row({
556
- model,
557
- entry,
558
- selected: selected === entry.id,
559
- })}{:else}<span class="file-icon" aria-hidden="true"
560
- >{@html entryIcon(entryType(entry))}</span
755
+ {#each ui.columns as column}
756
+ {#if column === "name"}<span class="identity"
757
+ >{#if entry.href}<a
758
+ class="name"
759
+ href={safeHref(entry)}
760
+ target={entry.exitsExplorer ? "_blank" : undefined}
761
+ rel={entry.exitsExplorer ? "noopener" : undefined}
762
+ onclick={(event) => navigate(event, entry)}
763
+ onfocus={() => (selected = entry.id)}
764
+ >{#if row}{@render row({
765
+ model,
766
+ entry,
767
+ selected: selected === entry.id,
768
+ })}{:else}<span class="file-icon" aria-hidden="true"
769
+ >{@html entryIcon(entryType(entry))}</span
770
+ >
771
+ {entry.name}{isDirectory(entry) ? "/" : ""}{/if}</a
772
+ >{:else}<span
773
+ >{#if row}{@render row({
774
+ model,
775
+ entry,
776
+ selected: selected === entry.id,
777
+ })}{:else}{entry.name}{/if}</span
778
+ >{/if}{#if entry.link}<small class="target"
779
+ >{entry.link.unavailable
780
+ ? ui.labels.unavailable + " · "
781
+ : "→ "}{entry.link.target}</small
782
+ >{/if}</span
783
+ >
784
+ {:else if column === "size"}<span class="kind"
785
+ >{isDirectory(entry)
786
+ ? ui.labels.folder
787
+ : size(entry.size)}</span
788
+ >
789
+ {:else if column === "modified"}<button
790
+ class="timestamp-button"
791
+ disabled={!ready}
792
+ onclick={() => (timestamp = entry.modifiedAt)}
793
+ ><time datetime={entry.modifiedAt}
794
+ >{formattedDate(entry.modifiedAt, ui, ready)}</time
795
+ ></button
796
+ >
797
+ {:else}<span class="entry-data"
798
+ >{publicDataValue(entry.data?.[column.dataKey])}</span
799
+ >{/if}{/each}
800
+ {#if actions || ui.actions.length}<span
801
+ data-dirwell-actions
802
+ style="grid-column:1/-1;display:flex;flex-wrap:wrap;gap:.5rem"
803
+ >
804
+ {#if actions}{@render actions({
805
+ model,
806
+ entry,
807
+ selected: selected === entry.id,
808
+ })}{/if}
809
+ {#each ui.actions as action}
810
+ {#if action === "download" && entry.href && !isDirectory(entry) && !entry.link?.unavailable}<a
811
+ href={safeHref(entry)}
812
+ download>{ui.labels.download}</a
561
813
  >
562
- {entry.name}{isDirectory(entry) ? "/" : ""}{/if}</a
563
- >{:else}<span
564
- >{#if row}{@render row({
565
- model,
566
- entry,
567
- selected: selected === entry.id,
568
- })}{:else}{entry.name}{/if}</span
569
- >{/if}{#if entry.link}<small class="target"
570
- >{entry.link.unavailable ? "Target unavailable · " : "→ "}{entry
571
- .link.target}</small
572
- >{/if}</span
573
- >
574
- <span class="kind"
575
- >{isDirectory(entry) ? "Folder" : size(entry.size)}</span
576
- >
577
- <button
578
- class="timestamp-button"
579
- disabled={!ready}
580
- onclick={() => (timestamp = entry.modifiedAt)}
581
- ><time datetime={entry.modifiedAt}
582
- >{modified(entry.modifiedAt, ready)}</time
583
- ></button
584
- >
814
+ {:else if action !== "download"}<button
815
+ disabled={!ready || (action === "copy-link" && !entry.href)}
816
+ aria-label={(action === "copy-link"
817
+ ? ui.labels.copyLink
818
+ : ui.labels.copyPath) +
819
+ ": " +
820
+ entry.name}
821
+ onclick={async () => {
822
+ try {
823
+ await copyEntryAction(action, entry);
824
+ error = ui.labels.copied;
825
+ failure = null;
826
+ } catch {
827
+ error = ui.labels.copyError;
828
+ failure = "copy";
829
+ }
830
+ }}
831
+ >{action === "copy-link"
832
+ ? ui.labels.copyLink
833
+ : ui.labels.copyPath}</button
834
+ >{/if}
835
+ {/each}
836
+ </span>{/if}
585
837
  </li>
586
838
  {/each}
587
839
  {#if end < rows.length}<li
@@ -589,42 +841,60 @@
589
841
  style:height={offsets[rows.length] - offsets[end] + "px"}
590
842
  ></li>{/if}
591
843
  </ul>
592
- {#if rows.length === 0}<p>No matching entries.</p>{/if}
844
+ {#if rows.length === 0 && !busy}<p>
845
+ {model.total === 0 ? ui.labels.empty : ui.labels.noMatches}
846
+ </p>{/if}
593
847
  </section>
594
- {#if model.page?.count > 1 && !complete}<nav aria-label="Pagination">
848
+ {#if model.page?.count > 1 && !complete}<nav
849
+ aria-label={ui.labels.pagination}
850
+ >
595
851
  {#if model.page.previousHref}<a rel="prev" href={model.page.previousHref}
596
- >Previous</a
597
- >{/if} Page {model.page.number} of {model.page.count}
598
- {#if model.page.nextHref}<a rel="next" href={model.page.nextHref}>Next</a
852
+ >{ui.labels.previous}</a
853
+ >{/if}
854
+ {label(ui, "page", { page: model.page.number, pages: model.page.count })}
855
+ {#if model.page.nextHref}<a rel="next" href={model.page.nextHref}
856
+ >{ui.labels.next}</a
599
857
  >{/if}
600
858
  </nav>{/if}
601
- {#if footer}{@render footer({ model })}{:else}<p class="keyboard-help">
602
- Arrow keys select files · Home / End jump · / searches · Backspace opens
603
- parent
859
+ {#if footer}{@render footer({ model })}{:else if ui.brand.footerText}<p
860
+ class="brand-footer"
861
+ >
862
+ {#if ui.brand.footerUrl}<a href={ui.brand.footerUrl}
863
+ >{ui.brand.footerText}</a
864
+ >{:else}{ui.brand.footerText}{/if}
865
+ </p>{:else}<p class="keyboard-help">
866
+ {ui.labels.keyboardHelp}
604
867
  </p>{/if}
605
868
  </main>
606
869
  {#if timestamp}<dialog
607
870
  bind:this={timeDialog}
608
871
  onclose={() => (timestamp = null)}
609
872
  >
610
- <h2>Modified time</h2>
873
+ <h2>{ui.labels.modifiedTime}</h2>
611
874
  <p>{timestamp}</p>
612
- <p>{new Date(timestamp).toLocaleString()}</p>
613
- <button onclick={() => (timestamp = null)}>Close</button>
875
+ <p>
876
+ {ui.dateOptions.locale !== undefined ||
877
+ ui.dateOptions.timeZone !== undefined
878
+ ? formattedDate(timestamp, ui, true)
879
+ : new Date(timestamp).toLocaleString()}
880
+ </p>
881
+ <button onclick={() => (timestamp = null)}>{ui.labels.close}</button>
614
882
  </dialog>{/if}
615
883
  {#if globalOpen}<dialog
616
884
  bind:this={globalDialog}
617
885
  onclose={() => (globalOpen = false)}
618
886
  >
619
- <h2>Search archive</h2>
887
+ <h2>{ui.labels.searchArchive}</h2>
620
888
  <input
621
- aria-label="Search archive files"
889
+ aria-label={ui.labels.archiveSearchInput}
622
890
  bind:this={globalInput}
623
891
  type="search"
624
892
  bind:value={globalQuery}
625
893
  oninput={searchArchive}
626
894
  />
627
895
  <p role="status">{globalStatus}</p>
896
+ {#if archiveFailed}<button onclick={searchArchive}>{ui.labels.retry}</button
897
+ >{/if}
628
898
  <ul>
629
899
  {#each globalRows as record}<li>
630
900
  {#if record.href}<a
@@ -635,5 +905,5 @@
635
905
  >{:else}{record.path}{/if}
636
906
  </li>{/each}
637
907
  </ul>
638
- <button onclick={() => (globalOpen = false)}>Close</button>
908
+ <button onclick={() => (globalOpen = false)}>{ui.labels.close}</button>
639
909
  </dialog>{/if}