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