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)
This commit is contained in:
parent
f90e1400b7
commit
56aa194785
|
|
@ -19,6 +19,9 @@
|
||||||
|
|
||||||
### UI
|
### 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)
|
- HsvColorArea extracted as shared component (ColorPicker + FillPicker)
|
||||||
- Scrollable app menu without visible scrollbar
|
- Scrollable app menu without visible scrollbar
|
||||||
- Selection broadcasting to remote peers
|
- Selection broadcasting to remote peers
|
||||||
|
|
|
||||||
2
components.d.ts
vendored
2
components.d.ts
vendored
|
|
@ -14,6 +14,7 @@ declare module 'vue' {
|
||||||
APIKeySetup: typeof import('./src/components/chat/APIKeySetup.vue')['default']
|
APIKeySetup: typeof import('./src/components/chat/APIKeySetup.vue')['default']
|
||||||
AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default']
|
AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default']
|
||||||
AppMenu: typeof import('./src/components/AppMenu.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']
|
CanvasContextMenu: typeof import('./src/components/CanvasContextMenu.vue')['default']
|
||||||
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
|
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
|
||||||
ChatMessage: typeof import('./src/components/chat/ChatMessage.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']
|
FontPicker: typeof import('./src/components/FontPicker.vue')['default']
|
||||||
HsvColorArea: typeof import('./src/components/HsvColorArea.vue')['default']
|
HsvColorArea: typeof import('./src/components/HsvColorArea.vue')['default']
|
||||||
IconLucideALargeSmall: typeof import('~icons/lucide/a-large-small')['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']
|
IconLucideAlignCenter: typeof import('~icons/lucide/align-center')['default']
|
||||||
IconLucideAlignHorizontalJustifyCenter: typeof import('~icons/lucide/align-horizontal-justify-center')['default']
|
IconLucideAlignHorizontalJustifyCenter: typeof import('~icons/lucide/align-horizontal-justify-center')['default']
|
||||||
IconLucideAlignHorizontalJustifyEnd: typeof import('~icons/lucide/align-horizontal-justify-end')['default']
|
IconLucideAlignHorizontalJustifyEnd: typeof import('~icons/lucide/align-horizontal-justify-end')['default']
|
||||||
|
|
|
||||||
12
src/App.vue
12
src/App.vue
|
|
@ -1,3 +1,15 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
import AppToast from '@/components/AppToast.vue'
|
||||||
|
import { toast } from '@/composables/use-toast'
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
toast.setupGlobalErrorHandler()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
<AppToast />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
29
src/components/AppToast.vue
Normal file
29
src/components/AppToast.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ToastProvider, ToastRoot, ToastDescription, ToastViewport } from 'reka-ui'
|
||||||
|
|
||||||
|
import { toast } from '@/composables/use-toast'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ToastProvider :duration="toast.TOAST_DURATION" swipe-direction="up">
|
||||||
|
<ToastRoot
|
||||||
|
v-for="t in toast.toasts.value"
|
||||||
|
:key="t.id"
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1 data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1 data-[swipe=move]:translate-y-[var(--reka-toast-swipe-move-y)] data-[swipe=cancel]:translate-y-0 data-[swipe=cancel]:transition-transform"
|
||||||
|
:class="t.variant === 'error' ? 'bg-red-600' : 'bg-blue-600'"
|
||||||
|
@update:open="
|
||||||
|
(open) => {
|
||||||
|
if (!open) toast.remove(t.id)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<icon-lucide-check v-if="t.variant === 'default'" class="size-3 shrink-0" />
|
||||||
|
<icon-lucide-alert-triangle v-else class="size-3 shrink-0" />
|
||||||
|
<ToastDescription>{{ t.message }}</ToastDescription>
|
||||||
|
</ToastRoot>
|
||||||
|
|
||||||
|
<ToastViewport
|
||||||
|
class="fixed top-2 left-1/2 z-[9999] flex -translate-x-1/2 flex-col items-center gap-1.5"
|
||||||
|
/>
|
||||||
|
</ToastProvider>
|
||||||
|
</template>
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
|
|
||||||
import type { CollabState, RemotePeer } from '@/composables/use-collab'
|
import type { CollabState, RemotePeer } from '@/composables/use-collab'
|
||||||
|
import { toast } from '@/composables/use-toast'
|
||||||
import type { Color } from '@/types'
|
import type { Color } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -45,6 +46,7 @@ const isJoining = computed(() => !!props.pendingRoomId && !props.state.connected
|
||||||
function copyLink() {
|
function copyLink() {
|
||||||
if (!shareUrl.value) return
|
if (!shareUrl.value) return
|
||||||
navigator.clipboard.writeText(shareUrl.value)
|
navigator.clipboard.writeText(shareUrl.value)
|
||||||
|
toast.show('Link copied to clipboard')
|
||||||
copied.value = true
|
copied.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copied.value = false
|
copied.value = false
|
||||||
|
|
|
||||||
34
src/composables/use-toast.ts
Normal file
34
src/composables/use-toast.ts
Normal file
|
|
@ -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<Toast[]>([])
|
||||||
|
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 }
|
||||||
|
|
@ -7,6 +7,7 @@ import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
|
||||||
import { useKeyboard } from '@/composables/use-keyboard'
|
import { useKeyboard } from '@/composables/use-keyboard'
|
||||||
import { useMenu } from '@/composables/use-menu'
|
import { useMenu } from '@/composables/use-menu'
|
||||||
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
|
import { useCollab, COLLAB_KEY } from '@/composables/use-collab'
|
||||||
|
import { toast } from '@/composables/use-toast'
|
||||||
import { createDemoShapes } from '@/demo'
|
import { createDemoShapes } from '@/demo'
|
||||||
import { provideEditorStore } from '@/stores/editor'
|
import { provideEditorStore } from '@/stores/editor'
|
||||||
|
|
||||||
|
|
@ -47,6 +48,8 @@ const pendingRoomId = (route.params.roomId as string) || null
|
||||||
function onShare() {
|
function onShare() {
|
||||||
const roomId = collab.shareCurrentDoc()
|
const roomId = collab.shareCurrentDoc()
|
||||||
router.push(`/share/${roomId}`)
|
router.push(`/share/${roomId}`)
|
||||||
|
navigator.clipboard.writeText(`${window.location.origin}/share/${roomId}`)
|
||||||
|
toast.show('Link copied to clipboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
function onJoin(roomId: string) {
|
function onJoin(roomId: string) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue