@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.
- package/README.md +97 -18
- package/bin/create-blueos-extension.mjs +72 -0
- package/dist/bluevue.js +2464 -778
- package/dist/components/BlueApp.vue.d.ts +50 -0
- package/dist/components/BlueBanner.vue.d.ts +49 -0
- package/dist/components/BlueBannerGroup.vue.d.ts +20 -0
- package/dist/components/BlueButton.vue.d.ts +59 -0
- package/dist/components/BlueCard.vue.d.ts +45 -0
- package/dist/components/BlueCheckbox.vue.d.ts +34 -0
- package/dist/components/BlueChip.vue.d.ts +47 -0
- package/dist/components/BlueConfirmDialog.vue.d.ts +193 -0
- package/dist/components/BlueDialog.vue.d.ts +60 -0
- package/dist/components/BlueFileDrop.vue.d.ts +32 -0
- package/dist/components/BlueIcon.vue.d.ts +26 -0
- package/dist/components/BlueInput.vue.d.ts +1 -0
- package/dist/components/BlueProgressBar.vue.d.ts +28 -0
- package/dist/components/BlueRadioGroup.vue.d.ts +40 -0
- package/dist/components/BlueSection.vue.d.ts +36 -0
- package/dist/components/BlueSnackbar.vue.d.ts +19 -0
- package/dist/components/BlueSpinner.vue.d.ts +25 -0
- package/dist/components/BlueStat.vue.d.ts +21 -0
- package/dist/components/BlueStepsDialog.vue.d.ts +62 -0
- package/dist/components/BlueTable.vue.d.ts +62 -0
- package/dist/components/BlueTabs.vue.d.ts +37 -0
- package/dist/components/BlueTextarea.vue.d.ts +40 -0
- package/dist/components/BlueTooltip.vue.d.ts +44 -0
- package/dist/components/BlueWindRose.vue.d.ts +37 -0
- package/dist/composables/useBlueLoading.d.ts +22 -0
- package/dist/composables/useBlueOs.d.ts +39 -0
- package/dist/composables/useBlueSnackbar.d.ts +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/services/blueos.d.ts +37 -0
- package/dist/style.css +1 -1
- package/dist/types/banner.d.ts +16 -0
- package/dist/utils/files.d.ts +13 -0
- package/dist/utils/theme.d.ts +22 -0
- package/package.json +8 -2
- package/src/components/BlueApp.vue +84 -0
- package/src/components/BlueBanner.vue +196 -0
- package/src/components/BlueBannerGroup.vue +47 -0
- package/src/components/BlueButton.vue +134 -0
- package/src/components/BlueButtonGroup.vue +28 -26
- package/src/components/BlueCard.vue +62 -0
- package/src/components/BlueCheckbox.vue +100 -0
- package/src/components/BlueChip.vue +77 -0
- package/src/components/BlueConfirmDialog.vue +73 -0
- package/src/components/BlueDialog.vue +135 -0
- package/src/components/BlueFileDrop.vue +94 -0
- package/src/components/BlueIcon.vue +37 -0
- package/src/components/BlueInput.vue +23 -13
- package/src/components/BlueLoadingDialog.vue +22 -53
- package/src/components/BlueProgressBar.vue +46 -0
- package/src/components/BluePromptDialog.vue +51 -99
- package/src/components/BlueRadioGroup.vue +104 -0
- package/src/components/BlueSection.vue +33 -0
- package/src/components/BlueSelect.vue +13 -11
- package/src/components/BlueSlider.vue +1 -3
- package/src/components/BlueSnackbar.vue +79 -0
- package/src/components/BlueSpinner.vue +37 -0
- package/src/components/BlueStat.vue +26 -0
- package/src/components/BlueStepsDialog.vue +180 -0
- package/src/components/BlueSwitch.vue +18 -15
- package/src/components/BlueTable.vue +154 -0
- package/src/components/BlueTabs.vue +77 -0
- package/src/components/BlueTextarea.vue +102 -0
- package/src/components/BlueTooltip.vue +88 -0
- package/src/components/BlueWindRose.vue +269 -0
- package/src/composables/useBlueLoading.ts +38 -0
- package/src/composables/useBlueOs.ts +154 -0
- package/src/composables/useBlueSnackbar.ts +89 -0
- package/src/index.ts +40 -0
- package/src/services/blueos.ts +95 -0
- package/src/styles/bluevue.css +144 -0
- package/src/types/banner.ts +17 -0
- package/src/utils/files.ts +44 -0
- package/src/utils/theme.ts +37 -0
- package/templates/extension/.github/workflows/deploy.yml +23 -0
- package/templates/extension/Dockerfile +56 -0
- package/templates/extension/README.md +35 -0
- package/templates/extension/backend/main.py +45 -0
- package/templates/extension/backend/requirements.txt +2 -0
- package/templates/extension/frontend/.eslintrc.cjs +20 -0
- package/templates/extension/frontend/env.d.ts +7 -0
- package/templates/extension/frontend/index.html +12 -0
- package/templates/extension/frontend/package.json +30 -0
- package/templates/extension/frontend/src/App.vue +51 -0
- package/templates/extension/frontend/src/main.ts +8 -0
- package/templates/extension/frontend/src/styles/global.css +11 -0
- package/templates/extension/frontend/tsconfig.json +25 -0
- package/templates/extension/frontend/tsconfig.node.json +11 -0
- 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'
|