Add toasts to communicate with user

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-06-13 18:58:57 +02:00
parent f140bd7d92
commit 7f833fc60e
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
8 changed files with 191 additions and 21 deletions

View file

@ -1,14 +1,10 @@
<template> <template>
<nav class="navbar navbar-expand-lg bg-body-tertiary"> <nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid"> <div class="container-fluid">
<a <span class="navbar-brand">
class="navbar-brand"
href="#"
@click.prevent
>
<i class="fas fa-robot fa-fw me-1 text-info" /> <i class="fas fa-robot fa-fw me-1 text-info" />
Twitch-Bot Twitch-Bot
</a> </span>
<button <button
class="navbar-toggler" class="navbar-toggler"

82
src/components/_toast.vue Normal file
View file

@ -0,0 +1,82 @@
<template>
<div
ref="toast"
:class="classForToast(toast)"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<div class="d-flex">
<div class="toast-body">
{{ toast.text }}
</div>
<button
type="button"
:class="classForCloseButton(toast)"
data-bs-dismiss="toast"
aria-label="Close"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { Toast } from 'bootstrap'
import { ToastContent } from './_toaster.vue'
export default defineComponent({
data() {
return {
hdl: null,
}
},
emits: ['hidden'],
methods: {
classForCloseButton(toast: ToastContent): string {
const classes = [
'btn-close',
'me-2',
'm-auto',
]
if (toast.color) {
classes.push('btn-close-white')
}
return classes.join(' ')
},
classForToast(toast: ToastContent): string {
const classes = [
'toast',
'align-items-center',
]
if (toast.color) {
classes.push('border-0', `text-bg-${toast.color}`)
}
return classes.join(' ')
},
},
mounted() {
this.$refs.toast.addEventListener('hidden.bs.toast', () => this.$emit('hidden'))
this.hdl = new Toast(this.$refs.toast)
this.hdl.show()
},
name: 'TwitchBotEditorToast',
props: {
toast: {
required: true,
type: Object as PropType<ToastContent>,
},
},
})
</script>

View file

@ -0,0 +1,47 @@
<template>
<div class="toast-container bottom-0 end-0 p-3">
<toast
v-for="toast in toasts"
:key="toast.id"
:toast="toast"
@hidden="removeToast(toast.id)"
/>
</div>
</template>
<script lang="ts">
import BusEventTypes from '../helpers/busevents'
import { defineComponent } from 'vue'
import Toast from './_toast.vue'
export type ToastContent = {
id: string
color: string | undefined
text: string
}
export default defineComponent({
components: { Toast },
data() {
return {
toasts: [] as ToastContent[],
}
},
methods: {
removeToast(id: string) {
this.toasts = this.toasts.filter((t: ToastContent) => t.id !== id)
},
},
mounted() {
this.bus.on(BusEventTypes.Toast, (toast: ToastContent) => this.toasts.push({
...toast,
id: crypto.randomUUID(),
}))
},
name: 'TwitchBotEditorToaster',
})
</script>

View file

@ -48,6 +48,8 @@
</div> </div>
<div class="layoutContent"> <div class="layoutContent">
<router-view /> <router-view />
<toaster />
</div> </div>
</div> </div>
</div> </div>
@ -57,9 +59,10 @@
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import HeadNav from './_headNav.vue' import HeadNav from './_headNav.vue'
import Toaster from './_toaster.vue'
export default defineComponent({ export default defineComponent({
components: { HeadNav }, components: { HeadNav, Toaster },
name: 'TwitchBotEditorApp', name: 'TwitchBotEditorApp',
}) })

View file

@ -11,16 +11,19 @@
Login with Twitch Login with Twitch
</button> </button>
</div> </div>
<toaster />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import BusEventTypes from '../helpers/busevents'
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import HeadNav from './_headNav.vue' import HeadNav from './_headNav.vue'
import Toaster from './_toaster.vue'
export default defineComponent({ export default defineComponent({
components: { HeadNav }, components: { HeadNav, Toaster },
computed: { computed: {
authURL() { authURL() {
@ -49,7 +52,7 @@ export default defineComponent({
}, },
mounted() { mounted() {
this.$root.bus.on('login-processing', (loading: boolean) => { this.$root.bus.on(BusEventTypes.LoginProcessing, (loading: boolean) => {
this.loading = loading this.loading = loading
}) })
}, },

13
src/helpers/busevents.ts Normal file
View file

@ -0,0 +1,13 @@
/* eslint-disable no-unused-vars */
enum BusEventTypes {
ChangePending = 'changePending',
ConfigReload = 'configReload',
Error = 'error',
FetchError = 'fetchError',
LoadingData = 'loadingData',
LoginProcessing = 'loginProcessing',
Toast = 'toast',
}
export default BusEventTypes

15
src/helpers/template.ts Normal file
View file

@ -0,0 +1,15 @@
export const BuiltinTemplateFunctions = [
'and',
'call',
'html',
'index',
'slice',
'js',
'len',
'not',
'or',
'print',
'printf',
'println',
'urlquery',
]

View file

@ -9,7 +9,9 @@ import 'bootstrap/dist/js/bootstrap.bundle' // Popper & Bootstrap globally avail
import { createApp, h } from 'vue' import { createApp, h } from 'vue'
import mitt from 'mitt' import mitt from 'mitt'
import BusEventTypes from './helpers/busevents'
import ConfigNotifyListener from './helpers/configNotify' import ConfigNotifyListener from './helpers/configNotify'
import { ToastContent } from './components/_toaster.vue'
import router from './router' import router from './router'
import App from './components/app.vue' import App from './components/app.vue'
@ -36,20 +38,17 @@ const app = createApp({
// We renew 720sec before expiration (0.8 * 1h) // We renew 720sec before expiration (0.8 * 1h)
return new Date(this.tokenExpiresAt.getTime() - 720000) return new Date(this.tokenExpiresAt.getTime() - 720000)
} },
}, },
data(): Object { data(): Object {
return { return {
now: new Date(), now: new Date(),
tickers: {},
token: '', token: '',
tokenExpiresAt: null as Date | null, tokenExpiresAt: null as Date | null,
tokenUser: '', tokenUser: '',
userInfo: null as null | {}, userInfo: null as null | {},
tickers: {},
vars: {}, vars: {},
} }
}, },
@ -64,8 +63,10 @@ const app = createApp({
*/ */
check403(resp: Response): Response { check403(resp: Response): Response {
if (resp.status === 403) { if (resp.status === 403) {
// User token is not valid and therefore should be removed /*
// which essentially triggers a logout * User token is not valid and therefore should be removed
* which essentially triggers a logout
*/
this.logout() this.logout()
throw new Error('user has been logged out') throw new Error('user has been logged out')
} }
@ -76,7 +77,9 @@ const app = createApp({
loadVars(): Promise<void | Response> { loadVars(): Promise<void | Response> {
return fetch('editor/vars.json') return fetch('editor/vars.json')
.then((resp: Response) => resp.json()) .then((resp: Response) => resp.json())
.then((data: any) => { this.vars = data }) .then((data: any) => {
this.vars = data
})
}, },
login(token: string, expiresAt: Date, username: string): void { login(token: string, expiresAt: Date, username: string): void {
@ -128,18 +131,22 @@ const app = createApp({
mounted(): void { mounted(): void {
this.bus.on('logout', this.logout) this.bus.on('logout', this.logout)
this.$root.registerTicker('updateRootNow', () => { this.now = new Date() }, 30000) this.$root.registerTicker('updateRootNow', () => {
this.now = new Date()
}, 30000)
this.$root.registerTicker('renewToken', () => this.renewToken(), 60000) this.$root.registerTicker('renewToken', () => this.renewToken(), 60000)
// Start background-listen for config updates // Start background-listen for config updates
new ConfigNotifyListener((msgType: string) => { this.$root.bus.emit(msgType) }) new ConfigNotifyListener((msgType: string) => {
this.$root.bus.emit(msgType)
})
this.loadVars() this.loadVars()
const params = new URLSearchParams(window.location.hash.replace(/^[#/]+/, '')) const params = new URLSearchParams(window.location.hash.replace(/^[#/]+/, ''))
const authToken = params.get('access_token') const authToken = params.get('access_token')
if (authToken) { if (authToken) {
this.$root.bus.emit('login-processing', true) this.$root.bus.emit(BusEventTypes.LoginProcessing, true)
fetch('config-editor/login', { fetch('config-editor/login', {
body: JSON.stringify({ token: authToken }), body: JSON.stringify({ token: authToken }),
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -147,7 +154,11 @@ const app = createApp({
}) })
.then((resp: Response): any => { .then((resp: Response): any => {
if (resp.status !== 200) { if (resp.status !== 200) {
this.$root.bus.emit('login-processing', false) this.$root.bus.emit(BusEventTypes.LoginProcessing, false)
this.bus.emit(BusEventTypes.Toast, {
color: 'danger',
text: 'Login failed',
} as ToastContent)
throw new Error(`login failed, status=${resp.status}`) throw new Error(`login failed, status=${resp.status}`)
} }