Files
stream.ui/src/components/app/AppToastHost.vue
2026-03-06 18:46:21 +00:00

102 lines
3.1 KiB
Vue

<script setup lang="ts">
import CheckCircleIcon from '@/components/icons/CheckCircleIcon.vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
import { cn } from '@/lib/utils';
import { onBeforeUnmount, watchEffect } from 'vue';
import { useAppToast, type AppToastSeverity } from '@/composables/useAppToast';
const { toasts, remove } = useAppToast();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const dismiss = (id: string) => {
const timer = timers.get(id);
if (timer) {
clearTimeout(timer);
timers.delete(id);
}
remove(id);
};
const iconFor = (severity: AppToastSeverity) => {
switch (severity) {
case 'success':
return CheckCircleIcon;
case 'warn':
return AlertTriangleIcon;
case 'error':
return XCircleIcon;
case 'info':
default:
return InfoIcon;
}
};
const toneClass = (severity: AppToastSeverity) => {
switch (severity) {
case 'success':
return 'border-success/25 bg-success/5';
case 'warn':
return 'border-warning/25 bg-warning/5';
case 'error':
return 'border-danger/25 bg-danger/5';
case 'info':
default:
return 'border-info/25 bg-info/5';
}
};
watchEffect(() => {
if (typeof window === 'undefined') return;
for (const t of toasts.value) {
if (timers.has(t.id)) continue;
const life = Math.max(0, t.life ?? 3000);
const timer = setTimeout(() => {
dismiss(t.id);
}, life);
timers.set(t.id, timer);
}
});
onBeforeUnmount(() => {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
});
</script>
<template>
<div class="fixed top-4 right-4 z-[10000] flex flex-col gap-2 w-[360px] max-w-[calc(100vw-2rem)]">
<TransitionGroup
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-for="t in toasts"
:key="t.id"
:class="cn('flex items-start gap-3 p-3 rounded-lg border shadow-sm', toneClass(t.severity))"
>
<component :is="iconFor(t.severity)" class="w-5 h-5 mt-0.5" :class="t.severity === 'success' ? 'text-success' : t.severity === 'warn' ? 'text-warning' : t.severity === 'error' ? 'text-danger' : 'text-info'" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-foreground truncate">{{ t.summary }}</p>
<p v-if="t.detail" class="text-xs text-foreground/70 mt-0.5 break-words">{{ t.detail }}</p>
</div>
<button
type="button"
class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all"
@click="dismiss(t.id)"
:aria-label="$t('toast.dismissAria')"
>
<XIcon class="w-4 h-4" />
</button>
</div>
</TransitionGroup>
</div>
</template>