@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
@@ -17,6 +17,8 @@ import { tabResources } from '../../stores/tab-resources.svelte.js';
17
17
  import type { Tab } from '../../types';
18
18
  import { detectRTL, processDirection, renderMarkdown } from '../../utils/markdown';
19
19
  import { getQueryEngine } from '../../query/index.js';
20
+ import ViewerHeader from './ViewerHeader.svelte';
21
+ import ViewerStatus from './ViewerStatus.svelte';
20
22
 
21
23
  let mermaidInitialized = false;
22
24
  const CAIRO_FONT = '"Cairo", sans-serif';
@@ -204,24 +206,22 @@ async function saveMarkdown(markdown: string) {
204
206
  editMode = false;
205
207
  await loadMarkdown();
206
208
  } catch (err) {
207
- error = err instanceof Error ? err.message : String(err);
209
+ error = handleLoadError(err);
208
210
  }
209
211
  }
210
212
  </script>
211
213
 
212
214
  <div class="flex h-full flex-col">
213
- <div
214
- 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"
215
- >
216
- <span class="truncate max-w-[120px] text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300">{tab.name}</span>
217
- <Badge variant="secondary">{t('markdown.badge')}</Badge>
218
- {#if hasSqlBlocks}
219
- <Badge variant="outline" class="border-blue-200 text-blue-600 dark:border-blue-800 dark:text-blue-300">
220
- {t('markdown.evidence')}
221
- </Badge>
222
- {/if}
223
-
224
- <div class="ms-auto">
215
+ <ViewerHeader {tab}>
216
+ {#snippet badge()}
217
+ <Badge variant="secondary">{t('markdown.badge')}</Badge>
218
+ {#if hasSqlBlocks}
219
+ <Badge variant="outline" class="border-blue-200 text-blue-600 dark:border-blue-800 dark:text-blue-300">
220
+ {t('markdown.evidence')}
221
+ </Badge>
222
+ {/if}
223
+ {/snippet}
224
+ {#snippet actions()}
225
225
  <Button
226
226
  variant="ghost"
227
227
  size="sm"
@@ -230,18 +230,14 @@ async function saveMarkdown(markdown: string) {
230
230
  >
231
231
  {editMode ? t('markdown.view') : t('markdown.edit')}
232
232
  </Button>
233
- </div>
234
- </div>
233
+ {/snippet}
234
+ </ViewerHeader>
235
235
 
236
236
  <div class="flex-1 overflow-auto">
237
237
  {#if loading}
238
- <div class="flex h-full items-center justify-center">
239
- <p class="text-sm text-zinc-400">Loading...</p>
240
- </div>
238
+ <ViewerStatus kind="loading" />
241
239
  {:else if error}
242
- <div class="flex h-full items-center justify-center">
243
- <p class="text-sm text-red-400">{error}</p>
244
- </div>
240
+ <ViewerStatus kind="error" message={error} />
245
241
  {:else if editMode}
246
242
  {#await import('../editor/MilkdownEditor.svelte') then MilkdownEditor}
247
243
  <MilkdownEditor.default
@@ -6,6 +6,8 @@ import { getAdapter } from '../../storage/index.js';
6
6
  import { tabResources } from '../../stores/tab-resources.svelte.js';
7
7
  import type { Tab } from '../../types';
8
8
  import { buildHttpsUrl, canStreamDirectly } from '../../utils/signed-url.js';
9
+ import ViewerHeader from './ViewerHeader.svelte';
10
+ import ViewerStatus from './ViewerStatus.svelte';
9
11
 
10
12
  let { tab }: { tab: Tab } = $props();
11
13
 
@@ -72,22 +74,19 @@ onDestroy(cleanup);
72
74
  </script>
73
75
 
74
76
  <div class="flex h-full flex-col">
75
- <div class="flex items-center gap-2 border-b border-zinc-200 px-4 py-2 dark:border-zinc-800">
76
- <span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{tab.name}</span>
77
- <span
78
- class="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400"
79
- >
80
- {mediaType}
81
- </span>
82
- </div>
77
+ <ViewerHeader {tab}>
78
+ {#snippet badge()}
79
+ <span class="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
80
+ {mediaType}
81
+ </span>
82
+ {/snippet}
83
+ </ViewerHeader>
83
84
 
84
85
  <div class="flex flex-1 items-center justify-center bg-zinc-950 p-4">
85
86
  {#if loading}
86
- <p class="text-sm text-zinc-400">Loading {mediaType}...</p>
87
+ <ViewerStatus kind="loading" />
87
88
  {:else if error}
88
- <div class="rounded-lg border border-red-300 bg-red-50 px-6 py-4 text-center dark:border-red-800 dark:bg-red-950">
89
- <p class="text-sm text-red-600 dark:text-red-400">{error}</p>
90
- </div>
89
+ <ViewerStatus kind="error" message={error} />
91
90
  {:else if mediaSrc}
92
91
  {#if mediaType === 'video'}
93
92
  <video
@@ -7,7 +7,6 @@ import { onDestroy } from 'svelte';
7
7
  import { Badge } from '../ui/badge/index.js';
8
8
  import { Button } from '../ui/button/index.js';
9
9
  import * as DropdownMenu from '../ui/dropdown-menu/index.js';
10
- import { Separator } from '../ui/separator/index.js';
11
10
  import { t } from '../../i18n/index.svelte.js';
12
11
  import { getAdapter } from '../../storage/index.js';
13
12
  import { tabResources } from '../../stores/tab-resources.svelte.js';
@@ -18,6 +17,8 @@ import {
18
17
  loadModel,
19
18
  type ModelScene
20
19
  } from '../../utils/model3d';
20
+ import ViewerHeader from './ViewerHeader.svelte';
21
+ import ViewerStatus from './ViewerStatus.svelte';
21
22
 
22
23
  let { tab }: { tab: Tab } = $props();
23
24
 
@@ -101,19 +102,15 @@ onDestroy(cleanup);
101
102
  </script>
102
103
 
103
104
  <div class="flex h-full flex-col">
104
- <div
105
- 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"
106
- >
107
- <span class="truncate max-w-[120px] text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300">{tab.name}</span>
108
- <Badge variant="secondary">{t('model.badge')}</Badge>
109
-
110
- {#if meshCount > 0}
111
- <span class="hidden text-xs text-zinc-400 sm:inline">
112
- {meshCount} {t('model.meshes')} &middot; {vertexCount.toLocaleString()} {t('model.vertices')}
113
- </span>
114
- {/if}
105
+ <ViewerHeader {tab}>
106
+ {#snippet badge()}<Badge variant="secondary">{t('model.badge')}</Badge>{/snippet}
107
+ {#snippet actions()}
108
+ {#if meshCount > 0}
109
+ <span class="hidden text-xs text-muted-foreground sm:inline">
110
+ {meshCount} {t('model.meshes')} &middot; {vertexCount.toLocaleString()} {t('model.vertices')}
111
+ </span>
112
+ {/if}
115
113
 
116
- <div class="ms-auto flex items-center gap-1">
117
114
  <!-- Desktop controls -->
118
115
  <div class="hidden items-center gap-1 sm:flex">
119
116
  <Button
@@ -135,7 +132,7 @@ onDestroy(cleanup);
135
132
  <!-- Mobile overflow menu -->
136
133
  <div class="flex sm:hidden">
137
134
  <DropdownMenu.Root>
138
- <DropdownMenu.Trigger class="rounded p-1 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
135
+ <DropdownMenu.Trigger class="rounded p-1 text-muted-foreground hover:bg-muted">
139
136
  <EllipsisVerticalIcon class="size-4" />
140
137
  </DropdownMenu.Trigger>
141
138
  <DropdownMenu.Content align="end" class="w-40">
@@ -147,18 +144,18 @@ onDestroy(cleanup);
147
144
  </DropdownMenu.Content>
148
145
  </DropdownMenu.Root>
149
146
  </div>
150
- </div>
151
- </div>
147
+ {/snippet}
148
+ </ViewerHeader>
152
149
 
153
150
  <div class="relative flex-1 overflow-hidden">
154
151
  {#if loading}
155
- <div class="absolute inset-0 z-10 flex items-center justify-center bg-zinc-900/80">
156
- <p class="text-sm text-zinc-400">{t('model.loading')}</p>
152
+ <div class="absolute inset-0 z-10 bg-zinc-900/80">
153
+ <ViewerStatus kind="loading" message={t('model.loading')} />
157
154
  </div>
158
155
  {/if}
159
156
  {#if error}
160
- <div class="absolute inset-0 z-10 flex items-center justify-center bg-zinc-900/80">
161
- <p class="text-sm text-red-400">{error}</p>
157
+ <div class="absolute inset-0 z-10 bg-zinc-900/80">
158
+ <ViewerStatus kind="error" message={error} />
162
159
  </div>
163
160
  {/if}
164
161
  <canvas bind:this={canvasEl} class="h-full w-full"></canvas>
@@ -10,8 +10,11 @@ import {
10
10
  compositeFromUrl,
11
11
  compositeToUrl,
12
12
  extractCogAssets,
13
+ handleLoadError,
14
+ isAbortError,
13
15
  isSingleAssetComposite,
14
16
  isStacItem,
17
+ LruCache,
15
18
  PRESETS,
16
19
  pickNaturalColorComposite,
17
20
  presetMatchesComposite,
@@ -91,7 +94,7 @@ let abortController = new AbortController();
91
94
  let mapRef: maplibregl.Map | null = null;
92
95
  let overlayRef: MapboxOverlay | null = null;
93
96
  let hasFittedOnce = false;
94
- let presignCache = new Map<string, Promise<string>>();
97
+ const presignCache = new LruCache<string, Promise<string>>({ max: 64 });
95
98
 
96
99
  // Pixel inspection: same UX as CogViewer / StacMosaicViewer. Click → read one
97
100
  // pixel from each active composite channel's GeoTIFF and show channel/asset/value.
@@ -114,7 +117,7 @@ let detachInspector: (() => void) | null = null;
114
117
  // custom getTileData/renderTile pair that honors per-channel bandIndex picks.
115
118
  // Without this, the single-asset multi-band path (e.g. Sentinel-2 `visual`,
116
119
  // NAIP `image`) silently falls back to bands 0/1/2 regardless of the picker.
117
- let geotiffCache = new Map<string, Promise<GeoTIFF>>();
120
+ const geotiffCache = new LruCache<string, Promise<GeoTIFF>>({ max: 64 });
118
121
  let loadGen = 0;
119
122
  let layerVersion = 0;
120
123
  let rebuildTimer: number | null = null;
@@ -160,8 +163,8 @@ function resetViewer(): void {
160
163
  overlayRef = null;
161
164
  assets = [];
162
165
  composite = null;
163
- presignCache = new Map();
164
- geotiffCache = new Map();
166
+ presignCache.clear();
167
+ geotiffCache.clear();
165
168
  loading = true;
166
169
  error = null;
167
170
  bounds = undefined;
@@ -395,9 +398,9 @@ async function loadItem(map: maplibregl.Map): Promise<void> {
395
398
  if (gen !== loadGen || signal.aborted) return;
396
399
  if (!result.ok) smokeWarning = result.reason;
397
400
  } catch (err) {
398
- if (err instanceof DOMException && err.name === 'AbortError') return;
401
+ if (isAbortError(err)) return;
399
402
  if (gen !== loadGen) return;
400
- smokeWarning = err instanceof Error ? err.message : String(err);
403
+ smokeWarning = handleLoadError(err);
401
404
  }
402
405
  })();
403
406
  }
@@ -421,8 +424,8 @@ async function loadItem(map: maplibregl.Map): Promise<void> {
421
424
  } catch (err) {
422
425
  if (gen !== loadGen) return;
423
426
  if (signal.aborted) return;
424
- if (err instanceof DOMException && err.name === 'AbortError') return;
425
- error = err instanceof Error ? err.message : String(err);
427
+ if (isAbortError(err)) return;
428
+ error = handleLoadError(err);
426
429
  loading = false;
427
430
  }
428
431
  }
@@ -16,6 +16,8 @@ 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 { highlightCodeReversed } from '../../utils/shiki';
19
+ import ViewerHeader from './ViewerHeader.svelte';
20
+ import ViewerStatus from './ViewerStatus.svelte';
19
21
 
20
22
  let { tab }: { tab: Tab } = $props();
21
23
 
@@ -135,23 +137,21 @@ async function copyRaw() {
135
137
  </script>
136
138
 
137
139
  <div class="flex h-full flex-col">
138
- <div
139
- 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"
140
- >
141
- <span class="truncate max-w-[120px] text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300">{tab.name}</span>
142
- <Badge variant="secondary">{t('notebook.badge')}</Badge>
143
- {#if kernelName}
144
- <Badge variant="outline" class="hidden border-orange-200 text-orange-600 sm:inline-flex dark:border-orange-800 dark:text-orange-300">
145
- {kernelName}
146
- </Badge>
147
- {/if}
148
- {#if cellCount > 0}
149
- <span class="hidden text-xs text-muted-foreground sm:inline">
150
- {cellCount} {t('notebook.cells')}
151
- </span>
152
- {/if}
153
-
154
- <div class="ms-auto flex items-center gap-1 sm:gap-2">
140
+ <ViewerHeader {tab}>
141
+ {#snippet badge()}
142
+ <Badge variant="secondary">{t('notebook.badge')}</Badge>
143
+ {#if kernelName}
144
+ <Badge variant="outline" class="hidden border-orange-200 text-orange-600 sm:inline-flex dark:border-orange-800 dark:text-orange-300">
145
+ {kernelName}
146
+ </Badge>
147
+ {/if}
148
+ {#if cellCount > 0}
149
+ <span class="hidden text-xs text-muted-foreground sm:inline">
150
+ {cellCount} {t('notebook.cells')}
151
+ </span>
152
+ {/if}
153
+ {/snippet}
154
+ {#snippet actions()}
155
155
  <!-- Desktop controls -->
156
156
  <div class="hidden items-center gap-1 sm:flex">
157
157
  <Button variant="ghost" size="sm" class="h-7 px-2 text-xs" onclick={toggleCode}>
@@ -165,7 +165,7 @@ async function copyRaw() {
165
165
  <!-- Mobile overflow menu -->
166
166
  <div class="flex sm:hidden">
167
167
  <DropdownMenu.Root>
168
- <DropdownMenu.Trigger class="rounded p-1 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
168
+ <DropdownMenu.Trigger class="rounded p-1 text-muted-foreground hover:bg-muted">
169
169
  <EllipsisVerticalIcon class="size-4" />
170
170
  </DropdownMenu.Trigger>
171
171
  <DropdownMenu.Content align="end" class="w-44">
@@ -178,18 +178,14 @@ async function copyRaw() {
178
178
  </DropdownMenu.Content>
179
179
  </DropdownMenu.Root>
180
180
  </div>
181
- </div>
182
- </div>
181
+ {/snippet}
182
+ </ViewerHeader>
183
183
 
184
184
  <div class="notebook-viewer flex-1 overflow-auto" dir="ltr">
185
185
  {#if loading}
186
- <div class="flex h-full items-center justify-center">
187
- <p class="text-sm text-zinc-400">{t('notebook.loading')}</p>
188
- </div>
186
+ <ViewerStatus kind="loading" message={t('notebook.loading')} />
189
187
  {:else if error}
190
- <div class="flex h-full items-center justify-center">
191
- <p class="text-sm text-red-400">{error}</p>
192
- </div>
188
+ <ViewerStatus kind="error" message={error} />
193
189
  {/if}
194
190
  <div bind:this={container} class="notebook-content" class:hidden={loading || !!error}></div>
195
191
  </div>
@@ -17,6 +17,8 @@ import { tabResources } from '../../stores/tab-resources.svelte.js';
17
17
  import type { Tab } from '../../types';
18
18
  import { loadPdfDocument, loadPdfFromUrl } from '../../utils/pdf';
19
19
  import { buildHttpsUrl, canStreamDirectly } from '../../utils/signed-url.js';
20
+ import ViewerHeader from './ViewerHeader.svelte';
21
+ import ViewerStatus from './ViewerStatus.svelte';
20
22
 
21
23
  const LOAD_TIMEOUT_MS = 20_000;
22
24
 
@@ -136,7 +138,7 @@ async function renderPage(
136
138
  await page.render({ canvasContext: ctx, viewport, canvas } as any).promise;
137
139
  } catch (err) {
138
140
  if (gen === renderGeneration) {
139
- error = err instanceof Error ? err.message : String(err);
141
+ error = handleLoadError(err);
140
142
  }
141
143
  }
142
144
  }
@@ -174,17 +176,10 @@ onDestroy(cleanup);
174
176
  </script>
175
177
 
176
178
  <div class="flex h-full flex-col">
177
- <div
178
- 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"
179
- >
180
- <span
181
- class="truncate max-w-[120px] text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300"
182
- >{tab.name}</span
183
- >
184
- <Badge variant="secondary">{t("pdf.badge")}</Badge>
185
-
186
- {#if totalPages > 0}
187
- <div class="ms-auto flex items-center gap-1 sm:gap-2">
179
+ <ViewerHeader {tab}>
180
+ {#snippet badge()}<Badge variant="secondary">{t('pdf.badge')}</Badge>{/snippet}
181
+ {#snippet actions()}
182
+ {#if totalPages > 0}
188
183
  <!-- Pagination (always visible) -->
189
184
  <Button
190
185
  variant="ghost"
@@ -194,9 +189,9 @@ onDestroy(cleanup);
194
189
  disabled={currentPage <= 1}
195
190
  >
196
191
  <ChevronLeftIcon class="size-3.5" />
197
- <span class="hidden sm:inline">{t("pdf.prev")}</span>
192
+ <span class="hidden sm:inline">{t('pdf.prev')}</span>
198
193
  </Button>
199
- <span class="text-xs text-zinc-500 dark:text-zinc-400">
194
+ <span class="text-xs text-muted-foreground">
200
195
  {currentPage} / {totalPages}
201
196
  </span>
202
197
  <Button
@@ -206,7 +201,7 @@ onDestroy(cleanup);
206
201
  onclick={nextPage}
207
202
  disabled={currentPage >= totalPages}
208
203
  >
209
- <span class="hidden sm:inline">{t("pdf.next")}</span>
204
+ <span class="hidden sm:inline">{t('pdf.next')}</span>
210
205
  <ChevronRightIcon class="size-3.5" />
211
206
  </Button>
212
207
 
@@ -218,11 +213,11 @@ onDestroy(cleanup);
218
213
  size="sm"
219
214
  class="h-7 px-1.5"
220
215
  onclick={zoomOut}
221
- title={t("pdf.zoomOut")}
216
+ title={t('pdf.zoomOut')}
222
217
  >
223
218
  <MinusIcon class="size-3.5" />
224
219
  </Button>
225
- <span class="text-xs text-zinc-500 dark:text-zinc-400">
220
+ <span class="text-xs text-muted-foreground">
226
221
  {Math.round(scale * 100)}%
227
222
  </span>
228
223
  <Button
@@ -230,7 +225,7 @@ onDestroy(cleanup);
230
225
  size="sm"
231
226
  class="h-7 px-1.5"
232
227
  onclick={zoomIn}
233
- title={t("pdf.zoomIn")}
228
+ title={t('pdf.zoomIn')}
234
229
  >
235
230
  <PlusIcon class="size-3.5" />
236
231
  </Button>
@@ -240,39 +235,35 @@ onDestroy(cleanup);
240
235
  <div class="flex sm:hidden">
241
236
  <DropdownMenu.Root>
242
237
  <DropdownMenu.Trigger
243
- class="rounded p-1 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
238
+ class="rounded p-1 text-muted-foreground hover:bg-muted"
244
239
  >
245
240
  <EllipsisVerticalIcon class="size-4" />
246
241
  </DropdownMenu.Trigger>
247
242
  <DropdownMenu.Content align="end" class="w-44">
248
243
  <DropdownMenu.Item onclick={zoomIn}>
249
- {t("pdf.zoomIn")}
244
+ {t('pdf.zoomIn')}
250
245
  </DropdownMenu.Item>
251
246
  <DropdownMenu.Item onclick={zoomOut}>
252
- {t("pdf.zoomOut")}
247
+ {t('pdf.zoomOut')}
253
248
  </DropdownMenu.Item>
254
249
  <DropdownMenu.Separator />
255
250
  <DropdownMenu.Item disabled>
256
- {t("pdf.zoom")}: {Math.round(scale * 100)}%
251
+ {t('pdf.zoom')}: {Math.round(scale * 100)}%
257
252
  </DropdownMenu.Item>
258
253
  </DropdownMenu.Content>
259
254
  </DropdownMenu.Root>
260
255
  </div>
261
- </div>
262
- {/if}
263
- </div>
256
+ {/if}
257
+ {/snippet}
258
+ </ViewerHeader>
264
259
 
265
260
  <div
266
261
  class="flex flex-1 items-start justify-center overflow-auto bg-zinc-200 p-4 dark:bg-zinc-800"
267
262
  >
268
263
  {#if loading}
269
- <div class="flex h-full items-center justify-center">
270
- <p class="text-sm text-zinc-400">{t("pdf.loading")}</p>
271
- </div>
264
+ <ViewerStatus kind="loading" message={t('pdf.loading')} />
272
265
  {:else if error}
273
- <div class="flex h-full items-center justify-center">
274
- <p class="text-sm text-red-400">{error}</p>
275
- </div>
266
+ <ViewerStatus kind="error" message={error} />
276
267
  {:else}
277
268
  <canvas bind:this={canvasEl} class="shadow-lg"></canvas>
278
269
  {/if}
@@ -2,6 +2,7 @@
2
2
  import ArchiveIcon from '@lucide/svelte/icons/archive';
3
3
  import GridIcon from '@lucide/svelte/icons/grid-3x3';
4
4
  import MapIcon from '@lucide/svelte/icons/map';
5
+ import { handleLoadError } from '@walkthru-earth/objex-utils';
5
6
  import type { PMTiles } from 'pmtiles';
6
7
  import { onDestroy, untrack } from 'svelte';
7
8
  import { Badge } from '../ui/badge/index.js';
@@ -75,7 +76,7 @@ async function load() {
75
76
  pmtilesInstance = result.pmtiles;
76
77
  metadata = result.metadata;
77
78
  } catch (err) {
78
- error = err instanceof Error ? err.message : String(err);
79
+ error = handleLoadError(err);
79
80
  } finally {
80
81
  loading = false;
81
82
  }
@@ -88,18 +89,18 @@ const fileName = $derived(tab.path.split('/').pop() ?? 'pmtiles');
88
89
  <!-- Toolbar -->
89
90
  {#if !loading && !error && metadata}
90
91
  <div
91
- 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"
92
+ class="flex items-center gap-1 border-b border-border px-2 py-1.5 sm:gap-2 sm:px-4"
92
93
  >
93
94
  <!-- File info -->
94
95
  <span
95
- class="max-w-[100px] truncate text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300"
96
+ class="max-w-[100px] truncate text-sm font-medium text-foreground sm:max-w-none"
96
97
  >
97
98
  {fileName}
98
99
  </span>
99
100
  <Badge variant="outline" class="hidden text-[10px] sm:inline-flex">
100
101
  {metadata.formatLabel}
101
102
  </Badge>
102
- <span class="hidden text-xs text-zinc-400 sm:inline dark:text-zinc-500">
103
+ <span class="hidden text-xs text-muted-foreground sm:inline">
103
104
  z{metadata.minZoom}-{metadata.maxZoom} · {metadata.numAddressedTiles.toLocaleString()} tiles
104
105
  </span>
105
106
 
@@ -109,7 +110,7 @@ const fileName = $derived(tab.path.split('/').pop() ?? 'pmtiles');
109
110
  variant={viewMode === 'map' ? 'default' : 'outline'}
110
111
  size="sm"
111
112
  class="h-7 gap-1 px-2 text-xs {viewMode !== 'map'
112
- ? 'border-zinc-300 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-900'
113
+ ? 'border-border text-muted-foreground hover:bg-muted'
113
114
  : ''}"
114
115
  onclick={() => setViewMode('map')}
115
116
  >
@@ -121,7 +122,7 @@ const fileName = $derived(tab.path.split('/').pop() ?? 'pmtiles');
121
122
  variant={viewMode === 'archive' ? 'default' : 'outline'}
122
123
  size="sm"
123
124
  class="h-7 gap-1 px-2 text-xs {viewMode !== 'archive'
124
- ? 'border-zinc-300 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-900'
125
+ ? 'border-border text-muted-foreground hover:bg-muted'
125
126
  : ''}"
126
127
  onclick={() => setViewMode('archive')}
127
128
  >
@@ -133,7 +134,7 @@ const fileName = $derived(tab.path.split('/').pop() ?? 'pmtiles');
133
134
  variant={viewMode === 'inspector' ? 'default' : 'outline'}
134
135
  size="sm"
135
136
  class="h-7 gap-1 px-2 text-xs {viewMode !== 'inspector'
136
- ? 'border-zinc-300 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-900'
137
+ ? 'border-border text-muted-foreground hover:bg-muted'
137
138
  : ''}"
138
139
  onclick={() => setViewMode('inspector')}
139
140
  >
@@ -148,11 +149,11 @@ const fileName = $derived(tab.path.split('/').pop() ?? 'pmtiles');
148
149
  <div class="min-h-0 flex-1 overflow-hidden">
149
150
  {#if loading}
150
151
  <div class="flex h-full items-center justify-center">
151
- <p class="text-sm text-zinc-400">{t('map.loadingPmtiles')}</p>
152
+ <p class="text-sm text-muted-foreground">{t('map.loadingPmtiles')}</p>
152
153
  </div>
153
154
  {:else if error}
154
155
  <div class="flex h-full items-center justify-center">
155
- <p class="text-sm text-red-400">{error}</p>
156
+ <p class="text-sm text-destructive">{error}</p>
156
157
  </div>
157
158
  {:else if metadata && pmtilesInstance}
158
159
  {#if viewMode === 'map'}
@@ -50,22 +50,22 @@ function truncateSql(sql: string, maxLen = 120): string {
50
50
  {#if visible}
51
51
  <!-- Mobile: absolute overlay; Desktop: flex sidebar -->
52
52
  <div
53
- class="absolute inset-y-0 end-0 z-10 flex w-72 flex-col overflow-hidden border-s border-zinc-200 bg-zinc-50 sm:relative sm:z-auto sm:shrink-0 dark:border-zinc-800 dark:bg-zinc-900"
53
+ class="absolute inset-y-0 end-0 z-10 flex w-72 flex-col overflow-hidden border-s border-border bg-muted sm:relative sm:z-auto sm:shrink-0"
54
54
  >
55
55
  <!-- Header -->
56
56
  <div
57
- class="flex items-center justify-between border-b border-zinc-200 px-3 py-2 dark:border-zinc-800"
57
+ class="flex items-center justify-between border-b border-border px-3 py-2"
58
58
  >
59
59
  <div class="flex items-center gap-1.5">
60
- <ClockIcon class="size-3.5 text-zinc-500" />
61
- <h3 class="text-xs font-medium text-zinc-500 dark:text-zinc-400">
60
+ <ClockIcon class="size-3.5 text-muted-foreground" />
61
+ <h3 class="text-xs font-medium text-muted-foreground">
62
62
  {t('queryHistory.title')}
63
63
  </h3>
64
64
  </div>
65
65
  <div class="flex items-center gap-2">
66
66
  {#if queryHistory.entries.length > 0}
67
67
  <button
68
- class="text-[10px] text-zinc-400 hover:text-red-500 dark:hover:text-red-400"
68
+ class="text-[10px] text-muted-foreground hover:text-destructive"
69
69
  onclick={() => queryHistory.clear()}
70
70
  >
71
71
  {t('queryHistory.clearAll')}
@@ -73,7 +73,7 @@ function truncateSql(sql: string, maxLen = 120): string {
73
73
  {/if}
74
74
  {#if onClose}
75
75
  <button
76
- class="rounded p-0.5 text-zinc-400 hover:bg-zinc-200 hover:text-zinc-600 sm:hidden dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
76
+ class="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground sm:hidden"
77
77
  onclick={onClose}
78
78
  >
79
79
  <XIcon class="size-3.5" />
@@ -83,20 +83,20 @@ function truncateSql(sql: string, maxLen = 120): string {
83
83
  </div>
84
84
 
85
85
  <!-- Search -->
86
- <div class="border-b border-zinc-200 px-3 py-1.5 dark:border-zinc-800">
86
+ <div class="border-b border-border px-3 py-1.5">
87
87
  <div
88
- class="flex items-center gap-1.5 rounded border border-zinc-200 bg-white px-2 py-1 dark:border-zinc-700 dark:bg-zinc-800"
88
+ class="flex items-center gap-1.5 rounded border border-border bg-background px-2 py-1"
89
89
  >
90
- <SearchIcon class="size-3 shrink-0 text-zinc-400" />
90
+ <SearchIcon class="size-3 shrink-0 text-muted-foreground" />
91
91
  <input
92
92
  type="text"
93
- class="w-full bg-transparent text-xs outline-none placeholder:text-zinc-400"
93
+ class="w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
94
94
  placeholder={t('queryHistory.searchPlaceholder')}
95
95
  bind:value={searchQuery}
96
96
  />
97
97
  {#if searchQuery}
98
98
  <button
99
- class="shrink-0 text-zinc-400 hover:text-zinc-600"
99
+ class="shrink-0 text-muted-foreground hover:text-foreground"
100
100
  onclick={() => {
101
101
  searchQuery = '';
102
102
  }}
@@ -110,14 +110,14 @@ function truncateSql(sql: string, maxLen = 120): string {
110
110
  <!-- Entries -->
111
111
  <ScrollArea class="flex-1">
112
112
  {#if filteredEntries.length === 0}
113
- <div class="px-3 py-6 text-center text-xs text-zinc-400">
113
+ <div class="px-3 py-6 text-center text-xs text-muted-foreground">
114
114
  {searchQuery ? 'No matching queries' : 'No query history yet'}
115
115
  </div>
116
116
  {:else}
117
- <div class="divide-y divide-zinc-100 dark:divide-zinc-800">
117
+ <div class="divide-y divide-border">
118
118
  {#each filteredEntries as entry (entry.id)}
119
119
  <div
120
- class="group flex w-full cursor-pointer flex-col gap-0.5 px-3 py-2 text-start hover:bg-zinc-100 dark:hover:bg-zinc-800"
120
+ class="group flex w-full cursor-pointer flex-col gap-0.5 px-3 py-2 text-start hover:bg-accent"
121
121
  role="button"
122
122
  tabindex="0"
123
123
  onclick={() => onSelect?.(entry.sql)}
@@ -126,18 +126,18 @@ function truncateSql(sql: string, maxLen = 120): string {
126
126
  }}
127
127
  >
128
128
  <div
129
- class="font-mono text-[11px] leading-snug text-zinc-600 dark:text-zinc-300"
129
+ class="font-mono text-[11px] leading-snug text-foreground"
130
130
  >
131
131
  {truncateSql(entry.sql)}
132
132
  </div>
133
- <div class="flex items-center gap-2 text-[10px] text-zinc-400">
133
+ <div class="flex items-center gap-2 text-[10px] text-muted-foreground">
134
134
  <span>{formatTime(entry.timestamp)}</span>
135
135
  <span>{entry.durationMs}ms</span>
136
136
  {#if entry.rowCount > 0}
137
137
  <span>{entry.rowCount.toLocaleString()} rows</span>
138
138
  {/if}
139
139
  {#if entry.error}
140
- <span class="text-red-400">error</span>
140
+ <span class="text-destructive">error</span>
141
141
  {/if}
142
142
  <button
143
143
  class="ms-auto opacity-0 group-hover:opacity-100"
@@ -147,7 +147,7 @@ function truncateSql(sql: string, maxLen = 120): string {
147
147
  }}
148
148
  title="Remove"
149
149
  >
150
- <TrashIcon class="size-3 text-zinc-400 hover:text-red-500" />
150
+ <TrashIcon class="size-3 text-muted-foreground hover:text-destructive" />
151
151
  </button>
152
152
  </div>
153
153
  </div>