d3760cccb5
**Design System (Phase 1 completion):** - tokens.ts: 12-category token system (colors, typography, spacing, shadows, etc.) - tokens.css: CSS variables with light/dark theme support - App.vue: Integrated token system app-wide **Shared Components (Phase 2):** - SkeletonLoader.vue: 5 loader types (text, card, avatar, table, list) - ErrorBoundary.vue: Error state recovery with retry - ToastNotification.vue: Toast alerts with 4 types + auto-dismiss - ToastContainer.vue: Toast provider with fixed positioning - Modal.vue: Animated modal with 4 size variants **Page Enhancements:** - ShadowRunQueue.vue: Loading → Skeleton + Error → ErrorBoundary + Normal states - ModelList.vue: Loading → Skeleton + Error → ErrorBoundary + Normal states - ApprovalQueue.vue: Loading → Skeleton + Error → ErrorBoundary + Normal states **Testing:** - All 3 pages: ✅ 100% PASS (12/12 selectors) - Playwright tests verify: loading, error, normal states - All states render correctly with token-based styling **Next: Phase 3 (2-4h)** - Layout unification (sidebar, header, footer) - Complete dark theme implementation - Mobile responsiveness & navigation Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
177 lines
3.5 KiB
Vue
177 lines
3.5 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, ref } from 'vue'
|
||
|
||
interface Props {
|
||
id: string
|
||
type?: 'success' | 'error' | 'warning' | 'info'
|
||
message: string
|
||
duration?: number
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
type: 'info',
|
||
duration: 4000,
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
close: [id: string]
|
||
}>()
|
||
|
||
const isClosing = ref(false)
|
||
|
||
onMounted(() => {
|
||
if (props.duration > 0) {
|
||
setTimeout(() => {
|
||
close()
|
||
}, props.duration)
|
||
}
|
||
})
|
||
|
||
const close = () => {
|
||
isClosing.value = true
|
||
setTimeout(() => {
|
||
emit('close', props.id)
|
||
}, 150) // Match animation duration
|
||
}
|
||
|
||
const getIcon = (type: string) => {
|
||
const icons: Record<string, string> = {
|
||
success: '✓',
|
||
error: '✕',
|
||
warning: '!',
|
||
info: 'ℹ',
|
||
}
|
||
return icons[type] || 'ℹ'
|
||
}
|
||
|
||
const getColor = (type: string) => {
|
||
const colors: Record<string, string> = {
|
||
success: 'var(--color-success-500)',
|
||
error: 'var(--color-danger-500)',
|
||
warning: 'var(--color-warning-500)',
|
||
info: 'var(--color-primary-500)',
|
||
}
|
||
return colors[type] || 'var(--color-primary-500)'
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="toast-notification" :class="[`toast-${type}`, { 'is-closing': isClosing }]">
|
||
<div class="toast-icon" :style="{ backgroundColor: getColor(type) }">
|
||
{{ getIcon(type) }}
|
||
</div>
|
||
<div class="toast-content">
|
||
<p class="toast-message">{{ message }}</p>
|
||
</div>
|
||
<button class="toast-close" @click="close" aria-label="Close notification">
|
||
×
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.toast-notification {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-3);
|
||
padding: var(--spacing-3);
|
||
background: var(--color-background-primary);
|
||
border: var(--border-width-1) solid var(--color-border-primary);
|
||
border-radius: var(--border-radius-lg);
|
||
box-shadow: var(--shadow-lg);
|
||
animation: slideIn 150ms ease-out;
|
||
min-width: 300px;
|
||
max-width: 500px;
|
||
}
|
||
|
||
.toast-notification.is-closing {
|
||
animation: slideOut 150ms ease-out forwards;
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from {
|
||
transform: translateX(400px);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes slideOut {
|
||
from {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
to {
|
||
transform: translateX(400px);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
.toast-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 32px;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: var(--border-radius-base);
|
||
color: white;
|
||
font-weight: var(--font-weight-bold);
|
||
font-size: var(--font-size-lg);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toast-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-1);
|
||
}
|
||
|
||
.toast-message {
|
||
margin: 0;
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-primary);
|
||
line-height: var(--line-height-normal);
|
||
}
|
||
|
||
.toast-close {
|
||
padding: 0;
|
||
width: 24px;
|
||
height: 24px;
|
||
min-width: 24px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--color-text-tertiary);
|
||
cursor: pointer;
|
||
font-size: var(--font-size-xl);
|
||
font-weight: var(--font-weight-light);
|
||
transition: color var(--transition-fast);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toast-close:hover {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
/* Type-specific colors */
|
||
.toast-success .toast-icon {
|
||
background: var(--color-success-500);
|
||
}
|
||
|
||
.toast-error .toast-icon {
|
||
background: var(--color-danger-500);
|
||
}
|
||
|
||
.toast-warning .toast-icon {
|
||
background: var(--color-warning-500);
|
||
}
|
||
|
||
.toast-info .toast-icon {
|
||
background: var(--color-primary-500);
|
||
}
|
||
</style>
|