refactor: restructure project to classic MVC pattern

This commit is contained in:
2025-09-09 19:57:43 +02:00
parent a28728af2a
commit 2a8d5d093c
58 changed files with 1601 additions and 1991 deletions

View File

@@ -1,44 +0,0 @@
<template>
<footer class="mt-12 pt-8 border-t border-white/10 text-center text-gray-400">
<div class="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<div class="flex items-center space-x-4">
<a
href="https://github.com/tu-usuario"
target="_blank"
class="hover:text-white transition-colors"
>
<Github class="w-5 h-5" />
</a>
<a
href="https://linkedin.com/in/tu-perfil"
target="_blank"
class="hover:text-white transition-colors"
>
<Linkedin class="w-5 h-5" />
</a>
<a
href="mailto:tu.email@ejemplo.com"
class="hover:text-white transition-colors"
>
<Mail class="w-5 h-5" />
</a>
</div>
<div class="text-sm">
<p>&copy; {{ currentYear }} Tu Nombre. Hecho con y Vue.js</p>
</div>
<div class="text-xs">
<p>Versión 1.0.0 Node.js {{ nodeVersion }}</p>
</div>
</div>
</footer>
</template>
<script setup>
import { computed } from 'vue'
import { Github, Linkedin, Mail } from 'lucide-vue-next'
const currentYear = computed(() => new Date().getFullYear())
const nodeVersion = '18+'
</script>

View File

@@ -1,57 +0,0 @@
<template>
<header class="border-b border-white/10 backdrop-blur-sm bg-black/20 sticky top-0 z-50">
<div class="container mx-auto px-4 py-4 flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
<img src="/src/assets/avatar-bot.png" alt="Avatar" class=" rounded-full object-cover" />
</div>
<div>
<h1 class="text-xl font-bold">Pablo de la Torre Jamardo</h1>
<div class="flex items-center space-x-2">
<div class="flex items-center space-x-1">
<div
class="w-2 h-2 rounded-full"
:class="isOnline ? 'bg-green-400' : 'bg-red-400'"
></div>
<span class="text-xs text-gray-300">
{{ isOnline ? 'Online' : 'Offline' }}
</span>
</div>
<span class="text-xs text-gray-400"></span>
<span class="text-xs text-gray-300">Virtual Me, Powered by Code</span>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<button
@click="$emit('toggle-theme')"
class="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
:title="isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'"
>
<component :is="isDark ? 'Sun' : 'Moon'" class="w-5 h-5" />
</button>
<a
href="https://github.com/tu-usuario/ai-portfolio-chat"
target="_blank"
class="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
title="Ver código en GitHub"
>
<Github class="w-5 h-5" />
</a>
</div>
</div>
</header>
</template>
<script setup>
import { Bot, Sun, Moon, Github } from 'lucide-vue-next'
defineProps({
isDark: Boolean,
isOnline: Boolean
})
defineEmits(['toggle-theme'])
</script>

View File

@@ -1,65 +0,0 @@
<template>
<form @submit.prevent="handleSubmit" class="relative">
<div class="flex space-x-2">
<div class="flex-1 relative">
<input
:value="input"
@input="$emit('update:input', $event.target.value)"
:disabled="isLoading"
placeholder="Pregúntame sobre mi experiencia, habilidades, proyectos..."
class="w-full px-4 py-3 pr-12 glass-effect rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent placeholder-gray-400 transition-all"
@keydown.enter.prevent="handleSubmit"
/>
<!-- Character counter -->
<div class="absolute right-3 top-1/2 transform -translate-y-1/2 text-xs text-gray-500">
{{ input.length }}/500
</div>
</div>
<button
type="submit"
:disabled="isLoading || !input.trim() || input.length > 500"
class="px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl font-semibold disabled:opacity-50 disabled:cursor-not-allowed hover:from-purple-600 hover:to-pink-600 transition-all transform hover:scale-105 active:scale-95"
>
<Send v-if="!isLoading" class="w-5 h-5" />
<div v-else class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
</button>
</div>
<!-- Quick suggestions -->
<div v-if="!input && quickSuggestions.length > 0" class="flex flex-wrap gap-2 mt-3">
<button
v-for="suggestion in quickSuggestions.slice(0, 3)"
:key="suggestion"
@click="$emit('update:input', suggestion)"
class="px-3 py-1 text-sm bg-white/5 hover:bg-white/10 rounded-full border border-white/10 transition-colors"
>
{{ suggestion }}
</button>
</div>
</form>
</template>
<script setup>
import { Send } from 'lucide-vue-next'
const props = defineProps({
input: String,
isLoading: Boolean
})
const emit = defineEmits(['update:input', 'send-message'])
const quickSuggestions = [
'¿Cuál es tu experiencia?',
'¿Qué tecnologías usas?',
'Háblame de tus proyectos'
]
function handleSubmit() {
if (props.input.trim() && !props.isLoading && props.input.length <= 500) {
emit('send-message')
}
}
</script>

View File

@@ -1,92 +0,0 @@
<template>
<div class="space-y-4 mb-6 max-h-96 overflow-y-auto" ref="messagesContainer">
<TransitionGroup name="chat-message" tag="div">
<div
v-for="message in messages"
:key="message.id"
class="flex items-start space-x-3"
:class="message.role === 'user' ? 'flex-row-reverse space-x-reverse' : ''"
>
<div class="flex-shrink-0">
<div
class="w-8 h-8 rounded-full flex items-center justify-center"
:class="message.role === 'user'
? 'bg-gradient-to-r from-blue-500 to-cyan-500'
: 'bg-gradient-to-r from-purple-500 to-pink-500'"
>
<component :is="message.role === 'user' ? 'User' : 'Bot'" class="w-4 h-4" />
<img
:src="message.role === 'user' ? avatarUser : avatarBot"
alt="avatar"
class="w-8 h-8 rounded-full object-cover"
/>
</div>
</div>
<div
class="max-w-xs lg:max-w-md px-4 py-3 rounded-2xl"
:class="message.role === 'user'
? 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white'
: 'glass-effect'"
>
<!-- Typing indicator -->
<div v-if="message.role === 'assistant' && message.typing" class="flex space-x-1">
<div class="w-2 h-2 bg-purple-400 rounded-full typing-indicator"></div>
<div class="w-2 h-2 bg-purple-400 rounded-full typing-indicator"></div>
<div class="w-2 h-2 bg-purple-400 rounded-full typing-indicator"></div>
</div>
<!-- Message content -->
<div v-else>
<div v-html="formatMessage(message.content)" class="prose prose-invert max-w-none"></div>
<div v-if="message.role === 'assistant'" class="text-xs text-gray-400 mt-2">
{{ formatTime(message.timestamp) }}
</div>
</div>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
import { ref, nextTick, watch } from 'vue'
import avatarUser from './../assets/avatar-user.jpg'
import avatarBot from './../assets/avatar-bot.png'
const props = defineProps({
messages: Array,
isLoading: Boolean
})
const messagesContainer = ref(null)
function formatMessage(content) {
return content.replace(/\n/g, '<br>')
}
function formatTime(timestamp) {
if (!timestamp) return ''
return new Date(timestamp).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
})
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// Watch for new messages and scroll to bottom
watch(() => props.messages.length, () => {
nextTick(() => scrollToBottom())
})
// Expose scrollToBottom method
defineExpose({
scrollToBottom
})
</script>

View File

@@ -1,26 +0,0 @@
<template>
<div class="mt-8 p-6 glass-effect rounded-xl">
<h3 class="text-lg font-semibold mb-4 flex items-center">
<Code class="w-5 h-5 mr-2 text-purple-400" />
Stack Tecnológico Principal
</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="(tech, index) in techStack"
:key="tech"
class="px-3 py-1 bg-purple-500/20 text-purple-300 rounded-full text-sm border border-purple-500/30 hover:bg-purple-500/30 transition-colors cursor-default"
:style="{ animationDelay: `${index * 50}ms` }"
>
{{ tech }}
</span>
</div>
</div>
</template>
<script setup>
import { Code } from 'lucide-vue-next'
defineProps({
techStack: Array
})
</script>

View File

@@ -1,64 +0,0 @@
<template>
<div class="text-center mb-8 animate-fade-in">
<div class="mb-6">
<div class="w-24 h-24 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full mx-auto mb-4 flex items-center justify-center animate-pulse-slow">
<img src="/src/assets/avatar-bot.png" alt="Avatar" class="rounded-full object-cover" />
</div>
<h2 class="text-3xl font-bold mb-2 gradient-text">
¡Hola! Soy tu asistente personal de portfolio
</h2>
<p class="text-gray-300 text-lg max-w-2xl mx-auto">
Pregúntame sobre mi experiencia, habilidades, proyectos o cualquier detalle técnico.
¡Estoy listo para charlar y ayudarte a descubrir mi perfil profesional!
</p>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<button
v-for="(suggestion, index) in quickSuggestions"
:key="suggestion.text"
@click="$emit('send-message', suggestion.text)"
class="p-4 glass-effect rounded-xl transition-all hover:scale-105 hover:bg-white/15 text-left group"
:style="{ animationDelay: `${index * 100}ms` }"
>
<component
:is="suggestion.icon"
class="w-6 h-6 mb-2 text-purple-400 group-hover:text-purple-300 transition-colors"
/>
<h3 class="font-semibold mb-1 text-white group-hover:text-purple-100 transition-colors">
{{ suggestion.title }}
</h3>
<p class="text-sm text-gray-400 group-hover:text-gray-300 transition-colors">
{{ suggestion.text }}
</p>
</button>
</div>
<!-- Stats -->
<div class="flex justify-center space-x-8 text-sm text-gray-400">
<div class="text-center">
<div class="text-2xl font-bold text-purple-400">5+</div>
<div>Años Experiencia</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-purple-400">50+</div>
<div>Proyectos</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-purple-400">15+</div>
<div>Tecnologías</div>
</div>
</div>
</div>
</template>
<script setup>
import { Bot, Briefcase, Code, Rocket, GraduationCap, Mail, DollarSign } from 'lucide-vue-next'
defineProps({
quickSuggestions: Array
})
defineEmits(['send-message'])
</script>

View File

@@ -1,3 +1,9 @@
<script lang="ts" setup>
import {MessageCircle} from 'lucide-vue-next'
defineEmits(['toggle-chat'])
</script>
<template>
<div class="fixed bottom-6 right-6 z-50">
<button
@@ -14,8 +20,3 @@
</div>
</template>
<script setup>
import { MessageCircle } from 'lucide-vue-next'
defineEmits(['toggle-chat'])
</script>

View File

@@ -1,3 +1,55 @@
<script lang="ts" setup>
import {computed, nextTick, onMounted, ref} from 'vue'
import {Send, X} from 'lucide-vue-next'
import {useChatService} from '@/services/ChatService.ts'
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>
<template>
<div class="fixed inset-0 z-50 flex items-end justify-end p-4">
<!-- Backdrop -->
@@ -93,56 +145,4 @@
</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>
</template>

View File

@@ -1,22 +1,44 @@
<script lang="ts" setup>
import {computed} from 'vue'
import {Github, Globe, Linkedin} from 'lucide-vue-next'
import config from "@/config";
import type {Profile} from '@/domain/models/Profile'
defineProps<{
profile: Profile
}>()
const currentYear = computed(() => new Date().getFullYear())
function getSocialIcon(platform) {
const icons = {
github: Github,
linkedin: Linkedin,
portfolio: Globe
}
return icons[platform] || Globe
}
</script>
<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>
<h3 class="text-xl font-bold mb-4">{{ profile?.name }}</h3>
<p class="text-gray-300 mb-4">
{{ personal.title }} especializado en crear experiencias web excepcionales.
{{ profile?.title }}
</p>
<div class="flex space-x-4">
<a
v-for="(url, platform) in personal.social"
:key="platform"
:href="url"
v-for="social in profile?.social"
:key="social.platform"
:href="social.url"
target="_blank"
class="text-gray-400 hover:text-white transition-colors"
>
<component :is="getSocialIcon(platform)" class="w-5 h-5" />
<component :is="getSocialIcon(social.platform)" class="w-5 h-5"/>
</a>
</div>
</div>
@@ -25,11 +47,21 @@
<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>
<li v-if="config.sections.aboutEnabled">
<a class="text-gray-300 hover:text-white transition-colors" href="#about">Sobre </a>
</li>
<li v-if="config.sections.experienceEnabled">
<a class="text-gray-300 hover:text-white transition-colors" href="#experience">Experiencia</a>
</li>
<li v-if="config.sections.projectsEnabled">
<a class="text-gray-300 hover:text-white transition-colors" href="#projects">Proyectos</a>
</li>
<li v-if="config.sections.skillsEnabled">
<a class="text-gray-300 hover:text-white transition-colors" href="#skills">Habilidades</a>
</li>
<li v-if="config.sections.contactEnabled">
<a class="text-gray-300 hover:text-white transition-colors" href="#contact">Contacto</a>
</li>
</ul>
</div>
@@ -37,38 +69,18 @@
<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>
<p>{{ profile?.email }}</p>
<p style="display:none">{{ profile?.phone }}</p>
<p>{{ profile?.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>&copy; {{ currentYear }} {{ profile?.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

@@ -1,3 +1,27 @@
<script lang="ts" setup>
import {ref} from 'vue'
import {Menu, X} from 'lucide-vue-next'
import type {Profile} from '@/domain/models/Profile'
defineProps<{
sections: Array,
profile: Profile
}>()
const emit = defineEmits(['navigate'])
const isMobileMenuOpen = ref(false)
function toggleMobileMenu() {
isMobileMenuOpen.value = !isMobileMenuOpen.value
}
function handleMobileNavigate(sectionId) {
emit('navigate', sectionId)
isMobileMenuOpen.value = false
}
</script>
<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">
@@ -5,21 +29,26 @@
<!-- 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>
<span class="text-white font-bold text-sm">{{ profile.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="flex flex-col">
<span class="font-bold text-gray-900 dark:text-white">{{ profile.name }}</span>
<span class="text-gray-500 dark:text-gray-300 text-sm">{{ profile.title }}</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>
<template v-for="section in sections">
<button
v-if="section.enabled === true"
:key="section.id"
class="text-gray-600 dark:text-gray-300 hover:text-purple-600 dark:hover:text-purple-400 transition-colors font-medium"
@click="$emit('navigate', section.id)"
>
{{ section.label }}
</button>
</template>
</div>
<!-- Mobile Menu Button -->
@@ -35,38 +64,19 @@
<!-- 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>
<template v-for="section in sections">
<button
v-if="section.enabled === true"
:key="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"
@click="handleMobileNavigate(section.id)"
>
{{ section.label }}
</button>
</template>
</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

@@ -1,3 +1,15 @@
<script lang="ts" setup>
import {Mail, MapPin, Phone} from 'lucide-vue-next'
import type {Profile} from '@/domain/models/Profile'
defineProps<{
profile: Profile
}>()
</script>
<template>
<section id="about" class="py-20 bg-white dark:bg-gray-800">
<div class="container mx-auto px-4">
@@ -5,40 +17,40 @@
<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">
<div class="grid md:grid-cols-1 gap-12 items-center">
<!-- Bio -->
<div>
<p class="text-lg text-gray-600 dark:text-gray-300 leading-relaxed mb-6">
{{ personal.bio }}
{{ profile?.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>
<span class="text-gray-700 dark:text-gray-300">{{ profile?.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 :href="`mailto:${profile?.email}`" class="text-purple-600 hover:underline">
{{ profile?.email }}
</a>
</div>
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-3" style="display:none">
<Phone class="w-5 h-5 text-purple-600" />
<span class="text-gray-700 dark:text-gray-300">{{ personal.phone }}</span>
<span class="text-gray-700 dark:text-gray-300">{{ profile?.phone }}</span>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 gap-6">
<div v-if="1===3" 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-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">7+</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-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">10+</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">
@@ -56,10 +68,3 @@
</section>
</template>
<script setup>
import { MapPin, Mail, Phone } from 'lucide-vue-next'
defineProps({
personal: Object
})
</script>

View File

@@ -0,0 +1,74 @@
<script lang="ts" setup>
import {type Certification} from '@/domain/models/Certification.ts'
import {Award, Calendar} from 'lucide-vue-next'
defineProps<{
certification: Certification[]
}>()
</script>
<template>
<section id="certification" 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">
Certificaciones
</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="(cert, index) in certification"
:key="cert.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">
<Award 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">
{{ cert.name }}
</h3>
<h4 class="text-lg text-purple-600 dark:text-purple-400 font-semibold">
{{ cert.issuer }}
</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>{{ cert.date }}</span>
</div>
</div>
</div>
<p class="text-gray-600 dark:text-gray-300 mb-4">
{{ cert.description }}
</p>
<!-- Technologies -->
<div class="flex flex-wrap gap-2 mb-4">
<span
class="px-3 py-1 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded-full text-sm"
>
{{ cert.credentialId }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,3 +1,52 @@
<script lang="ts" setup>
import {ref} from 'vue'
import {Github, Globe, Linkedin, Mail, MapPin, Phone} from 'lucide-vue-next'
import type {Profile} from '@/domain/models/Profile'
defineProps<{
profile: Profile
}>()
const form = ref({
name: '',
email: '',
message: ''
})
const isSubmitting = ref(false)
function getSocialIcon(platform) {
const icons = {
github: Github,
linkedin: Linkedin,
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>
<template>
<section id="contact" class="py-20 bg-white dark:bg-gray-800">
<div class="container mx-auto px-4">
@@ -25,19 +74,19 @@
</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 :href="`mailto:${profile?.email}`" class="text-purple-600 hover:underline">
{{ profile?.email }}
</a>
</div>
</div>
<div class="flex items-center space-x-4">
<div v-if="profile?.phone" 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 style="display:none">
<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>
<span class="text-gray-600 dark:text-gray-300">{{ profile?.phone }}</span>
</div>
</div>
@@ -47,7 +96,7 @@
</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>
<span class="text-gray-600 dark:text-gray-300">{{ profile?.location }}</span>
</div>
</div>
</div>
@@ -55,25 +104,28 @@
<!-- Social Links -->
<div class="flex space-x-4">
<a
v-for="(url, platform) in personal.social"
:key="platform"
:href="url"
v-for="social in profile?.social"
:key="social.platform"
:href="social.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" />
<component :is="getSocialIcon(social.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">
<p class="m-5">¡Hola! Por el momento mi servidor SMTP está de vacaciones 😅.</p>
<p class="m-5">Si quieres contactarme, envíame un correo electrónico directamente y prometo responderte
rápido.</p>
<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
<input disabled
v-model="form.name"
type="text"
required
@@ -85,7 +137,7 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email
</label>
<input
<input disabled
v-model="form.email"
type="email"
required
@@ -97,7 +149,7 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Mensaje
</label>
<textarea
<textarea disabled
v-model="form.message"
rows="4"
required
@@ -107,7 +159,7 @@
<button
type="submit"
:disabled="isSubmitting"
:disabled="isSubmitting || 1===1"
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' }}
@@ -120,51 +172,3 @@
</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,74 @@
<script lang="ts" setup>
import {Calendar, GraduationCap} from 'lucide-vue-next'
import type {Education} from '@/domain/models/Education'
defineProps<{
education: Education[]
}>()
</script>
<template>
<section id="education" 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">
Educación
</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="(edu, index) in education"
:key="edu.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">
<GraduationCap 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">
{{ edu.degree }}
</h3>
<h4 class="text-lg text-purple-600 dark:text-purple-400 font-semibold">
{{ edu.institution }}
</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>{{ edu.period }}</span>
</div>
</div>
</div>
<p class="text-gray-600 dark:text-gray-300 mb-4">
{{ edu.description }}
</p>
<!-- Technologies -->
<div class="flex flex-wrap gap-2 mb-4">
<span
class="px-3 py-1 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded-full text-sm"
>
{{ edu.grade }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,3 +1,12 @@
<script lang="ts" setup>
import {Briefcase, Calendar, CheckCircle, MapPin} from 'lucide-vue-next'
import type {Experience} from '@/domain/models/Experience'
defineProps<{
experience: Experience[]
}>()
</script>
<template>
<section id="experience" class="py-20 bg-gray-50 dark:bg-gray-900">
<div class="container mx-auto px-4">
@@ -83,10 +92,3 @@
</section>
</template>
<script setup>
import { Briefcase, Calendar, MapPin, CheckCircle } from 'lucide-vue-next'
defineProps({
experience: Array
})
</script>

View File

@@ -1,38 +1,74 @@
<script lang="ts" setup>
import {ChevronDown, Github, Globe, Linkedin} from 'lucide-vue-next'
import avatarUser from '@/assets/avatar-bot.png'
import config from "@/config";
import type {Profile} from '@/domain/models/Profile'
defineProps<{
profile: Profile
}>()
function getSocialIcon(platform) {
const icons = {
github: Github,
linkedin: Linkedin,
portfolio: Globe
}
return icons[platform] || Globe
}
function scrollToSection(sectionId) {
document.getElementById(sectionId)?.scrollIntoView({behavior: 'smooth'})
}
</script>
<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">
<section
class="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-200 to-pink-200 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"
:alt="profile?.name"
:src="avatarUser"
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 }}
{{ profile?.name }}
</h1>
<h2 class="text-2xl md:text-3xl text-purple-600 dark:text-purple-400 font-semibold mb-6">
{{ personal.title }}
<VueTyper
:erase-delay='250'
:erase-on-complete='false'
:pre-erase-delay='2000'
:pre-type-delay='100'
:repeat='Infinity'
:shuffle='false'
:text="profile?.title ? [profile.title] : ['Cargando...']"
:type-delay='100'
caret-animation='smooth'
erase-style='clear'
initial-action='typing'
></VueTyper>
</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-8 max-w-2xl mx-auto">
{{ personal.subtitle }}
{{ profile?.subtitle }}
</p>
<!-- CTA Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-12">
<button
<button v-if="config.sections.projectsEnabled"
@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
<button v-if="config.sections.contactEnabled"
@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"
>
@@ -41,20 +77,20 @@
</div>
<!-- Social Links -->
<div class="flex justify-center space-x-6">
<div class="flex justify-center gap-4 mb-12">
<a
v-for="(url, platform) in personal.social"
:key="platform"
:href="url"
v-for="social in profile?.social"
:key="social.platform"
:href="social.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" />
<component :is="getSocialIcon(social.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">
<div class="flex flex-col sm:flex-row justify-center animate-bounce">
<ChevronDown class="w-8 h-8 text-gray-400" />
</div>
</div>
@@ -62,24 +98,3 @@
</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

@@ -1,3 +1,12 @@
<script lang="ts" setup>
import {ExternalLink, Github} from 'lucide-vue-next'
import type {Project} from '@/domain/models/Project'
defineProps<{
projects: Project[]
}>()
</script>
<template>
<section id="projects" class="py-20 bg-white dark:bg-gray-800">
<div class="container mx-auto px-4">
@@ -64,8 +73,8 @@
<!-- Links -->
<div class="flex space-x-2">
<a
v-if="project.links.demo"
:href="project.links.demo"
v-if="project.demo"
:href="project.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"
>
@@ -73,8 +82,8 @@
Demo
</a>
<a
v-if="project.links.github"
:href="project.links.github"
v-if="project.repository"
:href="project.repository"
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"
>
@@ -85,15 +94,9 @@
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ExternalLink, Github } from 'lucide-vue-next'
defineProps({
projects: Array
})
</script>

View File

@@ -1,3 +1,22 @@
<script lang="ts" setup>
import {Cloud, Code, Database, Server} from 'lucide-vue-next'
import {SkillGroup} from "@/domain/models/Skill";
defineProps<{
skillGroups: SkillGroup[]
}>()
function getCategoryIcon(category) {
const icons = {
frontend: Code,
backend: Server,
database: Database,
devops: Cloud
}
return icons[category] || Code
}
</script>
<template>
<section id="skills" class="py-20 bg-gray-50 dark:bg-gray-900">
<div class="container mx-auto px-4">
@@ -8,18 +27,18 @@
<div class="grid md:grid-cols-2 gap-8">
<div
v-for="(skillGroup, category) in skills"
:key="category"
v-for="skillGroup in skillGroups"
:key="skillGroup.name"
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) }}
<component :is="skillGroup.icon" class="w-6 h-6 mr-2 text-purple-600"/>
{{ skillGroup.name }}
</h3>
<div class="space-y-4">
<div
v-for="skill in skillGroup"
v-for="skill in skillGroup.skills"
:key="skill.name"
class="space-y-2"
>
@@ -46,30 +65,3 @@
</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>