@joewinke/jatui 0.1.19 → 0.1.21
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/package.json +1 -1
- package/src/lib/actions/collapse.ts +134 -0
- package/src/lib/components/ChipInput.svelte +12 -13
- package/src/lib/components/Collapse.svelte +82 -0
- package/src/lib/components/GPSTracker.svelte +202 -0
- package/src/lib/components/InlineEdit.svelte +7 -9
- package/src/lib/components/KeyboardShortcutsOverlay.svelte +296 -0
- package/src/lib/components/LocationMap.svelte +186 -0
- package/src/lib/components/MapView.svelte +341 -0
- package/src/lib/components/linked-columns/LinkedColumns.svelte +520 -0
- package/src/lib/components/replay/ChapterTimeline.svelte +326 -0
- package/src/lib/components/session-nav/transcriptModel.ts +352 -0
- package/src/lib/index.ts +50 -0
- package/src/lib/types/googleMaps.d.ts +51 -0
- package/src/lib/types/maps.ts +43 -0
- package/src/lib/utils/googleMapsLoader.ts +84 -0
package/package.json
CHANGED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tall-content collapse — Svelte action.
|
|
3
|
+
*
|
|
4
|
+
* Caps an arbitrarily-tall block at a max height with a fade-out gradient so it
|
|
5
|
+
* shows a preview instead of overflowing its container. The consumer renders the
|
|
6
|
+
* "Show more / Show less" toggle and owns the `expanded` boolean; the action does
|
|
7
|
+
* the measuring and applies the collapse styling.
|
|
8
|
+
*
|
|
9
|
+
* Why an action (not a wrapper-only component): most adoption sites are existing
|
|
10
|
+
* elements (`<p>`, a `prose` div, a `<ul>`, a `<pre>`), and an action attaches
|
|
11
|
+
* with zero DOM restructuring. A batteries-included `<Collapse>` wrapper that
|
|
12
|
+
* uses this action is also provided for greenfield use.
|
|
13
|
+
*
|
|
14
|
+
* ─── Why inline styles, not a CSS class ───────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* The action writes `max-height` / `overflow` / `mask-image` directly on the node
|
|
17
|
+
* when collapsed. That means it needs NO accompanying stylesheet — it works
|
|
18
|
+
* identically in Tailwind-utility components and scoped-CSS components, and
|
|
19
|
+
* consumers don't have to copy a `.collapse-*` rule into their app.css (the same
|
|
20
|
+
* trap rail.css has). Expanding clears the inline styles.
|
|
21
|
+
*
|
|
22
|
+
* ─── Why ResizeObserver, not a one-shot rAF ───────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* Streaming chat answers and `{@html}` markdown bodies grow AFTER mount. A
|
|
25
|
+
* one-shot measurement on mount would miss them (and would wrongly hide the
|
|
26
|
+
* toggle on content that hasn't streamed in yet). A ResizeObserver re-measures
|
|
27
|
+
* whenever the content's natural height changes, so `collapsible` stays correct.
|
|
28
|
+
*
|
|
29
|
+
* `scrollHeight` reports the FULL content height even while `max-height` +
|
|
30
|
+
* `overflow:hidden` are applied, so the action can keep measuring without ever
|
|
31
|
+
* having to un-collapse first.
|
|
32
|
+
*
|
|
33
|
+
* ─── Usage — action form (most sites) ─────────────────────────────────────────
|
|
34
|
+
*
|
|
35
|
+
* <script>
|
|
36
|
+
* import { collapse } from 'jatui'
|
|
37
|
+
* let collapsible = $state(false)
|
|
38
|
+
* let expanded = $state(false)
|
|
39
|
+
* </script>
|
|
40
|
+
*
|
|
41
|
+
* <div use:collapse={{ expanded, onCollapsible: (c) => (collapsible = c) }}>
|
|
42
|
+
* {content}
|
|
43
|
+
* </div>
|
|
44
|
+
* {#if collapsible}
|
|
45
|
+
* <button onclick={() => (expanded = !expanded)}>
|
|
46
|
+
* {expanded ? 'Show less ↑' : 'Show more ↓'}
|
|
47
|
+
* </button>
|
|
48
|
+
* {/if}
|
|
49
|
+
*
|
|
50
|
+
* For mapped lists, give each row its own `expanded` / `collapsible` state
|
|
51
|
+
* (e.g. a `SvelteSet` of ids) — one action instance attaches per row element.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
export interface CollapseOptions {
|
|
55
|
+
/** Max collapsed height in px. Content taller than this collapses. Default 120. */
|
|
56
|
+
threshold?: number;
|
|
57
|
+
/** Controlled expand state. When true, the node is never collapsed. Default false. */
|
|
58
|
+
expanded?: boolean;
|
|
59
|
+
/** Where the fade-to-transparent begins, as a %. Default 55. */
|
|
60
|
+
fadeStart?: number;
|
|
61
|
+
/** Slack (px) added to the threshold so a near-fit doesn't toggle. Default 4. */
|
|
62
|
+
epsilon?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Called whenever collapsibility changes (content crosses the threshold).
|
|
65
|
+
* Drive the toggle button's visibility from this — it self-hides when the
|
|
66
|
+
* content is shorter than the threshold.
|
|
67
|
+
*/
|
|
68
|
+
onCollapsible?: (collapsible: boolean) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULTS = { threshold: 120, expanded: false, fadeStart: 55, epsilon: 4 } as const;
|
|
72
|
+
|
|
73
|
+
export function collapse(node: HTMLElement, options: CollapseOptions = {}) {
|
|
74
|
+
let opts = { ...DEFAULTS, ...options };
|
|
75
|
+
let collapsible = false;
|
|
76
|
+
let rafId = 0;
|
|
77
|
+
let applied = false;
|
|
78
|
+
|
|
79
|
+
function gradient() {
|
|
80
|
+
return `linear-gradient(to bottom, black ${opts.fadeStart}%, transparent 100%)`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function apply() {
|
|
84
|
+
const shouldCollapse = collapsible && !opts.expanded;
|
|
85
|
+
if (shouldCollapse === applied) return; // idempotent — avoids RO feedback loops
|
|
86
|
+
applied = shouldCollapse;
|
|
87
|
+
if (shouldCollapse) {
|
|
88
|
+
node.style.maxHeight = `${opts.threshold}px`;
|
|
89
|
+
node.style.overflow = 'hidden';
|
|
90
|
+
node.style.maskImage = gradient();
|
|
91
|
+
node.style.webkitMaskImage = gradient();
|
|
92
|
+
} else {
|
|
93
|
+
node.style.maxHeight = '';
|
|
94
|
+
node.style.overflow = '';
|
|
95
|
+
node.style.maskImage = '';
|
|
96
|
+
node.style.webkitMaskImage = '';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function measure() {
|
|
101
|
+
// scrollHeight is the full content height even while collapsed.
|
|
102
|
+
const next = node.scrollHeight > opts.threshold + opts.epsilon;
|
|
103
|
+
if (next !== collapsible) {
|
|
104
|
+
collapsible = next;
|
|
105
|
+
opts.onCollapsible?.(collapsible);
|
|
106
|
+
}
|
|
107
|
+
apply();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scheduleMeasure() {
|
|
111
|
+
cancelAnimationFrame(rafId);
|
|
112
|
+
rafId = requestAnimationFrame(measure);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Re-measure when the content's natural size changes (streaming, {@html}, lazy images).
|
|
116
|
+
const ro = new ResizeObserver(scheduleMeasure);
|
|
117
|
+
ro.observe(node);
|
|
118
|
+
|
|
119
|
+
scheduleMeasure();
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
update(next: CollapseOptions = {}) {
|
|
123
|
+
opts = { ...DEFAULTS, ...next };
|
|
124
|
+
// expanded/threshold/fade may have changed — re-apply (and re-measure
|
|
125
|
+
// in case threshold moved across the content height).
|
|
126
|
+
applied = !applied; // force apply() to re-evaluate against new opts
|
|
127
|
+
scheduleMeasure();
|
|
128
|
+
},
|
|
129
|
+
destroy() {
|
|
130
|
+
cancelAnimationFrame(rafId);
|
|
131
|
+
ro.disconnect();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
<script
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ChipInput - Generic contenteditable input with inline chips and autocomplete.
|
|
4
|
+
*
|
|
5
|
+
* Handles:
|
|
6
|
+
* - Contenteditable div with placeholder
|
|
7
|
+
* - Chip insertion/deletion (non-editable inline spans)
|
|
8
|
+
* - Autocomplete dropdown with keyboard navigation
|
|
9
|
+
* - Serialization (DOM -> text with chip markers)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// --- Types ---
|
|
2
13
|
export interface ChipSuggestion {
|
|
3
14
|
label: string;
|
|
4
15
|
description?: string;
|
|
@@ -28,18 +39,6 @@
|
|
|
28
39
|
/** If true, insert as plain text instead of a chip element */
|
|
29
40
|
insertAsText?: boolean;
|
|
30
41
|
}
|
|
31
|
-
</script>
|
|
32
|
-
|
|
33
|
-
<script lang="ts">
|
|
34
|
-
/**
|
|
35
|
-
* ChipInput - Generic contenteditable input with inline chips and autocomplete.
|
|
36
|
-
*
|
|
37
|
-
* Handles:
|
|
38
|
-
* - Contenteditable div with placeholder
|
|
39
|
-
* - Chip insertion/deletion (non-editable inline spans)
|
|
40
|
-
* - Autocomplete dropdown with keyboard navigation
|
|
41
|
-
* - Serialization (DOM -> text with chip markers)
|
|
42
|
-
*/
|
|
43
42
|
|
|
44
43
|
// --- Props ---
|
|
45
44
|
let {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Collapse — batteries-included tall-content collapser.
|
|
4
|
+
*
|
|
5
|
+
* Wraps children in a region that caps at `threshold` px with a fade-out
|
|
6
|
+
* gradient and renders a "Show more / Show less" toggle that self-hides when
|
|
7
|
+
* the content is shorter than the threshold. Uses the `collapse` action for
|
|
8
|
+
* measurement (ResizeObserver-backed, so streaming / {@html} content works).
|
|
9
|
+
*
|
|
10
|
+
* <Collapse>
|
|
11
|
+
* {@html renderedMarkdown}
|
|
12
|
+
* </Collapse>
|
|
13
|
+
*
|
|
14
|
+
* For existing elements you don't want to wrap (a bare `<p>` inside an
|
|
15
|
+
* `{#each}`, a `<pre>`), use the `collapse` action directly instead.
|
|
16
|
+
*/
|
|
17
|
+
import { collapse } from '../actions/collapse';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** Max collapsed height in px. Default 120. */
|
|
21
|
+
threshold?: number;
|
|
22
|
+
/** Where the fade begins, as a %. Default 55. */
|
|
23
|
+
fadeStart?: number;
|
|
24
|
+
/** Start expanded. Default false. */
|
|
25
|
+
expanded?: boolean;
|
|
26
|
+
/** Toggle label when collapsed. Default 'Show more ↓'. */
|
|
27
|
+
moreLabel?: string;
|
|
28
|
+
/** Toggle label when expanded. Default 'Show less ↑'. */
|
|
29
|
+
lessLabel?: string;
|
|
30
|
+
/** Extra classes on the content region. */
|
|
31
|
+
class?: string;
|
|
32
|
+
children?: import('svelte').Snippet;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
threshold = 120,
|
|
37
|
+
fadeStart = 55,
|
|
38
|
+
expanded = $bindable(false),
|
|
39
|
+
moreLabel = 'Show more ↓',
|
|
40
|
+
lessLabel = 'Show less ↑',
|
|
41
|
+
class: className = '',
|
|
42
|
+
children,
|
|
43
|
+
}: Props = $props();
|
|
44
|
+
|
|
45
|
+
let collapsible = $state(false);
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<div
|
|
49
|
+
class={`jatui-collapse-body ${className}`.trim()}
|
|
50
|
+
use:collapse={{ threshold, fadeStart, expanded, onCollapsible: (c) => (collapsible = c) }}
|
|
51
|
+
>
|
|
52
|
+
{#if children}
|
|
53
|
+
{@render children()}
|
|
54
|
+
{/if}
|
|
55
|
+
</div>
|
|
56
|
+
{#if collapsible}
|
|
57
|
+
<button type="button" class="jatui-collapse-toggle" onclick={() => (expanded = !expanded)}>
|
|
58
|
+
{expanded ? lessLabel : moreLabel}
|
|
59
|
+
</button>
|
|
60
|
+
{/if}
|
|
61
|
+
|
|
62
|
+
<style>
|
|
63
|
+
.jatui-collapse-body {
|
|
64
|
+
position: relative;
|
|
65
|
+
}
|
|
66
|
+
.jatui-collapse-toggle {
|
|
67
|
+
display: block;
|
|
68
|
+
margin-top: 4px;
|
|
69
|
+
padding: 2px 0;
|
|
70
|
+
font-size: 11px;
|
|
71
|
+
color: var(--color-primary, oklch(0.65 0.12 250));
|
|
72
|
+
background: none;
|
|
73
|
+
border: none;
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
opacity: 0.75;
|
|
76
|
+
transition: opacity 0.1s;
|
|
77
|
+
}
|
|
78
|
+
.jatui-collapse-toggle:hover {
|
|
79
|
+
opacity: 1;
|
|
80
|
+
text-decoration: underline;
|
|
81
|
+
}
|
|
82
|
+
</style>
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* GPSTracker — headless GPS tracking action component.
|
|
4
|
+
*
|
|
5
|
+
* Watches the device's geolocation and fires `onLocationUpdate` on each new
|
|
6
|
+
* fix. Uses `watchPosition` on mobile; falls back to interval polling on
|
|
7
|
+
* desktop. Renders a small fixed status indicator in the corner.
|
|
8
|
+
*
|
|
9
|
+
* The host app is responsible for sending coordinates to the server —
|
|
10
|
+
* use the `onLocationUpdate` callback to do so (no hardcoded endpoint).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
*
|
|
14
|
+
* <GPSTracker
|
|
15
|
+
* enabled={true}
|
|
16
|
+
* onLocationUpdate={async (pos) => {
|
|
17
|
+
* await fetch('/api/location', {
|
|
18
|
+
* method: 'POST',
|
|
19
|
+
* body: JSON.stringify({ lat: pos.coords.latitude, lng: pos.coords.longitude })
|
|
20
|
+
* });
|
|
21
|
+
* }}
|
|
22
|
+
* />
|
|
23
|
+
*
|
|
24
|
+
* Ported from flush technician/GPSTracker (298L) — steelbridge fork capture (jst-e0d1u.4).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
/** How often to poll position in seconds (mobile uses watchPosition + this; desktop uses this * 2). */
|
|
30
|
+
updateInterval?: number;
|
|
31
|
+
onLocationUpdate?: (position: GeolocationPosition) => void;
|
|
32
|
+
onError?: (error: GeolocationPositionError) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let { enabled = true, updateInterval = 30, onLocationUpdate, onError }: Props = $props();
|
|
36
|
+
|
|
37
|
+
let watchId: number | null = null;
|
|
38
|
+
let intervalId: number | null = null;
|
|
39
|
+
let lastPosition = $state<GeolocationPosition | null>(null);
|
|
40
|
+
let isTracking = $state(false);
|
|
41
|
+
let hasPermission = $state<boolean | null>(null);
|
|
42
|
+
let lastUpdate = $state<Date | null>(null);
|
|
43
|
+
let isMounted = false;
|
|
44
|
+
|
|
45
|
+
import { onMount, onDestroy } from 'svelte';
|
|
46
|
+
|
|
47
|
+
onMount(() => {
|
|
48
|
+
if (isMounted) return;
|
|
49
|
+
isMounted = true;
|
|
50
|
+
if (typeof navigator !== 'undefined' && enabled) {
|
|
51
|
+
checkPermission();
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
if (hasPermission !== false) startTracking();
|
|
54
|
+
}, 100);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
onDestroy(() => {
|
|
59
|
+
isMounted = false;
|
|
60
|
+
stopTracking();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function checkPermission() {
|
|
64
|
+
if (typeof navigator === 'undefined' || !navigator.permissions) return;
|
|
65
|
+
try {
|
|
66
|
+
const result = await navigator.permissions.query({ name: 'geolocation' });
|
|
67
|
+
hasPermission = result.state === 'granted';
|
|
68
|
+
result.addEventListener('change', () => {
|
|
69
|
+
hasPermission = result.state === 'granted';
|
|
70
|
+
if (hasPermission && enabled) startTracking();
|
|
71
|
+
else stopTracking();
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
// permissions API not available — proceed optimistically
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function startTracking() {
|
|
79
|
+
if (typeof navigator === 'undefined' || !navigator.geolocation || !enabled || isTracking) return;
|
|
80
|
+
|
|
81
|
+
isTracking = true;
|
|
82
|
+
|
|
83
|
+
// Initial fix with relaxed settings (faster on desktop)
|
|
84
|
+
navigator.geolocation.getCurrentPosition(
|
|
85
|
+
(pos) => {
|
|
86
|
+
handleSuccess(pos);
|
|
87
|
+
// Try for a better fix
|
|
88
|
+
navigator.geolocation.getCurrentPosition(handleSuccess, () => {}, {
|
|
89
|
+
enableHighAccuracy: true,
|
|
90
|
+
timeout: 10000,
|
|
91
|
+
maximumAge: 0
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
(err) => {
|
|
95
|
+
if (err.code === 3) {
|
|
96
|
+
// TIMEOUT — try once more with any cached position
|
|
97
|
+
navigator.geolocation.getCurrentPosition(handleSuccess, handleError, {
|
|
98
|
+
enableHighAccuracy: false,
|
|
99
|
+
timeout: 15000,
|
|
100
|
+
maximumAge: Infinity
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
handleError(err);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 600000 }
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Continuous watch on mobile only
|
|
110
|
+
const isMobile =
|
|
111
|
+
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
112
|
+
if (isMobile) {
|
|
113
|
+
watchId = navigator.geolocation.watchPosition(handleSuccess, handleError, {
|
|
114
|
+
enableHighAccuracy: true,
|
|
115
|
+
timeout: 10000,
|
|
116
|
+
maximumAge: 30000
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Interval polling
|
|
121
|
+
const freq = (isMobile ? updateInterval : updateInterval * 2) * 1000;
|
|
122
|
+
intervalId = window.setInterval(() => {
|
|
123
|
+
navigator.geolocation.getCurrentPosition(
|
|
124
|
+
handleSuccess,
|
|
125
|
+
() => {}, // ignore interval failures silently
|
|
126
|
+
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 }
|
|
127
|
+
);
|
|
128
|
+
}, freq);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stopTracking() {
|
|
132
|
+
isTracking = false;
|
|
133
|
+
if (watchId !== null) {
|
|
134
|
+
navigator.geolocation.clearWatch(watchId);
|
|
135
|
+
watchId = null;
|
|
136
|
+
}
|
|
137
|
+
if (intervalId !== null) {
|
|
138
|
+
clearInterval(intervalId);
|
|
139
|
+
intervalId = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function handleSuccess(position: GeolocationPosition) {
|
|
144
|
+
lastPosition = position;
|
|
145
|
+
lastUpdate = new Date();
|
|
146
|
+
onLocationUpdate?.(position);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function handleError(error: GeolocationPositionError) {
|
|
150
|
+
if (error.code === error.PERMISSION_DENIED) {
|
|
151
|
+
hasPermission = false;
|
|
152
|
+
stopTracking();
|
|
153
|
+
}
|
|
154
|
+
onError?.(error);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
$effect(() => {
|
|
158
|
+
if (enabled && !isTracking && hasPermission !== false) startTracking();
|
|
159
|
+
else if (!enabled && isTracking) stopTracking();
|
|
160
|
+
});
|
|
161
|
+
</script>
|
|
162
|
+
|
|
163
|
+
<!-- GPS status indicator (fixed bottom-right) -->
|
|
164
|
+
{#if typeof navigator !== 'undefined'}
|
|
165
|
+
<div class="fixed bottom-20 right-4 bg-base-200 rounded-lg shadow-lg p-2 text-xs z-40">
|
|
166
|
+
<div class="flex items-center gap-2">
|
|
167
|
+
<div
|
|
168
|
+
class="w-2 h-2 rounded-full {isTracking
|
|
169
|
+
? 'bg-success'
|
|
170
|
+
: hasPermission === false
|
|
171
|
+
? 'bg-error'
|
|
172
|
+
: 'bg-warning'}"
|
|
173
|
+
></div>
|
|
174
|
+
<span>GPS {isTracking ? 'Active' : hasPermission === false ? 'Denied' : 'Inactive'}</span>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{#if hasPermission === false}
|
|
178
|
+
<div class="mt-2">
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
class="btn btn-xs btn-primary"
|
|
182
|
+
onclick={() => {
|
|
183
|
+
hasPermission = null;
|
|
184
|
+
isTracking = false;
|
|
185
|
+
startTracking();
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
Enable GPS
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="text-error mt-1">Location required</div>
|
|
192
|
+
{/if}
|
|
193
|
+
|
|
194
|
+
{#if lastUpdate && isTracking}
|
|
195
|
+
<div class="text-base-content/70">Updated: {lastUpdate.toLocaleTimeString()}</div>
|
|
196
|
+
{/if}
|
|
197
|
+
|
|
198
|
+
{#if lastPosition && isTracking}
|
|
199
|
+
<div class="text-base-content/70">Accuracy: {Math.round(lastPosition.coords.accuracy)}m</div>
|
|
200
|
+
{/if}
|
|
201
|
+
</div>
|
|
202
|
+
{/if}
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
<script module lang="ts">
|
|
2
|
-
/** A display segment for formula-aware rendering */
|
|
3
|
-
export interface DisplaySegment {
|
|
4
|
-
type: 'text' | 'formula';
|
|
5
|
-
display: string;
|
|
6
|
-
tooltip?: string;
|
|
7
|
-
}
|
|
8
|
-
</script>
|
|
9
|
-
|
|
10
1
|
<script lang="ts">
|
|
11
2
|
/**
|
|
12
3
|
* InlineEdit Component
|
|
@@ -23,6 +14,13 @@
|
|
|
23
14
|
* - Optional formula-aware display: segments with type/display/tooltip
|
|
24
15
|
*/
|
|
25
16
|
|
|
17
|
+
/** A display segment for formula-aware rendering */
|
|
18
|
+
export interface DisplaySegment {
|
|
19
|
+
type: 'text' | 'formula';
|
|
20
|
+
display: string;
|
|
21
|
+
tooltip?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
26
24
|
interface Props {
|
|
27
25
|
/** Current value */
|
|
28
26
|
value: string;
|