From 56aa1947856c49fd216bb2d576e97a5141843a38 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 1 Mar 2026 18:25:04 +0300 Subject: [PATCH] Toast notifications via Reka UI Toast - AppToast component: top-center blue pill (info), red pill (errors) - Global error handler catches window.error + unhandledrejection - Link copied toast on share and copy link button - use-toast composable: toast.show(message, variant) --- CHANGELOG.md | 3 +++ components.d.ts | 2 ++ src/App.vue | 12 ++++++++++++ src/components/AppToast.vue | 29 +++++++++++++++++++++++++++++ src/components/CollabPanel.vue | 2 ++ src/composables/use-toast.ts | 34 ++++++++++++++++++++++++++++++++++ src/views/EditorView.vue | 3 +++ 7 files changed, 85 insertions(+) create mode 100644 src/components/AppToast.vue create mode 100644 src/composables/use-toast.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 161bdc9fd..305ca0d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ ### UI +- Toast notifications via Reka UI Toast — top-center blue pill for info, red for errors +- Global error handler (window.error + unhandledrejection) shows errors as toasts +- Link copied toast on share and copy link actions - HsvColorArea extracted as shared component (ColorPicker + FillPicker) - Scrollable app menu without visible scrollbar - Selection broadcasting to remote peers diff --git a/components.d.ts b/components.d.ts index 5345b737b..b6654b790 100644 --- a/components.d.ts +++ b/components.d.ts @@ -14,6 +14,7 @@ declare module 'vue' { APIKeySetup: typeof import('./src/components/chat/APIKeySetup.vue')['default'] AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default'] AppMenu: typeof import('./src/components/AppMenu.vue')['default'] + AppToast: typeof import('./src/components/AppToast.vue')['default'] CanvasContextMenu: typeof import('./src/components/CanvasContextMenu.vue')['default'] ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default'] ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default'] @@ -31,6 +32,7 @@ declare module 'vue' { FontPicker: typeof import('./src/components/FontPicker.vue')['default'] HsvColorArea: typeof import('./src/components/HsvColorArea.vue')['default'] IconLucideALargeSmall: typeof import('~icons/lucide/a-large-small')['default'] + IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] IconLucideAlignCenter: typeof import('~icons/lucide/align-center')['default'] IconLucideAlignHorizontalJustifyCenter: typeof import('~icons/lucide/align-horizontal-justify-center')['default'] IconLucideAlignHorizontalJustifyEnd: typeof import('~icons/lucide/align-horizontal-justify-end')['default'] diff --git a/src/App.vue b/src/App.vue index 7c2aa3f3e..db52a9dfb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,3 +1,15 @@ + + diff --git a/src/components/AppToast.vue b/src/components/AppToast.vue new file mode 100644 index 000000000..407e07a6f --- /dev/null +++ b/src/components/AppToast.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/CollabPanel.vue b/src/components/CollabPanel.vue index 7984c7c48..e21a6855e 100644 --- a/src/components/CollabPanel.vue +++ b/src/components/CollabPanel.vue @@ -13,6 +13,7 @@ import { } from 'reka-ui' import type { CollabState, RemotePeer } from '@/composables/use-collab' +import { toast } from '@/composables/use-toast' import type { Color } from '@/types' const props = defineProps<{ @@ -45,6 +46,7 @@ const isJoining = computed(() => !!props.pendingRoomId && !props.state.connected function copyLink() { if (!shareUrl.value) return navigator.clipboard.writeText(shareUrl.value) + toast.show('Link copied to clipboard') copied.value = true setTimeout(() => { copied.value = false diff --git a/src/composables/use-toast.ts b/src/composables/use-toast.ts new file mode 100644 index 000000000..e7163a2d5 --- /dev/null +++ b/src/composables/use-toast.ts @@ -0,0 +1,34 @@ +import { ref } from 'vue' + +export type ToastVariant = 'default' | 'error' + +export interface Toast { + id: number + message: string + variant: ToastVariant +} + +const TOAST_DURATION = 3000 + +const toasts = ref([]) +let nextId = 0 + +function show(message: string, variant: ToastVariant = 'default') { + toasts.value.push({ id: ++nextId, message, variant }) +} + +function remove(id: number) { + toasts.value = toasts.value.filter((t) => t.id !== id) +} + +function setupGlobalErrorHandler() { + window.addEventListener('error', (e) => { + show(e.message || 'An unexpected error occurred', 'error') + }) + window.addEventListener('unhandledrejection', (e) => { + const msg = e.reason instanceof Error ? e.reason.message : String(e.reason) + show(msg || 'An unexpected error occurred', 'error') + }) +} + +export const toast = { show, remove, toasts, setupGlobalErrorHandler, TOAST_DURATION } diff --git a/src/views/EditorView.vue b/src/views/EditorView.vue index 90a718de2..84d62b11e 100644 --- a/src/views/EditorView.vue +++ b/src/views/EditorView.vue @@ -7,6 +7,7 @@ import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui' import { useKeyboard } from '@/composables/use-keyboard' import { useMenu } from '@/composables/use-menu' import { useCollab, COLLAB_KEY } from '@/composables/use-collab' +import { toast } from '@/composables/use-toast' import { createDemoShapes } from '@/demo' import { provideEditorStore } from '@/stores/editor' @@ -47,6 +48,8 @@ const pendingRoomId = (route.params.roomId as string) || null function onShare() { const roomId = collab.shareCurrentDoc() router.push(`/share/${roomId}`) + navigator.clipboard.writeText(`${window.location.origin}/share/${roomId}`) + toast.show('Link copied to clipboard') } function onJoin(roomId: string) {