feat(#33): Add a global toast/notification component
This commit is contained in:
@@ -3,6 +3,7 @@ import {computed} from 'vue';
|
|||||||
import {RouterView, useRoute} from 'vue-router';
|
import {RouterView, useRoute} from 'vue-router';
|
||||||
import {Sidebar} from './sidebar';
|
import {Sidebar} from './sidebar';
|
||||||
import {ConfirmModal} from '@/confirm';
|
import {ConfirmModal} from '@/confirm';
|
||||||
|
import {ToastContainer} from '@/toast';
|
||||||
import {routeNames} from '@/routes';
|
import {routeNames} from '@/routes';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -23,6 +24,7 @@ const hideSidebar = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<ConfirmModal />
|
<ConfirmModal />
|
||||||
|
<ToastContainer />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import log from "loglevel";
|
|||||||
import {ScriptEditor, useRuleBookStore} from "@/rules";
|
import {ScriptEditor, useRuleBookStore} from "@/rules";
|
||||||
import {PlusIcon, TrashIcon} from "@heroicons/vue/24/outline";
|
import {PlusIcon, TrashIcon} from "@heroicons/vue/24/outline";
|
||||||
import {isMain, Ledger, LedgerSelect, systemLedger, useLedgersStore} from "@/ledger";
|
import {isMain, Ledger, LedgerSelect, systemLedger, useLedgersStore} from "@/ledger";
|
||||||
|
import {toast} from "@/toast";
|
||||||
|
|
||||||
type Binding = { ref: string; ledger: Ledger };
|
type Binding = { ref: string; ledger: Ledger };
|
||||||
|
|
||||||
@@ -42,7 +43,9 @@ const save = () => ruleBookStore.update({
|
|||||||
.map(b => [b.ref, b.ledger.ledgerId])
|
.map(b => [b.ref, b.ledger.ledgerId])
|
||||||
),
|
),
|
||||||
script: script.value
|
script: script.value
|
||||||
});
|
})
|
||||||
|
.then(() => toast.success('Rule book saved'))
|
||||||
|
.catch(() => toast.error('Failed to save rule book'));
|
||||||
|
|
||||||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import type {ToastType} from './useToast';
|
||||||
|
import {useToastStore} from './useToast';
|
||||||
|
|
||||||
|
const toastStore = useToastStore();
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: CheckCircleIcon,
|
||||||
|
error: XCircleIcon,
|
||||||
|
info: InformationCircleIcon,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const accent = (type: ToastType) => `accent-${type}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toast-container">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<div v-for="toast in toastStore.toasts" :key="toast.id" class="toast" :class="accent(toast.type)">
|
||||||
|
<component :is="icons[toast.type]" class="icon" />
|
||||||
|
<div class="body">
|
||||||
|
<span v-if="toast.title" class="title">{{ toast.title }}</span>
|
||||||
|
<span class="message">{{ toast.message }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-icon close" @click="toastStore.dismiss(toast.id)">
|
||||||
|
<XMarkIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/style.css";
|
||||||
|
|
||||||
|
div.toast-container {
|
||||||
|
@apply fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)];
|
||||||
|
}
|
||||||
|
|
||||||
|
div.toast {
|
||||||
|
@apply flex items-start gap-2 p-3 rounded bg-slate-700 border-l-4 shadow-lg;
|
||||||
|
|
||||||
|
&.accent-success {
|
||||||
|
@apply border-emerald-500;
|
||||||
|
> .icon { @apply text-emerald-500; }
|
||||||
|
}
|
||||||
|
&.accent-error {
|
||||||
|
@apply border-amber-700;
|
||||||
|
> .icon { @apply text-amber-700; }
|
||||||
|
}
|
||||||
|
&.accent-info {
|
||||||
|
@apply border-sky-600;
|
||||||
|
> .icon { @apply text-sky-600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
> .icon {
|
||||||
|
@apply w-6 h-6 shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .body {
|
||||||
|
@apply flex flex-col grow min-w-0;
|
||||||
|
|
||||||
|
> .title {
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
> .message {
|
||||||
|
@apply break-words;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .close {
|
||||||
|
@apply shrink-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active,
|
||||||
|
.toast-move {
|
||||||
|
@apply transition-all duration-300;
|
||||||
|
}
|
||||||
|
.toast-enter-from,
|
||||||
|
.toast-leave-to {
|
||||||
|
@apply opacity-0 translate-x-8;
|
||||||
|
}
|
||||||
|
.toast-leave-active {
|
||||||
|
@apply absolute w-full;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as ToastContainer } from './ToastContainer.vue';
|
||||||
|
export { toast, useToastStore } from './useToast';
|
||||||
|
export type { ToastOptions, ToastType } from './useToast';
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {defineStore} from "pinia";
|
||||||
|
import {ref} from "vue";
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
export interface ToastOptions {
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
type?: ToastType;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
type: ToastType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DURATION = 5000;
|
||||||
|
|
||||||
|
export const useToastStore = defineStore('toast', () => {
|
||||||
|
const toasts = ref<Toast[]>([]);
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
const dismiss = (id: number) => {
|
||||||
|
toasts.value = toasts.value.filter(toast => toast.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const push = (opts: ToastOptions | string): number => {
|
||||||
|
const options = typeof opts === "string" ? {message: opts} : opts;
|
||||||
|
const id = nextId++;
|
||||||
|
toasts.value.push({
|
||||||
|
id,
|
||||||
|
message: options.message,
|
||||||
|
title: options.title,
|
||||||
|
type: options.type ?? 'info',
|
||||||
|
});
|
||||||
|
const duration = options.duration ?? DEFAULT_DURATION;
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => dismiss(id), duration);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {toasts, push, dismiss};
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ToastHelper {
|
||||||
|
(opts: ToastOptions | string): number;
|
||||||
|
success: (message: string) => number;
|
||||||
|
error: (message: string) => number;
|
||||||
|
info: (message: string) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toast: ToastHelper = Object.assign(
|
||||||
|
(opts: ToastOptions | string): number => useToastStore().push(opts),
|
||||||
|
{
|
||||||
|
success: (message: string): number => useToastStore().push({message, type: 'success'}),
|
||||||
|
error: (message: string): number => useToastStore().push({message, type: 'error'}),
|
||||||
|
info: (message: string): number => useToastStore().push({message, type: 'info'}),
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user