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:
14
frontend/src/components/common/AppFooter.vue
Normal file
14
frontend/src/components/common/AppFooter.vue
Normal 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>© {{ 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>
|
||||
95
frontend/src/components/common/AppHeader.vue
Normal file
95
frontend/src/components/common/AppHeader.vue
Normal 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>
|
||||
80
frontend/src/components/common/ColorPicker.vue
Normal file
80
frontend/src/components/common/ColorPicker.vue
Normal 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>
|
||||
97
frontend/src/components/common/EmptyState.vue
Normal file
97
frontend/src/components/common/EmptyState.vue
Normal 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>
|
||||
55
frontend/src/components/common/LoadingSpinner.vue
Normal file
55
frontend/src/components/common/LoadingSpinner.vue
Normal 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>
|
||||
124
frontend/src/components/common/Modal.vue
Normal file
124
frontend/src/components/common/Modal.vue
Normal 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>
|
||||
87
frontend/src/components/common/NotificationContainer.vue
Normal file
87
frontend/src/components/common/NotificationContainer.vue
Normal 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>
|
||||
78
frontend/src/components/common/SkeletonLoader.vue
Normal file
78
frontend/src/components/common/SkeletonLoader.vue
Normal 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>
|
||||
Reference in New Issue
Block a user