@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.
- package/README.md +9 -9
- package/dist/components/layout/ConnectionDialog.svelte +7 -2
- package/dist/components/layout/SettingsSheet.svelte +2 -1
- package/dist/components/layout/StatusBar.svelte +16 -13
- package/dist/components/layout/TabBar.svelte +2 -2
- package/dist/components/viewers/ArchiveViewer.svelte +139 -112
- package/dist/components/viewers/CodeViewer.svelte +15 -27
- package/dist/components/viewers/CodeViewer.svelte.d.ts +1 -1
- package/dist/components/viewers/CogViewer.svelte +8 -6
- package/dist/components/viewers/CopcViewer.svelte +8 -15
- package/dist/components/viewers/DatabaseViewer.svelte +22 -21
- package/dist/components/viewers/FileInfo.svelte +16 -16
- package/dist/components/viewers/FlatGeobufViewer.svelte +15 -45
- package/dist/components/viewers/GeoParquetMapViewer.svelte +5 -3
- package/dist/components/viewers/ImageViewer.svelte +10 -12
- package/dist/components/viewers/LoadProgress.svelte +6 -6
- package/dist/components/viewers/MarkdownViewer.svelte +17 -21
- package/dist/components/viewers/MediaViewer.svelte +11 -12
- package/dist/components/viewers/ModelViewer.svelte +17 -20
- package/dist/components/viewers/MultiCogViewer.svelte +11 -8
- package/dist/components/viewers/NotebookViewer.svelte +22 -26
- package/dist/components/viewers/PdfViewer.svelte +22 -31
- package/dist/components/viewers/PmtilesViewer.svelte +10 -9
- package/dist/components/viewers/QueryHistoryPanel.svelte +18 -18
- package/dist/components/viewers/RawViewer.svelte +21 -18
- package/dist/components/viewers/StacMapViewer.svelte +6 -13
- package/dist/components/viewers/StacMosaicViewer.svelte +9 -7
- package/dist/components/viewers/StacTabViewer.svelte +2 -2
- package/dist/components/viewers/TableGrid.svelte +34 -30
- package/dist/components/viewers/TableStatusBar.svelte +6 -6
- package/dist/components/viewers/TableToolbar.svelte +9 -8
- package/dist/components/viewers/TableViewer.svelte +22 -13
- package/dist/components/viewers/ViewerHeader.svelte +18 -0
- package/dist/components/viewers/ViewerHeader.svelte.d.ts +10 -0
- package/dist/components/viewers/ViewerStatus.svelte +19 -0
- package/dist/components/viewers/ViewerStatus.svelte.d.ts +7 -0
- package/dist/components/viewers/ZarrMapViewer.svelte +13 -12
- package/dist/components/viewers/ZarrViewer.svelte +94 -61
- package/dist/components/viewers/map/AttributeTable.svelte +6 -6
- package/dist/components/viewers/map/MapContainer.svelte +2 -2
- package/dist/components/viewers/pmtiles/PmtilesArchiveView.svelte +109 -83
- package/dist/components/viewers/pmtiles/PmtilesTileInspector.svelte +16 -16
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +8 -0
- package/dist/i18n/ar.js +3 -0
- package/dist/i18n/en.js +3 -0
- package/dist/query/stac-source-parquet.js +8 -5
- package/dist/query/wasm.js +6 -63
- package/dist/storage/presign.js +2 -1
- package/dist/storage/providers.js +2 -1
- package/dist/stores/settings.svelte.js +3 -3
- package/dist/utils/deck.d.ts +2 -0
- package/dist/utils/deck.js +5 -3
- package/dist/utils/media-query.svelte.d.ts +14 -0
- package/dist/utils/media-query.svelte.js +29 -0
- package/dist/utils/signed-url-effect.d.ts +7 -0
- package/dist/utils/signed-url-effect.js +19 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/walkthru-earth/objex/actions/workflows/ci.yml)
|
|
6
6
|
[](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
|
|
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
|
|
26
|
-
- **Configure** without a rebuild
|
|
27
|
-
- **i18n**
|
|
28
|
-
- **Zero backend**
|
|
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`
|
|
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
|
|
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`
|
|
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/)
|
|
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 {
|
|
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(
|
|
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),
|
|
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
|
|
49
|
-
|
|
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-
|
|
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
|
|
169
|
-
error =
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
438
|
-
<span class="text-sm text-
|
|
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-
|
|
445
|
+
<p class="text-sm text-destructive">{error}</p>
|
|
443
446
|
</div>
|
|
444
447
|
{:else}
|
|
445
448
|
<!-- Column browser (resizable) -->
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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-
|
|
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
|
-
{
|
|
454
|
-
<span class="ms-1 normal-case tracking-normal">({(
|
|
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
|
|
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
|
|
532
|
+
{#each selectedDirContents.directories as subDir, i}
|
|
464
533
|
{#if i < MAX_ITEMS}
|
|
465
|
-
{@const
|
|
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-
|
|
468
|
-
|
|
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">{
|
|
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
|
|
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-
|
|
489
|
-
class:bg-
|
|
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
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
585
|
+
<ResizableHandle />
|
|
577
586
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
</
|
|
584
|
-
|
|
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,
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
<
|
|
374
|
-
|
|
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-
|
|
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
|
-
|
|
543
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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, {}, "
|
|
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
|
|
264
|
-
smokeWarning =
|
|
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
|
|
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
|
|
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
|
|
365
|
-
error =
|
|
366
|
+
if (isAbortError(err)) return;
|
|
367
|
+
error = handleLoadError(err);
|
|
366
368
|
loading = false;
|
|
367
369
|
}
|
|
368
370
|
}
|