@walkthru-earth/objex 1.4.0 → 1.5.0

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.
Files changed (58) hide show
  1. package/README.md +9 -9
  2. package/dist/components/layout/ConnectionDialog.svelte +7 -2
  3. package/dist/components/layout/SettingsSheet.svelte +2 -1
  4. package/dist/components/layout/StatusBar.svelte +16 -13
  5. package/dist/components/layout/TabBar.svelte +2 -2
  6. package/dist/components/viewers/ArchiveViewer.svelte +139 -112
  7. package/dist/components/viewers/CodeViewer.svelte +15 -27
  8. package/dist/components/viewers/CodeViewer.svelte.d.ts +1 -1
  9. package/dist/components/viewers/CogViewer.svelte +8 -6
  10. package/dist/components/viewers/CopcViewer.svelte +8 -15
  11. package/dist/components/viewers/DatabaseViewer.svelte +22 -21
  12. package/dist/components/viewers/FileInfo.svelte +16 -16
  13. package/dist/components/viewers/FlatGeobufViewer.svelte +15 -45
  14. package/dist/components/viewers/GeoParquetMapViewer.svelte +5 -3
  15. package/dist/components/viewers/ImageViewer.svelte +10 -12
  16. package/dist/components/viewers/LoadProgress.svelte +6 -6
  17. package/dist/components/viewers/MarkdownViewer.svelte +17 -21
  18. package/dist/components/viewers/MediaViewer.svelte +11 -12
  19. package/dist/components/viewers/ModelViewer.svelte +17 -20
  20. package/dist/components/viewers/MultiCogViewer.svelte +11 -8
  21. package/dist/components/viewers/NotebookViewer.svelte +22 -26
  22. package/dist/components/viewers/PdfViewer.svelte +22 -31
  23. package/dist/components/viewers/PmtilesViewer.svelte +10 -9
  24. package/dist/components/viewers/QueryHistoryPanel.svelte +18 -18
  25. package/dist/components/viewers/RawViewer.svelte +21 -18
  26. package/dist/components/viewers/StacMapViewer.svelte +6 -13
  27. package/dist/components/viewers/StacMosaicViewer.svelte +9 -7
  28. package/dist/components/viewers/StacTabViewer.svelte +2 -2
  29. package/dist/components/viewers/TableGrid.svelte +34 -30
  30. package/dist/components/viewers/TableStatusBar.svelte +6 -6
  31. package/dist/components/viewers/TableToolbar.svelte +9 -8
  32. package/dist/components/viewers/TableViewer.svelte +22 -13
  33. package/dist/components/viewers/ViewerHeader.svelte +18 -0
  34. package/dist/components/viewers/ViewerHeader.svelte.d.ts +10 -0
  35. package/dist/components/viewers/ViewerStatus.svelte +19 -0
  36. package/dist/components/viewers/ViewerStatus.svelte.d.ts +7 -0
  37. package/dist/components/viewers/ZarrMapViewer.svelte +13 -12
  38. package/dist/components/viewers/ZarrViewer.svelte +94 -61
  39. package/dist/components/viewers/map/AttributeTable.svelte +6 -6
  40. package/dist/components/viewers/map/MapContainer.svelte +2 -2
  41. package/dist/components/viewers/pmtiles/PmtilesArchiveView.svelte +109 -83
  42. package/dist/components/viewers/pmtiles/PmtilesTileInspector.svelte +16 -16
  43. package/dist/constants.d.ts +6 -0
  44. package/dist/constants.js +8 -0
  45. package/dist/i18n/ar.js +3 -0
  46. package/dist/i18n/en.js +3 -0
  47. package/dist/query/stac-source-parquet.js +8 -5
  48. package/dist/query/wasm.js +6 -63
  49. package/dist/storage/presign.js +2 -1
  50. package/dist/storage/providers.js +2 -1
  51. package/dist/stores/settings.svelte.js +3 -3
  52. package/dist/utils/deck.d.ts +2 -0
  53. package/dist/utils/deck.js +5 -3
  54. package/dist/utils/media-query.svelte.d.ts +14 -0
  55. package/dist/utils/media-query.svelte.js +29 -0
  56. package/dist/utils/signed-url-effect.d.ts +7 -0
  57. package/dist/utils/signed-url-effect.js +19 -0
  58. package/package.json +2 -2
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![CI](https://github.com/walkthru-earth/objex/actions/workflows/ci.yml/badge.svg)](https://github.com/walkthru-earth/objex/actions/workflows/ci.yml)
6
6
  [![License: CC BY 4.0](https://img.shields.io/badge/license-CC%20BY%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by/4.0/)
7
7
 
8
- Cloud storage explorer that runs entirely in the browser. Connect to S3, Azure, GCS, R2, MinIO -- browse files, query data with SQL, and visualize geospatial formats on interactive maps. No backend required.
8
+ Cloud storage explorer that runs entirely in the browser. Connect to S3, Azure, GCS, R2, MinIO - browse files, query data with SQL, and visualize geospatial formats on interactive maps. No backend required.
9
9
 
10
10
  ```mermaid
11
11
  graph LR
@@ -22,10 +22,10 @@ graph LR
22
22
  - **Query** Parquet, CSV, JSONL with SQL (DuckDB-WASM, cancellable queries)
23
23
  - **Visualize** GeoParquet, GeoJSON, COG, PMTiles, FlatGeobuf, Zarr (incl. GeoZarr), STAC catalogs, and stac-geoparquet on maps (MapLibre + deck.gl)
24
24
  - **View** 100+ file formats: code (30+ languages), Jupyter notebooks, PDF, 3D models, archives, media
25
- - **Share** via URL -- `?url=<storage-url>#<view>` encodes full viewer state
26
- - **Configure** without a rebuild -- bundled `config.json` (or remote `?config=<url>`) sets defaults, basemaps, and seed connections, with an in-app settings panel
27
- - **i18n** -- English + Arabic with automatic RTL layout
28
- - **Zero backend** -- everything runs client-side
25
+ - **Share** via URL - `?url=<storage-url>#<view>` encodes full viewer state
26
+ - **Configure** without a rebuild - bundled `config.json` (or remote `?config=<url>`) sets defaults, basemaps, and seed connections, with an in-app settings panel
27
+ - **i18n** - English + Arabic with automatic RTL layout
28
+ - **Zero backend** - everything runs client-side
29
29
 
30
30
  ## Supported Formats
31
31
 
@@ -48,7 +48,7 @@ graph LR
48
48
 
49
49
  Two packages are published for downstream use:
50
50
 
51
- ### `@walkthru-earth/objex` -- Full Svelte 5 Library
51
+ ### `@walkthru-earth/objex` - Full Svelte 5 Library
52
52
 
53
53
  Components, stores, and utilities for building geospatial storage explorers.
54
54
 
@@ -62,9 +62,9 @@ import { UrlAdapter } from '@walkthru-earth/objex/storage';
62
62
  import { getFileTypeInfo } from '@walkthru-earth/objex/file-icons';
63
63
  ```
64
64
 
65
- Requires `svelte ^5` and `@sveltejs/kit ^2` as peer dependencies. Heavy deps (DuckDB, deck.gl, MapLibre, Arrow, hyparquet, hyparquet-compressors, yaml) are optional peers -- only install what you need.
65
+ Requires `svelte ^5` and `@sveltejs/kit ^2` as peer dependencies. Heavy deps (DuckDB, deck.gl, MapLibre, Arrow, hyparquet, hyparquet-compressors, yaml) are optional peers - only install what you need.
66
66
 
67
- ### `@walkthru-earth/objex-utils` -- Pure TypeScript Utilities
67
+ ### `@walkthru-earth/objex-utils` - Pure TypeScript Utilities
68
68
 
69
69
  Zero Svelte dependency. Works with any JS framework or Node.js.
70
70
 
@@ -131,4 +131,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture, geos
131
131
 
132
132
  ## License
133
133
 
134
- [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) -- hi@walkthru.earth
134
+ [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) - hi@walkthru.earth
@@ -10,7 +10,12 @@ import LockIcon from '@lucide/svelte/icons/lock';
10
10
  import PlugZapIcon from '@lucide/svelte/icons/plug-zap';
11
11
  import ShieldIcon from '@lucide/svelte/icons/shield';
12
12
  import XIcon from '@lucide/svelte/icons/x';
13
- import { describeParseResult, looksLikeUrl, parseStorageUrl } from '@walkthru-earth/objex-utils';
13
+ import {
14
+ DEFAULT_AWS_REGION,
15
+ describeParseResult,
16
+ looksLikeUrl,
17
+ parseStorageUrl
18
+ } from '@walkthru-earth/objex-utils';
14
19
  import { Button } from '../ui/button/index.js';
15
20
  import { Input } from '../ui/input/index.js';
16
21
  import {
@@ -56,7 +61,7 @@ let {
56
61
  let name = $state('');
57
62
  let provider = $state<ProviderId>('s3');
58
63
  let bucket = $state('');
59
- let region = $state('us-east-1');
64
+ let region = $state(DEFAULT_AWS_REGION);
60
65
  let endpoint = $state('');
61
66
  let anonymous = $state(true);
62
67
  let accessKey = $state('');
@@ -2,6 +2,7 @@
2
2
  import CheckIcon from '@lucide/svelte/icons/check';
3
3
  import CopyIcon from '@lucide/svelte/icons/copy';
4
4
  import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
5
+ import { COPY_FEEDBACK_MS } from '@walkthru-earth/objex-utils';
5
6
  import {
6
7
  Sheet,
7
8
  SheetContent,
@@ -48,7 +49,7 @@ function buildExportConfig(): string {
48
49
  async function copyConfig() {
49
50
  await navigator.clipboard.writeText(buildExportConfig());
50
51
  copied = true;
51
- setTimeout(() => (copied = false), 1500);
52
+ setTimeout(() => (copied = false), COPY_FEEDBACK_MS);
52
53
  }
53
54
  </script>
54
55
 
@@ -32,29 +32,32 @@ let activeFileInfo = $derived(activeTab ? getFileTypeInfo(activeTab.extension) :
32
32
  {browser.activeConnection.name}
33
33
  </span>
34
34
  {#if displayPath}
35
- <span class="text-muted-foreground/50">/</span>
36
- <span class="max-w-[200px] truncate" title={displayPath}>{displayPath}</span>
35
+ <span class="hidden text-muted-foreground/50 sm:inline">/</span>
36
+ <span class="hidden max-w-[200px] truncate sm:inline" title={displayPath}>{displayPath}</span>
37
37
  {/if}
38
38
  <Separator orientation="vertical" class="mx-1.5 h-3.5" />
39
39
  {:else if displayPath}
40
- <FolderIcon class="size-3 shrink-0" />
41
- <span class="max-w-[300px] truncate" title={displayPath}>{displayPath}</span>
42
- <Separator orientation="vertical" class="mx-1.5 h-3.5" />
40
+ <FolderIcon class="hidden size-3 shrink-0 sm:block" />
41
+ <span class="hidden max-w-[300px] truncate sm:inline" title={displayPath}>{displayPath}</span>
42
+ <Separator orientation="vertical" class="mx-1.5 hidden h-3.5 sm:block" />
43
43
  {/if}
44
44
 
45
- <!-- Entry count -->
45
+ <!-- Entry count — hidden on mobile -->
46
46
  {#if displayCount > 0}
47
- <FileTextIcon class="size-3 shrink-0" />
48
- <span>{displayCount} {displayCount === 1 ? t('statusBar.item') : t('statusBar.items')}</span>
49
- <Separator orientation="vertical" class="mx-1.5 h-3.5" />
47
+ <FileTextIcon class="hidden size-3 shrink-0 sm:block" />
48
+ <span class="hidden sm:inline"
49
+ >{displayCount}
50
+ {displayCount === 1 ? t('statusBar.item') : t('statusBar.items')}</span
51
+ >
52
+ <Separator orientation="vertical" class="mx-1.5 hidden h-3.5 sm:block" />
50
53
  {/if}
51
54
 
52
- <!-- Active file info -->
55
+ <!-- Active file info: type label hidden on mobile, size kept -->
53
56
  {#if activeTab && activeFileInfo}
54
- <InfoIcon class="size-3 shrink-0" />
55
- <span>{activeFileInfo.label}</span>
57
+ <InfoIcon class="hidden size-3 shrink-0 sm:block" />
58
+ <span class="hidden sm:inline">{activeFileInfo.label}</span>
56
59
  {#if activeTab.size}
57
- <span class="text-muted-foreground/50">·</span>
60
+ <span class="hidden text-muted-foreground/50 sm:inline">·</span>
58
61
  <span>{formatFileSize(activeTab.size)}</span>
59
62
  {/if}
60
63
  <Separator orientation="vertical" class="mx-1.5 h-3.5" />
@@ -60,8 +60,8 @@ async function handleCopy(type: 'https' | 's3', tab: (typeof tabs.items)[0]) {
60
60
  <Button
61
61
  variant="ghost"
62
62
  size="icon-sm"
63
- class="ms-1 size-5 opacity-0 transition-opacity group-hover:opacity-100
64
- {isActive ? 'opacity-60' : ''}"
63
+ class="ms-1 size-5 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100
64
+ {isActive ? 'sm:opacity-60' : ''}"
65
65
  onclick={(e: MouseEvent) => handleClose(e, tab.id)}
66
66
  aria-label={t('tabBar.closeTab', { name: tab.name })}
67
67
  >
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { Archive, ChevronRight, Download, File, Folder, Loader } from '@lucide/svelte';
3
- import { formatFileSize } from '@walkthru-earth/objex-utils';
3
+ import { formatFileSize, handleLoadError, isAbortError } from '@walkthru-earth/objex-utils';
4
4
  import type { Entry } from '@zip.js/zip.js';
5
5
  import { onDestroy, untrack } from 'svelte';
6
6
  import { Badge } from '../ui/badge/index.js';
@@ -30,9 +30,12 @@ import {
30
30
  streamZipEntriesFromUrl
31
31
  } from '../../utils/archive';
32
32
  import { buildHttpsUrlAsync } from '../../utils/signed-url.js';
33
+ import { useIsWide } from '../../utils/media-query.svelte.js';
33
34
 
34
35
  let { tab }: { tab: Tab } = $props();
35
36
 
37
+ const isWide = useIsWide();
38
+
36
39
  const MAX_ITEMS = 500;
37
40
 
38
41
  // ── State ──────────────────────────────────────────────────────────────
@@ -165,8 +168,8 @@ async function loadArchive() {
165
168
  error = t('archive.unsupported');
166
169
  }
167
170
  } catch (err) {
168
- if ((err as DOMException)?.name === 'AbortError') return;
169
- error = err instanceof Error ? err.message : String(err);
171
+ if (isAbortError(err)) return;
172
+ error = handleLoadError(err);
170
173
  } finally {
171
174
  scanning = false;
172
175
  if (initializing) initializing = false;
@@ -187,7 +190,7 @@ async function loadZip() {
187
190
  loadMethod = 'range';
188
191
  return;
189
192
  } catch (err) {
190
- if ((err as DOMException)?.name === 'AbortError') throw err;
193
+ if (isAbortError(err)) throw err;
191
194
  entryList = [];
192
195
  scanCount = 0;
193
196
  zipEntryMap.clear();
@@ -218,7 +221,7 @@ async function loadTar() {
218
221
  loadMethod = 'range';
219
222
  return;
220
223
  } catch (err) {
221
- if ((err as DOMException)?.name === 'AbortError') throw err;
224
+ if (isAbortError(err)) throw err;
222
225
  entryList = [];
223
226
  scanCount = 0;
224
227
  remoteUrl = '';
@@ -260,7 +263,7 @@ async function loadTarGz() {
260
263
  loadMethod = 'full';
261
264
  return;
262
265
  } catch (err) {
263
- if ((err as DOMException)?.name === 'AbortError') throw err;
266
+ if (isAbortError(err)) throw err;
264
267
  // Fall through to full-buffer approach
265
268
  entryList = [];
266
269
  scanCount = 0;
@@ -312,7 +315,7 @@ const totalFiles = $derived(contents.files.length);
312
315
  {#if selectedFile}
313
316
  {@const fileName = selectedFile.filename.split('/').pop()}
314
317
  <div
315
- class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
318
+ class="shrink-0 border-b border-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
316
319
  >
317
320
  {t('archive.fileDetails')}
318
321
  </div>
@@ -367,10 +370,10 @@ const totalFiles = $derived(contents.files.length);
367
370
 
368
371
  <div class="flex h-full flex-col">
369
372
  <!-- Header bar -->
370
- <div class="shrink-0 border-b border-zinc-200 px-3 py-2 sm:px-4 dark:border-zinc-800">
373
+ <div class="shrink-0 border-b border-border px-3 py-2 sm:px-4">
371
374
  <div class="flex items-center gap-1.5 sm:gap-2">
372
375
  <Archive class="h-4 w-4 shrink-0 text-amber-500" />
373
- <span class="max-w-[140px] truncate text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300">
376
+ <span class="max-w-[140px] truncate text-sm font-medium text-foreground sm:max-w-none">
374
377
  {tab.name}
375
378
  </span>
376
379
  <Badge variant="outline" class="text-[10px]">{formatLabel}</Badge>
@@ -434,60 +437,118 @@ const totalFiles = $derived(contents.files.length);
434
437
  <!-- Content area -->
435
438
  {#if initializing}
436
439
  <div class="flex flex-1 items-center justify-center gap-2">
437
- <Loader class="h-5 w-5 animate-spin text-zinc-400" />
438
- <span class="text-sm text-zinc-400">{t('archive.loading')}</span>
440
+ <Loader class="h-5 w-5 animate-spin text-muted-foreground" />
441
+ <span class="text-sm text-muted-foreground">{t('archive.loading')}</span>
439
442
  </div>
440
443
  {:else if error}
441
444
  <div class="flex flex-1 items-center justify-center px-4">
442
- <p class="text-sm text-red-400">{error}</p>
445
+ <p class="text-sm text-destructive">{error}</p>
443
446
  </div>
444
447
  {:else}
445
448
  <!-- Column browser (resizable) -->
446
- <ResizablePaneGroup direction="horizontal" class="min-h-0 flex-1">
447
- <!-- Column 1: Current path entries -->
448
- <ResizablePane defaultSize={35} minSize={20}>
449
- <div class="flex h-full flex-col">
449
+ {#snippet archiveContents()}
450
+ <div class="flex h-full flex-col">
451
+ <div
452
+ class="shrink-0 border-b border-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
453
+ >
454
+ {t('archive.contents')}
455
+ <span class="ms-1 normal-case tracking-normal">({(totalDirs + totalFiles).toLocaleString()})</span>
456
+ </div>
457
+ <div class="flex-1 overflow-auto">
458
+ {#if contents.directories.length === 0 && contents.files.length === 0 && !scanning}
459
+ <div class="p-4 text-center text-xs text-muted-foreground">
460
+ {t('archive.empty')}
461
+ </div>
462
+ {/if}
463
+
464
+ {#each contents.directories as dir, i}
465
+ {#if i < MAX_ITEMS}
466
+ {@const dirName = dir.split('/').pop()}
467
+ <button
468
+ class="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted"
469
+ class:bg-muted={selectedDir === dir}
470
+ onclick={() => selectDirectory(dir)}
471
+ ondblclick={() => navigateIntoDir(dir)}
472
+ >
473
+ <Folder class="size-3.5 shrink-0 text-amber-500/70" />
474
+ <span class="truncate font-medium">{dirName}</span>
475
+ <ChevronRight class="ms-auto size-3 shrink-0 text-muted-foreground" />
476
+ </button>
477
+ {:else if i === MAX_ITEMS}
478
+ <div class="px-3 py-1.5 text-[10px] text-muted-foreground">
479
+ +{contents.directories.length - MAX_ITEMS} more
480
+ </div>
481
+ {/if}
482
+ {/each}
483
+
484
+ {#each contents.files as file, i}
485
+ {#if i < MAX_ITEMS}
486
+ {@const fileName = file.filename.split('/').pop()}
487
+ <button
488
+ class="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted"
489
+ class:bg-muted={selectedFile?.filename === file.filename}
490
+ onclick={() => selectFile(file)}
491
+ >
492
+ <File class="size-3.5 shrink-0 text-muted-foreground/70" />
493
+ <span class="truncate">{fileName}</span>
494
+ <span class="ms-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
495
+ {formatFileSize(file.uncompressedSize)}
496
+ </span>
497
+ </button>
498
+ {:else if i === MAX_ITEMS}
499
+ <div class="px-3 py-1.5 text-[10px] text-muted-foreground">
500
+ +{contents.files.length - MAX_ITEMS} more
501
+ </div>
502
+ {/if}
503
+ {/each}
504
+
505
+ {#if scanning}
506
+ <div class="flex items-center gap-2 px-3 py-2 text-[10px] text-muted-foreground">
507
+ <Loader class="h-3 w-3 animate-spin" />
508
+ <span>{t('archive.scanningProgress', { count: scanCount.toLocaleString() })}</span>
509
+ </div>
510
+ {/if}
511
+ </div>
512
+ </div>
513
+ {/snippet}
514
+
515
+ {#snippet archiveSelectedDir()}
516
+ <div class="flex h-full flex-col">
517
+ {#if selectedDir}
518
+ {@const dirName = selectedDir.split('/').pop()}
450
519
  <div
451
- class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
520
+ class="shrink-0 border-b border-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
452
521
  >
453
- {t('archive.contents')}
454
- <span class="ms-1 normal-case tracking-normal">({(totalDirs + totalFiles).toLocaleString()})</span>
522
+ {dirName}
523
+ <span class="ms-1 normal-case tracking-normal">({(selectedDirContents.directories.length + selectedDirContents.files.length).toLocaleString()})</span>
455
524
  </div>
456
525
  <div class="flex-1 overflow-auto">
457
- {#if contents.directories.length === 0 && contents.files.length === 0 && !scanning}
526
+ {#if selectedDirContents.directories.length === 0 && selectedDirContents.files.length === 0}
458
527
  <div class="p-4 text-center text-xs text-muted-foreground">
459
528
  {t('archive.empty')}
460
529
  </div>
461
530
  {/if}
462
531
 
463
- {#each contents.directories as dir, i}
532
+ {#each selectedDirContents.directories as subDir, i}
464
533
  {#if i < MAX_ITEMS}
465
- {@const dirName = dir.split('/').pop()}
534
+ {@const subDirName = subDir.split('/').pop()}
466
535
  <button
467
- class="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800/50"
468
- class:bg-zinc-100={selectedDir === dir}
469
- class:dark:bg-zinc-800={selectedDir === dir}
470
- onclick={() => selectDirectory(dir)}
471
- ondblclick={() => navigateIntoDir(dir)}
536
+ class="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted"
537
+ onclick={() => navigateIntoDir(subDir)}
472
538
  >
473
539
  <Folder class="size-3.5 shrink-0 text-amber-500/70" />
474
- <span class="truncate font-medium">{dirName}</span>
540
+ <span class="truncate font-medium">{subDirName}</span>
475
541
  <ChevronRight class="ms-auto size-3 shrink-0 text-muted-foreground" />
476
542
  </button>
477
- {:else if i === MAX_ITEMS}
478
- <div class="px-3 py-1.5 text-[10px] text-muted-foreground">
479
- +{contents.directories.length - MAX_ITEMS} more
480
- </div>
481
543
  {/if}
482
544
  {/each}
483
545
 
484
- {#each contents.files as file, i}
546
+ {#each selectedDirContents.files as file, i}
485
547
  {#if i < MAX_ITEMS}
486
548
  {@const fileName = file.filename.split('/').pop()}
487
549
  <button
488
- class="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800/50"
489
- class:bg-zinc-100={selectedFile?.filename === file.filename}
490
- class:dark:bg-zinc-800={selectedFile?.filename === file.filename}
550
+ class="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted"
551
+ class:bg-muted={selectedFile?.filename === file.filename}
491
552
  onclick={() => selectFile(file)}
492
553
  >
493
554
  <File class="size-3.5 shrink-0 text-muted-foreground/70" />
@@ -496,91 +557,57 @@ const totalFiles = $derived(contents.files.length);
496
557
  {formatFileSize(file.uncompressedSize)}
497
558
  </span>
498
559
  </button>
499
- {:else if i === MAX_ITEMS}
500
- <div class="px-3 py-1.5 text-[10px] text-muted-foreground">
501
- +{contents.files.length - MAX_ITEMS} more
502
- </div>
503
560
  {/if}
504
561
  {/each}
505
-
506
- {#if scanning}
507
- <div class="flex items-center gap-2 px-3 py-2 text-[10px] text-muted-foreground">
508
- <Loader class="h-3 w-3 animate-spin" />
509
- <span>{t('archive.scanningProgress', { count: scanCount.toLocaleString() })}</span>
510
- </div>
511
- {/if}
512
562
  </div>
513
- </div>
514
- </ResizablePane>
563
+ {:else}
564
+ <div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">
565
+ {t('archive.selectFolder')}
566
+ </div>
567
+ {/if}
568
+ </div>
569
+ {/snippet}
515
570
 
516
- <ResizableHandle />
571
+ {#if isWide.value}
572
+ <ResizablePaneGroup direction="horizontal" class="min-h-0 flex-1">
573
+ <!-- Column 1: Current path entries -->
574
+ <ResizablePane defaultSize={35} minSize={20}>
575
+ {@render archiveContents()}
576
+ </ResizablePane>
517
577
 
518
- <!-- Column 2: Selected directory contents -->
519
- <ResizablePane defaultSize={35} minSize={20}>
520
- <div class="flex h-full flex-col">
521
- {#if selectedDir}
522
- {@const dirName = selectedDir.split('/').pop()}
523
- <div
524
- class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
525
- >
526
- {dirName}
527
- <span class="ms-1 normal-case tracking-normal">({(selectedDirContents.directories.length + selectedDirContents.files.length).toLocaleString()})</span>
528
- </div>
529
- <div class="flex-1 overflow-auto">
530
- {#if selectedDirContents.directories.length === 0 && selectedDirContents.files.length === 0}
531
- <div class="p-4 text-center text-xs text-muted-foreground">
532
- {t('archive.empty')}
533
- </div>
534
- {/if}
578
+ <ResizableHandle />
535
579
 
536
- {#each selectedDirContents.directories as subDir, i}
537
- {#if i < MAX_ITEMS}
538
- {@const subDirName = subDir.split('/').pop()}
539
- <button
540
- class="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800/50"
541
- onclick={() => navigateIntoDir(subDir)}
542
- >
543
- <Folder class="size-3.5 shrink-0 text-amber-500/70" />
544
- <span class="truncate font-medium">{subDirName}</span>
545
- <ChevronRight class="ms-auto size-3 shrink-0 text-muted-foreground" />
546
- </button>
547
- {/if}
548
- {/each}
549
-
550
- {#each selectedDirContents.files as file, i}
551
- {#if i < MAX_ITEMS}
552
- {@const fileName = file.filename.split('/').pop()}
553
- <button
554
- class="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800/50"
555
- class:bg-zinc-100={selectedFile?.filename === file.filename}
556
- class:dark:bg-zinc-800={selectedFile?.filename === file.filename}
557
- onclick={() => selectFile(file)}
558
- >
559
- <File class="size-3.5 shrink-0 text-muted-foreground/70" />
560
- <span class="truncate">{fileName}</span>
561
- <span class="ms-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
562
- {formatFileSize(file.uncompressedSize)}
563
- </span>
564
- </button>
565
- {/if}
566
- {/each}
567
- </div>
568
- {:else}
569
- <div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">
570
- {t('archive.selectFolder')}
571
- </div>
572
- {/if}
573
- </div>
574
- </ResizablePane>
580
+ <!-- Column 2: Selected directory contents -->
581
+ <ResizablePane defaultSize={35} minSize={20}>
582
+ {@render archiveSelectedDir()}
583
+ </ResizablePane>
575
584
 
576
- <ResizableHandle />
585
+ <ResizableHandle />
577
586
 
578
- <!-- Column 3: File details -->
579
- <ResizablePane defaultSize={30} minSize={15}>
580
- <div class="flex h-full flex-col">
587
+ <!-- Column 3: File details -->
588
+ <ResizablePane defaultSize={30} minSize={15}>
589
+ <div class="flex h-full flex-col">
590
+ {@render fileDetails()}
591
+ </div>
592
+ </ResizablePane>
593
+ </ResizablePaneGroup>
594
+ {:else}
595
+ <div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
596
+ <!-- Contents: fixed height list -->
597
+ <div class="max-h-52 shrink-0 border-b border-border">
598
+ {@render archiveContents()}
599
+ </div>
600
+ <!-- Selected dir: fixed height list (hidden when empty) -->
601
+ {#if selectedDir}
602
+ <div class="max-h-52 shrink-0 border-b border-border">
603
+ {@render archiveSelectedDir()}
604
+ </div>
605
+ {/if}
606
+ <!-- File details: grows to fill remaining space -->
607
+ <div class="flex flex-1 flex-col">
581
608
  {@render fileDetails()}
582
609
  </div>
583
- </ResizablePane>
584
- </ResizablePaneGroup>
610
+ </div>
611
+ {/if}
585
612
  {/if}
586
613
  </div>
@@ -16,9 +16,12 @@ import { getAdapter } from '../../storage/index.js';
16
16
  import { tabResources } from '../../stores/tab-resources.svelte.js';
17
17
  import type { Tab } from '../../types';
18
18
  import { extensionToShikiLang, highlightCode } from '../../utils/shiki';
19
- import { buildHttpsUrl, buildHttpsUrlAsync, canStreamDirectly } from '../../utils/signed-url.js';
19
+ import { buildHttpsUrl, canStreamDirectly } from '../../utils/signed-url.js';
20
+ import { resolveSignedTabUrl } from '../../utils/signed-url-effect.js';
20
21
  import { getUrlView, pickViewMode, updateUrlView } from '../../utils/url-state.js';
21
22
  import { openZarrTab } from '../../utils/zarr-tab.js';
23
+ import ViewerHeader from './ViewerHeader.svelte';
24
+ import ViewerStatus from './ViewerStatus.svelte';
22
25
 
23
26
  interface CodeActions {
24
27
  toggleFormat: () => Promise<void>;
@@ -123,17 +126,10 @@ const stacBadgeKey = $derived<Record<string, string>>({
123
126
  // must wait for the presign so the iframe never loads a bare `s3://` href.
124
127
  let styleUrl = $state('');
125
128
  $effect(() => {
126
- const id = tab.id;
127
129
  styleUrl = canStreamDirectly(tab) ? buildHttpsUrl(tab) : '';
128
- let cancelled = false;
129
- (async () => {
130
- const url = await buildHttpsUrlAsync(tab);
131
- if (cancelled || id !== tab.id) return;
132
- styleUrl = url;
133
- })();
134
- return () => {
135
- cancelled = true;
136
- };
130
+ return resolveSignedTabUrl(tab, (u) => {
131
+ styleUrl = u;
132
+ });
137
133
  });
138
134
  const stacBrowserSrc = $derived(
139
135
  `https://radiantearth.github.io/stac-browser/#/external/${styleUrl}`
@@ -370,13 +366,9 @@ async function copyCode() {
370
366
 
371
367
  <div class="flex h-full flex-col">
372
368
  {#if !nested}
373
- <div
374
- class="flex items-center gap-1 border-b border-zinc-200 px-2 py-1.5 sm:gap-2 sm:px-4 dark:border-zinc-800"
375
- >
376
- <span class="truncate max-w-[120px] text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300">{tab.name}</span>
377
- <Badge variant="secondary">{language}</Badge>
378
-
379
- <div class="ms-auto flex items-center gap-1 sm:gap-2">
369
+ <ViewerHeader {tab}>
370
+ {#snippet badge()}<Badge variant="secondary">{language}</Badge>{/snippet}
371
+ {#snippet actions()}
380
372
  {#if jsonKind === 'maplibre-style'}
381
373
  <Badge variant="outline" class="hidden border-blue-200 text-blue-600 sm:inline-flex dark:border-blue-800 dark:text-blue-300">
382
374
  {t('code.maplibreStyle')}
@@ -482,7 +474,7 @@ async function copyCode() {
482
474
  <!-- Mobile overflow menu -->
483
475
  <div class="flex sm:hidden">
484
476
  <DropdownMenu.Root>
485
- <DropdownMenu.Trigger class="rounded p-1 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
477
+ <DropdownMenu.Trigger class="rounded p-1 text-muted-foreground hover:bg-muted">
486
478
  <EllipsisVerticalIcon class="size-4" />
487
479
  </DropdownMenu.Trigger>
488
480
  <DropdownMenu.Content align="end" class="w-44">
@@ -539,8 +531,8 @@ async function copyCode() {
539
531
  </DropdownMenu.Content>
540
532
  </DropdownMenu.Root>
541
533
  </div>
542
- </div>
543
- </div>
534
+ {/snippet}
535
+ </ViewerHeader>
544
536
  {/if}
545
537
 
546
538
  {#if viewMode === 'stac-browser' && styleUrl}
@@ -596,13 +588,9 @@ async function copyCode() {
596
588
  class:word-wrap={wordWrap}
597
589
  >
598
590
  {#if loading}
599
- <div class="flex h-full items-center justify-center">
600
- <p class="text-sm text-zinc-400">{t('code.loading')}</p>
601
- </div>
591
+ <ViewerStatus kind="loading" message={t('code.loading')} />
602
592
  {:else if error}
603
- <div class="flex h-full items-center justify-center">
604
- <p class="text-sm text-red-400">{error}</p>
605
- </div>
593
+ <ViewerStatus kind="error" message={error} />
606
594
  {:else}
607
595
  {@html html}
608
596
  {/if}
@@ -12,6 +12,6 @@ type $$ComponentProps = {
12
12
  wordWrap?: boolean;
13
13
  actions?: CodeActions | null;
14
14
  };
15
- declare const CodeViewer: import("svelte").Component<$$ComponentProps, {}, "wordWrap" | "actions">;
15
+ declare const CodeViewer: import("svelte").Component<$$ComponentProps, {}, "actions" | "wordWrap">;
16
16
  type CodeViewer = ReturnType<typeof CodeViewer>;
17
17
  export default CodeViewer;
@@ -6,6 +6,8 @@ import {
6
6
  attachPixelInspector,
7
7
  type ChannelComposite,
8
8
  type CogAsset,
9
+ handleLoadError,
10
+ isAbortError,
9
11
  smokeTestHref,
10
12
  syntheticSelfAsset
11
13
  } from '@walkthru-earth/objex-utils';
@@ -260,8 +262,8 @@ async function loadCog(map: maplibregl.Map) {
260
262
  if (signal.aborted) return;
261
263
  if (!result.ok) smokeWarning = result.reason;
262
264
  } catch (err) {
263
- if (err instanceof DOMException && err.name === 'AbortError') return;
264
- smokeWarning = err instanceof Error ? err.message : String(err);
265
+ if (isAbortError(err)) return;
266
+ smokeWarning = handleLoadError(err);
265
267
  }
266
268
  })();
267
269
  }
@@ -279,7 +281,7 @@ async function loadCog(map: maplibregl.Map) {
279
281
  const _crs = preflightGeotiff.crs;
280
282
  void _crs;
281
283
  } catch (crsErr) {
282
- const msg = crsErr instanceof Error ? crsErr.message : String(crsErr);
284
+ const msg = handleLoadError(crsErr) ?? String(crsErr);
283
285
  error = `Unsupported CRS: ${msg}`;
284
286
  loading = false;
285
287
  return;
@@ -293,7 +295,7 @@ async function loadCog(map: maplibregl.Map) {
293
295
  // recognized as being in a supported file format" on the same file).
294
296
  // Surface a clear message and bail — COGLayer would re-invoke the
295
297
  // same loader and throw the identical error uncaught during update.
296
- const msg = preflightErr instanceof Error ? preflightErr.message : String(preflightErr);
298
+ const msg = handleLoadError(preflightErr) ?? String(preflightErr);
297
299
  if (/Only tiff supported version|not a tiff|Invalid.*magic/i.test(msg)) {
298
300
  error = t('map.cogInvalidTiff');
299
301
  loading = false;
@@ -361,8 +363,8 @@ async function loadCog(map: maplibregl.Map) {
361
363
  buildAndAddLayer(map, preflightGeotiff, signal);
362
364
  } catch (err) {
363
365
  if (signal.aborted) return;
364
- if (err instanceof DOMException && err.name === 'AbortError') return;
365
- error = err instanceof Error ? err.message : String(err);
366
+ if (isAbortError(err)) return;
367
+ error = handleLoadError(err);
366
368
  loading = false;
367
369
  }
368
370
  }