Initial functional version of the portfolio chatbot site

This commit is contained in:
Pablo de la Torre Jamardo 2025-07-22 08:06:10 +02:00
parent 9eca12ebca
commit a28728af2a
21 changed files with 1484 additions and 205 deletions

View File

@ -463,11 +463,13 @@ onMounted(() => {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(147, 51, 234, 0.5); /*background: rgba(147, 51, 234, 0.5);*/
background: rgba(59, 130, 246, 0.5); /* Blue-500 */
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(147, 51, 234, 0.7); /*background: rgba(147, 51, 234, 0.7);*7
background: rgba(59, 130, 246, 0.7); /* Blue-500 */
} }
</style> </style>

122
package-lock.json generated
View File

@ -72,9 +72,9 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.28.1", "version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.27.1", "@babel/helper-string-parser": "^7.27.1",
@ -882,39 +882,39 @@
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.17", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.17.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.18.tgz",
"integrity": "sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA==", "integrity": "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.27.5", "@babel/parser": "^7.28.0",
"@vue/shared": "3.5.17", "@vue/shared": "3.5.18",
"entities": "^4.5.0", "entities": "^4.5.0",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
} }
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.17", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.17.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz",
"integrity": "sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ==", "integrity": "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.17", "@vue/compiler-core": "3.5.18",
"@vue/shared": "3.5.17" "@vue/shared": "3.5.18"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.17", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.17.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.18.tgz",
"integrity": "sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww==", "integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.27.5", "@babel/parser": "^7.28.0",
"@vue/compiler-core": "3.5.17", "@vue/compiler-core": "3.5.18",
"@vue/compiler-dom": "3.5.17", "@vue/compiler-dom": "3.5.18",
"@vue/compiler-ssr": "3.5.17", "@vue/compiler-ssr": "3.5.18",
"@vue/shared": "3.5.17", "@vue/shared": "3.5.18",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@ -922,63 +922,63 @@
} }
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.17", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.17.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.18.tgz",
"integrity": "sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ==", "integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.17", "@vue/compiler-dom": "3.5.18",
"@vue/shared": "3.5.17" "@vue/shared": "3.5.18"
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.17", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.17.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.18.tgz",
"integrity": "sha512-l/rmw2STIscWi7SNJp708FK4Kofs97zc/5aEPQh4bOsReD/8ICuBcEmS7KGwDj5ODQLYWVN2lNibKJL1z5b+Lw==", "integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.5.17" "@vue/shared": "3.5.18"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.17", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.17.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.18.tgz",
"integrity": "sha512-QQLXa20dHg1R0ri4bjKeGFKEkJA7MMBxrKo2G+gJikmumRS7PTD4BOU9FKrDQWMKowz7frJJGqBffYMgQYS96Q==", "integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.17", "@vue/reactivity": "3.5.18",
"@vue/shared": "3.5.17" "@vue/shared": "3.5.18"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.17", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.17.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.18.tgz",
"integrity": "sha512-8El0M60TcwZ1QMz4/os2MdlQECgGoVHPuLnQBU3m9h3gdNRW9xRmI8iLS4t/22OQlOE6aJvNNlBiCzPHur4H9g==", "integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.17", "@vue/reactivity": "3.5.18",
"@vue/runtime-core": "3.5.17", "@vue/runtime-core": "3.5.18",
"@vue/shared": "3.5.17", "@vue/shared": "3.5.18",
"csstype": "^3.1.3" "csstype": "^3.1.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.17", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.17.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.18.tgz",
"integrity": "sha512-BOHhm8HalujY6lmC3DbqF6uXN/K00uWiEeF22LfEsm9Q93XeJ/plHTepGwf6tqFcF7GA5oGSSAAUock3VvzaCA==", "integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.17", "@vue/compiler-ssr": "3.5.18",
"@vue/shared": "3.5.17" "@vue/shared": "3.5.18"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "3.5.17" "vue": "3.5.18"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.17", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.17.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.18.tgz",
"integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==", "integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
@ -1328,9 +1328,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.187", "version": "1.5.191",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.191.tgz",
"integrity": "sha512-cl5Jc9I0KGUoOoSbxvTywTa40uspGJt/BDBoDLoxJRSBpWh4FFXBsjNRHfQrONsV/OoEjDfHUmZQa2d6Ze4YgA==", "integrity": "sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -2909,16 +2909,16 @@
} }
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.17", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==", "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.17", "@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.17", "@vue/compiler-sfc": "3.5.18",
"@vue/runtime-dom": "3.5.17", "@vue/runtime-dom": "3.5.18",
"@vue/server-renderer": "3.5.17", "@vue/server-renderer": "3.5.18",
"@vue/shared": "3.5.17" "@vue/shared": "3.5.18"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"

View File

@ -1,156 +1,103 @@
<template> <template>
<div :class="{ 'dark': isDark }" class="min-h-screen transition-colors duration-300"> <div class="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-300">
<div class="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 text-white"> <!-- Navigation -->
<!-- Header Component --> <AppNavigation
<AppHeader :sections="navigationSections"
:isDark="isDark" @navigate="scrollToSection"
@toggle-theme="toggleTheme" />
:isOnline="isOnline"
<!-- Main Content -->
<main>
<!-- Hero Section -->
<HeroSection
id="hero"
:personal="portfolioData.personal"
/> />
<div class="container mx-auto px-4 py-8 max-w-4xl"> <!-- About Section -->
<!-- Welcome Section --> <AboutSection
<WelcomeSection id="about"
v-if="messages.length === 0" :personal="portfolioData.personal"
:quickSuggestions="quickSuggestions" />
@send-message="sendMessage"
/>
<!-- Chat Messages --> <!-- Experience Section -->
<ChatMessages <ExperienceSection
:messages="messages" id="experience"
:isLoading="isLoading" :experience="portfolioData.experience"
ref="chatMessages" />
/>
<!-- Input Form --> <!-- Projects Section -->
<ChatInput <ProjectsSection
:input="input" id="projects"
:isLoading="isLoading" :projects="portfolioData.projects"
@update:input="input = $event" />
@send-message="handleSubmit"
/>
<!-- Tech Stack Display --> <!-- Skills Section -->
<TechStack :techStack="techStack" /> <SkillsSection
id="skills"
:skills="portfolioData.skills"
/>
<!-- Footer --> <!-- Contact Section -->
<AppFooter /> <ContactSection
</div> id="contact"
</div> :personal="portfolioData.personal"
/>
</main>
<!-- Footer -->
<AppFooter :personal="portfolioData.personal" />
<!-- Floating Chat Button -->
<ChatFloatingButton @toggle-chat="toggleChat" />
<!-- Chat Popup -->
<ChatPopup
v-if="isChatOpen"
:chatConfig="portfolioData.chatbot"
@close="toggleChat"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted } from 'vue'
import AppHeader from './components/AppHeader.vue' import { usePortfolioData } from '@/composables/usePortfolioData'
import WelcomeSection from './components/WelcomeSection.vue' import { useNavigation } from '@/composables/useNavigation'
import ChatMessages from './components/ChatMessages.vue'
import ChatInput from './components/ChatInput.vue'
import TechStack from './components/TechStack.vue'
import AppFooter from './components/AppFooter.vue'
import { useKnowledgeBase } from './composables/useKnowledgeBase'
import { useChat } from './composables/useChat'
const isDark = ref(true) // Components
const isOnline = ref(navigator.onLine) import AppNavigation from '@/components/layout/AppNavigation.vue'
import AppFooter from '@/components/layout/AppFooter.vue'
import HeroSection from '@/components/sections/HeroSection.vue'
import AboutSection from '@/components/sections/AboutSection.vue'
import ExperienceSection from '@/components/sections/ExperienceSection.vue'
import ProjectsSection from '@/components/sections/ProjectsSection.vue'
import SkillsSection from '@/components/sections/SkillsSection.vue'
import ContactSection from '@/components/sections/ContactSection.vue'
import ChatFloatingButton from '@/components/chat/ChatFloatingButton.vue'
import ChatPopup from '@/components/chat/ChatPopup.vue'
// Composables // Composables
const { findBestResponse } = useKnowledgeBase() const { portfolioData, loadPortfolioData } = usePortfolioData()
const { const { scrollToSection } = useNavigation()
messages,
input,
isLoading,
sendMessage: sendChatMessage,
handleSubmit
} = useChat(findBestResponse)
const chatMessages = ref(null) // State
const isChatOpen = ref(false)
const techStack = [ const navigationSections = [
'Vue.js 3', 'React 18', 'Node.js', 'TypeScript', 'Python', 'Docker', { id: 'hero', label: 'Inicio' },
'AWS', 'MongoDB', 'PostgreSQL', 'Git', 'CI/CD', 'Microservicios', { id: 'about', label: 'Sobre mí' },
'Tailwind CSS', 'Express.js', 'FastAPI', 'Redis', 'Kubernetes' { id: 'experience', label: 'Experiencia' },
{ id: 'projects', label: 'Proyectos' },
{ id: 'skills', label: 'Habilidades' },
{ id: 'contact', label: 'Contacto' }
] ]
const quickSuggestions = [ function toggleChat() {
{ isChatOpen.value = !isChatOpen.value
icon: 'Briefcase',
title: 'Experiencia',
text: '¿Cuál es tu experiencia laboral?'
},
{
icon: 'Code',
title: 'Habilidades',
text: '¿Qué tecnologías dominas?'
},
{
icon: 'Rocket',
title: 'Proyectos',
text: 'Cuéntame sobre tus proyectos destacados'
},
{
icon: 'GraduationCap',
title: 'Educación',
text: '¿Cuál es tu formación académica?'
},
{
icon: 'Mail',
title: 'Contacto',
text: '¿Cómo puedo contactarte?'
},
{
icon: 'DollarSign',
title: 'Salario',
text: '¿Cuáles son tus expectativas salariales?'
}
]
function sendMessage(text) {
sendChatMessage(text)
nextTick(() => {
if (chatMessages.value) {
chatMessages.value.scrollToBottom()
}
})
} }
function toggleTheme() { onMounted(async () => {
isDark.value = !isDark.value await loadPortfolioData()
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
// Event listeners
onMounted(() => {
// Cargar tema guardado
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
isDark.value = savedTheme === 'dark'
}
// Listener para estado de conexión
window.addEventListener('online', () => isOnline.value = true)
window.addEventListener('offline', () => isOnline.value = false)
// Mensaje de bienvenida
setTimeout(() => {
const welcomeMessage = {
id: Date.now(),
role: 'assistant',
content: `
¡Hola! 👋 Soy tu asistente de portfolio inteligente.<br><br>
Puedo contarte sobre:<br>
💼 Mi experiencia profesional<br>
🚀 Habilidades técnicas<br>
🎯 Proyectos destacados<br>
🎓 Formación académica<br>
📞 Información de contacto<br><br>
<em>¿Qué te gustaría saber?</em>
`
}
//messages.value.push(welcomeMessage)
}, 1000)
}) })
</script> </script>

View File

@ -0,0 +1,21 @@
<template>
<div class="fixed bottom-6 right-6 z-50">
<button
@click="$emit('toggle-chat')"
class="w-16 h-16 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white rounded-full shadow-lg hover:shadow-xl transition-all transform hover:scale-110 flex items-center justify-center"
>
<MessageCircle class="w-8 h-8" />
</button>
<!-- Notification Badge -->
<div class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs rounded-full flex items-center justify-center animate-pulse">
1
</div>
</div>
</template>
<script setup>
import { MessageCircle } from 'lucide-vue-next'
defineEmits(['toggle-chat'])
</script>

View File

@ -0,0 +1,148 @@
<template>
<div class="fixed inset-0 z-50 flex items-end justify-end p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/20 backdrop-blur-sm"
@click="$emit('close')"
></div>
<!-- Chat Window -->
<div class="relative w-full max-w-md h-96 bg-white dark:bg-gray-800 rounded-xl shadow-2xl flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<img
:src="avatarBot"
alt="Assistant"
class="w-8 h-8 rounded-full"
/>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">Asistente Virtual</h3>
<p class="text-xs text-green-500">En línea</p>
</div>
</div>
<button
@click="$emit('close')"
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors"
>
<X class="w-5 h-5" />
</button>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-4" ref="messagesContainer">
<div
v-for="message in messages"
:key="message.id"
class="flex items-start space-x-2"
:class="message.role === 'user' ? 'flex-row-reverse space-x-reverse' : ''"
>
<img
:src="message.role === 'user' ? avatarUser : avatarBot"
alt="avatar"
class="w-6 h-6 rounded-full flex-shrink-0"
/>
<div
class="max-w-xs px-3 py-2 rounded-lg text-sm"
:class="message.role === 'user'
? 'bg-purple-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'"
>
<div v-if="message.typing" class="flex space-x-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
</div>
<div v-else v-html="message.content"></div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div v-if="showQuickActions" class="px-4 pb-2">
<div class="flex flex-wrap gap-2">
<button
v-for="action in chatConfig.welcome.quickActions"
:key="action.text"
@click="sendMessage(action.text)"
class="px-3 py-1 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded-full text-xs hover:bg-purple-200 dark:hover:bg-purple-800 transition-colors"
>
{{ action.text }}
</button>
</div>
</div>
<!-- Input -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex space-x-2">
<input
v-model="input"
@keydown.enter="handleSubmit"
:disabled="isLoading"
placeholder="Escribe tu pregunta..."
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
<button
@click="handleSubmit"
:disabled="isLoading || !input.trim()"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white rounded-lg transition-colors"
>
<Send class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, computed } from 'vue'
import { X, Send } from 'lucide-vue-next'
import { useChatService } from '@/services/ChatService'
import avatarUser from '@/assets/avatar-user.jpg'
import avatarBot from '@/assets/avatar-bot.png'
const props = defineProps({
chatConfig: Object
})
defineEmits(['close'])
const { messages, input, isLoading, sendMessage: sendChatMessage } = useChatService(props.chatConfig)
const messagesContainer = ref(null)
const showQuickActions = computed(() => {
return messages.value.length <= 1 && props.chatConfig?.welcome?.quickActions
})
function sendMessage(text) {
sendChatMessage(text)
nextTick(() => scrollToBottom())
}
function handleSubmit() {
if (input.value.trim()) {
sendMessage(input.value)
}
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
onMounted(() => {
// Send welcome message
if (props.chatConfig?.welcome?.message) {
setTimeout(() => {
messages.value.push({
id: Date.now(),
role: 'assistant',
content: props.chatConfig.welcome.message
})
nextTick(() => scrollToBottom())
}, 500)
}
})
</script>

View File

@ -0,0 +1,74 @@
<template>
<footer class="bg-gray-900 text-white py-12">
<div class="container mx-auto px-4">
<div class="grid md:grid-cols-3 gap-8">
<!-- About -->
<div>
<h3 class="text-xl font-bold mb-4">{{ personal.name }}</h3>
<p class="text-gray-300 mb-4">
{{ personal.title }} especializado en crear experiencias web excepcionales.
</p>
<div class="flex space-x-4">
<a
v-for="(url, platform) in personal.social"
:key="platform"
:href="url"
target="_blank"
class="text-gray-400 hover:text-white transition-colors"
>
<component :is="getSocialIcon(platform)" class="w-5 h-5" />
</a>
</div>
</div>
<!-- Quick Links -->
<div>
<h3 class="text-xl font-bold mb-4">Enlaces Rápidos</h3>
<ul class="space-y-2">
<li><a href="#about" class="text-gray-300 hover:text-white transition-colors">Sobre </a></li>
<li><a href="#experience" class="text-gray-300 hover:text-white transition-colors">Experiencia</a></li>
<li><a href="#projects" class="text-gray-300 hover:text-white transition-colors">Proyectos</a></li>
<li><a href="#skills" class="text-gray-300 hover:text-white transition-colors">Habilidades</a></li>
<li><a href="#contact" class="text-gray-300 hover:text-white transition-colors">Contacto</a></li>
</ul>
</div>
<!-- Contact Info -->
<div>
<h3 class="text-xl font-bold mb-4">Contacto</h3>
<div class="space-y-2 text-gray-300">
<p>{{ personal.email }}</p>
<p>{{ personal.phone }}</p>
<p>{{ personal.location }}</p>
</div>
</div>
</div>
<div class="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<p>&copy; {{ currentYear }} {{ personal.name }}. Todos los derechos reservados.</p>
<p class="mt-2 text-sm">Hecho con y Vue.js</p>
</div>
</div>
</footer>
</template>
<script setup>
import { computed } from 'vue'
import { Github, Linkedin, Twitter, Globe } from 'lucide-vue-next'
defineProps({
personal: Object
})
const currentYear = computed(() => new Date().getFullYear())
function getSocialIcon(platform) {
const icons = {
github: Github,
linkedin: Linkedin,
twitter: Twitter,
portfolio: Globe
}
return icons[platform] || Globe
}
</script>

View File

@ -0,0 +1,72 @@
<template>
<nav class="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-700">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<div class="flex items-center space-x-2">
<div class="w-8 h-8 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
<span class="text-white font-bold text-sm">P</span>
</div>
<span class="font-bold text-gray-900 dark:text-white">Pablo de la Torre Jamardo</span>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8">
<button
v-for="section in sections"
:key="section.id"
@click="$emit('navigate', section.id)"
class="text-gray-600 dark:text-gray-300 hover:text-purple-600 dark:hover:text-purple-400 transition-colors font-medium"
>
{{ section.label }}
</button>
</div>
<!-- Mobile Menu Button -->
<button
@click="toggleMobileMenu"
class="md:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
>
<Menu v-if="!isMobileMenuOpen" class="w-6 h-6" />
<X v-else class="w-6 h-6" />
</button>
</div>
<!-- Mobile Navigation -->
<div v-if="isMobileMenuOpen" class="md:hidden py-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-col space-y-2">
<button
v-for="section in sections"
:key="section.id"
@click="handleMobileNavigate(section.id)"
class="text-left px-4 py-2 text-gray-600 dark:text-gray-300 hover:text-purple-600 dark:hover:text-purple-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
{{ section.label }}
</button>
</div>
</div>
</div>
</nav>
</template>
<script setup>
import { ref } from 'vue'
import { Menu, X } from 'lucide-vue-next'
defineProps({
sections: Array
})
const emit = defineEmits(['navigate'])
const isMobileMenuOpen = ref(false)
function toggleMobileMenu() {
isMobileMenuOpen.value = !isMobileMenuOpen.value
}
function handleMobileNavigate(sectionId) {
emit('navigate', sectionId)
isMobileMenuOpen.value = false
}
</script>

View File

@ -0,0 +1,65 @@
<template>
<section id="about" class="py-20 bg-white dark:bg-gray-800">
<div class="container mx-auto px-4">
<div class="max-w-4xl mx-auto">
<h2 class="text-4xl font-bold text-center text-gray-900 dark:text-white mb-12">
Sobre
</h2>
<div class="grid md:grid-cols-2 gap-12 items-center">
<!-- Bio -->
<div>
<p class="text-lg text-gray-600 dark:text-gray-300 leading-relaxed mb-6">
{{ personal.bio }}
</p>
<div class="space-y-4">
<div class="flex items-center space-x-3">
<MapPin class="w-5 h-5 text-purple-600" />
<span class="text-gray-700 dark:text-gray-300">{{ personal.location }}</span>
</div>
<div class="flex items-center space-x-3">
<Mail class="w-5 h-5 text-purple-600" />
<a :href="`mailto:${personal.email}`" class="text-purple-600 hover:underline">
{{ personal.email }}
</a>
</div>
<div class="flex items-center space-x-3">
<Phone class="w-5 h-5 text-purple-600" />
<span class="text-gray-700 dark:text-gray-300">{{ personal.phone }}</span>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 gap-6">
<div class="text-center p-6 bg-purple-50 dark:bg-gray-700 rounded-xl">
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">5+</div>
<div class="text-gray-600 dark:text-gray-300">Años de Experiencia</div>
</div>
<div class="text-center p-6 bg-purple-50 dark:bg-gray-700 rounded-xl">
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">50+</div>
<div class="text-gray-600 dark:text-gray-300">Proyectos Completados</div>
</div>
<div class="text-center p-6 bg-purple-50 dark:bg-gray-700 rounded-xl">
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">15+</div>
<div class="text-gray-600 dark:text-gray-300">Tecnologías</div>
</div>
<div class="text-center p-6 bg-purple-50 dark:bg-gray-700 rounded-xl">
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">100%</div>
<div class="text-gray-600 dark:text-gray-300">Satisfacción</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { MapPin, Mail, Phone } from 'lucide-vue-next'
defineProps({
personal: Object
})
</script>

View File

@ -0,0 +1,170 @@
<template>
<section id="contact" class="py-20 bg-white dark:bg-gray-800">
<div class="container mx-auto px-4">
<div class="max-w-4xl mx-auto">
<h2 class="text-4xl font-bold text-center text-gray-900 dark:text-white mb-12">
Contacto
</h2>
<div class="grid md:grid-cols-2 gap-12">
<!-- Contact Info -->
<div class="space-y-8">
<div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">
¡Hablemos!
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6">
Estoy siempre abierto a discutir nuevas oportunidades, proyectos interesantes o simplemente charlar sobre tecnología.
</p>
</div>
<div class="space-y-4">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center">
<Mail class="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-white">Email</div>
<a :href="`mailto:${personal.email}`" class="text-purple-600 hover:underline">
{{ personal.email }}
</a>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center">
<Phone class="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-white">Teléfono</div>
<span class="text-gray-600 dark:text-gray-300">{{ personal.phone }}</span>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center">
<MapPin class="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-white">Ubicación</div>
<span class="text-gray-600 dark:text-gray-300">{{ personal.location }}</span>
</div>
</div>
</div>
<!-- Social Links -->
<div class="flex space-x-4">
<a
v-for="(url, platform) in personal.social"
:key="platform"
:href="url"
target="_blank"
class="w-12 h-12 bg-gray-100 dark:bg-gray-700 hover:bg-purple-100 dark:hover:bg-purple-900 rounded-full flex items-center justify-center transition-colors"
>
<component :is="getSocialIcon(platform)" class="w-6 h-6 text-gray-600 dark:text-gray-300" />
</a>
</div>
</div>
<!-- Contact Form -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-xl p-8">
<form @submit.prevent="handleSubmit" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Nombre
</label>
<input
v-model="form.name"
type="text"
required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email
</label>
<input
v-model="form.email"
type="email"
required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Mensaje
</label>
<textarea
v-model="form.message"
rows="4"
required
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
></textarea>
</div>
<button
type="submit"
:disabled="isSubmitting"
class="w-full px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white rounded-lg font-semibold transition-colors"
>
{{ isSubmitting ? 'Enviando...' : 'Enviar Mensaje' }}
</button>
</form>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue'
import { Mail, Phone, MapPin, Github, Linkedin, Twitter, Globe } from 'lucide-vue-next'
defineProps({
personal: Object
})
const form = ref({
name: '',
email: '',
message: ''
})
const isSubmitting = ref(false)
function getSocialIcon(platform) {
const icons = {
github: Github,
linkedin: Linkedin,
twitter: Twitter,
portfolio: Globe
}
return icons[platform] || Globe
}
async function handleSubmit() {
isSubmitting.value = true
// Simulate form submission
await new Promise(resolve => setTimeout(resolve, 1000))
// Here you would typically send the form data to your backend
console.log('Form submitted:', form.value)
// Reset form
form.value = {
name: '',
email: '',
message: ''
}
isSubmitting.value = false
// Show success message (you could use a toast notification)
alert('¡Mensaje enviado correctamente! Te responderé pronto.')
}
</script>

View File

@ -0,0 +1,92 @@
<template>
<section id="experience" class="py-20 bg-gray-50 dark:bg-gray-900">
<div class="container mx-auto px-4">
<div class="max-w-4xl mx-auto">
<h2 class="text-4xl font-bold text-center text-gray-900 dark:text-white mb-12">
Experiencia Profesional
</h2>
<div class="relative">
<!-- Timeline Line -->
<div class="absolute left-8 top-0 bottom-0 w-0.5 bg-purple-200 dark:bg-purple-800"></div>
<!-- Experience Items -->
<div class="space-y-12">
<div
v-for="(exp, index) in experience"
:key="exp.id"
class="relative flex items-start space-x-6"
>
<!-- Timeline Dot -->
<div class="flex-shrink-0 w-16 h-16 bg-purple-600 rounded-full flex items-center justify-center relative z-10">
<Briefcase class="w-8 h-8 text-white" />
</div>
<!-- Content -->
<div class="flex-1 bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-4">
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white">
{{ exp.position }}
</h3>
<h4 class="text-lg text-purple-600 dark:text-purple-400 font-semibold">
{{ exp.company }}
</h4>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400 mt-2 md:mt-0">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4" />
<span>{{ exp.period }}</span>
</div>
<div class="flex items-center space-x-2 mt-1">
<MapPin class="w-4 h-4" />
<span>{{ exp.location }}</span>
</div>
</div>
</div>
<p class="text-gray-600 dark:text-gray-300 mb-4">
{{ exp.description }}
</p>
<!-- Technologies -->
<div class="flex flex-wrap gap-2 mb-4">
<span
v-for="tech in exp.technologies"
:key="tech"
class="px-3 py-1 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded-full text-sm"
>
{{ tech }}
</span>
</div>
<!-- Achievements -->
<div class="space-y-2">
<h5 class="font-semibold text-gray-900 dark:text-white">Logros destacados:</h5>
<ul class="space-y-1">
<li
v-for="achievement in exp.achievements"
:key="achievement"
class="flex items-start space-x-2 text-gray-600 dark:text-gray-300"
>
<CheckCircle class="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>{{ achievement }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Briefcase, Calendar, MapPin, CheckCircle } from 'lucide-vue-next'
defineProps({
experience: Array
})
</script>

View File

@ -0,0 +1,85 @@
<template>
<section class="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-50 dark:from-gray-900 dark:to-purple-900 pt-16">
<div class="container mx-auto px-4 text-center">
<div class="max-w-4xl mx-auto">
<!-- Avatar -->
<div class="mb-8">
<img
:src="personal.avatar"
:alt="personal.name"
class="w-32 h-32 rounded-full mx-auto shadow-2xl border-4 border-white dark:border-gray-700"
/>
</div>
<!-- Name and Title -->
<h1 class="text-5xl md:text-7xl font-bold text-gray-900 dark:text-white mb-4">
{{ personal.name }}
</h1>
<h2 class="text-2xl md:text-3xl text-purple-600 dark:text-purple-400 font-semibold mb-6">
{{ personal.title }}
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-8 max-w-2xl mx-auto">
{{ personal.subtitle }}
</p>
<!-- CTA Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-12">
<button
@click="scrollToSection('projects')"
class="px-8 py-4 bg-purple-600 hover:bg-purple-700 text-white rounded-full font-semibold transition-all transform hover:scale-105 shadow-lg"
>
Ver Proyectos
</button>
<button
@click="scrollToSection('contact')"
class="px-8 py-4 border-2 border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-600 hover:text-white rounded-full font-semibold transition-all"
>
Contactar
</button>
</div>
<!-- Social Links -->
<div class="flex justify-center space-x-6">
<a
v-for="(url, platform) in personal.social"
:key="platform"
:href="url"
target="_blank"
class="p-3 bg-white dark:bg-gray-800 rounded-full shadow-lg hover:shadow-xl transition-all transform hover:scale-110"
>
<component :is="getSocialIcon(platform)" class="w-6 h-6 text-gray-600 dark:text-gray-300" />
</a>
</div>
<!-- Scroll Indicator -->
<div class="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<ChevronDown class="w-8 h-8 text-gray-400" />
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Github, Linkedin, Twitter, Globe, ChevronDown } from 'lucide-vue-next'
defineProps({
personal: Object
})
function getSocialIcon(platform) {
const icons = {
github: Github,
linkedin: Linkedin,
twitter: Twitter,
portfolio: Globe
}
return icons[platform] || Globe
}
function scrollToSection(sectionId) {
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth' })
}
</script>

View File

@ -0,0 +1,99 @@
<template>
<section id="projects" class="py-20 bg-white dark:bg-gray-800">
<div class="container mx-auto px-4">
<div class="max-w-6xl mx-auto">
<h2 class="text-4xl font-bold text-center text-gray-900 dark:text-white mb-12">
Proyectos Destacados
</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<div
v-for="project in projects"
:key="project.id"
class="bg-gray-50 dark:bg-gray-700 rounded-xl overflow-hidden shadow-lg hover:shadow-xl transition-all transform hover:scale-105"
>
<!-- Project Image -->
<div class="relative h-48 overflow-hidden">
<img
:src="project.image"
:alt="project.title"
class="w-full h-full object-cover"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
</div>
<!-- Project Content -->
<div class="p-6">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">
{{ project.title }}
</h3>
<p class="text-gray-600 dark:text-gray-300 mb-4 line-clamp-3">
{{ project.description }}
</p>
<!-- Technologies -->
<div class="flex flex-wrap gap-2 mb-4">
<span
v-for="tech in project.technologies.slice(0, 3)"
:key="tech"
class="px-2 py-1 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded text-xs"
>
{{ tech }}
</span>
<span
v-if="project.technologies.length > 3"
class="px-2 py-1 bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded text-xs"
>
+{{ project.technologies.length - 3 }}
</span>
</div>
<!-- Metrics -->
<div class="grid grid-cols-2 gap-4 mb-4 text-sm">
<div
v-for="(value, key) in project.metrics"
:key="key"
class="text-center p-2 bg-white dark:bg-gray-600 rounded"
>
<div class="font-bold text-purple-600 dark:text-purple-400">{{ value }}</div>
<div class="text-gray-500 dark:text-gray-400 capitalize">{{ key }}</div>
</div>
</div>
<!-- Links -->
<div class="flex space-x-2">
<a
v-if="project.links.demo"
:href="project.links.demo"
target="_blank"
class="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-center text-sm font-medium transition-colors"
>
<ExternalLink class="w-4 h-4 inline mr-1" />
Demo
</a>
<a
v-if="project.links.github"
:href="project.links.github"
target="_blank"
class="flex-1 px-4 py-2 border border-purple-600 text-purple-600 hover:bg-purple-600 hover:text-white rounded-lg text-center text-sm font-medium transition-colors"
>
<Github class="w-4 h-4 inline mr-1" />
Código
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ExternalLink, Github } from 'lucide-vue-next'
defineProps({
projects: Array
})
</script>

View File

@ -0,0 +1,75 @@
<template>
<section id="skills" class="py-20 bg-gray-50 dark:bg-gray-900">
<div class="container mx-auto px-4">
<div class="max-w-4xl mx-auto">
<h2 class="text-4xl font-bold text-center text-gray-900 dark:text-white mb-12">
Habilidades Técnicas
</h2>
<div class="grid md:grid-cols-2 gap-8">
<div
v-for="(skillGroup, category) in skills"
:key="category"
class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg"
>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-6 capitalize flex items-center">
<component :is="getCategoryIcon(category)" class="w-6 h-6 mr-2 text-purple-600" />
{{ getCategoryName(category) }}
</h3>
<div class="space-y-4">
<div
v-for="skill in skillGroup"
:key="skill.name"
class="space-y-2"
>
<div class="flex justify-between items-center">
<span class="font-medium text-gray-900 dark:text-white">{{ skill.name }}</span>
<div class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<span>{{ skill.years }} años</span>
<span>{{ skill.level }}%</span>
</div>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full transition-all duration-1000"
:style="{ width: `${skill.level}%` }"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Code, Server, Database, Cloud } from 'lucide-vue-next'
defineProps({
skills: Object
})
function getCategoryIcon(category) {
const icons = {
frontend: Code,
backend: Server,
database: Database,
devops: Cloud
}
return icons[category] || Code
}
function getCategoryName(category) {
const names = {
frontend: 'Frontend',
backend: 'Backend',
database: 'Base de Datos',
devops: 'DevOps'
}
return names[category] || category
}
</script>

View File

@ -0,0 +1,18 @@
export function useNavigation() {
function scrollToSection(sectionId) {
const element = document.getElementById(sectionId)
if (element) {
const offset = 80 // Account for fixed header
const elementPosition = element.offsetTop - offset
window.scrollTo({
top: elementPosition,
behavior: "smooth",
})
}
}
return {
scrollToSection,
}
}

View File

@ -0,0 +1,30 @@
import { ref } from "vue"
import { PortfolioRepository } from "@/infrastructure/repositories/PortfolioRepository"
export function usePortfolioData() {
const portfolioData = ref({})
const isLoading = ref(false)
const error = ref(null)
const portfolioRepository = new PortfolioRepository()
async function loadPortfolioData() {
try {
isLoading.value = true
error.value = null
portfolioData.value = await portfolioRepository.getPortfolioData()
} catch (err) {
error.value = err.message
console.error("Error loading portfolio data:", err)
} finally {
isLoading.value = false
}
}
return {
portfolioData,
isLoading,
error,
loadPortfolioData,
}
}

View File

@ -0,0 +1,228 @@
{
"personal": {
"name": "Pablo de la Torre Jamardo",
"title": "Senior Full Stack Developer",
"subtitle": "Especializado en Vue.js, React y Node.js",
"email": "pablo.delatorre@ejemplo.com",
"phone": "+54 11 1234-5678",
"location": "Buenos Aires, Argentina",
"avatar": "/src/assets/avatar-bot.png",
"bio": "Desarrollador Full Stack con más de 5 años de experiencia creando aplicaciones web escalables y modernas. Especializado en JavaScript, Vue.js, React y arquitecturas de microservicios.",
"social": {
"github": "https://github.com/pablo-delatorre",
"linkedin": "https://linkedin.com/in/pablo-delatorre",
"twitter": "https://twitter.com/pablo_dev",
"portfolio": "https://pablo-portfolio.com"
}
},
"experience": [
{
"id": "exp1",
"company": "TechCorp Solutions",
"position": "Senior Full Stack Developer",
"period": "2021 - Presente",
"location": "Buenos Aires, Argentina",
"description": "Liderazgo de equipo de 5 desarrolladores, arquitectura de microservicios con Node.js y Docker, implementación de CI/CD reduciendo deploys en 80%.",
"technologies": ["Vue.js", "Node.js", "Docker", "AWS", "PostgreSQL"],
"achievements": [
"Migración de aplicaciones legacy a arquitecturas modernas",
"Reducción del 80% en tiempo de deployment",
"Implementación de testing automatizado"
]
},
{
"id": "exp2",
"company": "StartupXYZ",
"position": "Frontend Developer",
"period": "2019 - 2021",
"location": "Buenos Aires, Argentina",
"description": "Desarrollo de SPAs con Vue.js y React, optimización de performance y colaboración con equipos UX/UI.",
"technologies": ["Vue.js", "React", "TypeScript", "Tailwind CSS"],
"achievements": [
"Optimización de Core Web Vitals",
"Implementación de design system",
"Mejora del 40% en performance"
]
},
{
"id": "exp3",
"company": "DevAgency",
"position": "Junior Developer",
"period": "2018 - 2019",
"location": "Buenos Aires, Argentina",
"description": "Desarrollo de APIs REST con Express.js, integración con bases de datos y metodologías ágiles.",
"technologies": ["Node.js", "Express.js", "MongoDB", "Git"],
"achievements": [
"Desarrollo de 15+ APIs REST",
"Participación en metodologías ágiles",
"Contribución a proyectos open source"
]
}
],
"projects": [
{
"id": "proj1",
"title": "E-commerce Platform",
"description": "Plataforma completa de comercio electrónico con más de 50,000 usuarios activos mensuales",
"image": "/placeholder.svg?height=300&width=400",
"technologies": ["Vue.js", "Node.js", "PostgreSQL", "Redis", "Stripe"],
"features": [
"Integración con múltiples pasarelas de pago",
"Panel de administración con analytics",
"Sistema de inventario en tiempo real"
],
"metrics": {
"users": "50,000+",
"uptime": "99.9%",
"loadTime": "2s"
},
"links": {
"demo": "https://demo-ecommerce.com",
"github": "https://github.com/pablo/ecommerce"
}
},
{
"id": "proj2",
"title": "Real-time Analytics Dashboard",
"description": "Dashboard empresarial con visualizaciones interactivas y procesamiento de más de 1M eventos/día",
"image": "/placeholder.svg?height=300&width=400",
"technologies": ["React", "D3.js", "Socket.io", "InfluxDB", "Node.js"],
"features": ["Visualizaciones en tiempo real", "Exportación de reportes automatizada", "Alertas personalizables"],
"metrics": {
"events": "1M+/día",
"reduction": "70% tiempo análisis",
"users": "500+"
},
"links": {
"demo": "https://analytics-demo.com",
"github": "https://github.com/pablo/analytics"
}
},
{
"id": "proj3",
"title": "Microservices Architecture",
"description": "Migración de monolito a arquitectura de microservicios con 12 servicios distribuidos",
"image": "/placeholder.svg?height=300&width=400",
"technologies": ["Node.js", "Docker", "Kubernetes", "AWS EKS", "MongoDB"],
"features": ["Event Sourcing y CQRS", "Monitoreo con Prometheus", "Auto-scaling automático"],
"metrics": {
"services": "12",
"latencyReduction": "60%",
"availability": "99.95%"
},
"links": {
"github": "https://github.com/pablo/microservices"
}
}
],
"skills": {
"frontend": [
{ "name": "Vue.js", "level": 95, "years": 4 },
{ "name": "React", "level": 90, "years": 3 },
{ "name": "TypeScript", "level": 85, "years": 3 },
{ "name": "Tailwind CSS", "level": 90, "years": 2 },
{ "name": "Nuxt.js", "level": 80, "years": 2 }
],
"backend": [
{ "name": "Node.js", "level": 95, "years": 5 },
{ "name": "Express.js", "level": 90, "years": 4 },
{ "name": "NestJS", "level": 80, "years": 2 },
{ "name": "Python", "level": 75, "years": 2 },
{ "name": "GraphQL", "level": 70, "years": 1 }
],
"database": [
{ "name": "PostgreSQL", "level": 85, "years": 4 },
{ "name": "MongoDB", "level": 80, "years": 3 },
{ "name": "Redis", "level": 75, "years": 2 }
],
"devops": [
{ "name": "Docker", "level": 85, "years": 3 },
{ "name": "AWS", "level": 80, "years": 2 },
{ "name": "Kubernetes", "level": 70, "years": 1 },
{ "name": "CI/CD", "level": 85, "years": 3 }
]
},
"education": [
{
"id": "edu1",
"institution": "Universidad Tecnológica Nacional",
"degree": "Ingeniería en Sistemas de Información",
"period": "2014 - 2018",
"description": "Especialización en Desarrollo de Software. Proyecto final: Sistema de gestión hospitalaria.",
"grade": "8.5/10"
}
],
"certifications": [
{
"id": "cert1",
"name": "AWS Certified Developer Associate",
"issuer": "Amazon Web Services",
"date": "2022",
"credentialId": "AWS-123456"
},
{
"id": "cert2",
"name": "MongoDB Certified Developer",
"issuer": "MongoDB Inc.",
"date": "2021",
"credentialId": "MONGO-789012"
},
{
"id": "cert3",
"name": "Certified Scrum Master",
"issuer": "Scrum Alliance",
"date": "2020",
"credentialId": "CSM-345678"
}
],
"chatbot": {
"welcome": {
"message": "¡Hola! 👋 Soy el asistente virtual de Pablo. Puedo contarte sobre su experiencia, habilidades, proyectos y más. ¿Qué te gustaría saber?",
"quickActions": [
{
"text": "¿Cuál es su experiencia laboral?",
"category": "experience"
},
{
"text": "¿Qué tecnologías domina?",
"category": "skills"
},
{
"text": "Cuéntame sobre sus proyectos",
"category": "projects"
},
{
"text": "¿Cómo puedo contactarlo?",
"category": "contact"
}
]
},
"responses": {
"experience": {
"keywords": ["experiencia", "trabajo", "laboral", "empresa", "puesto", "carrera"],
"response": "Pablo tiene más de 5 años de experiencia como desarrollador Full Stack. Actualmente es Senior Developer en TechCorp Solutions, donde lidera un equipo de 5 desarrolladores y ha implementado arquitecturas de microservicios que redujeron los tiempos de deployment en un 80%. Anteriormente trabajó en StartupXYZ optimizando performance y en DevAgency desarrollando APIs REST."
},
"skills": {
"keywords": ["habilidades", "tecnologías", "stack", "lenguajes", "frameworks"],
"response": "Pablo domina un amplio stack tecnológico: Frontend con Vue.js (95%), React (90%) y TypeScript (85%). En Backend maneja Node.js (95%), Express.js (90%) y Python (75%). También tiene experiencia en bases de datos como PostgreSQL y MongoDB, y en DevOps con Docker, AWS y Kubernetes."
},
"projects": {
"keywords": ["proyectos", "desarrollado", "creado", "portfolio", "aplicaciones"],
"response": "Entre sus proyectos destacados están: una plataforma de e-commerce con +50,000 usuarios activos, un dashboard de analytics en tiempo real que procesa +1M eventos/día, y la migración de un monolito a arquitectura de microservicios que mejoró la disponibilidad al 99.95%."
},
"contact": {
"keywords": ["contacto", "email", "teléfono", "linkedin", "ubicación"],
"response": "Puedes contactar a Pablo por email: pablo.delatorre@ejemplo.com, teléfono: +54 11 1234-5678. También está en LinkedIn: linkedin.com/in/pablo-delatorre y GitHub: github.com/pablo-delatorre. Se encuentra en Buenos Aires, Argentina."
},
"education": {
"keywords": ["educación", "estudios", "universidad", "carrera", "certificaciones"],
"response": "Pablo es Ingeniero en Sistemas de Información por la UTN (2014-2018) con especialización en Desarrollo de Software. Tiene certificaciones de AWS Developer Associate, MongoDB Certified Developer y Certified Scrum Master."
}
},
"fallback": [
"Esa es una excelente pregunta. Pablo siempre busca mantenerse actualizado con las últimas tecnologías.",
"Interesante punto. En su experiencia, ha encontrado que la clave está en el equilibrio entre innovación y estabilidad.",
"Desde su perspectiva técnica, considera fundamental evaluar cada herramienta según el contexto del proyecto."
]
}
}

View File

@ -0,0 +1,34 @@
export class ChatRepository {
constructor(chatConfig) {
this.chatConfig = chatConfig
}
async getResponse(message) {
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 1000 + Math.random() * 1000))
const lowerMessage = message.toLowerCase()
// Search in configured responses
for (const [category, data] of Object.entries(this.chatConfig.responses)) {
if (data.keywords.some((keyword) => lowerMessage.includes(keyword))) {
return data.response
}
}
// Return fallback response
const fallbackResponses = this.chatConfig.fallback || [
"Esa es una excelente pregunta. ¿Hay algo específico que te gustaría saber?",
]
return fallbackResponses[Math.floor(Math.random() * fallbackResponses.length)]
}
async getWelcomeMessage() {
return this.chatConfig.welcome?.message || "¡Hola! ¿En qué puedo ayudarte?"
}
async getQuickActions() {
return this.chatConfig.welcome?.quickActions || []
}
}

View File

@ -0,0 +1,40 @@
import portfolioConfig from "@/data/portfolio-config.json"
export class PortfolioRepository {
async getPortfolioData() {
// Simulate API call delay
await new Promise((resolve) => setTimeout(resolve, 100))
return portfolioConfig
}
async getPersonalInfo() {
const data = await this.getPortfolioData()
return data.personal
}
async getExperience() {
const data = await this.getPortfolioData()
return data.experience
}
async getProjects() {
const data = await this.getPortfolioData()
return data.projects
}
async getSkills() {
const data = await this.getPortfolioData()
return data.skills
}
async getEducation() {
const data = await this.getPortfolioData()
return data.education
}
async getCertifications() {
const data = await this.getPortfolioData()
return data.certifications
}
}

View File

@ -0,0 +1,77 @@
import { ref } from "vue"
import { ChatRepository } from "@/infrastructure/repositories/ChatRepository"
export function useChatService(chatConfig) {
const messages = ref([])
const input = ref("")
const isLoading = ref(false)
const chatRepository = new ChatRepository(chatConfig)
async function sendMessage(text = null) {
const messageText = text || input.value.trim()
if (!messageText || isLoading.value) return
// Add user message
const userMessage = {
id: Date.now(),
role: "user",
content: messageText,
timestamp: Date.now(),
}
messages.value.push(userMessage)
// Clear input
input.value = ""
isLoading.value = true
// Add typing indicator
const typingMessage = {
id: Date.now() + 1,
role: "assistant",
content: "",
typing: true,
}
messages.value.push(typingMessage)
try {
// Get response from repository
const response = await chatRepository.getResponse(messageText)
// Remove typing indicator
messages.value.pop()
// Add assistant response
const assistantMessage = {
id: Date.now() + 2,
role: "assistant",
content: response,
timestamp: Date.now(),
}
messages.value.push(assistantMessage)
} catch (error) {
console.error("Error getting chat response:", error)
// Remove typing indicator
messages.value.pop()
// Add error message
const errorMessage = {
id: Date.now() + 2,
role: "assistant",
content: "Lo siento, ha ocurrido un error. Por favor, intenta de nuevo.",
timestamp: Date.now(),
}
messages.value.push(errorMessage)
} finally {
isLoading.value = false
}
}
return {
messages,
input,
isLoading,
sendMessage,
}
}

View File

@ -52,12 +52,14 @@
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(147, 51, 234, 0.5); /*background: rgba(147, 51, 234, 0.5);*/
background: rgba(59, 130, 246, 0.5); /* Blue-500 */
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(147, 51, 234, 0.7); /*background: rgba(147, 51, 234, 0.7);*7
background: rgba(59, 130, 246, 0.7); /* Blue-500 */
} }
/* Animaciones personalizadas */ /* Animaciones personalizadas */

View File

@ -51,13 +51,13 @@ module.exports = {
foreground: "hsl(var(--card-foreground))", foreground: "hsl(var(--card-foreground))",
}, },
purple: { purple: {
400: "#a855f7", 400: "#475569", // slate-600
500: "#9333ea", 500: "#334155", // slate-700
600: "#7c3aed", 600: "#1e293b", // slate-800
}, },
pink: { pink: {
500: "#ec4899", 500: "#0ea5e9", // sky-500 (azul vibrante, pero no chillón)
600: "#db2777", 600: "#0284c7", // sky-600
}, },
}, },
animation: { animation: {
@ -82,4 +82,4 @@ module.exports = {
}, },
}, },
plugins: [], plugins: [],
}; };