Migration v1 (Flask) -> v2 (FastAPI + Vue.js) complétée

 Changements majeurs:
- Suppression complète du code Flask legacy
- Migration backend FastAPI vers racine /backend
- Migration frontend Vue.js vers racine /frontend
- Suppression de notytex-v2/ (code monté à la racine)

 Validations:
- Backend démarre correctement (port 8000)
- API /api/v2/health répond healthy
- 99/99 tests unitaires passent
- Frontend configuré avec proxy Vite

📝 Documentation:
- README.md réécrit pour v2
- Instructions de démarrage mises à jour
- .gitignore adapté pour backend/frontend/

🎯 Architecture finale:
notytex/
├── backend/     # FastAPI + SQLAlchemy + Pydantic
├── frontend/    # Vue 3 + Vite + TailwindCSS
├── docs/        # Documentation
└── school_management.db  # Base de données (inchangée)

Jalon 6 complété: Application v2 prête pour utilisation!
This commit is contained in:
2025-11-25 21:09:47 +01:00
parent 60c60c1605
commit 2b08eb534a
4125 changed files with 303 additions and 453271 deletions

View File

@@ -0,0 +1,14 @@
<template>
<footer class="bg-white border-t border-gray-200 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col sm:flex-row justify-between items-center text-sm text-gray-500">
<p>&copy; {{ currentYear }} Notytex - Gestion Scolaire</p>
<p class="mt-2 sm:mt-0">Version 2.0</p>
</div>
</div>
</footer>
</template>
<script setup>
const currentYear = new Date().getFullYear()
</script>

View File

@@ -0,0 +1,95 @@
<template>
<header class="bg-white border-b border-gray-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<!-- Logo and main nav -->
<div class="flex">
<router-link to="/" class="flex items-center">
<span class="text-xl font-bold text-primary-600">Notytex</span>
</router-link>
<nav class="hidden md:ml-8 md:flex md:space-x-4">
<router-link
v-for="item in navigation"
:key="item.name"
:to="item.to"
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors"
:class="isActive(item.to)
? 'text-primary-600 bg-primary-50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'"
>
<component :is="item.icon" class="w-4 h-4 mr-2" />
{{ item.name }}
</router-link>
</nav>
</div>
<!-- Right side -->
<div class="flex items-center space-x-4">
<router-link to="/config" class="text-gray-500 hover:text-gray-700">
<CogIcon class="w-5 h-5" />
</router-link>
<!-- Mobile menu button -->
<button
@click="mobileMenuOpen = !mobileMenuOpen"
class="md:hidden p-2 rounded-md text-gray-500 hover:text-gray-700"
>
<MenuIcon v-if="!mobileMenuOpen" class="w-6 h-6" />
<XIcon v-else class="w-6 h-6" />
</button>
</div>
</div>
</div>
<!-- Mobile menu -->
<div v-if="mobileMenuOpen" class="md:hidden border-t border-gray-200">
<nav class="px-4 py-3 space-y-1">
<router-link
v-for="item in navigation"
:key="item.name"
:to="item.to"
class="flex items-center px-3 py-2 text-sm font-medium rounded-md"
:class="isActive(item.to)
? 'text-primary-600 bg-primary-50'
: 'text-gray-600 hover:bg-gray-50'"
@click="mobileMenuOpen = false"
>
<component :is="item.icon" class="w-4 h-4 mr-2" />
{{ item.name }}
</router-link>
</nav>
</div>
</header>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
// Simple icon components
const HomeIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>' }
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
const MenuIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>' }
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
const route = useRoute()
const mobileMenuOpen = ref(false)
const navigation = [
{ name: 'Accueil', to: '/', icon: HomeIcon },
{ name: 'Classes', to: '/classes', icon: UsersIcon },
{ name: 'Évaluations', to: '/assessments', icon: ClipboardIcon },
{ name: 'Élèves', to: '/students', icon: AcademicCapIcon }
]
function isActive(path) {
if (path === '/') {
return route.path === '/'
}
return route.path.startsWith(path)
}
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div class="flex items-center gap-2">
<input
type="color"
:value="modelValue"
@input="onColorInput"
class="w-10 h-10 border border-gray-300 rounded-md cursor-pointer"
:title="label || 'Choisir une couleur'"
>
<input
type="text"
:value="modelValue"
@input="onTextInput"
@blur="validateAndEmit"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm"
:class="{ 'border-red-500': !isValid }"
pattern="#[0-9a-fA-F]{6}"
placeholder="#000000"
>
<div
v-if="showPreview"
class="w-8 h-8 rounded-full border-2 border-white shadow-sm"
:style="{ backgroundColor: modelValue }"
></div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: '#000000'
},
label: {
type: String,
default: ''
},
showPreview: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const isValid = computed(() => {
return /^#[0-9a-fA-F]{6}$/.test(props.modelValue)
})
function onColorInput(event) {
emit('update:modelValue', event.target.value)
}
function onTextInput(event) {
const value = event.target.value
// Ajouter # si manquant
if (value && !value.startsWith('#')) {
event.target.value = '#' + value
}
}
function validateAndEmit(event) {
let value = event.target.value.trim()
// Ajouter # si manquant
if (value && !value.startsWith('#')) {
value = '#' + value
}
// Valider le format
if (/^#[0-9a-fA-F]{6}$/i.test(value)) {
emit('update:modelValue', value.toLowerCase())
} else if (value === '') {
emit('update:modelValue', '#000000')
}
// Si invalide, garder l'ancienne valeur (ne pas émettre)
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div class="text-center py-12">
<!-- Icon -->
<div class="mx-auto w-16 h-16 mb-4 rounded-full flex items-center justify-center" :class="iconBgClass">
<component :is="iconComponent" class="w-8 h-8" :class="iconColorClass" />
</div>
<!-- Title -->
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ title }}</h3>
<!-- Description -->
<p class="text-gray-500 mb-6 max-w-sm mx-auto">{{ description }}</p>
<!-- Actions -->
<div class="flex justify-center gap-3">
<slot name="actions">
<router-link v-if="primaryAction" :to="primaryAction.to" class="btn btn-primary">
{{ primaryAction.label }}
</router-link>
<button v-if="secondaryAction" @click="$emit('secondary')" class="btn btn-secondary">
{{ secondaryAction.label }}
</button>
</slot>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
title: {
type: String,
required: true
},
description: {
type: String,
required: true
},
icon: {
type: String,
default: 'folder'
},
variant: {
type: String,
default: 'default',
validator: (value) => ['default', 'info', 'warning', 'error'].includes(value)
},
primaryAction: {
type: Object,
default: null
},
secondaryAction: {
type: Object,
default: null
}
})
defineEmits(['secondary'])
// Icons components
const FolderIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>' }
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
const SearchIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>' }
const icons = {
folder: FolderIcon,
users: UsersIcon,
clipboard: ClipboardIcon,
chart: ChartIcon,
search: SearchIcon
}
const iconComponent = computed(() => icons[props.icon] || FolderIcon)
const iconBgClass = computed(() => {
const classes = {
default: 'bg-gray-100',
info: 'bg-blue-100',
warning: 'bg-yellow-100',
error: 'bg-red-100'
}
return classes[props.variant]
})
const iconColorClass = computed(() => {
const classes = {
default: 'text-gray-400',
info: 'text-blue-500',
warning: 'text-yellow-500',
error: 'text-red-500'
}
return classes[props.variant]
})
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div class="flex items-center justify-center" :class="containerClass">
<div
class="animate-spin rounded-full border-2 border-gray-200"
:class="[sizeClass, colorClass]"
></div>
<span v-if="text" class="ml-3 text-gray-600">{{ text }}</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md', 'lg'].includes(value)
},
color: {
type: String,
default: 'primary'
},
text: {
type: String,
default: ''
},
fullPage: {
type: Boolean,
default: false
}
})
const sizeClass = computed(() => {
const sizes = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12'
}
return sizes[props.size]
})
const colorClass = computed(() => {
const colors = {
primary: 'border-t-primary-600',
success: 'border-t-success-600',
danger: 'border-t-danger-600'
}
return colors[props.color] || colors.primary
})
const containerClass = computed(() => {
return props.fullPage ? 'min-h-[50vh]' : ''
})
</script>

View File

@@ -0,0 +1,124 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="modelValue"
class="fixed inset-0 z-50 overflow-y-auto"
@click.self="closeOnBackdrop && close()"
>
<div class="flex min-h-full items-center justify-center p-4">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"></div>
<!-- Modal content -->
<div
class="relative bg-white rounded-xl shadow-xl w-full transform transition-all"
:class="sizeClass"
>
<!-- Header -->
<div v-if="title || $slots.header" class="px-6 py-4 border-b border-gray-200">
<slot name="header">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">{{ title }}</h3>
<button
v-if="showClose"
@click="close"
class="text-gray-400 hover:text-gray-500"
>
<XIcon class="w-5 h-5" />
</button>
</div>
</slot>
</div>
<!-- Body -->
<div class="p-6">
<slot></slot>
</div>
<!-- Footer -->
<div v-if="$slots.footer" class="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-xl">
<slot name="footer"></slot>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, watch } from 'vue'
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md', 'lg', 'xl'].includes(value)
},
showClose: {
type: Boolean,
default: true
},
closeOnBackdrop: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:modelValue', 'close'])
const sizeClass = computed(() => {
const sizes = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl'
}
return sizes[props.size]
})
function close() {
emit('update:modelValue', false)
emit('close')
}
// Handle escape key
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
} else {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
}
})
function handleEscape(e) {
if (e.key === 'Escape') {
close()
}
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="fixed top-4 right-4 z-50 space-y-2 w-80">
<TransitionGroup name="notification">
<div
v-for="notification in notifications"
:key="notification.id"
class="rounded-lg shadow-lg p-4 flex items-start"
:class="notificationClasses(notification.type)"
>
<component :is="getIcon(notification.type)" class="w-5 h-5 mr-3 flex-shrink-0" />
<p class="flex-1 text-sm">{{ notification.message }}</p>
<button
@click="remove(notification.id)"
class="ml-2 opacity-70 hover:opacity-100"
>
<XIcon class="w-4 h-4" />
</button>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useNotificationsStore } from '@/stores/notifications'
const notificationsStore = useNotificationsStore()
const { notifications } = storeToRefs(notificationsStore)
const { remove, error } = notificationsStore
// Icon components
const CheckIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>' }
const ExclamationIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" /></svg>' }
const InfoIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>' }
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
function notificationClasses(type) {
const classes = {
success: 'bg-success-50 text-success-600 border border-success-100',
error: 'bg-danger-50 text-danger-600 border border-danger-100',
warning: 'bg-warning-50 text-warning-600 border border-warning-100',
info: 'bg-primary-50 text-primary-600 border border-primary-100'
}
return classes[type] || classes.info
}
function getIcon(type) {
const icons = {
success: CheckIcon,
error: ExclamationIcon,
warning: ExclamationIcon,
info: InfoIcon
}
return icons[type] || InfoIcon
}
// Listen for API errors
function handleApiError(event) {
error(event.detail.message)
}
onMounted(() => {
window.addEventListener('api-error', handleApiError)
})
onUnmounted(() => {
window.removeEventListener('api-error', handleApiError)
})
</script>
<style scoped>
.notification-enter-active,
.notification-leave-active {
transition: all 0.3s ease;
}
.notification-enter-from {
opacity: 0;
transform: translateX(100%);
}
.notification-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<div class="animate-pulse">
<!-- Card variant -->
<div v-if="variant === 'card'" class="bg-white rounded-xl shadow-md overflow-hidden">
<div class="h-24 bg-gray-200"></div>
<div class="p-4 space-y-3">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
<div class="grid grid-cols-2 gap-2">
<div class="h-8 bg-gray-200 rounded"></div>
<div class="h-8 bg-gray-200 rounded"></div>
</div>
</div>
</div>
<!-- Stats variant -->
<div v-else-if="variant === 'stats'" class="bg-white rounded-xl shadow-md p-6">
<div class="h-3 bg-gray-200 rounded w-1/3 mb-2"></div>
<div class="h-8 bg-gray-200 rounded w-1/2 mb-2"></div>
<div class="h-2 bg-gray-200 rounded w-2/3"></div>
</div>
<!-- Table row variant -->
<div v-else-if="variant === 'table-row'" class="flex items-center space-x-4 py-4">
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
<div class="h-4 bg-gray-200 rounded w-1/6"></div>
<div class="h-4 bg-gray-200 rounded w-1/6"></div>
</div>
<!-- List item variant -->
<div v-else-if="variant === 'list-item'" class="bg-white rounded-lg shadow p-4 flex items-center space-x-4">
<div class="h-12 w-12 bg-gray-200 rounded-full"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
<!-- Chart variant -->
<div v-else-if="variant === 'chart'" class="bg-white rounded-xl shadow-md p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-48 bg-gray-200 rounded"></div>
</div>
<!-- Hero variant -->
<div v-else-if="variant === 'hero'" class="bg-gray-200 rounded-2xl p-8">
<div class="h-8 bg-gray-300 rounded w-1/3 mb-4"></div>
<div class="h-4 bg-gray-300 rounded w-1/4 mb-4"></div>
<div class="flex gap-4">
<div class="h-3 bg-gray-300 rounded w-20"></div>
<div class="h-3 bg-gray-300 rounded w-20"></div>
<div class="h-3 bg-gray-300 rounded w-20"></div>
</div>
</div>
<!-- Default text lines -->
<div v-else class="space-y-3">
<div class="h-4 bg-gray-200 rounded" :style="{ width: `${lines[0] || 100}%` }"></div>
<div v-for="(width, index) in lines.slice(1)" :key="index"
class="h-4 bg-gray-200 rounded"
:style="{ width: `${width}%` }"></div>
</div>
</div>
</template>
<script setup>
defineProps({
variant: {
type: String,
default: 'default',
validator: (value) => ['default', 'card', 'stats', 'table-row', 'list-item', 'chart', 'hero'].includes(value)
},
lines: {
type: Array,
default: () => [100, 80, 60]
}
})
</script>