@walkthru-earth/objex 1.3.0 → 1.4.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 (182) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +20 -12
  3. package/dist/components/browser/FileTreeSidebar.svelte +32 -17
  4. package/dist/components/layout/AboutSheet.svelte +5 -2
  5. package/dist/components/layout/ConnectionDialog.svelte +1 -1
  6. package/dist/components/layout/SettingsSheet.svelte +237 -0
  7. package/dist/components/layout/SettingsSheet.svelte.d.ts +6 -0
  8. package/dist/components/layout/Sidebar.svelte +73 -6
  9. package/dist/components/layout/Sidebar.svelte.d.ts +4 -1
  10. package/dist/components/layout/StatusBar.svelte +1 -1
  11. package/dist/components/layout/TabBar.svelte +2 -2
  12. package/dist/components/ui/context-menu/context-menu-radio-group.svelte.d.ts +1 -1
  13. package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte.d.ts +1 -1
  14. package/dist/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte.d.ts +1 -1
  15. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  16. package/dist/components/ui/resizable/index.d.ts +1 -1
  17. package/dist/components/ui/resizable/index.js +2 -2
  18. package/dist/components/ui/slider/index.d.ts +3 -0
  19. package/dist/components/ui/slider/index.js +5 -0
  20. package/dist/components/ui/slider/range-slider.svelte +94 -0
  21. package/dist/components/ui/slider/range-slider.svelte.d.ts +21 -0
  22. package/dist/components/ui/slider/slider.svelte +83 -0
  23. package/dist/components/ui/slider/slider.svelte.d.ts +7 -0
  24. package/dist/components/viewers/ArchiveViewer.svelte +2 -2
  25. package/dist/components/viewers/CodeViewer.svelte +31 -22
  26. package/dist/components/viewers/CogControls.svelte +338 -184
  27. package/dist/components/viewers/CogControls.svelte.d.ts +33 -10
  28. package/dist/components/viewers/CogViewer.svelte +320 -119
  29. package/dist/components/viewers/CopcViewer.svelte +1 -1
  30. package/dist/components/viewers/FlatGeobufViewer.svelte +1 -1
  31. package/dist/components/viewers/GeoParquetMapViewer.svelte +6 -6
  32. package/dist/components/viewers/GeoParquetMapViewer.svelte.d.ts +1 -1
  33. package/dist/components/viewers/ImageViewer.svelte +2 -2
  34. package/dist/components/viewers/MarkdownViewer.svelte +12 -9
  35. package/dist/components/viewers/MediaViewer.svelte +2 -2
  36. package/dist/components/viewers/ModelViewer.svelte +1 -1
  37. package/dist/components/viewers/MultiCogViewer.svelte +467 -102
  38. package/dist/components/viewers/MultiCogViewer.svelte.d.ts +1 -1
  39. package/dist/components/viewers/NotebookViewer.svelte +6 -3
  40. package/dist/components/viewers/PdfViewer.svelte +2 -2
  41. package/dist/components/viewers/PmtilesViewer.svelte +3 -6
  42. package/dist/components/viewers/RawViewer.svelte +6 -3
  43. package/dist/components/viewers/StacMapViewer.svelte +10 -2
  44. package/dist/components/viewers/StacMosaicViewer.svelte +1800 -362
  45. package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +1 -1
  46. package/dist/components/viewers/StacTabViewer.svelte +24 -13
  47. package/dist/components/viewers/StacTabViewer.svelte.d.ts +1 -1
  48. package/dist/components/viewers/TableGrid.svelte +4 -4
  49. package/dist/components/viewers/TableStatusBar.svelte +1 -1
  50. package/dist/components/viewers/TableToolbar.svelte +1 -1
  51. package/dist/components/viewers/TableViewer.svelte +25 -17
  52. package/dist/components/viewers/TableViewer.svelte.d.ts +1 -0
  53. package/dist/components/viewers/ViewerRouter.svelte +16 -8
  54. package/dist/components/viewers/ZarrMapViewer.svelte +11 -9
  55. package/dist/components/viewers/ZarrViewer.svelte +4 -4
  56. package/dist/components/viewers/cog/ChannelPicker.svelte +83 -0
  57. package/dist/components/viewers/cog/ChannelPicker.svelte.d.ts +13 -0
  58. package/dist/components/viewers/cog/PixelInspectorPanel.svelte +87 -0
  59. package/dist/components/viewers/cog/PixelInspectorPanel.svelte.d.ts +17 -0
  60. package/dist/components/viewers/cog/buildRgbLayer.d.ts +78 -0
  61. package/dist/components/viewers/cog/buildRgbLayer.js +176 -0
  62. package/dist/components/viewers/map/AttributeTable.svelte +1 -1
  63. package/dist/components/viewers/map/MapContainer.svelte +37 -11
  64. package/dist/components/viewers/pmtiles/PmtilesArchiveView.svelte +1 -1
  65. package/dist/components/viewers/pmtiles/PmtilesTileInspector.svelte +1 -1
  66. package/dist/components/viewers/stac/StacDatetimeBar.svelte +175 -0
  67. package/dist/components/viewers/stac/StacDatetimeBar.svelte.d.ts +10 -0
  68. package/dist/components/viewers/stac/StacFilterPanel.svelte +243 -0
  69. package/dist/components/viewers/stac/StacFilterPanel.svelte.d.ts +14 -0
  70. package/dist/components/viewers/stac/StacItemInspector.svelte +223 -0
  71. package/dist/components/viewers/stac/StacItemInspector.svelte.d.ts +10 -0
  72. package/dist/components/viewers/stac/StacItemStrip.svelte +228 -0
  73. package/dist/components/viewers/stac/StacItemStrip.svelte.d.ts +12 -0
  74. package/dist/file-icons/index.d.ts +1 -1
  75. package/dist/file-icons/index.js +1 -1
  76. package/dist/i18n/ar.js +110 -2
  77. package/dist/i18n/en.js +110 -2
  78. package/dist/index.d.ts +2 -28
  79. package/dist/index.js +7 -23
  80. package/dist/query/engine.d.ts +10 -0
  81. package/dist/query/source.js +1 -1
  82. package/dist/query/stac-source-factory.d.ts +65 -0
  83. package/dist/query/stac-source-factory.js +77 -0
  84. package/dist/query/stac-source-parquet.d.ts +135 -0
  85. package/dist/query/stac-source-parquet.js +465 -0
  86. package/dist/query/wasm.d.ts +8 -0
  87. package/dist/query/wasm.js +304 -2
  88. package/dist/storage/presign.js +1 -1
  89. package/dist/storage/providers.js +5 -5
  90. package/dist/stores/config.svelte.d.ts +15 -0
  91. package/dist/stores/config.svelte.js +46 -0
  92. package/dist/stores/connections.svelte.d.ts +2 -2
  93. package/dist/stores/connections.svelte.js +1 -2
  94. package/dist/stores/files.svelte.d.ts +1 -1
  95. package/dist/stores/files.svelte.js +1 -1
  96. package/dist/stores/query-history.svelte.js +1 -1
  97. package/dist/stores/settings.svelte.d.ts +16 -1
  98. package/dist/stores/settings.svelte.js +104 -48
  99. package/dist/stores/tabs.svelte.d.ts +3 -0
  100. package/dist/stores/tabs.svelte.js +17 -0
  101. package/dist/utils/cog-histogram.d.ts +121 -0
  102. package/dist/utils/cog-histogram.js +424 -0
  103. package/dist/utils/cog.d.ts +200 -60
  104. package/dist/utils/cog.js +377 -114
  105. package/dist/utils/colormap-sprite.d.ts +0 -9
  106. package/dist/utils/colormap-sprite.js +0 -21
  107. package/dist/utils/deck.d.ts +16 -12
  108. package/dist/utils/deck.js +10 -4
  109. package/dist/utils/pmtiles-tile.js +2 -2
  110. package/dist/utils/{url.d.ts → signed-url.d.ts} +15 -1
  111. package/dist/utils/{url.js → signed-url.js} +32 -10
  112. package/dist/utils/url-state.d.ts +36 -0
  113. package/dist/utils/url-state.js +72 -2
  114. package/dist/utils/zarr-tab.d.ts +1 -2
  115. package/dist/utils/zarr-tab.js +1 -2
  116. package/dist/utils/zarr.d.ts +0 -17
  117. package/dist/utils/zarr.js +1 -45
  118. package/package.json +55 -84
  119. package/dist/components/browser/Breadcrumb.svelte +0 -50
  120. package/dist/components/browser/Breadcrumb.svelte.d.ts +0 -7
  121. package/dist/components/browser/CreateFolderDialog.svelte +0 -98
  122. package/dist/components/browser/CreateFolderDialog.svelte.d.ts +0 -6
  123. package/dist/components/browser/DeleteConfirmDialog.svelte +0 -90
  124. package/dist/components/browser/DeleteConfirmDialog.svelte.d.ts +0 -8
  125. package/dist/components/browser/DropZone.svelte +0 -83
  126. package/dist/components/browser/DropZone.svelte.d.ts +0 -7
  127. package/dist/components/browser/FileBrowser.svelte +0 -252
  128. package/dist/components/browser/FileBrowser.svelte.d.ts +0 -3
  129. package/dist/components/browser/FileRow.svelte +0 -117
  130. package/dist/components/browser/FileRow.svelte.d.ts +0 -9
  131. package/dist/components/browser/RenameDialog.svelte +0 -101
  132. package/dist/components/browser/RenameDialog.svelte.d.ts +0 -8
  133. package/dist/components/browser/SearchBar.svelte +0 -40
  134. package/dist/components/browser/SearchBar.svelte.d.ts +0 -6
  135. package/dist/components/browser/UploadButton.svelte +0 -65
  136. package/dist/components/browser/UploadButton.svelte.d.ts +0 -3
  137. package/dist/query/stac-geoparquet.d.ts +0 -31
  138. package/dist/query/stac-geoparquet.js +0 -136
  139. package/dist/utils/clipboard.d.ts +0 -13
  140. package/dist/utils/clipboard.js +0 -38
  141. package/dist/utils/cloud-url.d.ts +0 -27
  142. package/dist/utils/cloud-url.js +0 -61
  143. package/dist/utils/column-types.d.ts +0 -5
  144. package/dist/utils/column-types.js +0 -137
  145. package/dist/utils/connection-identity.d.ts +0 -51
  146. package/dist/utils/connection-identity.js +0 -97
  147. package/dist/utils/error.d.ts +0 -8
  148. package/dist/utils/error.js +0 -12
  149. package/dist/utils/evidence-context.d.ts +0 -22
  150. package/dist/utils/evidence-context.js +0 -56
  151. package/dist/utils/export.d.ts +0 -22
  152. package/dist/utils/export.js +0 -76
  153. package/dist/utils/file-sort.d.ts +0 -20
  154. package/dist/utils/file-sort.js +0 -41
  155. package/dist/utils/format.d.ts +0 -24
  156. package/dist/utils/format.js +0 -78
  157. package/dist/utils/geoarrow.d.ts +0 -32
  158. package/dist/utils/geoarrow.js +0 -672
  159. package/dist/utils/geometry-type.d.ts +0 -52
  160. package/dist/utils/geometry-type.js +0 -76
  161. package/dist/utils/hex.d.ts +0 -10
  162. package/dist/utils/hex.js +0 -27
  163. package/dist/utils/host-detection.d.ts +0 -23
  164. package/dist/utils/host-detection.js +0 -95
  165. package/dist/utils/local-storage.d.ts +0 -16
  166. package/dist/utils/local-storage.js +0 -37
  167. package/dist/utils/markdown-sql.d.ts +0 -30
  168. package/dist/utils/markdown-sql.js +0 -72
  169. package/dist/utils/notebook.d.ts +0 -59
  170. package/dist/utils/notebook.js +0 -211
  171. package/dist/utils/parquet-metadata.d.ts +0 -64
  172. package/dist/utils/parquet-metadata.js +0 -262
  173. package/dist/utils/stac-geoparquet.d.ts +0 -90
  174. package/dist/utils/stac-geoparquet.js +0 -223
  175. package/dist/utils/stac-hydrate.d.ts +0 -38
  176. package/dist/utils/stac-hydrate.js +0 -243
  177. package/dist/utils/stac.d.ts +0 -136
  178. package/dist/utils/stac.js +0 -176
  179. package/dist/utils/storage-url.d.ts +0 -90
  180. package/dist/utils/storage-url.js +0 -568
  181. package/dist/utils/wkb.d.ts +0 -43
  182. package/dist/utils/wkb.js +0 -359
package/LICENSE CHANGED
@@ -1,3 +1,8 @@
1
+ Copyright (c) 2026 walkthru.earth <hi@walkthru.earth>
2
+
3
+ This work is licensed under the Creative Commons Attribution 4.0 International License.
4
+ To view a copy of this license, visit http://creativecommons.org/licenses/by/4.0/
5
+
1
6
  Attribution 4.0 International
2
7
 
3
8
  =======================================================================
package/README.md CHANGED
@@ -23,6 +23,7 @@ graph LR
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
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
26
27
  - **i18n** -- English + Arabic with automatic RTL layout
27
28
  - **Zero backend** -- everything runs client-side
28
29
 
@@ -58,9 +59,6 @@ npm install @walkthru-earth/objex
58
59
  ```ts
59
60
  import { parseStorageUrl, formatFileSize } from '@walkthru-earth/objex';
60
61
  import { UrlAdapter } from '@walkthru-earth/objex/storage';
61
- import { parseWKB } from '@walkthru-earth/objex/utils/wkb';
62
- import { buildGeoArrowTables } from '@walkthru-earth/objex/utils/geoarrow';
63
- import { readParquetMetadata } from '@walkthru-earth/objex/utils/parquet-metadata';
64
62
  import { getFileTypeInfo } from '@walkthru-earth/objex/file-icons';
65
63
  ```
66
64
 
@@ -95,20 +93,30 @@ Full per-module reference docs: [`packages/objex-utils/docs/`](packages/objex-ut
95
93
 
96
94
  | Export path | What |
97
95
  |-------------|------|
98
- | `@walkthru-earth/objex` | All types, utils, storage, query engine |
96
+ | `@walkthru-earth/objex` | All types, pure utils, storage, query engine |
99
97
  | `./storage` | `StorageAdapter`, `UrlAdapter` |
100
98
  | `./query` | `QueryEngine`, `QueryCancelledError` |
101
- | `./utils/wkb` | `parseWKB`, `toBinary`, `findGeoColumn` |
102
- | `./utils/geoarrow` | `buildGeoArrowTables`, `normalizeGeomType` |
103
- | `./utils/storage-url` | `parseStorageUrl`, `looksLikeUrl` |
104
- | `./utils/parquet-metadata` | `readParquetMetadata`, `extractEpsgFromGeoMeta` |
105
- | `./utils/format` | `formatFileSize`, `formatDate`, `formatValue`, `getFileExtension`, `jsonReplacerBigInt` |
106
- | `./utils/hex` | `generateHexDump` |
107
- | `./utils/column-types` | `classifyType`, `typeColor`, `typeBadgeClass` |
108
99
  | `./file-icons` | `getFileTypeInfo`, `getDuckDbReadFn`, `getViewerKind` |
109
100
  | `./types` | `FileEntry`, `Connection`, `Tab`, `WriteResult`, `Theme` |
110
101
 
111
- The main export also includes `copyToClipboard`, `handleLoadError`, the stac-geoparquet helpers (`isStacGeoparquetSchema`, `stacRowToItem`, `flattenStacBbox`, `pickStacPrimaryAsset`, `resolveStacAssetHref`), and shared constants (`WGS84_CODES`, `STORAGE_KEYS`, `DEFAULT_TARGET_CRS`, etc.).
102
+ The pure utilities live in `@walkthru-earth/objex-utils` and are also re-exported from the package root, so `parseStorageUrl`, `parseWKB`, `buildGeoArrowTables`, `readParquetMetadata`, `formatFileSize`, `generateHexDump`, `classifyType`, and the rest are importable straight from `@walkthru-earth/objex`. The root export also includes `copyToClipboard`, `handleLoadError`, the stac-geoparquet helpers (`isStacGeoparquetSchema`, `stacRowToItem`, `flattenStacBbox`, `pickStacPrimaryAsset`, `resolveStacAssetHref`), and shared constants (`WGS84_CODES`, `STORAGE_KEYS`, `DEFAULT_TARGET_CRS`, etc.).
103
+
104
+ ## Configuration
105
+
106
+ objex reads a bundled `static/config.json` at startup, so a host can customize the app without rebuilding. Pass `?config=<url>` to load a remote config that overrides the bundled one.
107
+
108
+ `config.json` sets the default theme and language, query and mosaic row limits, the basemap list with a per-theme default basemap, and seed connections that load on first visit. The `ui` block toggles whether the connection rail, file tree, and settings panel are shown.
109
+
110
+ Several of these are also reachable as query params, handy for embedding and deep links.
111
+
112
+ | Param | Effect |
113
+ |-------|--------|
114
+ | `?config=<url>` | Load a remote `config.json` instead of the bundled one |
115
+ | `?panel=settings` | Open the settings panel on load |
116
+ | `?rail=hide` / `?rail=show` | Hide or show the connection rail |
117
+ | `?tree=hide` / `?tree=show` | Hide or show the file tree |
118
+
119
+ Users can change theme, language, query limit, and basemap from the in-app settings panel (gear icon). Their changes persist locally and take precedence over config defaults, while settings they never touched keep following the config.
112
120
 
113
121
  ## Quick Start (Development)
114
122
 
@@ -9,6 +9,7 @@ import LinkIcon from '@lucide/svelte/icons/link';
9
9
  import Loader2Icon from '@lucide/svelte/icons/loader-2';
10
10
  import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
11
11
  import SearchIcon from '@lucide/svelte/icons/search';
12
+ import { getNativeScheme } from '@walkthru-earth/objex-utils';
12
13
  import * as ContextMenu from '../ui/context-menu/index.js';
13
14
  import { VIEWER_DIR_EXTENSIONS } from '../../constants.js';
14
15
  import FileTypeIcon from '../../file-icons/FileTypeIcon.svelte';
@@ -17,7 +18,7 @@ import { getAdapter } from '../../storage/index.js';
17
18
  import { browser } from '../../stores/browser.svelte.js';
18
19
  import { tabs } from '../../stores/tabs.svelte.js';
19
20
  import type { Connection, FileEntry } from '../../types.js';
20
- import { getNativeScheme } from '../../utils/cloud-url.js';
21
+ import { buildHttpsUrlForConnection } from '../../utils/signed-url.js';
21
22
  import { syncUrlParam } from '../../utils/url-state.js';
22
23
  import { detectZarrMarkers } from '../../utils/zarr.js';
23
24
  import { openZarrTab } from '../../utils/zarr-tab.js';
@@ -48,6 +49,8 @@ let rootLoading = $state(true);
48
49
  let rootLoadingMore = $state(false);
49
50
  let rootContinuationToken = $state<string | undefined>();
50
51
  let rootHasMore = $state(false);
52
+ /** Set when the root listing fails: 'cors' for a blocked fetch, 'generic' otherwise. */
53
+ let rootError = $state<'cors' | 'generic' | null>(null);
51
54
  let filterQuery = $state('');
52
55
  let scrollEl = $state<HTMLElement>();
53
56
  /** Paths of directories detected as Zarr stores (by marker files in children). */
@@ -222,15 +225,9 @@ function handleChevronClick(e: MouseEvent, node: TreeNode) {
222
225
 
223
226
  // ---------- URL builders ----------
224
227
 
225
- /** Build HTTPS URL for a file path. */
228
+ /** Build a provider-aware HTTPS URL for a file path (percent-encoded for copy). */
226
229
  function buildHttpUrl(path: string): string {
227
- const conn = connection;
228
- if (conn.endpoint) {
229
- const base = conn.endpoint.replace(/\/$/, '');
230
- return `${base}/${conn.bucket}/${encodeKeyPath(path)}`;
231
- }
232
- // Default AWS S3
233
- return `https://s3.${conn.region}.amazonaws.com/${conn.bucket}/${encodeKeyPath(path)}`;
230
+ return buildHttpsUrlForConnection(connection, path, { encode: true });
234
231
  }
235
232
 
236
233
  /** Build provider-native URI (s3://, gs://, r2://, az://). */
@@ -240,14 +237,8 @@ function buildNativeUri(path: string): string {
240
237
  return `${scheme}://${conn.bucket}/${path}`;
241
238
  }
242
239
 
243
- // getNativeScheme imported from $lib/utils/url.js
244
-
245
- function encodeKeyPath(key: string): string {
246
- return key
247
- .split('/')
248
- .map((s) => encodeURIComponent(s))
249
- .join('/');
250
- }
240
+ // getNativeScheme imported from @walkthru-earth/objex-utils (cloud-url.ts)
241
+ // buildHttpsUrlForConnection imported from utils/signed-url (provider-aware base)
251
242
 
252
243
  // ---------- Clipboard ----------
253
244
 
@@ -368,6 +359,19 @@ async function expandToPath(path: string) {
368
359
 
369
360
  // ---------- Root loading ----------
370
361
 
362
+ /**
363
+ * Browsers collapse a CORS block and a true network failure into the same
364
+ * opaque `TypeError` ('Failed to fetch' in Chrome, 'NetworkError when
365
+ * attempting to fetch resource' in Firefox, 'Load failed' in Safari), so this
366
+ * can't tell them apart. For a cloud bucket the dominant cause is a missing
367
+ * browser CORS policy, so we lead the message with that.
368
+ */
369
+ function isLikelyCorsError(err: unknown): boolean {
370
+ const e = err as { name?: string; message?: string } | null;
371
+ if (!e || e.name !== 'TypeError' || typeof e.message !== 'string') return false;
372
+ return /failed to fetch|networkerror|load failed/i.test(e.message);
373
+ }
374
+
371
375
  // Load root entries when connection changes
372
376
  $effect(() => {
373
377
  const _connId = connection.id;
@@ -376,6 +380,7 @@ $effect(() => {
376
380
 
377
381
  async function loadRoot() {
378
382
  rootLoading = true;
383
+ rootError = null;
379
384
  rootContinuationToken = undefined;
380
385
  rootHasMore = false;
381
386
  detectedZarrPaths = new Set();
@@ -399,6 +404,7 @@ async function loadRoot() {
399
404
  }
400
405
  } catch (err) {
401
406
  console.error('[FileTree] Error loading root:', err);
407
+ rootError = isLikelyCorsError(err) ? 'cors' : 'generic';
402
408
  } finally {
403
409
  rootLoading = false;
404
410
  }
@@ -456,6 +462,15 @@ async function loadMoreRoot() {
456
462
  <div class="flex items-center justify-center py-8">
457
463
  <Loader2Icon class="size-4 animate-spin text-muted-foreground" />
458
464
  </div>
465
+ {:else if rootError}
466
+ <div class="space-y-1.5 px-3 py-6 text-center text-xs">
467
+ <p class="font-medium text-foreground">
468
+ {rootError === 'cors' ? t('fileTree.corsError') : t('fileTree.loadError')}
469
+ </p>
470
+ <p class="text-muted-foreground">
471
+ {rootError === 'cors' ? t('fileTree.corsHint') : t('fileTree.loadErrorHint')}
472
+ </p>
473
+ </div>
459
474
  {:else if filteredNodes.length === 0}
460
475
  <div class="px-3 py-6 text-center text-xs text-muted-foreground">
461
476
  {filterQuery ? t('fileTree.noMatch') : t('fileTree.emptyBucket')}
@@ -9,7 +9,6 @@ declare const __THIRD_PARTY_LICENSES__: {
9
9
  <script lang="ts">
10
10
  import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
11
11
  import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
12
- import GithubIcon from '@lucide/svelte/icons/github';
13
12
  import {
14
13
  Sheet,
15
14
  SheetContent,
@@ -75,7 +74,11 @@ $effect(() => {
75
74
  rel="noopener noreferrer"
76
75
  class="inline-flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
77
76
  >
78
- <GithubIcon class="size-4" />
77
+ <svg class="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
78
+ <path
79
+ d="M12 .5C5.37.5 0 5.78 0 12.292c0 5.211 3.438 9.63 8.205 11.188.6.111.82-.254.82-.567 0-.279-.01-1.02-.015-2.002-3.338.711-4.042-1.582-4.042-1.582-.546-1.361-1.333-1.724-1.333-1.724-1.089-.731.083-.716.083-.716 1.205.082 1.84 1.215 1.84 1.215 1.07 1.797 2.807 1.278 3.492.977.108-.76.42-1.279.762-1.573-2.665-.295-5.466-1.309-5.466-5.827 0-1.287.465-2.339 1.235-3.164-.135-.295-.54-1.494.105-3.116 0 0 1.005-.31 3.3 1.209.957-.262 1.98-.392 3-.397 1.02.005 2.04.135 3 .397 2.28-1.519 3.285-1.209 3.285-1.209.645 1.622.24 2.821.12 3.116.765.825 1.23 1.877 1.23 3.164 0 4.53-2.805 5.527-5.475 5.817.42.354.81 1.077.81 2.182 0 1.578-.015 2.846-.015 3.229 0 .309.21.678.825.561C20.565 21.917 24 17.495 24 12.292 24 5.78 18.63.5 12 .5z"
80
+ />
81
+ </svg>
79
82
  {t('about.sourceCode')}
80
83
  </a>
81
84
 
@@ -10,6 +10,7 @@ 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
14
  import { Button } from '../ui/button/index.js';
14
15
  import { Input } from '../ui/input/index.js';
15
16
  import {
@@ -33,7 +34,6 @@ import {
33
34
  } from '../../storage/providers.js';
34
35
  import { connections, DuplicateConnectionError } from '../../stores/connections.svelte.js';
35
36
  import type { Connection, ConnectionConfig } from '../../types.js';
36
- import { describeParseResult, looksLikeUrl, parseStorageUrl } from '../../utils/storage-url.js';
37
37
 
38
38
  interface Props {
39
39
  open: boolean;
@@ -0,0 +1,237 @@
1
+ <script lang="ts">
2
+ import CheckIcon from '@lucide/svelte/icons/check';
3
+ import CopyIcon from '@lucide/svelte/icons/copy';
4
+ import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
5
+ import {
6
+ Sheet,
7
+ SheetContent,
8
+ SheetDescription,
9
+ SheetHeader,
10
+ SheetTitle
11
+ } from '../ui/sheet/index.js';
12
+ import { t } from '../../i18n/index.svelte.js';
13
+ import { appConfig } from '../../stores/config.svelte.js';
14
+ import { settings } from '../../stores/settings.svelte.js';
15
+ import type { Theme } from '../../types.js';
16
+
17
+ interface Props {
18
+ open: boolean;
19
+ }
20
+
21
+ let { open = $bindable(false) }: Props = $props();
22
+
23
+ const themes: Theme[] = ['light', 'dark', 'system'];
24
+
25
+ let copied = $state(false);
26
+
27
+ function buildExportConfig(): string {
28
+ const cfg = appConfig.value;
29
+ const exported = {
30
+ defaults: {
31
+ theme: settings.theme,
32
+ locale: settings.locale,
33
+ featureLimit: settings.featureLimit,
34
+ mosaicItemLimit: settings.mosaicItemLimit
35
+ },
36
+ ui: {
37
+ showConnectionRail: settings.showConnectionRail,
38
+ showFileTree: settings.showFileTree,
39
+ showSettings: cfg.ui.showSettings
40
+ },
41
+ basemaps: cfg.basemaps,
42
+ defaultBasemap: cfg.defaultBasemap,
43
+ connections: cfg.connections
44
+ };
45
+ return JSON.stringify(exported, null, 2);
46
+ }
47
+
48
+ async function copyConfig() {
49
+ await navigator.clipboard.writeText(buildExportConfig());
50
+ copied = true;
51
+ setTimeout(() => (copied = false), 1500);
52
+ }
53
+ </script>
54
+
55
+ <Sheet bind:open>
56
+ <SheetContent side="bottom" class="max-h-[85vh] sm:mx-auto sm:max-w-lg sm:rounded-t-lg">
57
+ <SheetHeader>
58
+ <SheetTitle>{t('settings.title')}</SheetTitle>
59
+ <SheetDescription class="sr-only">{t('settings.title')}</SheetDescription>
60
+ </SheetHeader>
61
+
62
+ <div class="flex flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6">
63
+ {#if appConfig.status === 'custom'}
64
+ <div class="rounded-md bg-primary/10 px-3 py-1.5 text-xs text-primary">
65
+ {t('settings.customConfig')}
66
+ </div>
67
+ {/if}
68
+
69
+ <!-- Appearance -->
70
+ <section class="flex flex-col gap-2">
71
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
72
+ {t('settings.appearance')}
73
+ </h3>
74
+ <div class="flex gap-2">
75
+ {#each themes as th}
76
+ <button
77
+ class="flex-1 rounded-md border px-3 py-1.5 text-sm transition-colors {settings.theme ===
78
+ th
79
+ ? 'border-primary bg-primary/10 text-primary'
80
+ : 'border-border text-muted-foreground hover:text-foreground'}"
81
+ onclick={() => settings.setTheme(th)}
82
+ >
83
+ {t(`theme.${th}`)}
84
+ </button>
85
+ {/each}
86
+ </div>
87
+ </section>
88
+
89
+ <!-- Language -->
90
+ <section class="flex flex-col gap-2">
91
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
92
+ {t('settings.language')}
93
+ </h3>
94
+ <div class="flex gap-2">
95
+ <button
96
+ class="flex-1 rounded-md border px-3 py-1.5 text-sm transition-colors {settings.locale ===
97
+ 'en'
98
+ ? 'border-primary bg-primary/10 text-primary'
99
+ : 'border-border text-muted-foreground hover:text-foreground'}"
100
+ onclick={() => settings.setLocale('en')}
101
+ >
102
+ English
103
+ </button>
104
+ <button
105
+ class="flex-1 rounded-md border px-3 py-1.5 text-sm transition-colors {settings.locale ===
106
+ 'ar'
107
+ ? 'border-primary bg-primary/10 text-primary'
108
+ : 'border-border text-muted-foreground hover:text-foreground'}"
109
+ onclick={() => settings.setLocale('ar')}
110
+ >
111
+ العربية
112
+ </button>
113
+ </div>
114
+ </section>
115
+
116
+ <!-- Map -->
117
+ {#if appConfig.value.basemaps.length > 0}
118
+ <section class="flex flex-col gap-2">
119
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
120
+ {t('settings.map')}
121
+ </h3>
122
+ <div class="flex flex-wrap gap-2">
123
+ <button
124
+ class="rounded-md border px-3 py-1.5 text-sm transition-colors {settings.basemapId ===
125
+ undefined
126
+ ? 'border-primary bg-primary/10 text-primary'
127
+ : 'border-border text-muted-foreground hover:text-foreground'}"
128
+ onclick={() => settings.setBasemap(undefined)}
129
+ >
130
+ {t('settings.basemapAuto')}
131
+ </button>
132
+ {#each appConfig.value.basemaps as bm (bm.id)}
133
+ <button
134
+ class="rounded-md border px-3 py-1.5 text-sm transition-colors {settings.basemapId ===
135
+ bm.id
136
+ ? 'border-primary bg-primary/10 text-primary'
137
+ : 'border-border text-muted-foreground hover:text-foreground'}"
138
+ onclick={() => settings.setBasemap(bm.id)}
139
+ >
140
+ {bm.label}
141
+ </button>
142
+ {/each}
143
+ </div>
144
+ </section>
145
+ {/if}
146
+
147
+ <!-- Data -->
148
+ <section class="flex flex-col gap-3">
149
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
150
+ {t('settings.data')}
151
+ </h3>
152
+ <label class="flex flex-col gap-1 text-sm">
153
+ <span>{t('settings.rowLimit')}</span>
154
+ <input
155
+ type="number"
156
+ min="1"
157
+ class="rounded-md border border-border bg-background px-2 py-1 text-sm"
158
+ value={settings.featureLimit}
159
+ onchange={(e) => settings.setFeatureLimit(Number(e.currentTarget.value))}
160
+ />
161
+ <span class="text-xs text-muted-foreground">{t('settings.rowLimitHelp')}</span>
162
+ </label>
163
+ <label class="flex flex-col gap-1 text-sm">
164
+ <span>{t('settings.mosaicLimit')}</span>
165
+ <input
166
+ type="number"
167
+ min="1"
168
+ class="rounded-md border border-border bg-background px-2 py-1 text-sm"
169
+ value={settings.mosaicItemLimit}
170
+ onchange={(e) => settings.setMosaicItemLimit(Number(e.currentTarget.value))}
171
+ />
172
+ <span class="text-xs text-muted-foreground">{t('settings.mosaicLimitHelp')}</span>
173
+ </label>
174
+ </section>
175
+
176
+ <!-- Interface -->
177
+ <section class="flex flex-col gap-3">
178
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
179
+ {t('settings.interface')}
180
+ </h3>
181
+ <label class="flex items-center justify-between gap-2 text-sm">
182
+ <span class="flex flex-col">
183
+ <span>{t('settings.showConnectionRail')}</span>
184
+ {#if settings.railLockedByParam}
185
+ <span class="text-xs text-muted-foreground">{t('settings.lockedByLink')}</span>
186
+ {/if}
187
+ </span>
188
+ <input
189
+ type="checkbox"
190
+ class="size-4"
191
+ disabled={settings.railLockedByParam}
192
+ checked={settings.showConnectionRail}
193
+ onchange={(e) => settings.setShowConnectionRail(e.currentTarget.checked)}
194
+ />
195
+ </label>
196
+ <label class="flex items-center justify-between gap-2 text-sm">
197
+ <span class="flex flex-col">
198
+ <span>{t('settings.showFileTree')}</span>
199
+ {#if settings.treeLockedByParam}
200
+ <span class="text-xs text-muted-foreground">{t('settings.lockedByLink')}</span>
201
+ {/if}
202
+ </span>
203
+ <input
204
+ type="checkbox"
205
+ class="size-4"
206
+ disabled={settings.treeLockedByParam}
207
+ checked={settings.showFileTree}
208
+ onchange={(e) => settings.setShowFileTree(e.currentTarget.checked)}
209
+ />
210
+ </label>
211
+ </section>
212
+
213
+ <!-- Footer actions -->
214
+ <div class="flex items-center justify-between gap-2 border-t pt-4">
215
+ <button
216
+ class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
217
+ onclick={() => settings.reset()}
218
+ >
219
+ <RotateCcwIcon class="size-3.5" />
220
+ {t('settings.reset')}
221
+ </button>
222
+ <button
223
+ class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
224
+ onclick={copyConfig}
225
+ >
226
+ {#if copied}
227
+ <CheckIcon class="size-3.5" />
228
+ {t('settings.copied')}
229
+ {:else}
230
+ <CopyIcon class="size-3.5" />
231
+ {t('settings.copyConfig')}
232
+ {/if}
233
+ </button>
234
+ </div>
235
+ </div>
236
+ </SheetContent>
237
+ </Sheet>
@@ -0,0 +1,6 @@
1
+ interface Props {
2
+ open: boolean;
3
+ }
4
+ declare const SettingsSheet: import("svelte").Component<Props, {}, "open">;
5
+ type SettingsSheet = ReturnType<typeof SettingsSheet>;
6
+ export default SettingsSheet;
@@ -4,7 +4,9 @@ import DatabaseIcon from '@lucide/svelte/icons/database';
4
4
  import GlobeIcon from '@lucide/svelte/icons/globe';
5
5
  import PencilIcon from '@lucide/svelte/icons/pencil';
6
6
  import PlusIcon from '@lucide/svelte/icons/plus';
7
+ import SettingsIcon from '@lucide/svelte/icons/settings';
7
8
  import TrashIcon from '@lucide/svelte/icons/trash-2';
9
+ import { type DetectedHost, detectHostBucket, parseStorageUrl } from '@walkthru-earth/objex-utils';
8
10
  import {
9
11
  ContextMenu,
10
12
  ContextMenuContent,
@@ -21,18 +23,21 @@ import {
21
23
  } from '../ui/tooltip/index.js';
22
24
  import { t } from '../../i18n/index.svelte.js';
23
25
  import { browser } from '../../stores/browser.svelte.js';
26
+ import { appConfig } from '../../stores/config.svelte.js';
24
27
  import { connections } from '../../stores/connections.svelte.js';
25
28
  import { credentialStore, loadFromNative } from '../../stores/credentials.svelte.js';
26
29
  import { eagerUrlTabId, tabs } from '../../stores/tabs.svelte.js';
27
30
  import type { Connection } from '../../types.js';
28
- import { type DetectedHost, detectHostBucket } from '../../utils/host-detection.js';
29
- import { parseStorageUrl } from '../../utils/storage-url.js';
30
31
  import { clearUrlState, syncUrlParam } from '../../utils/url-state.js';
31
32
  import AboutSheet from './AboutSheet.svelte';
32
33
  import ConnectionDialog from './ConnectionDialog.svelte';
33
34
  import LocaleToggle from './LocaleToggle.svelte';
34
35
  import ThemeToggle from './ThemeToggle.svelte';
35
36
 
37
+ // Settings panel is owned by +page.svelte so it stays reachable even when the
38
+ // connection rail is hidden; the gear button just requests it be opened.
39
+ let { onOpenSettings }: { onOpenSettings?: () => void } = $props();
40
+
36
41
  let aboutOpen = $state(false);
37
42
  let dialogOpen = $state(false);
38
43
  let editingConnection = $state<Connection | null>(null);
@@ -42,9 +47,9 @@ let autoConnecting = $state(false);
42
47
  $effect(() => {
43
48
  connections.load().then(async () => {
44
49
  await handleAutoDetection();
45
- // On first visit (no connections, no URL params), load the demo bucket
50
+ // On first visit (no connections, no URL params), seed connections from config
46
51
  if (connections.items.length === 0 && !new URL(window.location.href).searchParams.has('url')) {
47
- await loadDemoConnection();
52
+ await loadConfigConnections();
48
53
  }
49
54
  });
50
55
  });
@@ -94,9 +99,18 @@ async function handleAutoDetection() {
94
99
  return;
95
100
  }
96
101
 
102
+ const hasUrlParam = url.searchParams.has('url');
103
+
97
104
  // A recognizable storage provider was detected. Close the eagerly-opened
98
105
  // URL tab (if any) so we can re-open it with a proper connectionId that
99
106
  // provides S3 credentials and endpoint config for DuckDB httpfs.
107
+ // Mark the close + reopen as a migration so the tab-sync effect in
108
+ // +page.svelte doesn't clear `?url=` / `#hash` during the empty-tabs
109
+ // window between close and open. We end migration in `finally` so an
110
+ // abandoned credential prompt or thrown error still resets the flag.
111
+ const isMigrating = hasUrlParam;
112
+ if (isMigrating) tabs.beginMigration();
113
+
100
114
  if (rawUrl) {
101
115
  const eagerTabId = eagerUrlTabId(rawUrl);
102
116
  const eagerTab = tabs.items.find((t) => t.id === eagerTabId);
@@ -105,12 +119,17 @@ async function handleAutoDetection() {
105
119
  }
106
120
  }
107
121
 
108
- const hasUrlParam = url.searchParams.has('url');
109
-
110
122
  if (hasUrlParam) {
111
123
  // Auto-connect immediately for ?url= param (zero-friction)
112
124
  autoConnecting = true;
113
125
  try {
126
+ // TODO(stac-storage-ext): when `rawUrl` resolves to STAC content,
127
+ // peek-fetch the JSON, classify with `classifyStac`, pick the first
128
+ // item with non-empty hints, and pass `detected` through
129
+ // `applyStacItemStorageHints(detected, item)` BEFORE
130
+ // `saveHostConnection` so `storage:region` / `storage:platform` /
131
+ // `storage:requester_pays` flow into the auto-created connection.
132
+ // Helper lives in `utils/host-detection.ts` -- modular, callers opt in.
114
133
  const connId = await connections.saveHostConnection(detected);
115
134
  const conn = connections.getById(connId);
116
135
  if (!conn) return;
@@ -160,10 +179,44 @@ async function handleAutoDetection() {
160
179
  syncUrlParam(conn, prefixParam || undefined);
161
180
  } finally {
162
181
  autoConnecting = false;
182
+ if (isMigrating) tabs.endMigration();
163
183
  }
164
184
  } else {
165
185
  // Show indicator for hostname-detected bucket
166
186
  detectedHost = detected;
187
+ if (isMigrating) tabs.endMigration();
188
+ }
189
+ }
190
+
191
+ async function loadConfigConnections() {
192
+ const seeds = appConfig.value.connections;
193
+ if (seeds.length === 0) {
194
+ // No configured connections (e.g. config failed to load): preserve the
195
+ // historic first-run demo bucket so the empty app is never a dead end.
196
+ await loadDemoConnection();
197
+ return;
198
+ }
199
+ let firstAnon: Connection | null = null;
200
+ for (const seed of seeds) {
201
+ const { id } = await connections.save({
202
+ name: seed.name,
203
+ provider: seed.provider,
204
+ endpoint: seed.endpoint ?? '',
205
+ bucket: seed.bucket,
206
+ region: seed.region ?? '',
207
+ anonymous: seed.anonymous ?? false,
208
+ ...(seed.authMethod ? { authMethod: seed.authMethod } : {}),
209
+ ...(seed.rootPrefix ? { rootPrefix: seed.rootPrefix } : {})
210
+ });
211
+ const conn = connections.getById(id);
212
+ if (conn?.anonymous && !firstAnon) firstAnon = conn;
213
+ }
214
+ // Auto-open the first public bucket so the demo flow stays zero-click.
215
+ // Private seeds remain as un-browsed rows; clicking one runs the normal
216
+ // ensureCredentials prompt via handleBrowseConnection.
217
+ if (firstAnon) {
218
+ browser.browse(firstAnon);
219
+ syncUrlParam(firstAnon);
167
220
  }
168
221
  }
169
222
 
@@ -344,6 +397,20 @@ async function handleBrowseConnection(connection: Connection) {
344
397
 
345
398
  <!-- Bottom actions -->
346
399
  <div class="mt-auto flex flex-col items-center gap-1 pt-2">
400
+ {#if appConfig.value.ui.showSettings}
401
+ <Tooltip>
402
+ <TooltipTrigger>
403
+ <button
404
+ class="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
405
+ onclick={() => onOpenSettings?.()}
406
+ aria-label={t('settings.tooltip')}
407
+ >
408
+ <SettingsIcon class="size-4" />
409
+ </button>
410
+ </TooltipTrigger>
411
+ <TooltipContent side="right">{t('settings.tooltip')}</TooltipContent>
412
+ </Tooltip>
413
+ {/if}
347
414
  <LocaleToggle />
348
415
  <ThemeToggle />
349
416
  </div>
@@ -1,3 +1,6 @@
1
- declare const Sidebar: import("svelte").Component<Record<string, never>, {}, "">;
1
+ type $$ComponentProps = {
2
+ onOpenSettings?: () => void;
3
+ };
4
+ declare const Sidebar: import("svelte").Component<$$ComponentProps, {}, "">;
2
5
  type Sidebar = ReturnType<typeof Sidebar>;
3
6
  export default Sidebar;
@@ -4,13 +4,13 @@ import FileTextIcon from '@lucide/svelte/icons/file-text';
4
4
  import FolderIcon from '@lucide/svelte/icons/folder';
5
5
  import GlobeIcon from '@lucide/svelte/icons/globe';
6
6
  import InfoIcon from '@lucide/svelte/icons/info';
7
+ import { formatFileSize } from '@walkthru-earth/objex-utils';
7
8
  import { Separator } from '../ui/separator/index.js';
8
9
  import { getFileTypeInfo } from '../../file-icons/index.js';
9
10
  import { t } from '../../i18n/index.svelte.js';
10
11
  import { browser } from '../../stores/browser.svelte.js';
11
12
  import { files } from '../../stores/files.svelte.js';
12
13
  import { tabs } from '../../stores/tabs.svelte.js';
13
- import { formatFileSize } from '../../utils/format.js';
14
14
  import SafeLockToggle from './SafeLockToggle.svelte';
15
15
 
16
16
  let isBrowsingRemote = $derived(browser.activeConnection !== null);
@@ -2,14 +2,14 @@
2
2
  import DatabaseIcon from '@lucide/svelte/icons/database';
3
3
  import FileTextIcon from '@lucide/svelte/icons/file-text';
4
4
  import XIcon from '@lucide/svelte/icons/x';
5
+ import { copyToClipboard } from '@walkthru-earth/objex-utils';
5
6
  import type { Snippet } from 'svelte';
6
7
  import { Button } from '../ui/button/index.js';
7
8
  import * as ContextMenu from '../ui/context-menu/index.js';
8
9
  import { ScrollArea } from '../ui/scroll-area/index.js';
9
10
  import { t } from '../../i18n/index.svelte.js';
10
11
  import { tabs } from '../../stores/tabs.svelte.js';
11
- import { copyToClipboard } from '../../utils/clipboard.js';
12
- import { buildHttpsUrl, buildStorageUrl } from '../../utils/url.js';
12
+ import { buildHttpsUrl, buildStorageUrl } from '../../utils/signed-url.js';
13
13
 
14
14
  let { leading }: { leading?: Snippet } = $props();
15
15
 
@@ -1,4 +1,4 @@
1
1
  import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
2
- declare const ContextMenuRadioGroup: import("svelte").Component<ContextMenuPrimitive.RadioGroupProps, {}, "value" | "ref">;
2
+ declare const ContextMenuRadioGroup: import("svelte").Component<ContextMenuPrimitive.RadioGroupProps, {}, "ref" | "value">;
3
3
  type ContextMenuRadioGroup = ReturnType<typeof ContextMenuRadioGroup>;
4
4
  export default ContextMenuRadioGroup;
@@ -1,4 +1,4 @@
1
1
  import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
2
- declare const DropdownMenuCheckboxGroup: import("svelte").Component<DropdownMenuPrimitive.CheckboxGroupProps, {}, "value" | "ref">;
2
+ declare const DropdownMenuCheckboxGroup: import("svelte").Component<DropdownMenuPrimitive.CheckboxGroupProps, {}, "ref" | "value">;
3
3
  type DropdownMenuCheckboxGroup = ReturnType<typeof DropdownMenuCheckboxGroup>;
4
4
  export default DropdownMenuCheckboxGroup;