@bluerobotics/bluevue 0.1.1 → 0.2.1

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 (91) hide show
  1. package/README.md +97 -18
  2. package/bin/create-blueos-extension.mjs +72 -0
  3. package/dist/bluevue.js +2464 -778
  4. package/dist/components/BlueApp.vue.d.ts +50 -0
  5. package/dist/components/BlueBanner.vue.d.ts +49 -0
  6. package/dist/components/BlueBannerGroup.vue.d.ts +20 -0
  7. package/dist/components/BlueButton.vue.d.ts +59 -0
  8. package/dist/components/BlueCard.vue.d.ts +45 -0
  9. package/dist/components/BlueCheckbox.vue.d.ts +34 -0
  10. package/dist/components/BlueChip.vue.d.ts +47 -0
  11. package/dist/components/BlueConfirmDialog.vue.d.ts +193 -0
  12. package/dist/components/BlueDialog.vue.d.ts +60 -0
  13. package/dist/components/BlueFileDrop.vue.d.ts +32 -0
  14. package/dist/components/BlueIcon.vue.d.ts +26 -0
  15. package/dist/components/BlueInput.vue.d.ts +1 -0
  16. package/dist/components/BlueProgressBar.vue.d.ts +28 -0
  17. package/dist/components/BlueRadioGroup.vue.d.ts +40 -0
  18. package/dist/components/BlueSection.vue.d.ts +36 -0
  19. package/dist/components/BlueSnackbar.vue.d.ts +19 -0
  20. package/dist/components/BlueSpinner.vue.d.ts +25 -0
  21. package/dist/components/BlueStat.vue.d.ts +21 -0
  22. package/dist/components/BlueStepsDialog.vue.d.ts +62 -0
  23. package/dist/components/BlueTable.vue.d.ts +62 -0
  24. package/dist/components/BlueTabs.vue.d.ts +37 -0
  25. package/dist/components/BlueTextarea.vue.d.ts +40 -0
  26. package/dist/components/BlueTooltip.vue.d.ts +44 -0
  27. package/dist/components/BlueWindRose.vue.d.ts +37 -0
  28. package/dist/composables/useBlueLoading.d.ts +22 -0
  29. package/dist/composables/useBlueOs.d.ts +39 -0
  30. package/dist/composables/useBlueSnackbar.d.ts +30 -0
  31. package/dist/index.d.ts +30 -0
  32. package/dist/services/blueos.d.ts +37 -0
  33. package/dist/style.css +1 -1
  34. package/dist/types/banner.d.ts +16 -0
  35. package/dist/utils/files.d.ts +13 -0
  36. package/dist/utils/theme.d.ts +22 -0
  37. package/package.json +8 -2
  38. package/src/components/BlueApp.vue +84 -0
  39. package/src/components/BlueBanner.vue +196 -0
  40. package/src/components/BlueBannerGroup.vue +47 -0
  41. package/src/components/BlueButton.vue +134 -0
  42. package/src/components/BlueButtonGroup.vue +28 -26
  43. package/src/components/BlueCard.vue +62 -0
  44. package/src/components/BlueCheckbox.vue +100 -0
  45. package/src/components/BlueChip.vue +77 -0
  46. package/src/components/BlueConfirmDialog.vue +73 -0
  47. package/src/components/BlueDialog.vue +135 -0
  48. package/src/components/BlueFileDrop.vue +94 -0
  49. package/src/components/BlueIcon.vue +37 -0
  50. package/src/components/BlueInput.vue +23 -13
  51. package/src/components/BlueLoadingDialog.vue +22 -53
  52. package/src/components/BlueProgressBar.vue +46 -0
  53. package/src/components/BluePromptDialog.vue +51 -99
  54. package/src/components/BlueRadioGroup.vue +104 -0
  55. package/src/components/BlueSection.vue +33 -0
  56. package/src/components/BlueSelect.vue +13 -11
  57. package/src/components/BlueSlider.vue +1 -3
  58. package/src/components/BlueSnackbar.vue +79 -0
  59. package/src/components/BlueSpinner.vue +37 -0
  60. package/src/components/BlueStat.vue +26 -0
  61. package/src/components/BlueStepsDialog.vue +180 -0
  62. package/src/components/BlueSwitch.vue +18 -15
  63. package/src/components/BlueTable.vue +154 -0
  64. package/src/components/BlueTabs.vue +77 -0
  65. package/src/components/BlueTextarea.vue +102 -0
  66. package/src/components/BlueTooltip.vue +88 -0
  67. package/src/components/BlueWindRose.vue +269 -0
  68. package/src/composables/useBlueLoading.ts +38 -0
  69. package/src/composables/useBlueOs.ts +154 -0
  70. package/src/composables/useBlueSnackbar.ts +89 -0
  71. package/src/index.ts +40 -0
  72. package/src/services/blueos.ts +95 -0
  73. package/src/styles/bluevue.css +144 -0
  74. package/src/types/banner.ts +17 -0
  75. package/src/utils/files.ts +44 -0
  76. package/src/utils/theme.ts +37 -0
  77. package/templates/extension/.github/workflows/deploy.yml +23 -0
  78. package/templates/extension/Dockerfile +56 -0
  79. package/templates/extension/README.md +35 -0
  80. package/templates/extension/backend/main.py +45 -0
  81. package/templates/extension/backend/requirements.txt +2 -0
  82. package/templates/extension/frontend/.eslintrc.cjs +20 -0
  83. package/templates/extension/frontend/env.d.ts +7 -0
  84. package/templates/extension/frontend/index.html +12 -0
  85. package/templates/extension/frontend/package.json +30 -0
  86. package/templates/extension/frontend/src/App.vue +51 -0
  87. package/templates/extension/frontend/src/main.ts +8 -0
  88. package/templates/extension/frontend/src/styles/global.css +11 -0
  89. package/templates/extension/frontend/tsconfig.json +25 -0
  90. package/templates/extension/frontend/tsconfig.node.json +11 -0
  91. package/templates/extension/frontend/vite.config.ts +24 -0
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import type { Placement } from '@floating-ui/vue'
3
+ import { onBeforeUnmount } from 'vue'
4
+
5
+ import { useBluePopover } from '../composables/useBluePopover'
6
+
7
+ /**
8
+ * A label for whatever it wraps, shown on hover and on keyboard focus. It opens in the top layer,
9
+ * so a panel that clips its own overflow cannot cut it off.
10
+ */
11
+ const props = defineProps<{
12
+ text: string
13
+ /** Which side it hangs off (default 'top'). */
14
+ placement?: Placement
15
+ /** How long the pointer has to rest before it appears, in ms (default 300). */
16
+ openDelay?: number
17
+ /** Wraps its content and shows nothing, for a hint that only applies sometimes. */
18
+ disabled?: boolean
19
+ theme?: 'light' | 'dark'
20
+ }>()
21
+
22
+ const { anchorRef, popoverRef, popoverId, isPositioned, floatingStyles, show, hide, onToggle } = useBluePopover({
23
+ placement: props.placement ?? 'top',
24
+ })
25
+
26
+ // The rest before it appears is what keeps a pointer crossing a row of controls from trailing
27
+ // tooltips behind it.
28
+ let openTimer: number | null = null
29
+
30
+ const cancelOpen = (): void => {
31
+ if (openTimer === null) return
32
+ clearTimeout(openTimer)
33
+ openTimer = null
34
+ }
35
+
36
+ const open = (): void => {
37
+ if (props.disabled || !props.text) return
38
+ cancelOpen()
39
+ openTimer = window.setTimeout(() => {
40
+ openTimer = null
41
+ void show()
42
+ }, props.openDelay ?? 300)
43
+ }
44
+
45
+ const close = (): void => {
46
+ cancelOpen()
47
+ hide()
48
+ }
49
+
50
+ onBeforeUnmount(cancelOpen)
51
+ </script>
52
+
53
+ <template>
54
+ <span
55
+ ref="anchorRef"
56
+ class="inline-flex"
57
+ :aria-describedby="text && !disabled ? popoverId : undefined"
58
+ @mouseenter="open"
59
+ @mouseleave="close"
60
+ @focusin="open"
61
+ @focusout="close"
62
+ @keydown.escape="close"
63
+ >
64
+ <slot />
65
+
66
+ <!-- Manual rather than auto: a tooltip is dismissed by the pointer leaving what it describes,
67
+ and an auto popover would also take the next click on the page for itself. -->
68
+ <div
69
+ :id="popoverId"
70
+ ref="popoverRef"
71
+ popover="manual"
72
+ class="bluevue-popover"
73
+ :style="floatingStyles"
74
+ @toggle="onToggle"
75
+ >
76
+ <div
77
+ role="tooltip"
78
+ class="bluevue-elevation-5 max-w-[280px] rounded-[4px] px-2 py-1 text-xs leading-snug transition-opacity duration-150"
79
+ :class="[
80
+ theme === 'light' ? 'bg-white text-black' : 'bg-[#333333f2] text-white',
81
+ isPositioned ? 'opacity-100' : 'opacity-0',
82
+ ]"
83
+ >
84
+ {{ text }}
85
+ </div>
86
+ </div>
87
+ </span>
88
+ </template>
@@ -0,0 +1,269 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+
4
+ import { useBluePopover } from '../composables/useBluePopover'
5
+ import BlueIcon from './BlueIcon.vue'
6
+ import BlueInput from './BlueInput.vue'
7
+
8
+ const SIZE = 176
9
+ const CENTER = SIZE / 2
10
+ const TICKS = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330] as const
11
+ const CARDINALS: { label: string; deg: number }[] = [
12
+ { label: 'N', deg: 0 },
13
+ { label: 'E', deg: 90 },
14
+ { label: 'S', deg: 180 },
15
+ { label: 'W', deg: 270 },
16
+ ]
17
+
18
+ /**
19
+ * A heading around a circle: typed in the field, or pointed on the rose that opens from the
20
+ * compass beside it. The value wraps at the ends, so 360° and 0° are the same direction.
21
+ */
22
+ const props = defineProps<{
23
+ modelValue: number | null
24
+ label?: string
25
+ name?: string
26
+ /** Start of the circle (default 0). */
27
+ min?: number
28
+ /** End of the circle, wrapping back to min (default 360). */
29
+ max?: number
30
+ step?: number
31
+ suffix?: string
32
+ placeholder?: string
33
+ disabled?: boolean
34
+ theme?: 'light' | 'dark'
35
+ height?: string
36
+ width?: string
37
+ infoTooltip?: string
38
+ errorMessages?: string[]
39
+ }>()
40
+
41
+ const emit = defineEmits<{
42
+ (e: 'update:modelValue', value: number): void
43
+ }>()
44
+
45
+ const {
46
+ anchorRef,
47
+ popoverRef,
48
+ popoverId,
49
+ activatorProps,
50
+ isPositioned,
51
+ floatingStyles,
52
+ onToggle,
53
+ } = useBluePopover({ placement: 'bottom-end' })
54
+
55
+ const roseRef = ref<SVGSVGElement | null>(null)
56
+ const dragging = ref(false)
57
+
58
+ const min = computed(() => props.min ?? 0)
59
+ const max = computed(() => props.max ?? 360)
60
+ const step = computed(() => (props.step && props.step > 0 ? props.step : 1))
61
+
62
+ const period = computed(() => {
63
+ const span = max.value - min.value
64
+ return span > 0 ? span : 360
65
+ })
66
+
67
+ const current = computed(() => (typeof props.modelValue === 'number' ? props.modelValue : min.value))
68
+
69
+ const roseDeg = computed(() => ((wrap(current.value) - min.value) / period.value) * 360)
70
+
71
+ const isDark = computed(() => props.theme !== 'light')
72
+
73
+ function wrap(value: number): number {
74
+ return min.value + ((((value - min.value) % period.value) + period.value) % period.value)
75
+ }
76
+
77
+ function snap(value: number): number {
78
+ return min.value + Math.round((value - min.value) / step.value) * step.value
79
+ }
80
+
81
+ function commit(value: number): void {
82
+ emit('update:modelValue', wrap(snap(value)))
83
+ }
84
+
85
+ function onTyped(value: string | number | null): void {
86
+ if (typeof value !== 'number' || !Number.isFinite(value)) return
87
+ if (value < min.value || value >= max.value) {
88
+ emit('update:modelValue', wrap(value))
89
+ return
90
+ }
91
+ emit('update:modelValue', value)
92
+ }
93
+
94
+ function valueFromEvent(event: PointerEvent): number {
95
+ const svg = roseRef.value
96
+ if (!svg) return current.value
97
+ const rect = svg.getBoundingClientRect()
98
+ const deg =
99
+ (Math.atan2(event.clientX - (rect.left + rect.width / 2), rect.top + rect.height / 2 - event.clientY) *
100
+ 180) /
101
+ Math.PI
102
+ const heading = deg < 0 ? deg + 360 : deg
103
+ return min.value + (heading / 360) * period.value
104
+ }
105
+
106
+ function onPointerDown(event: PointerEvent): void {
107
+ if (props.disabled) return
108
+ dragging.value = true
109
+ roseRef.value?.setPointerCapture(event.pointerId)
110
+ commit(valueFromEvent(event))
111
+ }
112
+
113
+ function onPointerMove(event: PointerEvent): void {
114
+ if (!dragging.value) return
115
+ commit(valueFromEvent(event))
116
+ }
117
+
118
+ function onPointerUp(event: PointerEvent): void {
119
+ if (!dragging.value) return
120
+ dragging.value = false
121
+ roseRef.value?.releasePointerCapture(event.pointerId)
122
+ }
123
+
124
+ function tickPoint(deg: number, radius: number): { x: number; y: number } {
125
+ const rad = ((deg - 90) * Math.PI) / 180
126
+ return { x: CENTER + Math.cos(rad) * radius, y: CENTER + Math.sin(rad) * radius }
127
+ }
128
+ </script>
129
+
130
+ <template>
131
+ <div class="w-full">
132
+ <BlueInput
133
+ :model-value="modelValue"
134
+ type="number"
135
+ :label="label"
136
+ :name="name"
137
+ :suffix="suffix ?? '°'"
138
+ :placeholder="placeholder"
139
+ :disabled="disabled"
140
+ :theme="theme"
141
+ :height="height"
142
+ :width="width"
143
+ :info-tooltip="infoTooltip"
144
+ :error-messages="errorMessages"
145
+ :step="step"
146
+ @update:model-value="onTyped"
147
+ >
148
+ <template #prepend>
149
+ <button
150
+ ref="anchorRef"
151
+ v-bind="activatorProps"
152
+ type="button"
153
+ :disabled="disabled"
154
+ class="flex items-center justify-center rounded-[4px] p-[2px]"
155
+ :class="disabled ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-[#ffffff14]'"
156
+ :aria-label="label ? `Choose ${label}` : 'Choose heading'"
157
+ :title="label ? `Choose ${label}` : 'Choose heading'"
158
+ >
159
+ <BlueIcon
160
+ name="mdi-compass-rose"
161
+ :size="16"
162
+ :class="isDark ? 'text-white' : 'text-black'"
163
+ />
164
+ </button>
165
+ </template>
166
+ </BlueInput>
167
+
168
+ <div
169
+ :id="popoverId"
170
+ ref="popoverRef"
171
+ popover
172
+ class="bluevue-popover bluevue-popover--dimmed"
173
+ :style="floatingStyles"
174
+ @toggle="onToggle"
175
+ >
176
+ <!-- No padding of its own: the rose is drawn 6px inside its own box, which is the margin the
177
+ round card keeps outside the compass ring. -->
178
+ <div
179
+ class="bluevue-elevation-5 rounded-full"
180
+ :class="[
181
+ isDark ? 'bg-[var(--bluevue-surface)] text-white' : 'bg-white text-black',
182
+ isPositioned ? 'opacity-100' : 'opacity-0',
183
+ ]"
184
+ >
185
+ <svg
186
+ ref="roseRef"
187
+ :width="SIZE"
188
+ :height="SIZE"
189
+ :viewBox="`0 0 ${SIZE} ${SIZE}`"
190
+ class="block touch-none select-none"
191
+ :class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
192
+ @pointerdown.prevent="onPointerDown"
193
+ @pointermove="onPointerMove"
194
+ @pointerup="onPointerUp"
195
+ @pointercancel="onPointerUp"
196
+ >
197
+ <circle
198
+ :cx="CENTER"
199
+ :cy="CENTER"
200
+ :r="CENTER - 6"
201
+ fill="transparent"
202
+ :stroke="isDark ? '#ffffff22' : '#00000022'"
203
+ stroke-width="1"
204
+ />
205
+ <circle
206
+ :cx="CENTER"
207
+ :cy="CENTER"
208
+ :r="CENTER - 22"
209
+ :fill="isDark ? '#ffffff0d' : '#0000000d'"
210
+ />
211
+ <line
212
+ v-for="deg in TICKS"
213
+ :key="deg"
214
+ :x1="tickPoint(deg, deg % 90 === 0 ? 70 : 74).x"
215
+ :y1="tickPoint(deg, deg % 90 === 0 ? 70 : 74).y"
216
+ :x2="tickPoint(deg, 80).x"
217
+ :y2="tickPoint(deg, 80).y"
218
+ :stroke="isDark ? '#ffffff44' : '#00000044'"
219
+ :stroke-width="deg % 90 === 0 ? 2 : 1"
220
+ />
221
+ <text
222
+ v-for="cardinal in CARDINALS"
223
+ :key="cardinal.label"
224
+ :x="tickPoint(cardinal.deg, 58).x"
225
+ :y="tickPoint(cardinal.deg, 58).y"
226
+ text-anchor="middle"
227
+ dominant-baseline="middle"
228
+ class="text-[11px] font-medium"
229
+ :fill="isDark ? '#ffffff88' : '#00000088'"
230
+ >
231
+ {{ cardinal.label }}
232
+ </text>
233
+ <defs>
234
+ <filter
235
+ :id="`${popoverId}-arrow-shadow`"
236
+ x="-50%"
237
+ y="-50%"
238
+ width="200%"
239
+ height="200%"
240
+ >
241
+ <feDropShadow
242
+ dx="0"
243
+ dy="1"
244
+ stdDeviation="1.2"
245
+ flood-color="#000000"
246
+ flood-opacity="0.45"
247
+ />
248
+ </filter>
249
+ </defs>
250
+ <g
251
+ :transform="`rotate(${roseDeg} ${CENTER} ${CENTER})`"
252
+ :filter="`url(#${popoverId}-arrow-shadow)`"
253
+ >
254
+ <path
255
+ :d="`M ${CENTER} 18 L ${CENTER + 7} 36 L ${CENTER + 2.2} 36 L ${CENTER + 2.2} ${CENTER} L ${CENTER - 2.2} ${CENTER} L ${CENTER - 2.2} 36 L ${CENTER - 7} 36 Z`"
256
+ fill="#F5C400"
257
+ />
258
+ <circle
259
+ :cx="CENTER"
260
+ :cy="CENTER"
261
+ r="3.5"
262
+ fill="#F5C400"
263
+ />
264
+ </g>
265
+ </svg>
266
+ </div>
267
+ </div>
268
+ </div>
269
+ </template>
@@ -0,0 +1,38 @@
1
+ import { readonly, type Ref, ref } from 'vue'
2
+
3
+ export interface BlueLoadingState {
4
+ show: boolean
5
+ message: string
6
+ dismissible: boolean
7
+ }
8
+
9
+ interface BlueLoadingApi {
10
+ loading: Readonly<Ref<BlueLoadingState>>
11
+ /**
12
+ * Raises the overlay. Leave it blocking for anything that is writing to a vehicle: dismissing it
13
+ * would leave the page live over a change still in flight. A wait that only delays information,
14
+ * such as the first read of a vehicle, can be dismissible.
15
+ */
16
+ showLoading: (message?: string, dismissible?: boolean) => void
17
+ hideLoading: () => void
18
+ }
19
+
20
+ // One overlay for the whole application, so two operations cannot each raise their own and leave
21
+ // the second one holding the page after the first has finished.
22
+ const loading = ref<BlueLoadingState>({ show: false, message: 'Working…', dismissible: false })
23
+
24
+ const showLoading = (message = 'Working…', dismissible = false): void => {
25
+ loading.value = { show: true, message, dismissible }
26
+ }
27
+
28
+ const hideLoading = (): void => {
29
+ loading.value = { ...loading.value, show: false }
30
+ }
31
+
32
+ /**
33
+ * The application's blocking wait, which BlueApp renders as a BlueLoadingDialog.
34
+ * @returns {BlueLoadingApi} The state, and the ways to raise and lower it.
35
+ */
36
+ export function useBlueLoading(): BlueLoadingApi {
37
+ return { loading: readonly(loading) as Readonly<Ref<BlueLoadingState>>, showLoading, hideLoading }
38
+ }
@@ -0,0 +1,154 @@
1
+ import { onBeforeUnmount, onMounted, ref, type Ref, watch } from 'vue'
2
+
3
+ import { BlueOsError, blueOsService, isBlueOsExtension } from '../services/blueos'
4
+
5
+ const beacon = blueOsService('beacon')
6
+ const bag = blueOsService('bag')
7
+ const versionChooser = blueOsService('version-chooser')
8
+ const customization = blueOsService('customization')
9
+
10
+ export interface BlueOsHost {
11
+ /** What the vehicle is called, which is what an extension should call it too. */
12
+ vehicleName: Ref<string | null>
13
+ /** The address the vehicle answers to, without the .local suffix. */
14
+ hostname: Ref<string | null>
15
+ /** The BlueOS image currently running, as its docker tag. */
16
+ version: Ref<string | null>
17
+ /** The vehicle's primary colour, as #RRGGBB. Null until it arrives, and when it cannot. */
18
+ primaryColor: Ref<string | null>
19
+ /** Whether BlueOS itself is in dark mode. Extensions are drawn dark either way. */
20
+ isDarkTheme: Ref<boolean>
21
+ /** Whether BlueOS is in pirate mode, which is what hides or shows developer-only controls. */
22
+ isPirateMode: Ref<boolean>
23
+ /** Whether BlueOS is serving this page, as opposed to a development server. */
24
+ embedded: boolean
25
+ /** Why the last identity read failed, or null while the vehicle answered. */
26
+ error: Ref<BlueOsError | null>
27
+ /** Reads everything again, which a page does after something else may have changed it. */
28
+ refresh: () => Promise<void>
29
+ }
30
+
31
+ // One set of values for the whole page: BlueApp and whoever else asks share the same read rather
32
+ // than each hitting the vehicle for the same three strings.
33
+ const vehicleName = ref<string | null>(null)
34
+ const hostname = ref<string | null>(null)
35
+ const version = ref<string | null>(null)
36
+ const primaryColor = ref<string | null>(null)
37
+ const isDarkTheme = ref(true)
38
+ const isPirateMode = ref(false)
39
+ const error = ref<BlueOsError | null>(null)
40
+
41
+ const take = async <T>(task: Promise<T>, write: (value: T) => void): Promise<boolean> => {
42
+ try {
43
+ write(await task)
44
+ return true
45
+ } catch {
46
+ return false
47
+ }
48
+ }
49
+
50
+ const refresh = async (): Promise<void> => {
51
+ const [named, addressed, imaged] = await Promise.all([
52
+ take(beacon.get<string>('vehicle_name'), (value) => {
53
+ vehicleName.value = value
54
+ }),
55
+ take(beacon.get<string>('hostname'), (value) => {
56
+ hostname.value = value
57
+ }),
58
+ take(versionChooser.get<{ repository: string; tag: string }>('version/current'), (image) => {
59
+ version.value = image.tag
60
+ }),
61
+ ])
62
+
63
+ // Theme and pirate mode are extras: a vehicle that answers its name but not its colour is still
64
+ // a vehicle, and the kit keeps the colours it shipped with.
65
+ await Promise.all([
66
+ take(customization.get<{ primary: string }>('theme'), (theme) => {
67
+ primaryColor.value = theme.primary
68
+ }),
69
+ take(bag.get<{ is_dark_theme?: boolean; is_pirate_mode?: boolean }>('get/settings'), (settings) => {
70
+ if (typeof settings.is_dark_theme === 'boolean') isDarkTheme.value = settings.is_dark_theme
71
+ if (typeof settings.is_pirate_mode === 'boolean') isPirateMode.value = settings.is_pirate_mode
72
+ }),
73
+ ])
74
+
75
+ error.value = named || addressed || imaged
76
+ ? null
77
+ : new BlueOsError('Could not reach the vehicle', beacon.url('vehicle_name'), 0)
78
+ }
79
+
80
+ /**
81
+ * What the vehicle running this extension says about itself. Read once when the first consumer
82
+ * mounts, and again whenever it is asked for, since none of it changes on its own. Each field
83
+ * stays at its default when that particular call fails, so a page opened off the vehicle still
84
+ * renders.
85
+ * @returns {BlueOsHost} The vehicle's name, address, version and look, and the means to read them again.
86
+ */
87
+ export const useBlueOs = (): BlueOsHost => {
88
+ onMounted(() => {
89
+ void refresh()
90
+ })
91
+
92
+ return {
93
+ vehicleName,
94
+ hostname,
95
+ version,
96
+ primaryColor,
97
+ isDarkTheme,
98
+ isPirateMode,
99
+ embedded: isBlueOsExtension(),
100
+ error,
101
+ refresh,
102
+ }
103
+ }
104
+
105
+ /**
106
+ * A value kept on the vehicle rather than in this browser, so an extension is configured once and
107
+ * found configured from every machine that opens it. It reads as the fallback until the stored
108
+ * value arrives, and writes itself back, at most twice a second, whenever it is changed.
109
+ * @param {string} path Where it is kept, as 'extension-name/setting'. Slashes nest.
110
+ * @param {T} fallback What it reads as while there is nothing stored, and if the read fails.
111
+ * @returns {Ref<T>} The value, which is written back on assignment.
112
+ */
113
+ export const useBlueOsSetting = <T>(path: string, fallback: T): Ref<T> => {
114
+ const value = ref(fallback) as Ref<T>
115
+ // Assignments made while the stored value is still on its way would otherwise be written back as
116
+ // if they were the reader's, and the arriving value would then overwrite what the reader chose.
117
+ let settled = false
118
+ let pending: ReturnType<typeof setTimeout> | undefined
119
+
120
+ onMounted(async () => {
121
+ try {
122
+ value.value = await bag.get<T>(`get/${path}`)
123
+ } catch (raised) {
124
+ // 400 is what the bag answers with for a path holding nothing, which is the ordinary state
125
+ // of a setting nobody has changed yet. Anything else is a failure, and the fallback stands.
126
+ if ((raised as BlueOsError).status !== 400) console.warn(`Could not read ${path}:`, raised)
127
+ }
128
+ settled = true
129
+ })
130
+
131
+ const save = (): void => {
132
+ bag.post(`set/${path}`, value.value).catch((raised) => console.warn(`Could not save ${path}:`, raised))
133
+ }
134
+
135
+ const stop = watch(value, () => {
136
+ if (!settled) return
137
+ clearTimeout(pending)
138
+ pending = setTimeout(() => {
139
+ pending = undefined
140
+ save()
141
+ }, 500)
142
+ }, { deep: true })
143
+
144
+ onBeforeUnmount(() => {
145
+ stop()
146
+ // A page closed within half a second of a change would otherwise lose it.
147
+ if (pending !== undefined) {
148
+ clearTimeout(pending)
149
+ save()
150
+ }
151
+ })
152
+
153
+ return value
154
+ }
@@ -0,0 +1,89 @@
1
+ import { readonly, type Ref, ref } from 'vue'
2
+
3
+ export type BlueNoticeSeverity = 'success' | 'error' | 'warning' | 'info'
4
+
5
+ export interface BlueNotice {
6
+ id: number
7
+ text: string
8
+ severity: BlueNoticeSeverity
9
+ /** How many of the same message arrived in a row, so a repeat counts rather than stacks. */
10
+ repeats: number
11
+ }
12
+
13
+ interface BlueNoticeOptions {
14
+ /** Default 'info'. */
15
+ severity?: BlueNoticeSeverity
16
+ /** How long it stays, in ms. 0 keeps it until it is dismissed, which errors do by default. */
17
+ timeout?: number
18
+ }
19
+
20
+ interface BlueSnackbarApi {
21
+ notices: Readonly<Ref<readonly BlueNotice[]>>
22
+ /** Says something happened. Returns the notice's id, for dismissing it early. */
23
+ notify: (text: string, options?: BlueNoticeOptions) => number
24
+ /** Says a request failed, in whatever terms the failure came back in. */
25
+ notifyError: (error: unknown, fallback?: string) => number
26
+ dismiss: (id: number) => void
27
+ }
28
+
29
+ const DEFAULT_TIMEOUT = 5000
30
+
31
+ // Enough to read a burst without the stack becoming the page. Anything older than the last few is
32
+ // gone from the screen before it could be read anyway.
33
+ const MAX_ON_SCREEN = 4
34
+
35
+ // One queue for the whole application: the notices outlive whichever component raised them, and
36
+ // the bar rendering them is mounted once, at the root.
37
+ const notices = ref<BlueNotice[]>([])
38
+ const timers = new Map<number, number>()
39
+ let lastId = 0
40
+
41
+ const clearTimer = (id: number): void => {
42
+ const timer = timers.get(id)
43
+ if (timer === undefined) return
44
+ clearTimeout(timer)
45
+ timers.delete(id)
46
+ }
47
+
48
+ const dismiss = (id: number): void => {
49
+ clearTimer(id)
50
+ notices.value = notices.value.filter((notice) => notice.id !== id)
51
+ }
52
+
53
+ const notify = (text: string, options: BlueNoticeOptions = {}): number => {
54
+ const severity = options.severity ?? 'info'
55
+ // A failure is worth acting on, so it waits to be dismissed rather than passing while the reader
56
+ // is looking elsewhere.
57
+ const timeout = options.timeout ?? (severity === 'error' ? 0 : DEFAULT_TIMEOUT)
58
+
59
+ // A retry loop saying the same thing every second should read as one message that keeps
60
+ // happening, not as a column of identical ones.
61
+ const last = notices.value[notices.value.length - 1]
62
+ if (last && last.text === text && last.severity === severity) {
63
+ last.repeats += 1
64
+ clearTimer(last.id)
65
+ if (timeout > 0) timers.set(last.id, window.setTimeout(() => dismiss(last.id), timeout))
66
+ return last.id
67
+ }
68
+
69
+ const notice: BlueNotice = { id: ++lastId, text, severity, repeats: 1 }
70
+ notices.value = [...notices.value, notice].slice(-MAX_ON_SCREEN)
71
+ if (timeout > 0) timers.set(notice.id, window.setTimeout(() => dismiss(notice.id), timeout))
72
+ return notice.id
73
+ }
74
+
75
+ // An axios rejection carries the server's own words, which say more than the status line does.
76
+ const notifyError = (error: unknown, fallback = 'Request failed'): number => {
77
+ const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail
78
+ const message = (error as Error)?.message
79
+ return notify(detail || message || fallback, { severity: 'error' })
80
+ }
81
+
82
+ /**
83
+ * The application's notices. Raise them from anywhere; BlueSnackbar, which BlueApp mounts for you,
84
+ * is what puts them on screen.
85
+ * @returns {BlueSnackbarApi} The queue, and the ways to add to and remove from it.
86
+ */
87
+ export function useBlueSnackbar(): BlueSnackbarApi {
88
+ return { notices: readonly(notices), notify, notifyError, dismiss }
89
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,51 @@
1
+ export { default as BlueApp } from './components/BlueApp.vue'
2
+ export { default as BlueBanner } from './components/BlueBanner.vue'
3
+ export { default as BlueBannerGroup } from './components/BlueBannerGroup.vue'
4
+ export { default as BlueButton } from './components/BlueButton.vue'
1
5
  export { default as BlueButtonGroup } from './components/BlueButtonGroup.vue'
6
+ export { default as BlueCard } from './components/BlueCard.vue'
7
+ export { default as BlueCheckbox } from './components/BlueCheckbox.vue'
8
+ export { default as BlueChip } from './components/BlueChip.vue'
9
+ export { default as BlueConfirmDialog } from './components/BlueConfirmDialog.vue'
10
+ export { default as BlueDialog } from './components/BlueDialog.vue'
2
11
  export { default as BlueExpansiblePanel } from './components/BlueExpansiblePanel.vue'
12
+ export { default as BlueFileDrop } from './components/BlueFileDrop.vue'
13
+ export { default as BlueIcon } from './components/BlueIcon.vue'
3
14
  export { default as BlueInput } from './components/BlueInput.vue'
15
+ export { default as BlueStepsDialog, type BlueStep } from './components/BlueStepsDialog.vue'
4
16
  export { default as BlueLoadingDialog } from './components/BlueLoadingDialog.vue'
5
17
  export { default as BlueMenu, type BlueMenuItem } from './components/BlueMenu.vue'
18
+ export { default as BlueProgressBar } from './components/BlueProgressBar.vue'
6
19
  export { default as BluePromptDialog } from './components/BluePromptDialog.vue'
20
+ export { default as BlueRadioGroup, type BlueRadioItem } from './components/BlueRadioGroup.vue'
21
+ export { default as BlueSection } from './components/BlueSection.vue'
7
22
  export { default as BlueSelect } from './components/BlueSelect.vue'
8
23
  export { default as BlueSlider } from './components/BlueSlider.vue'
24
+ export { default as BlueSnackbar } from './components/BlueSnackbar.vue'
25
+ export { default as BlueSpinner } from './components/BlueSpinner.vue'
26
+ export { default as BlueStat } from './components/BlueStat.vue'
9
27
  export { default as BlueSwitch } from './components/BlueSwitch.vue'
28
+ export { default as BlueTable, type BlueColumn } from './components/BlueTable.vue'
29
+ export { default as BlueTabs, type BlueTab } from './components/BlueTabs.vue'
30
+ export { default as BlueTextarea } from './components/BlueTextarea.vue'
31
+ export { default as BlueTooltip } from './components/BlueTooltip.vue'
32
+ export { default as BlueWindRose } from './components/BlueWindRose.vue'
10
33
 
34
+ export { type BlueLoadingState, useBlueLoading } from './composables/useBlueLoading'
35
+ export { type BlueOsHost, useBlueOs, useBlueOsSetting } from './composables/useBlueOs'
11
36
  export { useBluePopover } from './composables/useBluePopover'
37
+ export { type BlueNotice, type BlueNoticeSeverity, useBlueSnackbar } from './composables/useBlueSnackbar'
38
+
39
+ export type { BannerContent, BannerSeverity } from './types/banner'
40
+
41
+ export {
42
+ BlueOsError,
43
+ type BlueOsService,
44
+ blueOsService,
45
+ type BlueOsServiceOptions,
46
+ isBlueOsExtension,
47
+ setBlueOsHost,
48
+ } from './services/blueos'
49
+
50
+ export { downloadJson, pickJsonFile } from './utils/files'
51
+ export { applyBlueTheme, type BlueTheme } from './utils/theme'