Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87da2f63a7 | |||
| 6fdba29870 | |||
| 91b185ef53 | |||
| 982257b4c1 | |||
| 450ae2a738 | |||
| 37ef0648d7 | |||
| 8f4d0e9433 | |||
| 20f0e79343 | |||
| 46912f0bf3 | |||
| ad9a46f554 | |||
| 33354509ce | |||
| d768908b1c | |||
| f3fc6a2632 | |||
| c6685e40d1 | |||
| aae1a28627 | |||
| b9db689981 | |||
| 88ecfed5c5 | |||
| ef958dffe5 | |||
| 075a58efe4 | |||
| ea5e0c5aab | |||
| 27cd4d06e8 | |||
| ddd109d6a3 | |||
| c8e680f7ea | |||
| 542419de45 | |||
| 29aa3d1fd7 | |||
| 57db625997 | |||
| 96ca60babe | |||
| 7322c1a158 | |||
| 76d9649ec2 | |||
| 9a2343a71e | |||
| c1a32988cb | |||
| 7a242ca7b4 | |||
| af456cafc2 | |||
| 3aebc82068 | |||
| 65eadcada7 | |||
| 9547bc7130 | |||
| 5c5f99b626 | |||
| a35db95ca4 | |||
| ae7d0a9ab7 | |||
| 668eb56c9f | |||
| 13c409065e | |||
| e9154b04f4 | |||
| 614edac5b6 | |||
| bc29ca4a05 | |||
| 6204ffdd6c | |||
| 6cd949c5ea | |||
| f5c78e6845 | |||
| 3b6d5e781a | |||
| fc1ca90b38 | |||
| 9164344375 | |||
| 2fbd88f28e | |||
| aae818fbc0 | |||
| 562552add8 | |||
| 574f97d5cd | |||
| 42fe69f5a0 | |||
| 837e6fac9d | |||
| 9018edd21a | |||
| ff7cd4287b | |||
| 1f17277a19 | |||
| 7b6062d0f2 | |||
| 0b672447f6 | |||
| ef25a9e233 | |||
| c044f785f3 | |||
| 9f6b44b478 | |||
| e865e0d9e0 | |||
| 65e6d174a3 | |||
| 328faae953 | |||
| 16530c6f03 | |||
| e56a86860f | |||
| b146264072 | |||
| f8b48b92a3 | |||
| 6781b632d5 | |||
| ded519758b | |||
| 7d0c2179cd | |||
| cfb1817daa | |||
| af72c74799 | |||
| 08b39653ba | |||
| 7e78bcdadf | |||
| 6e922fd1cb | |||
| 070dd9f1d8 | |||
| edf302b302 |
@@ -92,7 +92,7 @@ CONTACT_EMAIL=recipient@example.com
|
||||
#
|
||||
# Ollama settings (when MODEL_PROVIDER=ollama):
|
||||
# OLLAMA_HOST=http://localhost:11434
|
||||
# OLLAMA_MODEL=glm-4.7-flash
|
||||
# OLLAMA_MODEL=gemma4:26b
|
||||
|
||||
# Production Settings
|
||||
# Uncomment for production:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Deploy CV Server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to VPS
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
run: ssh -o StrictHostKeyChecking=no txeo@172.233.115.81 "cd /home/txeo/Git/yo/cv && sudo chown -R txeo:txeo . && git clean -fd && git remote set-url origin https://repos.txeo.club/txeo/cv-site.git && git pull origin main && sudo cp config/systemd/cv.service /etc/systemd/system/cv.service && sudo systemctl daemon-reload && sudo systemctl restart cv && sleep 3 && curl -sf http://localhost:1999/health && echo DEPLOY_OK"
|
||||
@@ -75,10 +75,28 @@ jobs:
|
||||
sudo cp config/systemd/cv.service /etc/systemd/system/$SERVICE_NAME.service
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
echo "🔄 Restarting service..."
|
||||
sudo systemctl restart $SERVICE_NAME
|
||||
echo "🔄 Stopping service..."
|
||||
sudo systemctl stop $SERVICE_NAME || true
|
||||
|
||||
echo "⏳ Waiting for service to start..."
|
||||
# Wait for port to be released (max 15s)
|
||||
echo "⏳ Waiting for port 1999 to be free..."
|
||||
for i in $(seq 1 15); do
|
||||
if ! sudo fuser 1999/tcp >/dev/null 2>&1; then
|
||||
echo "✅ Port 1999 free after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 15 ]; then
|
||||
echo "⚠️ Force-killing port 1999..."
|
||||
sudo fuser -k 1999/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🔄 Starting service..."
|
||||
sudo systemctl start $SERVICE_NAME
|
||||
|
||||
echo "⏳ Waiting for health check..."
|
||||
sleep 3
|
||||
|
||||
# Check service status
|
||||
|
||||
@@ -69,3 +69,9 @@ tests/screenshots/
|
||||
|
||||
# Personal learning documentation README (private goals and notes)
|
||||
_go-learning/README.md
|
||||
|
||||
# Personal prompt symlinks (Obsidian vault)
|
||||
*-PROMPT.md
|
||||
|
||||
# Built binary
|
||||
cv-site
|
||||
|
||||
@@ -147,7 +147,7 @@ If you want to explore the code or run it locally:
|
||||
|
||||
\`\`\`bash
|
||||
# Download the code
|
||||
git clone https://github.com/juanatsap/cv-site.git
|
||||
git clone https://repos.txeo.club/txeo/cv-site.git
|
||||
cd cv-site
|
||||
|
||||
# Option 1: Using Make - Development mode with hot reload (recommended)
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"personal": {
|
||||
"name": "Juan Andrés Moreno Rubio",
|
||||
"title": "Lead Technical Consultant, FullStack Developer",
|
||||
"title": "Senior Technical Consultant & Full-Stack Developer",
|
||||
"titleBadges": [
|
||||
"Technical Consultant",
|
||||
"Full-Stack Engineer",
|
||||
"Authentication Specialist",
|
||||
"Solution Architect"
|
||||
"Open Source Contributor"
|
||||
],
|
||||
"location": "Arrecife, Las Palmas de Gran Canaria, Spain",
|
||||
"email": "txeo.msx@gmail.com",
|
||||
"phone": "+34 676875420",
|
||||
"dateOfBirth": "1980-03-02",
|
||||
"placeOfBirth": "Plasencia (Cáceres), Spain",
|
||||
"phone": "",
|
||||
"dateOfBirth": "",
|
||||
"placeOfBirth": "",
|
||||
"citizenship": "Spanish",
|
||||
"linkedin": "https://www.linkedin.com/in/juan-andres-moreno-rubio",
|
||||
"github": "https://github.com/juanatsap",
|
||||
"domestika": "https://www.domestika.org/es/txeo/portfolio",
|
||||
"domestika": "",
|
||||
"website": "https://juan.andres.morenorub.io",
|
||||
"photo": "/static/images/profile.jpg",
|
||||
"firstName": "Juan Andrés",
|
||||
@@ -26,12 +26,12 @@
|
||||
"seo": {
|
||||
"pageTitle": "Curriculum Vitae",
|
||||
"metaTitle": "Professional CV",
|
||||
"metaDescription": "18 years of experience in web development, SAP CDC, React, Node.js, Go, HTMX and AI-assisted development",
|
||||
"ogDescription": "Senior Technical Consultant with 18 years of experience",
|
||||
"keywords": "CV, Resume, FullStack Developer, SAP CDC, React, Node.js, Go, HTMX, AI, Web Development, Technical Consultant"
|
||||
"metaDescription": "Senior Technical Consultant and Full-Stack Developer — Go, HTMX, React, Node.js. SAP CDC, authentication systems, AI integration, open-source tools",
|
||||
"ogDescription": "Senior Technical Consultant & Full-Stack Developer — Go, AI, HTMX, SAP CDC",
|
||||
"keywords": "CV, Resume, FullStack Developer, Go, Swift, macOS, MCP, AI, HTMX, React, Node.js, SAP CDC, Native Apps, CLI Tools, Open Source"
|
||||
},
|
||||
"summary": "Full-stack developer specialized in high-availability systems. I've worked on Olympic Games platforms, airport authentication systems with millions of users, and built around 20 websites for diverse sectors (e-commerce, enterprise, institutional). Certified SAP Customer Data Cloud consultant, advising 35-40 international clients on digital identity solutions.",
|
||||
"skillsSummary": "<strong>Full-stack</strong> developer with experience in <strong>Go</strong>, <strong>Node.js</strong>, <strong>React</strong>, and <strong>HTMX</strong> for <strong>modern applications</strong>, plus Java and PHP knowledge for legacy projects. I've worked on <strong>around 20 websites</strong> and provided <strong>consulting for 35-40 international clients</strong>, from e-commerce and enterprise platforms to <strong>authentication systems</strong> managing <strong>millions of users</strong>. Familiar with <strong>AI-assisted development</strong> workflows and infrastructure management (<strong>Linux</strong>, <strong>Docker</strong>, <strong>CI/CD</strong>). I adapt well to both independent work and collaborative teams across different countries.",
|
||||
"summary": "Full-stack developer specialized in authentication systems and high-availability platforms. I currently work on Olympic Games platforms, and have built airport authentication systems serving millions of users and around 20 websites for diverse sectors. Certified SAP CDC consultant, advising 35-40 international clients on digital identity. I also create open-source tools and native apps via drolosoft.",
|
||||
"skillsSummary": "<strong>Full-stack</strong> developer with experience in <strong>Go</strong>, <strong>Node.js</strong>, <strong>React</strong>, and <strong>HTMX</strong> for modern applications. I've provided <strong>consulting for 35-40 international clients</strong>, from e-commerce and enterprise platforms to <strong>authentication systems</strong> managing <strong>millions of users</strong>. I integrate <strong>AI tools</strong> into my development workflows and build projects that use <strong>LLMs</strong> and <strong>MCP</strong>. I also create <strong>open-source tools</strong> and <strong>native macOS apps</strong> independently. Comfortable managing infrastructure with <strong>Linux</strong>, <strong>Docker</strong>, and <strong>CI/CD</strong>.",
|
||||
"experience": [
|
||||
{
|
||||
"position": "Senior SAP Technical Consultant",
|
||||
@@ -350,24 +350,23 @@
|
||||
"technical": [
|
||||
{
|
||||
"category": "Programming Languages",
|
||||
"proficiency": 4,
|
||||
"proficiency": 7,
|
||||
"items": [
|
||||
"JavaScript (ES6+)",
|
||||
"Go",
|
||||
"Swift",
|
||||
"JavaScript (ES6+)",
|
||||
"TypeScript",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"Shell Scripting (Bash/Unix)",
|
||||
"PHP",
|
||||
"Java",
|
||||
"Groovy",
|
||||
"SQL",
|
||||
"Assembler"
|
||||
"SQL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "JavaScript Ecosystem",
|
||||
"proficiency": 5,
|
||||
"proficiency": 8,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"Node.js & Express",
|
||||
@@ -380,7 +379,7 @@
|
||||
},
|
||||
{
|
||||
"category": "Go Ecosystem",
|
||||
"proficiency": 5,
|
||||
"proficiency": 8,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"Hono - High-Performance Web Framework",
|
||||
@@ -393,15 +392,26 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Frontend Technologies",
|
||||
"category": "Swift & macOS Development",
|
||||
"proficiency": 5,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"Swift 6 & SwiftUI",
|
||||
"AppKit & macOS APIs",
|
||||
"Native Menu Bar Applications",
|
||||
"UserNotifications & AVFoundation",
|
||||
"macOS App Distribution & Notarization"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Frontend Technologies",
|
||||
"proficiency": 9,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"HTMX - Hypermedia-Driven Applications",
|
||||
"HTML5 & Semantic Web",
|
||||
"CSS3, Tailwind CSS, SASS/LESS",
|
||||
"JavaScript - DOM Manipulation & AJAX",
|
||||
"jQuery",
|
||||
"Progressive Enhancement & Accessibility",
|
||||
"Responsive & Mobile-First Design",
|
||||
"Template Engines (Handlebars, Panini, Mustache)"
|
||||
@@ -409,7 +419,7 @@
|
||||
},
|
||||
{
|
||||
"category": "Backend Technologies",
|
||||
"proficiency": 5,
|
||||
"proficiency": 8,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"Go - Current Primary Stack",
|
||||
@@ -420,21 +430,9 @@
|
||||
"Database Design & Optimization"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Legacy Enterprise Technologies",
|
||||
"proficiency": 3,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"Java & J2EE",
|
||||
"Spring Framework, Struts, Hibernate",
|
||||
"PHP & WordPress",
|
||||
"Yii Framework, Zend Framework",
|
||||
"Enterprise Application Servers (Tomcat, JBoss, WebLogic)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Infrastructure & Servers",
|
||||
"proficiency": 5,
|
||||
"proficiency": 7,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"Linux Server Administration",
|
||||
@@ -446,7 +444,7 @@
|
||||
},
|
||||
{
|
||||
"category": "DevOps & CI/CD",
|
||||
"proficiency": 5,
|
||||
"proficiency": 7,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"CI/CD Pipeline Design & Implementation",
|
||||
@@ -458,7 +456,7 @@
|
||||
},
|
||||
{
|
||||
"category": "Databases",
|
||||
"proficiency": 4,
|
||||
"proficiency": 6,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"PostgreSQL",
|
||||
@@ -472,32 +470,19 @@
|
||||
},
|
||||
{
|
||||
"category": "Team Management",
|
||||
"proficiency": 4,
|
||||
"proficiency": 6,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"Preparation and projects startup",
|
||||
"Fluid communication with clients",
|
||||
"Recruitment",
|
||||
"Tasks management",
|
||||
"Monthly reports"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Design Tools",
|
||||
"proficiency": 3,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"Corel Draw",
|
||||
"Adobe PhotoShop",
|
||||
"Adobe Illustrator",
|
||||
"Affinity",
|
||||
"Excalidraw",
|
||||
"GIMP"
|
||||
"Cross-Functional Team Coordination",
|
||||
"Client Advisory & Stakeholder Management",
|
||||
"Technical Mentoring & Onboarding",
|
||||
"Project Planning & Delivery",
|
||||
"International Team Collaboration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "SAP Technologies",
|
||||
"proficiency": 5,
|
||||
"proficiency": 9,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"SAP Customer Data Cloud (CDC)",
|
||||
@@ -507,15 +492,16 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "AI-Assisted Development",
|
||||
"proficiency": 5,
|
||||
"category": "AI Engineering & Integration",
|
||||
"proficiency": 7,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"AI Development Workflows (Claude Code, Copilot, GPT-4)",
|
||||
"Agent-Based & Spec-Driven Development",
|
||||
"Prompt Engineering & AI Integration",
|
||||
"Automated Code Generation & Documentation",
|
||||
"OpenAI & Anthropic APIs"
|
||||
"MCP Servers (Model Context Protocol)",
|
||||
"Google ADK & Gemini Integration",
|
||||
"LLM APIs (OpenAI, Anthropic, Google)",
|
||||
"AI-Driven Development Workflows (Claude Code, Copilot)",
|
||||
"CLIP Embeddings & Visual Search",
|
||||
"Agentic Workflows & Automation"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -528,7 +514,7 @@
|
||||
"Training & Mentoring",
|
||||
"Client Relationship Management",
|
||||
"Flexibility & Adaptability",
|
||||
"Marketing & Resource Management"
|
||||
"Product Development & Shipping"
|
||||
]
|
||||
},
|
||||
"languages": [
|
||||
@@ -556,10 +542,12 @@
|
||||
"projects": [
|
||||
{
|
||||
"title": "Immich Photo Manager - AI-Powered Photo Library MCP Server",
|
||||
"category": "cli",
|
||||
"projectName": "Immich Photo Manager",
|
||||
"projectDesc": "AI-Powered Photo Library MCP Server",
|
||||
"url": "https://drolosoft.com/immich-photo-manager.html?lang=en",
|
||||
"gitRepoUrl": "https://github.com/drolosoft/immich-photo-manager",
|
||||
"openSource": true,
|
||||
"projectLogo": "immich-photo-manager.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
@@ -581,12 +569,43 @@
|
||||
],
|
||||
"projectID": "immich-photo-manager"
|
||||
},
|
||||
{
|
||||
"title": "Go-Docs MCP - Multi-Format Document Access Server",
|
||||
"category": "cli",
|
||||
"projectName": "Go-Docs MCP",
|
||||
"projectDesc": "Multi-Format Document Access Server",
|
||||
"url": "https://drolosoft.com/go-docs-mcp.html",
|
||||
"gitRepoUrl": "https://github.com/drolosoft/go-docs-mcp",
|
||||
"openSource": true,
|
||||
"projectLogo": "go-docs-mcp.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
"current": true,
|
||||
"technologies": [
|
||||
"Go",
|
||||
"MCP (Model Context Protocol)",
|
||||
"PDF Extraction",
|
||||
"OCR (Tesseract)",
|
||||
"CLI Tools"
|
||||
],
|
||||
"shortDescription": "Open-source MCP server that gives AI assistants the ability to read any document — PDF, DOCX, Markdown, CSV, and images. Single binary, zero runtime dependencies, 13 tools including full-text search, OCR, table extraction, and URL fetching.",
|
||||
"responsibilities": [
|
||||
"Designed and built multi-format MCP server in Go — single binary with zero runtime dependencies",
|
||||
"Implemented 13 tools covering document reading, full-text search, OCR, image/table extraction, and URL fetching",
|
||||
"Built smart caching with mtime-based invalidation and directory-locked security with path traversal prevention",
|
||||
"Integrated Tesseract OCR for scanned PDFs and image text extraction with automatic fallback",
|
||||
"Published as open-source with one-command install (`go install`) for macOS and Linux"
|
||||
],
|
||||
"projectID": "go-docs-mcp"
|
||||
},
|
||||
{
|
||||
"title": "Cmux Resurrect - Terminal Session Persistence Tool",
|
||||
"category": "cli",
|
||||
"projectName": "Cmux Resurrect",
|
||||
"projectDesc": "Terminal Session Persistence Tool",
|
||||
"url": "https://drolosoft.com/cmux-resurrect.html?lang=en",
|
||||
"gitRepoUrl": "https://github.com/drolosoft/cmux-resurrect",
|
||||
"openSource": true,
|
||||
"projectLogo": "cmux-resurrect.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
@@ -608,8 +627,67 @@
|
||||
],
|
||||
"projectID": "cmux-resurrect"
|
||||
},
|
||||
{
|
||||
"title": "Gotify Commander - Bidirectional Server Control Plugin",
|
||||
"category": "plugin",
|
||||
"projectName": "Gotify Commander",
|
||||
"projectDesc": "Bidirectional Server Control Plugin",
|
||||
"url": "https://github.com/drolosoft/gotify-commander",
|
||||
"gitRepoUrl": "https://github.com/drolosoft/gotify-commander",
|
||||
"openSource": true,
|
||||
"projectLogo": "gotify-commander.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
"current": true,
|
||||
"technologies": [
|
||||
"Go",
|
||||
"Gotify Plugin System",
|
||||
"CGO",
|
||||
"SSH",
|
||||
"Pico CSS",
|
||||
"Self-hosted"
|
||||
],
|
||||
"shortDescription": "The first bidirectional Gotify plugin. Transforms your phone into a server control center with 23 commands for service management, system diagnostics, Nginx analytics, SSL monitoring, and GPS location — all from the Gotify mobile app.",
|
||||
"responsibilities": [
|
||||
"Designed and built bidirectional Gotify plugin in Go enabling remote server management via mobile notifications",
|
||||
"Implemented 23 curated commands for service control (systemd/launchctl), diagnostics, and monitoring",
|
||||
"Built multi-machine support through SSH for managing VPS and Mac servers from a single interface",
|
||||
"Created web-based control panel with Pico CSS and optional password authentication",
|
||||
"Integrated Nginx traffic analysis (rhit), SSL certificate monitoring, and GPS reverse geocoding via OpenStreetMap"
|
||||
],
|
||||
"projectID": "gotify-commander"
|
||||
},
|
||||
{
|
||||
"title": "SoundInbox - Native macOS Email Sound Alerts",
|
||||
"category": "app",
|
||||
"projectName": "SoundInbox",
|
||||
"projectDesc": "Native macOS Email Sound Alerts",
|
||||
"url": "https://drolosoft.com/soundinbox.html?lang=en",
|
||||
"gitRepoUrl": "https://github.com/drolosoft/soundinbox",
|
||||
"projectLogo": "soundinbox.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
"current": true,
|
||||
"technologies": [
|
||||
"Swift 6",
|
||||
"SwiftUI",
|
||||
"AppKit",
|
||||
"AVFoundation",
|
||||
"macOS Native"
|
||||
],
|
||||
"shortDescription": "Native macOS menu bar app that turns important emails into unmistakable sounds. Formula-based email detection with curated alert sounds, custom rule engine, and zero dependencies.",
|
||||
"responsibilities": [
|
||||
"Built native macOS menu bar application in Swift 6 with SwiftUI and AppKit",
|
||||
"Implemented formula-based email detection engine with AND/OR logic and regex matching",
|
||||
"Created 10 pre-built detection formulas (payments, sales, urgent, shipping, security)",
|
||||
"Designed match history timeline with statistics and 15 curated alert sounds",
|
||||
"Comprehensive test suite with 137 tests across 11 test suites"
|
||||
],
|
||||
"projectID": "soundinbox"
|
||||
},
|
||||
{
|
||||
"title": "Somos Una Ola - Beach Cleaning Initiative",
|
||||
"category": "web",
|
||||
"projectName": "Somos Una Ola",
|
||||
"projectDesc": "Beach Cleaning Initiative",
|
||||
"url": "https://somosunaola.org",
|
||||
@@ -633,6 +711,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Herrumbre Vivo Arte - Artist Portfolio Website",
|
||||
"category": "web",
|
||||
"projectName": "Herrumbre Vivo Arte",
|
||||
"projectDesc": "Artist Portfolio Website",
|
||||
"url": "https://herrumbrevivoarte.com",
|
||||
@@ -655,6 +734,7 @@
|
||||
},
|
||||
{
|
||||
"title": "La Porra.club - Football Prediction Platform",
|
||||
"category": "web",
|
||||
"projectName": "La Porra.club",
|
||||
"projectDesc": "Football Prediction Platform",
|
||||
"url": "https://laporra.club",
|
||||
@@ -681,10 +761,12 @@
|
||||
},
|
||||
{
|
||||
"title": "CDC Starter Kit - SAP Customer Data Cloud Demo",
|
||||
"category": "sdk",
|
||||
"projectName": "CDC Starter Kit",
|
||||
"projectDesc": "SAP Customer Data Cloud Demo",
|
||||
"url": "https://gigyademo.com/cdc-starter-kit/",
|
||||
"gitRepoUrl": "https://github.com/gigya/cdc-starter-kit",
|
||||
"openSource": true,
|
||||
"projectLogo": "sap.png",
|
||||
"logoIndex": 8,
|
||||
"location": "Online",
|
||||
@@ -707,8 +789,38 @@
|
||||
],
|
||||
"projectID": "cdc-starter-kit"
|
||||
},
|
||||
{
|
||||
"title": "gh-dashboard - Self-Hosted GitHub Analytics Dashboard",
|
||||
"category": "collab",
|
||||
"projectName": "gh-dashboard",
|
||||
"projectDesc": "Self-Hosted GitHub Analytics Dashboard",
|
||||
"url": "https://github.com/debba/gh-dashboard",
|
||||
"gitRepoUrl": "https://github.com/debba/gh-dashboard",
|
||||
"openSource": true,
|
||||
"projectLogo": "gh-dashboard.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
"current": true,
|
||||
"technologies": [
|
||||
"TypeScript",
|
||||
"React 19",
|
||||
"Vite 8",
|
||||
"Node.js",
|
||||
"GitHub API",
|
||||
"Vitest",
|
||||
"Self-hosted"
|
||||
],
|
||||
"shortDescription": "Open-source contributor to <strong><a href='https://github.com/debba/gh-dashboard' target='_blank' rel='noopener noreferrer'>gh-dashboard</a></strong> by Andrea Debernardi — a self-hosted GitHub analytics dashboard with repo health scores, cross-repo issue triage, daily digests, and Kanban boards.",
|
||||
"responsibilities": [
|
||||
"Contributed 2 merged PRs (750+ lines, 21 tests) improving UX, persistence, and responsive design",
|
||||
"Improved caching, responsive layout, and user identity features across the dashboard",
|
||||
"Deployed and maintain a live instance on personal VPS with GitHub OAuth and SSL"
|
||||
],
|
||||
"projectID": "gh-dashboard"
|
||||
},
|
||||
{
|
||||
"title": "Third Party Contributions",
|
||||
"category": "contrib",
|
||||
"url": "",
|
||||
"projectLogo": "",
|
||||
"location": "Various",
|
||||
@@ -834,10 +946,8 @@
|
||||
"duration": "Various",
|
||||
"shortDescription": "Professional development courses in SAP technologies, UX design, security, and data analytics through LinkedIn Learning's comprehensive training platform.",
|
||||
"responsibilities": [
|
||||
"<iconify-icon icon='mdi:book-open-page-variant' width='60' height='60' class='default-company-icon' style='color: #D97706;'></iconify-icon><div><strong>Aprende lectura rápida</strong> <em>April 2020</em>: Speed reading techniques and comprehension strategies for professional development and efficient information processing</div>",
|
||||
"<iconify-icon icon='mdi:cloud' width='60' height='60' class='default-company-icon' style='color: #0FAAFF;'></iconify-icon><div><strong>A Tour of the SAP Cloud Platform</strong> <em>February 2020</em>: Comprehensive overview of SAP Cloud Platform services, architecture, and integration capabilities for enterprise cloud solutions</div>",
|
||||
"<iconify-icon icon='mdi:android' width='60' height='60' class='default-company-icon' style='color: #3DDC84;'></iconify-icon><div><strong>Learning Android Security</strong> <em>February 2020</em>: Android security best practices, encryption methods, secure coding practices, and mobile application security fundamentals</div>",
|
||||
"<iconify-icon icon='mdi:account-group' width='60' height='60' class='default-company-icon' style='color: #EC4899;'></iconify-icon><div><strong>Persuasive UX: Creating Credibility</strong> <em>January 2020</em>: User experience design principles focused on building trust, credibility, and persuasive design patterns for web applications</div>",
|
||||
"<iconify-icon icon='mdi:database' width='60' height='60' class='default-company-icon' style='color: #3B82F6;'></iconify-icon><div><strong>Big Data Foundations: Techniques and Concepts</strong> <em>December 2019</em>: Fundamentals of big data technologies, distributed computing, data processing frameworks, and analytics techniques</div>"
|
||||
],
|
||||
"courseID": "linkedin-learning-certificatio"
|
||||
@@ -976,12 +1086,12 @@
|
||||
}
|
||||
],
|
||||
"other": {
|
||||
"driverLicense": "Type B"
|
||||
"driverLicense": ""
|
||||
},
|
||||
"meta": {
|
||||
"version": "2025-11-09",
|
||||
"lastUpdated": "2025-11-08",
|
||||
"version": "2026-04-12",
|
||||
"lastUpdated": "2026-04-12",
|
||||
"format": "JSON Resume Extended",
|
||||
"language": "en"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"personal": {
|
||||
"name": "Juan Andrés Moreno Rubio",
|
||||
"title": "Consultor Técnico Senior, Desarrollador FullStack",
|
||||
"title": "Consultor Técnico Senior & Desarrollador Full-Stack",
|
||||
"titleBadges": [
|
||||
"Consultor Técnico",
|
||||
"Ingeniero Full-Stack",
|
||||
"Especialista en Autenticación",
|
||||
"Arquitecto de Soluciones"
|
||||
"Contribuidor Open Source"
|
||||
],
|
||||
"location": "Arrecife, Las Palmas de Gran Canaria, España",
|
||||
"email": "txeo.msx@gmail.com",
|
||||
"phone": "+34 676875420",
|
||||
"dateOfBirth": "1980-03-02",
|
||||
"placeOfBirth": "Plasencia (Cáceres), España",
|
||||
"phone": "",
|
||||
"dateOfBirth": "",
|
||||
"placeOfBirth": "",
|
||||
"citizenship": "Española",
|
||||
"linkedin": "https://www.linkedin.com/in/juan-andres-moreno-rubio",
|
||||
"github": "https://github.com/juanatsap",
|
||||
"domestika": "https://www.domestika.org/es/txeo/portfolio",
|
||||
"domestika": "",
|
||||
"website": "https://juan.andres.morenorub.io",
|
||||
"photo": "/static/images/profile.jpg",
|
||||
"firstName": "Juan Andrés",
|
||||
@@ -26,12 +26,12 @@
|
||||
"seo": {
|
||||
"pageTitle": "Curriculum Vitae",
|
||||
"metaTitle": "CV Profesional",
|
||||
"metaDescription": "18 años de experiencia en desarrollo web, SAP CDC, React, Node.js, Go, HTMX y desarrollo asistido por IA",
|
||||
"ogDescription": "Consultor Técnico Senior con 18 años de experiencia",
|
||||
"keywords": "CV, Curriculum Vitae, Desarrollador FullStack, SAP CDC, React, Node.js, Go, HTMX, IA, Desarrollo Web, Consultor Técnico"
|
||||
"metaDescription": "Consultor Técnico Senior y Desarrollador Full-Stack — Go, HTMX, React, Node.js. SAP CDC, sistemas de autenticación, integración IA, herramientas open-source",
|
||||
"ogDescription": "Consultor Técnico Senior & Desarrollador Full-Stack — Go, IA, HTMX, SAP CDC",
|
||||
"keywords": "CV, Curriculum Vitae, Desarrollador FullStack, Go, Swift, macOS, MCP, IA, HTMX, React, Node.js, SAP CDC, Apps Nativas, Herramientas CLI, Open Source"
|
||||
},
|
||||
"summary": "Desarrollador full-stack especializado en sistemas de alta disponibilidad. He participado en plataformas de Juegos Olímpicos, sistemas de autenticación aeroportuaria con millones de usuarios, y desarrollado unos 20 sitios web para diversos sectores (e-commerce, empresariales, institucionales). Consultor certificado de SAP Customer Data Cloud, asesorando a 35-40 clientes internacionales en soluciones de identidad digital.",
|
||||
"skillsSummary": "Desarrollador <strong>full-stack</strong> con experiencia en <strong>Go</strong>, <strong>Node.js</strong>, <strong>React</strong> y <strong>HTMX</strong> para <strong>aplicaciones modernas</strong>, además de conocimientos en Java y PHP para proyectos legacy. He trabajado en <strong>unos 20 sitios web</strong> y realizado <strong>consultoría para 35-40 clientes internacionales</strong>, desde e-commerce y plataformas empresariales hasta <strong>sistemas de autenticación</strong> que gestionan <strong>millones de usuarios</strong>. Familiarizado con flujos de trabajo asistidos por <strong>IA</strong> y gestión de infraestructura (<strong>Linux</strong>, <strong>Docker</strong>, <strong>CI/CD</strong>). Me adapto bien tanto al trabajo independiente como colaborativo en equipos internacionales.",
|
||||
"summary": "Desarrollador full-stack especializado en sistemas de autenticación y plataformas de alta disponibilidad. Actualmente trabajo en plataformas de Juegos Olímpicos, y he construido sistemas de autenticación aeroportuaria con millones de usuarios y unos 20 sitios web para diversos sectores. Consultor certificado SAP CDC, asesorando a 35-40 clientes internacionales en identidad digital. También creo herramientas open-source y apps nativas vía drolosoft.",
|
||||
"skillsSummary": "Desarrollador <strong>full-stack</strong> con experiencia en <strong>Go</strong>, <strong>Node.js</strong>, <strong>React</strong> y <strong>HTMX</strong> para aplicaciones modernas. He realizado <strong>consultoría para 35-40 clientes internacionales</strong>, desde e-commerce y plataformas empresariales hasta <strong>sistemas de autenticación</strong> que gestionan <strong>millones de usuarios</strong>. Integro <strong>herramientas de IA</strong> en mis flujos de desarrollo y construyo proyectos que usan <strong>LLMs</strong> y <strong>MCP</strong>. También creo <strong>herramientas open-source</strong> y <strong>apps nativas macOS</strong> de forma independiente. Cómodo gestionando infraestructura con <strong>Linux</strong>, <strong>Docker</strong> y <strong>CI/CD</strong>.",
|
||||
"experience": [
|
||||
{
|
||||
"position": "Consultor Técnico Senior SAP",
|
||||
@@ -350,24 +350,23 @@
|
||||
"technical": [
|
||||
{
|
||||
"category": "Lenguajes de Programación",
|
||||
"proficiency": 4,
|
||||
"proficiency": 7,
|
||||
"items": [
|
||||
"JavaScript (ES6+)",
|
||||
"Go",
|
||||
"Swift",
|
||||
"JavaScript (ES6+)",
|
||||
"TypeScript",
|
||||
"Node.js",
|
||||
"Python",
|
||||
"Shell Scripting (Bash/Unix)",
|
||||
"PHP",
|
||||
"Java",
|
||||
"Groovy",
|
||||
"SQL",
|
||||
"Assembler"
|
||||
"SQL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Ecosistema JavaScript",
|
||||
"proficiency": 5,
|
||||
"proficiency": 8,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"Node.js y Express",
|
||||
@@ -380,7 +379,7 @@
|
||||
},
|
||||
{
|
||||
"category": "Ecosistema Go",
|
||||
"proficiency": 5,
|
||||
"proficiency": 8,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"Hono - Framework Web de Alto Rendimiento",
|
||||
@@ -393,15 +392,26 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Tecnologías Frontend",
|
||||
"category": "Swift y Desarrollo macOS",
|
||||
"proficiency": 5,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"Swift 6 y SwiftUI",
|
||||
"AppKit y APIs macOS",
|
||||
"Aplicaciones Nativas de Barra de Menú",
|
||||
"UserNotifications y AVFoundation",
|
||||
"Distribución y Notarización de Apps macOS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Tecnologías Frontend",
|
||||
"proficiency": 9,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"HTMX - Aplicaciones Basadas en Hipermedia",
|
||||
"HTML5 y Web Semántica",
|
||||
"CSS3, Tailwind CSS, SASS/LESS",
|
||||
"JavaScript - Manipulación DOM y AJAX",
|
||||
"jQuery",
|
||||
"Mejora Progresiva y Accesibilidad",
|
||||
"Diseño Responsive y Mobile-First",
|
||||
"Motores de Plantillas (Handlebars, Panini, Mustache)"
|
||||
@@ -409,7 +419,7 @@
|
||||
},
|
||||
{
|
||||
"category": "Tecnologías Backend",
|
||||
"proficiency": 5,
|
||||
"proficiency": 8,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"Go - Stack Principal Actual",
|
||||
@@ -420,21 +430,9 @@
|
||||
"Diseño y Optimización de Bases de Datos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Tecnologías Enterprise Anteriores",
|
||||
"proficiency": 3,
|
||||
"sidebar": "left",
|
||||
"items": [
|
||||
"Java y J2EE",
|
||||
"Spring Framework, Struts, Hibernate",
|
||||
"PHP y WordPress",
|
||||
"Yii Framework, Zend Framework",
|
||||
"Servidores de Aplicaciones Enterprise (Tomcat, JBoss, WebLogic)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Infraestructura y Servidores",
|
||||
"proficiency": 5,
|
||||
"proficiency": 7,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"Administración de Servidores Linux",
|
||||
@@ -446,7 +444,7 @@
|
||||
},
|
||||
{
|
||||
"category": "DevOps y CI/CD",
|
||||
"proficiency": 5,
|
||||
"proficiency": 7,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"Diseño e Implementación de Pipelines CI/CD",
|
||||
@@ -458,7 +456,7 @@
|
||||
},
|
||||
{
|
||||
"category": "Bases de Datos",
|
||||
"proficiency": 4,
|
||||
"proficiency": 6,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"PostgreSQL",
|
||||
@@ -472,32 +470,19 @@
|
||||
},
|
||||
{
|
||||
"category": "Gestión de Equipos",
|
||||
"proficiency": 4,
|
||||
"proficiency": 6,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"Preparación y puesta en marcha de proyectos",
|
||||
"Comunicación fluida con los clientes",
|
||||
"Contratación de personal",
|
||||
"Gestión de tareas",
|
||||
"Reportes mensuales"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Herramientas de Diseño",
|
||||
"proficiency": 3,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"Corel Draw",
|
||||
"Adobe PhotoShop",
|
||||
"Adobe Illustrator",
|
||||
"Affinity",
|
||||
"Excalidraw",
|
||||
"GIMP"
|
||||
"Coordinación de Equipos Multidisciplinares",
|
||||
"Asesoramiento a Clientes y Gestión de Stakeholders",
|
||||
"Mentoría Técnica e Incorporación",
|
||||
"Planificación y Entrega de Proyectos",
|
||||
"Colaboración en Equipos Internacionales"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Tecnologías SAP",
|
||||
"proficiency": 5,
|
||||
"proficiency": 9,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"SAP Customer Data Cloud (CDC)",
|
||||
@@ -507,15 +492,16 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Desarrollo Asistido por IA",
|
||||
"proficiency": 5,
|
||||
"category": "Ingeniería IA e Integración",
|
||||
"proficiency": 7,
|
||||
"sidebar": "right",
|
||||
"items": [
|
||||
"Flujos de Desarrollo con IA (Claude Code, Copilot, GPT-4)",
|
||||
"Desarrollo Basado en Agentes y Especificaciones",
|
||||
"Ingeniería de Prompts e Integración de IA",
|
||||
"Generación Automática de Código y Documentación",
|
||||
"APIs OpenAI y Anthropic"
|
||||
"Servidores MCP (Model Context Protocol)",
|
||||
"Google ADK e Integración con Gemini",
|
||||
"APIs de LLMs (OpenAI, Anthropic, Google)",
|
||||
"Flujos de Desarrollo con IA (Claude Code, Copilot)",
|
||||
"Embeddings CLIP y Búsqueda Visual",
|
||||
"Flujos de Trabajo Agénticos y Automatización"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -528,12 +514,7 @@
|
||||
"Formación y Mentoría",
|
||||
"Gestión de Relaciones con Clientes",
|
||||
"Flexibilidad y Adaptabilidad",
|
||||
"Marketing y Gestión de Recursos",
|
||||
"Preparación y puesta en marcha de proyectos",
|
||||
"Comunicación fluida con los clientes",
|
||||
"Contratación de personal",
|
||||
"Gestión de tareas",
|
||||
"Reportes mensuales"
|
||||
"Desarrollo de Producto y Publicación"
|
||||
]
|
||||
},
|
||||
"languages": [
|
||||
@@ -561,10 +542,12 @@
|
||||
"projects": [
|
||||
{
|
||||
"title": "Immich Photo Manager - Servidor MCP para Gestión de Fotos con IA",
|
||||
"category": "cli",
|
||||
"projectName": "Immich Photo Manager",
|
||||
"projectDesc": "Servidor MCP para Gestión de Fotos con IA",
|
||||
"url": "https://drolosoft.com/immich-photo-manager.html?lang=es",
|
||||
"gitRepoUrl": "https://github.com/drolosoft/immich-photo-manager",
|
||||
"openSource": true,
|
||||
"projectLogo": "immich-photo-manager.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
@@ -586,12 +569,43 @@
|
||||
],
|
||||
"projectID": "immich-photo-manager"
|
||||
},
|
||||
{
|
||||
"title": "Go-Docs MCP - Servidor de Acceso Multi-Formato a Documentos",
|
||||
"category": "cli",
|
||||
"projectName": "Go-Docs MCP",
|
||||
"projectDesc": "Servidor de Acceso Multi-Formato a Documentos",
|
||||
"url": "https://drolosoft.com/go-docs-mcp.html",
|
||||
"gitRepoUrl": "https://github.com/drolosoft/go-docs-mcp",
|
||||
"openSource": true,
|
||||
"projectLogo": "go-docs-mcp.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
"current": true,
|
||||
"technologies": [
|
||||
"Go",
|
||||
"MCP (Model Context Protocol)",
|
||||
"Extracción de PDF",
|
||||
"OCR (Tesseract)",
|
||||
"Herramientas CLI"
|
||||
],
|
||||
"shortDescription": "Servidor MCP open-source que permite a asistentes de IA leer cualquier documento — PDF, DOCX, Markdown, CSV e imágenes. Binario único, cero dependencias en tiempo de ejecución, 13 herramientas incluyendo búsqueda de texto completo, OCR, extracción de tablas y descarga desde URLs.",
|
||||
"responsibilities": [
|
||||
"Diseñé y desarrollé servidor MCP multi-formato en Go — binario único sin dependencias en tiempo de ejecución",
|
||||
"Implementé 13 herramientas de lectura de documentos, búsqueda de texto, OCR, extracción de imágenes/tablas y descarga desde URLs",
|
||||
"Desarrollé caché inteligente con invalidación basada en mtime y seguridad con bloqueo de directorio y prevención de path traversal",
|
||||
"Integré OCR con Tesseract para PDFs escaneados y extracción de texto de imágenes con fallback automático",
|
||||
"Publicado como proyecto open-source con instalación en un comando (`go install`) para macOS y Linux"
|
||||
],
|
||||
"projectID": "go-docs-mcp"
|
||||
},
|
||||
{
|
||||
"title": "Cmux Resurrect - Herramienta de Persistencia de Sesiones de Terminal",
|
||||
"category": "cli",
|
||||
"projectName": "Cmux Resurrect",
|
||||
"projectDesc": "Herramienta de Persistencia de Sesiones de Terminal",
|
||||
"url": "https://drolosoft.com/cmux-resurrect.html?lang=es",
|
||||
"gitRepoUrl": "https://github.com/drolosoft/cmux-resurrect",
|
||||
"openSource": true,
|
||||
"projectLogo": "cmux-resurrect.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
@@ -613,8 +627,67 @@
|
||||
],
|
||||
"projectID": "cmux-resurrect"
|
||||
},
|
||||
{
|
||||
"title": "Gotify Commander - Plugin Bidireccional de Control de Servidores",
|
||||
"category": "plugin",
|
||||
"projectName": "Gotify Commander",
|
||||
"projectDesc": "Plugin Bidireccional de Control de Servidores",
|
||||
"url": "https://github.com/drolosoft/gotify-commander",
|
||||
"gitRepoUrl": "https://github.com/drolosoft/gotify-commander",
|
||||
"openSource": true,
|
||||
"projectLogo": "gotify-commander.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
"current": true,
|
||||
"technologies": [
|
||||
"Go",
|
||||
"Sistema de Plugins Gotify",
|
||||
"CGO",
|
||||
"SSH",
|
||||
"Pico CSS",
|
||||
"Autoalojado"
|
||||
],
|
||||
"shortDescription": "El primer plugin bidireccional para Gotify. Transforma tu teléfono en un centro de control de servidores con 23 comandos para gestión de servicios, diagnósticos de sistema, analíticas Nginx, monitorización SSL y localización GPS — todo desde la app móvil de Gotify.",
|
||||
"responsibilities": [
|
||||
"Diseñé y desarrollé plugin bidireccional para Gotify en Go que permite gestión remota de servidores vía notificaciones móviles",
|
||||
"Implementé 23 comandos curados para control de servicios (systemd/launchctl), diagnósticos y monitorización",
|
||||
"Desarrollé soporte multi-máquina mediante SSH para gestionar servidores VPS y Mac desde una única interfaz",
|
||||
"Creé panel de control web con Pico CSS y autenticación opcional por contraseña",
|
||||
"Integré análisis de tráfico Nginx (rhit), monitorización de certificados SSL y geocodificación inversa GPS vía OpenStreetMap"
|
||||
],
|
||||
"projectID": "gotify-commander"
|
||||
},
|
||||
{
|
||||
"title": "SoundInbox - Alertas Sonoras de Email para macOS",
|
||||
"category": "app",
|
||||
"projectName": "SoundInbox",
|
||||
"projectDesc": "Alertas Sonoras de Email para macOS",
|
||||
"url": "https://drolosoft.com/soundinbox.html?lang=es",
|
||||
"gitRepoUrl": "https://github.com/drolosoft/soundinbox",
|
||||
"projectLogo": "soundinbox.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
"current": true,
|
||||
"technologies": [
|
||||
"Swift 6",
|
||||
"SwiftUI",
|
||||
"AppKit",
|
||||
"AVFoundation",
|
||||
"macOS Nativo"
|
||||
],
|
||||
"shortDescription": "App nativa de barra de menú para macOS que convierte emails importantes en sonidos inconfundibles. Detección basada en fórmulas con sonidos de alerta seleccionados, motor de reglas personalizado y cero dependencias.",
|
||||
"responsibilities": [
|
||||
"Desarrollé aplicación nativa de barra de menú para macOS en Swift 6 con SwiftUI y AppKit",
|
||||
"Implementé motor de detección de emails basado en fórmulas con lógica AND/OR y coincidencia regex",
|
||||
"Creé 10 fórmulas de detección predefinidas (pagos, ventas, urgente, envíos, seguridad)",
|
||||
"Diseñé historial de coincidencias con estadísticas y 15 sonidos de alerta seleccionados",
|
||||
"Suite de pruebas completa con 137 tests en 11 suites"
|
||||
],
|
||||
"projectID": "soundinbox"
|
||||
},
|
||||
{
|
||||
"title": "Somos Una Ola - Iniciativa de Limpieza de Playas",
|
||||
"category": "web",
|
||||
"projectName": "Somos Una Ola",
|
||||
"projectDesc": "Iniciativa de Limpieza de Playas",
|
||||
"url": "https://somosunaola.org",
|
||||
@@ -638,6 +711,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Herrumbre Vivo Arte - Sitio Web Portfolio de Artista",
|
||||
"category": "web",
|
||||
"projectName": "Herrumbre Vivo Arte",
|
||||
"projectDesc": "Sitio Web Portfolio de Artista",
|
||||
"url": "https://herrumbrevivoarte.com",
|
||||
@@ -660,6 +734,7 @@
|
||||
},
|
||||
{
|
||||
"title": "La Porra.club - Plataforma de Predicción de Fútbol",
|
||||
"category": "web",
|
||||
"projectName": "La Porra.club",
|
||||
"projectDesc": "Plataforma de Predicción de Fútbol",
|
||||
"url": "https://laporra.club",
|
||||
@@ -686,10 +761,12 @@
|
||||
},
|
||||
{
|
||||
"title": "CDC Starter Kit - Demo de SAP Customer Data Cloud",
|
||||
"category": "sdk",
|
||||
"projectName": "CDC Starter Kit",
|
||||
"projectDesc": "Demo de SAP Customer Data Cloud",
|
||||
"url": "https://gigyademo.com/cdc-starter-kit/",
|
||||
"gitRepoUrl": "https://github.com/gigya/cdc-starter-kit",
|
||||
"openSource": true,
|
||||
"projectLogo": "sap.png",
|
||||
"logoIndex": 8,
|
||||
"location": "Online",
|
||||
@@ -712,8 +789,38 @@
|
||||
],
|
||||
"projectID": "cdc-starter-kit"
|
||||
},
|
||||
{
|
||||
"title": "gh-dashboard - Panel de Analíticas de GitHub Autoalojado",
|
||||
"category": "collab",
|
||||
"projectName": "gh-dashboard",
|
||||
"projectDesc": "Panel de Analíticas de GitHub Autoalojado",
|
||||
"url": "https://github.com/debba/gh-dashboard",
|
||||
"gitRepoUrl": "https://github.com/debba/gh-dashboard",
|
||||
"openSource": true,
|
||||
"projectLogo": "gh-dashboard.png",
|
||||
"location": "Online",
|
||||
"startDate": "2026",
|
||||
"current": true,
|
||||
"technologies": [
|
||||
"TypeScript",
|
||||
"React 19",
|
||||
"Vite 8",
|
||||
"Node.js",
|
||||
"GitHub API",
|
||||
"Vitest",
|
||||
"Self-hosted"
|
||||
],
|
||||
"shortDescription": "Contribuidor open-source a <strong><a href='https://github.com/debba/gh-dashboard' target='_blank' rel='noopener noreferrer'>gh-dashboard</a></strong> de Andrea Debernardi — un panel de analíticas de GitHub autoalojado con puntuaciones de salud de repos, triaje de issues, resúmenes diarios y tableros Kanban.",
|
||||
"responsibilities": [
|
||||
"Contribuí 2 PRs fusionados (750+ líneas, 21 tests) mejorando UX, persistencia y diseño responsive",
|
||||
"Mejoré caché, diseño responsive e identidad de usuario en el dashboard",
|
||||
"Desplegué y mantengo una instancia en mi VPS personal con GitHub OAuth y SSL"
|
||||
],
|
||||
"projectID": "gh-dashboard"
|
||||
},
|
||||
{
|
||||
"title": "Contribuciones a Proyectos de Terceros",
|
||||
"category": "contrib",
|
||||
"url": "",
|
||||
"projectLogo": "",
|
||||
"location": "Varios",
|
||||
@@ -839,10 +946,8 @@
|
||||
"duration": "Varios",
|
||||
"shortDescription": "Cursos de desarrollo profesional en tecnologías SAP, diseño UX, seguridad y análisis de datos a través de la plataforma de formación integral de LinkedIn Learning.",
|
||||
"responsibilities": [
|
||||
"<iconify-icon icon='mdi:book-open-page-variant' width='60' height='60' class='default-company-icon' style='color: #D97706;'></iconify-icon><div><strong>Aprende lectura rápida</strong> <em>Abril 2020</em>: Técnicas de lectura rápida y estrategias de comprensión para desarrollo profesional y procesamiento eficiente de información</div>",
|
||||
"<iconify-icon icon='mdi:cloud' width='60' height='60' class='default-company-icon' style='color: #0FAAFF;'></iconify-icon><div><strong>A Tour of the SAP Cloud Platform</strong> <em>Febrero 2020</em>: Visión general completa de servicios de SAP Cloud Platform, arquitectura y capacidades de integración para soluciones empresariales en la nube</div>",
|
||||
"<iconify-icon icon='mdi:android' width='60' height='60' class='default-company-icon' style='color: #3DDC84;'></iconify-icon><div><strong>Learning Android Security</strong> <em>Febrero 2020</em>: Mejores prácticas de seguridad Android, métodos de encriptación, prácticas de codificación segura y fundamentos de seguridad de aplicaciones móviles</div>",
|
||||
"<iconify-icon icon='mdi:account-group' width='60' height='60' class='default-company-icon' style='color: #EC4899;'></iconify-icon><div><strong>Persuasive UX: Creating Credibility</strong> <em>Enero 2020</em>: Principios de diseño de experiencia de usuario enfocados en generar confianza, credibilidad y patrones de diseño persuasivo para aplicaciones web</div>",
|
||||
"<iconify-icon icon='mdi:database' width='60' height='60' class='default-company-icon' style='color: #3B82F6;'></iconify-icon><div><strong>Big Data Foundations: Techniques and Concepts</strong> <em>Diciembre 2019</em>: Fundamentos de tecnologías big data, computación distribuida, frameworks de procesamiento de datos y técnicas de análisis</div>"
|
||||
],
|
||||
"courseID": "certificaciones-linkedin-learn"
|
||||
@@ -981,12 +1086,12 @@
|
||||
}
|
||||
],
|
||||
"other": {
|
||||
"driverLicense": "Tipo B"
|
||||
"driverLicense": ""
|
||||
},
|
||||
"meta": {
|
||||
"version": "2025-11-09",
|
||||
"lastUpdated": "2025-11-08",
|
||||
"version": "2026-04-12",
|
||||
"lastUpdated": "2026-04-12",
|
||||
"format": "JSON Resume Extended",
|
||||
"language": "es"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1316,7 +1316,7 @@ end
|
||||
<script type="text/hyperscript" src="/static/hyperscript/color-theme._hs"></script>
|
||||
|
||||
<!-- 2. Then load hyperscript library -->
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
|
||||
<script src="/static/hyperscript/_hyperscript.min.js"></script>
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
@@ -1434,7 +1434,8 @@ end
|
||||
| **v2.0** | Phase 5 | Hyperscript zoom control | -343 lines |
|
||||
| **v2.1** | Phase 6 | Scroll & print + organization | -87 lines |
|
||||
| **v2.2** | Phase 9 | CSS Bundling (Lightning CSS) | N/A (CSS optimization) |
|
||||
| **Current** | v2.2 | Phase 9 Complete | **-715 JS lines + 54% CSS reduction** |
|
||||
| **v2.3** | Phase 11 | Self-hosted HTMX & Hyperscript | 0 lines (reliability + perf) |
|
||||
| **Current** | v2.3 | Phase 11 Complete | **-715 JS lines + 54% CSS reduction + zero CDN deps** |
|
||||
|
||||
---
|
||||
|
||||
@@ -1475,10 +1476,19 @@ end
|
||||
- ✅ **Mobile FAB overflow fix** (responsive buttons on 375px screens)
|
||||
- ✅ **Pre-push lint hook** catches CI issues before push
|
||||
|
||||
### Phase 11 Achievements (Self-Hosted Dependencies):
|
||||
- ✅ **HTMX 1.9.10 → 2.0.10** (major version upgrade, all attributes compatible)
|
||||
- ✅ **Hyperscript 0.9.14 → 0.9.91** (latest stable)
|
||||
- ✅ **Zero CDN dependencies** for core libraries (HTMX + Hyperscript served locally)
|
||||
- ✅ **Removed unpkg.com from CSP** (smaller attack surface)
|
||||
- ✅ **No external SPOF** (site functions fully even if CDNs are down)
|
||||
- ✅ **Faster loading** (no DNS lookup, TLS handshake, or redirect chains for core libs)
|
||||
|
||||
### Cumulative Achievements:
|
||||
- ✅ **715 lines of JavaScript eliminated total** (74.9% reduction)
|
||||
- ✅ **54% CSS size reduction** in production (Lightning CSS bundling)
|
||||
- ✅ **96% fewer CSS HTTP requests** in production (27 → 1)
|
||||
- ✅ **Zero CDN dependencies** for core libraries (HTMX + Hyperscript self-hosted)
|
||||
- ✅ **All modern features preserved** (no functionality loss)
|
||||
- ✅ **Improved maintainability** (organized external functions)
|
||||
- ✅ **Better performance** (hardware acceleration, reduced event loop blocking)
|
||||
@@ -1486,6 +1496,7 @@ end
|
||||
- ✅ **Smaller bundle size** (~35KB → ~15KB JavaScript, 188KB → 86KB CSS)
|
||||
- ✅ **Clean HTML templates** (no long inline hyperscript blocks)
|
||||
- ✅ **Professional code organization** (separated concerns)
|
||||
- ✅ **Reduced CSP attack surface** (removed unpkg.com from script-src)
|
||||
|
||||
---
|
||||
|
||||
@@ -1590,6 +1601,62 @@ expect(iphone13Mini.overflow).toBe(false);
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Phase 11: Self-Hosted Dependencies - Zero CDN for Core Libraries (COMPLETED)
|
||||
|
||||
### 13. Self-Hosted HTMX & Hyperscript - Eliminating External SPOFs
|
||||
|
||||
**Problem:** HTMX (1.9.10) and Hyperscript (0.9.14) were loaded from unpkg.com CDN. This created two risks:
|
||||
- **Availability**: If unpkg.com goes down, the entire site loses interactivity
|
||||
- **Performance**: Each CDN resource requires DNS lookup + TLS handshake + potential redirect chains
|
||||
- **Version drift**: HTMX 1.9.10 was two major versions behind (2.0.10 current)
|
||||
|
||||
**Solution:** Vendor both libraries locally and serve them from the same origin as the site.
|
||||
|
||||
#### Before (CDN):
|
||||
```html
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"
|
||||
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
|
||||
```
|
||||
|
||||
#### After (Self-hosted):
|
||||
```html
|
||||
<script src="/static/htmx/htmx.min.js"></script>
|
||||
<script src="/static/hyperscript/_hyperscript.min.js"></script>
|
||||
```
|
||||
|
||||
#### Why Self-Hosting Wins for This Project:
|
||||
|
||||
| Factor | CDN | Self-Hosted |
|
||||
|--------|-----|-------------|
|
||||
| **Availability** | Depends on unpkg.com | Same uptime as your site |
|
||||
| **Latency** | DNS + TLS + redirect | Already connected (same origin) |
|
||||
| **CSP surface** | Must whitelist `unpkg.com` | No external script domains needed |
|
||||
| **Version control** | URL-visible, easy to forget | Explicit file, tracked in docs |
|
||||
| **Cache behavior** | Shared CDN cache (maybe hit) | Guaranteed cache with your assets |
|
||||
| **HTMX 2.0 upgrade** | Just change URL | Download + test (but you control timing) |
|
||||
|
||||
#### CSP Impact:
|
||||
```go
|
||||
// Before
|
||||
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net ..."
|
||||
|
||||
// After — one fewer external domain
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ..."
|
||||
```
|
||||
|
||||
#### Breaking Changes Check (HTMX 1.9 → 2.0):
|
||||
All `hx-*` attributes used in this project (`hx-post`, `hx-get`, `hx-target`, `hx-swap`, `hx-indicator`, `hx-request`, `hx-push-url`, `hx-swap-oob`, `hx-headers`, `hx-head`) are fully supported in HTMX 2.0. The `hx-head` attribute (used for language switch SEO meta updates) moved from extension to built-in — a bonus.
|
||||
|
||||
#### Maintenance Note:
|
||||
Self-hosted libraries require manual version tracking. Current versions:
|
||||
- **HTMX**: 2.0.10 (`static/htmx/htmx.min.js`, 51KB)
|
||||
- **Hyperscript**: 0.9.91 (`static/hyperscript/_hyperscript.min.js`, 172KB)
|
||||
- **Iconify**: 2.1.0 (still on jsdelivr CDN — icon rendering library, acceptable external dep)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Phase 7-8: Smooth Toggle Animations - Pure Client-Side Pattern (COMPLETED)
|
||||
|
||||
### 9. HTMX `hx-swap="none"` + Inline Hyperscript - Client-First Toggles
|
||||
|
||||
@@ -988,7 +988,7 @@ isHTMX := r.Header.Get("HX-Request") != ""
|
||||
hx-push-url="/?lang=en"
|
||||
class="lang-btn">
|
||||
English
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<button
|
||||
hx-get="/cv?lang=es"
|
||||
@@ -1669,7 +1669,7 @@ func RateLimitMiddleware(limiter *IPRateLimiter) func(http.Handler) http.Handler
|
||||
class="lang-btn active"
|
||||
hx-get="/cv?lang=en"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="/?lang=en"
|
||||
onclick="setActive(this)">
|
||||
English
|
||||
@@ -2240,7 +2240,7 @@ go tool trace trace.out
|
||||
|
||||
### Support
|
||||
|
||||
**Issues:** [GitHub Issues](https://github.com/juanatsap/cv-site/issues)
|
||||
**Issues:** [GitHub Issues](https://repos.txeo.club/txeo/cv-site/issues)
|
||||
**Email:** [juan.a.moreno.rubio@gmail.com](mailto:juan.a.moreno.rubio@gmail.com)
|
||||
|
||||
---
|
||||
|
||||
@@ -147,7 +147,7 @@ static/hyperscript/
|
||||
<script type="text/hyperscript" src="/static/hyperscript/keyboard._hs"></script>
|
||||
<script type="text/hyperscript" src="/static/hyperscript/zoom._hs"></script>
|
||||
<script type="text/hyperscript" src="/static/hyperscript/pdf-modal._hs"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
|
||||
<script src="/static/hyperscript/_hyperscript.min.js"></script>
|
||||
```
|
||||
|
||||
## Required Functions
|
||||
@@ -288,5 +288,5 @@ end
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-30
|
||||
**Hyperscript Version**: 0.9.14
|
||||
**Hyperscript Version**: 0.9.91
|
||||
**Status**: MANDATORY - ALWAYS FOLLOW
|
||||
|
||||
@@ -71,7 +71,7 @@ This CV/Resume application is designed to be easily customizable. You can adapt
|
||||
|
||||
```bash
|
||||
# 1. Clone or download the project
|
||||
git clone https://github.com/juanatsap/cv-site.git my-cv
|
||||
git clone https://repos.txeo.club/txeo/cv-site.git my-cv
|
||||
cd my-cv
|
||||
|
||||
# 2. Edit your information
|
||||
|
||||
@@ -62,7 +62,7 @@ The fastest way to get started:
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/juanatsap/cv-site.git
|
||||
git clone https://repos.txeo.club/txeo/cv-site.git
|
||||
cd cv-site
|
||||
|
||||
# Copy environment configuration
|
||||
@@ -110,7 +110,7 @@ chromium-browser --version
|
||||
```bash
|
||||
# Clone repository
|
||||
cd /home/txeo/Git/yo
|
||||
git clone https://github.com/juanatsap/cv-site.git cv
|
||||
git clone https://repos.txeo.club/txeo/cv-site.git cv
|
||||
cd cv
|
||||
|
||||
# Build production binary
|
||||
@@ -531,7 +531,7 @@ Build and run without services.
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/juanatsap/cv-site.git
|
||||
git clone https://repos.txeo.club/txeo/cv-site.git
|
||||
cd cv-site
|
||||
|
||||
# Install dependencies
|
||||
|
||||
@@ -60,7 +60,7 @@ This website does NOT require accounts, logins, or user registration. No persona
|
||||
If you have questions about this privacy policy or data handling:
|
||||
|
||||
**Email:** Contact information available on the CV itself
|
||||
**GitHub:** [https://github.com/juanatsap/cv-site](https://github.com/juanatsap/cv-site)
|
||||
**GitHub:** [https://repos.txeo.club/txeo/cv-site](https://repos.txeo.club/txeo/cv-site)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SEO Implementation Guide
|
||||
|
||||
**Project:** CV Interactive Website
|
||||
**Last Updated:** 2025-11-30
|
||||
**Last Updated:** 2026-04-09
|
||||
**Status:** Production Ready
|
||||
|
||||
---
|
||||
@@ -197,6 +197,45 @@ curl -H "Accept: text/plain" https://juan.andres.morenorub.io/
|
||||
- Clean, structured text
|
||||
- All CV content preserved
|
||||
|
||||
#### Duplicate Content Prevention (April 2026)
|
||||
|
||||
**Problem discovered:** Google was indexing `/text` instead of the main HTML page, causing the plain text version to appear as the primary search result.
|
||||
|
||||
**Root cause:** The `/text` endpoint served the same CV content as the HTML page but with no SEO signals (no meta tags, no canonical, no noindex). Google favored it because plain text is easier to crawl and has dense keyword content.
|
||||
|
||||
**Solution implemented:**
|
||||
|
||||
1. **`X-Robots-Tag: noindex, nofollow`** HTTP header on `/text` responses
|
||||
- Tells search engines not to index the plain text version
|
||||
- Does NOT block crawling — LLMs and text browsers can still access it
|
||||
- Implementation: `internal/handlers/cv_text.go`
|
||||
|
||||
2. **`Link: canonical`** HTTP header on `/text` responses
|
||||
- Points to the HTML version: `<https://juan.andres.morenorub.io/?lang=en>; rel="canonical"`
|
||||
- Tells search engines which version is the "official" one
|
||||
|
||||
3. **robots.txt comment** (not a Disallow — intentionally crawlable for LLMs)
|
||||
- `/text` remains accessible for AI crawlers, curl, and text browsers
|
||||
- Only search engine indexing is prevented via the HTTP header
|
||||
|
||||
4. **Google Search Console verification**
|
||||
- `<meta name="google-site-verification">` tag added to `<head>`
|
||||
- Manual re-indexation requested for `/?lang=en` and `/?lang=es`
|
||||
- Manual removal of `/text` from search index
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
# Check that /text has noindex header:
|
||||
curl -sI 'https://juan.andres.morenorub.io/text?lang=en' | grep X-Robots
|
||||
# → X-Robots-Tag: noindex, nofollow
|
||||
|
||||
# Check canonical points to HTML version:
|
||||
curl -sI 'https://juan.andres.morenorub.io/text?lang=en' | grep Link
|
||||
# → Link: <https://juan.andres.morenorub.io/?lang=en>; rel="canonical"
|
||||
```
|
||||
|
||||
**Key principle:** The `/text` endpoint is for **consumption** (LLMs, terminals), not for **discovery** (search engines). Search results should always point to the rich HTML version with structured data, icons, and the AI chat agent.
|
||||
|
||||
---
|
||||
|
||||
#### robots.txt AI Bot Rules (`static/robots.txt`)
|
||||
@@ -253,13 +292,14 @@ The implementation supports Google's E-E-A-T (Experience, Expertise, Authority,
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `templates/index.html` | Meta tags, JSON-LD schemas |
|
||||
| `templates/partials/layout/head.html` | Meta tags, canonical, hreflang, Google verification |
|
||||
| `templates/partials/layout/head-structured-data.html` | JSON-LD schemas (Person, WebSite, etc.) |
|
||||
| `static/robots.txt` | Search engine and AI bot directives |
|
||||
| `static/llms.txt` | AI crawler information file |
|
||||
| `static/llms.txt` | AI crawler information file (llmstxt.org) |
|
||||
| `static/sitemap.xml` | XML sitemap for search engines |
|
||||
| `data/cv-en.json` | SEO fields (pageTitle, metaTitle, etc.) |
|
||||
| `data/cv-es.json` | Spanish SEO fields |
|
||||
| `/text` endpoint | Plain text CV for CLI/TUI browsers |
|
||||
| `internal/handlers/cv_text.go` | Plain text endpoint with noindex + canonical headers |
|
||||
| `templates/cv-text.txt` | Plain text template |
|
||||
|
||||
---
|
||||
@@ -324,6 +364,15 @@ Test at: [Google Robots.txt Tester](https://www.google.com/webmasters/tools/robo
|
||||
- [x] Comprehensive JSON-LD schemas
|
||||
- [x] AI bot permissions in robots.txt
|
||||
- [x] Clear, parseable content structure
|
||||
- [x] AI chat agent (Gemini) for interactive CV queries
|
||||
- [x] Plain text endpoint for LLM consumption (noindex for search engines)
|
||||
- [x] Google Search Console verified and monitored
|
||||
|
||||
### Duplicate Content Prevention
|
||||
- [x] `/text` endpoint: `X-Robots-Tag: noindex, nofollow`
|
||||
- [x] `/text` endpoint: `Link: canonical` pointing to HTML version
|
||||
- [x] Sitemap only contains HTML pages (not `/text`)
|
||||
- [x] Canonical URLs on all HTML pages
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
|
||||
<meta charset="UTF-8">
|
||||
<title>Contact Form</title>
|
||||
<!-- Include HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="/static/htmx/htmx.min.js"></script>
|
||||
<style>
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
label { display: block; margin-bottom: 0.5rem; }
|
||||
|
||||
@@ -170,7 +170,7 @@ POST /switch-language
|
||||
```go
|
||||
// Strong CSP policy
|
||||
Content-Security-Policy: default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net;
|
||||
script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||
...
|
||||
|
||||
@@ -221,8 +221,8 @@ go-git/go-git v5.16.4 // Git operations (no shell commands)
|
||||
**Frontend Dependencies:**
|
||||
```javascript
|
||||
// index.html - Using CDN with SRI
|
||||
htmx.org@1.9.10 (SRI: sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX...)
|
||||
hyperscript.org@0.9.14 (no SRI - ADD THIS)
|
||||
htmx 2.0.10 (self-hosted at /static/htmx/htmx.min.js)
|
||||
hyperscript 0.9.91 (self-hosted at /static/hyperscript/_hyperscript.min.js)
|
||||
iconify-icon@2.1.0 (no SRI - ADD THIS)
|
||||
```
|
||||
|
||||
@@ -259,9 +259,9 @@ iconify-icon@2.1.0 (no SRI - ADD THIS)
|
||||
|
||||
**Recommendations:**
|
||||
```html
|
||||
<!-- Add SRI hashes -->
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.14"
|
||||
integrity="sha384-[GENERATE_SRI_HASH]"
|
||||
<!-- HTMX and Hyperscript are now self-hosted (no SRI needed) -->
|
||||
<script src="/static/htmx/htmx.min.js"></script>
|
||||
<script src="/static/hyperscript/_hyperscript.min.js"></script>
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/iconify-icon@2.1.0/dist/iconify-icon.min.js"
|
||||
@@ -1112,7 +1112,7 @@ server {
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
|
||||
# CSP (delegated to Go app, but backup here)
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://matomo.morenorub.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.iconify.design https://matomo.morenorub.io; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://matomo.morenorub.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.iconify.design https://matomo.morenorub.io; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
||||
|
||||
# Hide Nginx version
|
||||
server_tokens off;
|
||||
@@ -1224,10 +1224,10 @@ go mod tidy
|
||||
|
||||
### Frontend Dependencies
|
||||
```bash
|
||||
# Check CDN resources for updates
|
||||
# HTMX: https://unpkg.com/htmx.org@latest
|
||||
# Hyperscript: https://unpkg.com/hyperscript.org@latest
|
||||
# Iconify: https://cdn.jsdelivr.net/npm/iconify-icon@latest
|
||||
# HTMX and Hyperscript are self-hosted (update by downloading new versions)
|
||||
# HTMX: static/htmx/htmx.min.js (currently 2.0.10)
|
||||
# Hyperscript: static/hyperscript/_hyperscript.min.js (currently 0.9.91)
|
||||
# Iconify (CDN): https://cdn.jsdelivr.net/npm/iconify-icon@latest
|
||||
|
||||
# Generate SRI hashes
|
||||
https://www.srihash.org/
|
||||
|
||||
@@ -510,7 +510,78 @@ Gemini 2.5 Flash free tier provides **15 requests/minute** with no credit card r
|
||||
|
||||
If the free tier is exceeded, Gemini returns a rate limit error, which the handler catches and displays as a generic error message to the user.
|
||||
|
||||
## 16. Dependencies
|
||||
## 16. LLM Provider Evolution & Benchmarks
|
||||
|
||||
### Provider Architecture
|
||||
|
||||
The chat uses a **dual-provider** strategy with automatic fallback:
|
||||
|
||||
```
|
||||
Primary: Gemini 2.5 Flash (Google API — production)
|
||||
↓ (if fails)
|
||||
Fallback: Gemma 4 26B MoE via Ollama (local — development)
|
||||
```
|
||||
|
||||
In development, set `GOOGLE_API_KEY=""` to force the local model.
|
||||
|
||||
### Model Selection History
|
||||
|
||||
| Date | Local Model | Why Changed |
|
||||
|------|-------------|-------------|
|
||||
| 2026-03 | Mistral Small 3.2 (24B) | Initial choice — good tool calling support |
|
||||
| 2026-04-09 | GLM-4.7-Flash (30B) | Better quality, Spanish support, 198K context |
|
||||
| 2026-04-09 | **Gemma 4 26B MoE** | 3-4x faster (MoE: only 4B active), less RAM, excellent quality |
|
||||
|
||||
### Benchmark: Same 4 Questions, Same Hardware
|
||||
|
||||
| Test | Gemini 2.5 Flash | Gemma 4 26B | GLM-4.7-Flash | Mistral 3.2 |
|
||||
|------|-----------------|-------------|---------------|-------------|
|
||||
| Summary (no tool) | **2s** | 5s | 7s | 10s |
|
||||
| Go question (tool call) | **6s** | 13s | 42s | 50s |
|
||||
| Spanish tech search | **4s** | 10s | 15s | No español |
|
||||
| All companies (heavy) | **3s** | 16s | 63s | Timeout |
|
||||
|
||||
### Quality Comparison
|
||||
|
||||
| Aspect | Gemini 2.5 Flash | Gemma 4 26B | GLM-4.7-Flash | Mistral 3.2 |
|
||||
|--------|-----------------|-------------|---------------|-------------|
|
||||
| Language detection | Excellent | Excellent | Good | Poor |
|
||||
| Tool calling | Native (ADK) | Via OpenAI compat | Via OpenAI compat | Via OpenAI compat |
|
||||
| CV navigation links | Correct | Partial | Rare | None |
|
||||
| Response exhaustiveness | Very complete | Very complete | Complete | Acceptable |
|
||||
| Hallucination rate | None (tool-grounded) | None | Low | Medium |
|
||||
|
||||
### Resource Usage
|
||||
|
||||
| Model | Parameters | Active | Disk | RAM (inference) | Offline |
|
||||
|-------|-----------|--------|------|-----------------|---------|
|
||||
| Gemini 2.5 Flash | Cloud | Cloud | 0 | 0 | No |
|
||||
| Gemma 4 26B MoE | 26B | **4B** | 18GB | **~8GB** | Yes |
|
||||
| GLM-4.7-Flash | 30B | 30B | 19GB | ~19GB | Yes |
|
||||
| Mistral Small 3.2 | 24B | 24B | 15GB | ~15GB | Yes |
|
||||
|
||||
### Why Gemma 4 26B MoE Wins for Local Development
|
||||
|
||||
1. **Mixture of Experts**: 26B total but only 4B activated per token — inference speed close to a 4B model with quality of a much larger one
|
||||
2. **3-4x faster** than dense 30B models (GLM) on the same hardware
|
||||
3. **~8GB RAM** vs 19GB for GLM — leaves room for other dev tools
|
||||
4. **Excellent Spanish**: Responds in the correct language consistently
|
||||
5. **Tool calling works**: Compatible with the OpenAI-compatible Ollama adapter
|
||||
6. **256K context**: Largest context window of all local options tested
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# .env — Development (local Gemma 4)
|
||||
OLLAMA_MODEL=gemma4:26b
|
||||
# GOOGLE_API_KEY= (leave empty to force local)
|
||||
|
||||
# .env — Production (Gemini API)
|
||||
GOOGLE_API_KEY=your-key
|
||||
OLLAMA_MODEL=gemma4:26b # fallback if Gemini fails
|
||||
```
|
||||
|
||||
## 17. Dependencies
|
||||
|
||||
| Package | Purpose | Size Impact |
|
||||
|---------|---------|-------------|
|
||||
@@ -519,7 +590,7 @@ If the free tier is exceeded, Gemini returns a rate limit error, which the handl
|
||||
|
||||
No frontend dependencies are added. The chat widget uses HTMX and Hyperscript which are already loaded by the site.
|
||||
|
||||
## 17. ADK Go Concepts Used
|
||||
## 18. ADK Go Concepts Used
|
||||
|
||||
| ADK Concept | Go Type / Function | Usage in This Project |
|
||||
|-------------|-------------------|----------------------|
|
||||
@@ -534,7 +605,7 @@ No frontend dependencies are added. The chat widget uses HTMX and Hyperscript wh
|
||||
| Tool Context | `tool.Context` | Passed to the tool function by ADK; provides access to session and agent state |
|
||||
| JSON Schema | `jsonschema:"..."` struct tags | Describes tool parameters to the LLM for function calling |
|
||||
|
||||
## 18. Relation to Other Documentation
|
||||
## 19. Relation to Other Documentation
|
||||
|
||||
- **[01-ARCHITECTURE.md](01-ARCHITECTURE.md)** — Overall system design
|
||||
- **[03-API.md](03-API.md)** — HTTP API reference (includes `POST /api/chat`)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Chat Module Portability Guide
|
||||
|
||||
**Project:** CV Interactive Website
|
||||
**Last Updated:** 2026-04-09
|
||||
**Tag Reference:** `v1.2.0`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The AI chat agent is a self-contained module that can be ported to other Go web applications. It provides an embeddable chat widget powered by Google Gemini (production) with Ollama fallback (local development).
|
||||
|
||||
---
|
||||
|
||||
## Module Files
|
||||
|
||||
### Backend (Go)
|
||||
|
||||
| File | Purpose | Dependencies |
|
||||
|------|---------|-------------|
|
||||
| `internal/chat/agent.go` | ADK agent definition, tools, prompt | `google.golang.org/adk`, your data model |
|
||||
| `internal/chat/handler.go` | HTTP handlers, provider init, warmup, icon injection | `internal/chat/agent.go`, `internal/cache` |
|
||||
| `internal/chat/ollama.go` | Ollama/OpenAI-compatible LLM adapter | None (standalone) |
|
||||
|
||||
### Frontend (HTML + CSS + JS)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `templates/partials/widgets/chat-widget.html` | Complete widget: panel, header, messages, input, JS |
|
||||
| `templates/partials/modals/chat-help-modal.html` | Help accordion with suggested questions |
|
||||
| `static/css/04-interactive/_chat.css` | All styling: layout modes, responsive, dark mode, animations |
|
||||
|
||||
### Configuration
|
||||
|
||||
| File | Keys |
|
||||
|------|------|
|
||||
| `.env` | `GOOGLE_API_KEY`, `OLLAMA_MODEL`, `OLLAMA_HOST` |
|
||||
| `config/systemd/cv.service` | `EnvironmentFile` for production secrets |
|
||||
|
||||
---
|
||||
|
||||
## Go Dependencies
|
||||
|
||||
Add to your `go.mod`:
|
||||
|
||||
```
|
||||
google.golang.org/adk v1.0.0
|
||||
google.golang.org/genai v1.x.x
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Steps
|
||||
|
||||
### 1. Copy the backend files
|
||||
|
||||
```bash
|
||||
mkdir -p internal/chat
|
||||
cp internal/chat/agent.go internal/chat/handler.go internal/chat/ollama.go YOUR_PROJECT/internal/chat/
|
||||
```
|
||||
|
||||
### 2. Adapt `agent.go`
|
||||
|
||||
This is the only file that needs significant changes per project:
|
||||
|
||||
- **`NewAgent()`**: Change the `Instruction` prompt to describe YOUR data, not a CV
|
||||
- **`QueryCVArgs` / `QueryCVResult`**: Rename and adapt the tool to query YOUR data source
|
||||
- **`newQueryCVTool()`**: Replace with a tool that queries your application's data
|
||||
- **Filter functions**: Adapt `filterExperience()`, `filterProjects()`, etc. to your data model
|
||||
|
||||
### 3. Adapt `handler.go`
|
||||
|
||||
Minimal changes needed:
|
||||
|
||||
- **`buildIconMap()`**: Remove or adapt for your icon system (sprite sheets, image files)
|
||||
- **`formatResponse()`**: The markdown→HTML converter works generically. Icon injection is optional.
|
||||
- **`NewHandler(dataCache)`**: Change the parameter type to your data source
|
||||
|
||||
### 4. Keep `ollama.go` as-is
|
||||
|
||||
This is a generic Ollama/OpenAI-compatible adapter. No changes needed — it implements `model.LLM` interface for any ADK agent.
|
||||
|
||||
### 5. Copy frontend files
|
||||
|
||||
```bash
|
||||
cp templates/partials/widgets/chat-widget.html YOUR_PROJECT/templates/partials/widgets/
|
||||
cp templates/partials/modals/chat-help-modal.html YOUR_PROJECT/templates/partials/modals/
|
||||
cp static/css/04-interactive/_chat.css YOUR_PROJECT/static/css/
|
||||
```
|
||||
|
||||
### 6. Adapt the widget
|
||||
|
||||
- Change suggested questions (chips) to match your domain
|
||||
- Change help modal questions
|
||||
- Update welcome message
|
||||
- Adjust CSS variables to match your theme
|
||||
|
||||
### 7. Register routes
|
||||
|
||||
```go
|
||||
// In your routes setup:
|
||||
chatHandler := chat.NewHandler(yourDataSource)
|
||||
mux.Handle("/api/chat", rateLimiter.Middleware(http.HandlerFunc(chatHandler.HandleChat)))
|
||||
mux.HandleFunc("/api/chat/warmup", chatHandler.HandleWarmup)
|
||||
mux.HandleFunc("/api/chat/status", chatHandler.HandleStatus)
|
||||
```
|
||||
|
||||
### 8. Include in templates
|
||||
|
||||
```html
|
||||
{{template "chat-widget" .}}
|
||||
{{template "chat-help-modal" .}}
|
||||
```
|
||||
|
||||
Conditionally load CSS:
|
||||
```html
|
||||
{{if .ChatEnabled}}
|
||||
<link rel="stylesheet" href="/static/css/_chat.css">
|
||||
{{end}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
User clicks chat → HTMX POST /api/chat
|
||||
↓
|
||||
handler.HandleChat()
|
||||
↓
|
||||
runAgent(primary) ← Gemini or Ollama
|
||||
↓
|
||||
ADK Runner loop:
|
||||
1. LLM sees prompt + user message
|
||||
2. LLM calls query tool (function calling)
|
||||
3. Tool queries your data source
|
||||
4. LLM generates response from tool results
|
||||
↓
|
||||
formatResponse() → HTML with icons + links
|
||||
↓
|
||||
HTMX appends to #chat-messages
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features Included
|
||||
|
||||
| Feature | What it does |
|
||||
|---------|-------------|
|
||||
| **Dual provider** | Gemini (prod) + Ollama (dev) with auto-fallback |
|
||||
| **Auto-warmup** | Local model pre-loaded on startup in dev mode |
|
||||
| **Status polling** | `/api/chat/status` → "Initializing AI model..." indicator |
|
||||
| **4 layout modes** | Compact, Side Panel, Floating (draggable), Full Screen |
|
||||
| **Mobile responsive** | Split mode on phones, desktop modes hidden |
|
||||
| **User + bot avatars** | Teams-style bubble layout |
|
||||
| **Inline icons** | Sprite + image fallback next to navigation links |
|
||||
| **External links** | `[text](https://...)` rendered as clickable links |
|
||||
| **Wave greeting** | 👋 animation to attract visitors |
|
||||
| **Help modal** | Accordion with suggested questions |
|
||||
| **Chip questions** | One-click with instant bubble rendering |
|
||||
| **Rate limiting** | 30 req/hour per IP (configurable) |
|
||||
| **Dark mode** | Lighter panel to contrast with dark backgrounds |
|
||||
| **HTMX timeout** | 120s for slow local models |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
| Test file | Assertions | Covers |
|
||||
|-----------|-----------|--------|
|
||||
| `tests/mjs/84-chat-layout-modes.test.mjs` | 38 | Desktop layout modes, drag, switching, avatars |
|
||||
| `tests/mjs/85-chat-mobile.test.mjs` | 79 | Mobile on 3 iPhone viewports + desktop sanity |
|
||||
| `tests/mjs/83-chat-mascot.test.mjs` | 39 | Chat UX, chips, responses, navigation |
|
||||
|
||||
---
|
||||
|
||||
## What to Customize Per Project
|
||||
|
||||
| Component | What to change |
|
||||
|-----------|---------------|
|
||||
| Agent prompt | `agent.go` — describe YOUR domain, not a CV |
|
||||
| Query tool | `agent.go` — query YOUR data source |
|
||||
| Suggested questions | `chat-widget.html` — chips and help modal |
|
||||
| Welcome message | `chat-widget.html` — greeting text |
|
||||
| Icons/sprites | `handler.go` — `buildIconMap()` and CSS |
|
||||
| CSS theme | `_chat.css` — colors, `--accent-green`, fonts |
|
||||
| Rate limits | `routes.go` — requests per hour |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Google ADK Go Documentation](https://google.github.io/adk-docs/)
|
||||
- [Ollama API](https://github.com/ollama/ollama/blob/main/docs/api.md)
|
||||
- [HTMX Documentation](https://htmx.org/docs/)
|
||||
- [doc/28-AI-CHAT-AGENT.md](./28-AI-CHAT-AGENT.md) — Full technical documentation
|
||||
- [doc/29-AI-CHAT-SHOWCASE.md](./29-AI-CHAT-SHOWCASE.md) — Public showcase writeup
|
||||
@@ -8,6 +8,7 @@ This document records key architectural decisions made for this project.
|
||||
- [ADR-002: Static Dates Instead of Git Integration](#adr-002-static-dates-instead-of-git-integration)
|
||||
- [ADR-003: CI/CD with GitHub Actions](#adr-003-cicd-with-github-actions)
|
||||
- [ADR-004: Application-Level Data Caching](#adr-004-application-level-data-caching)
|
||||
- [ADR-005: Self-Hosted Frontend Dependencies](#adr-005-self-hosted-frontend-dependencies)
|
||||
|
||||
---
|
||||
|
||||
@@ -215,6 +216,50 @@ See [23-DATA-CACHE.md](23-DATA-CACHE.md) for complete API reference and usage pa
|
||||
|
||||
---
|
||||
|
||||
## ADR-005: Self-Hosted Frontend Dependencies
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-14
|
||||
|
||||
### Context
|
||||
|
||||
HTMX (1.9.10) and Hyperscript (0.9.14) were loaded from the unpkg.com CDN. This introduced a single point of failure — if unpkg goes down, the site loses all interactivity. Additionally, each CDN request adds DNS resolution, TLS negotiation, and potential redirect overhead. HTMX 1.9.10 was also two major versions behind the current 2.0.10 release.
|
||||
|
||||
### Decision
|
||||
|
||||
**Self-host HTMX and Hyperscript as vendored files. Remove unpkg.com from the Content Security Policy.**
|
||||
|
||||
- `static/htmx/htmx.min.js` — HTMX 2.0.10 (51KB)
|
||||
- `static/hyperscript/_hyperscript.min.js` — Hyperscript 0.9.91 (172KB)
|
||||
- Iconify remains on jsdelivr CDN (icon rendering, acceptable external dependency)
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **No external SPOF**: The site functions fully even if all CDNs are down
|
||||
2. **Faster loading**: Same-origin assets skip DNS lookup and TLS handshake
|
||||
3. **Smaller CSP surface**: `unpkg.com` removed from `script-src` whitelist
|
||||
4. **Version upgrade**: HTMX 1.9.10 → 2.0.10 with zero breaking changes (all `hx-*` attributes are compatible; `hx-head` moved from extension to built-in)
|
||||
5. **Cache alignment**: Libraries cached alongside the site's own assets
|
||||
|
||||
### Consequences
|
||||
|
||||
- **Positive:**
|
||||
- Zero dependency on unpkg.com availability
|
||||
- Reduced CSP attack surface
|
||||
- HTMX 2.0 features available (built-in head support)
|
||||
- Faster page loads (no cross-origin requests for core libs)
|
||||
|
||||
- **Considerations:**
|
||||
- Version updates require manually downloading new files
|
||||
- Must track current versions in documentation
|
||||
- File sizes add ~223KB to the repository (acceptable trade-off)
|
||||
|
||||
### Documentation
|
||||
|
||||
See [02-MODERN-WEB-TECHNIQUES.md](02-MODERN-WEB-TECHNIQUES.md) Phase 11 for implementation details.
|
||||
|
||||
---
|
||||
|
||||
## How to Add New Decisions
|
||||
|
||||
When making significant architectural decisions, add a new section following this template:
|
||||
|
||||
@@ -28,17 +28,34 @@ func NewAgent(llm model.LLM, dataCache *cache.DataCache) (agent.Agent, error) {
|
||||
Name: "cv_assistant",
|
||||
Model: llm,
|
||||
Description: "Answers questions about Juan Andrés Moreno Rubio's CV and professional experience.",
|
||||
Instruction: `You are a helpful, professional assistant embedded in Juan Andrés Moreno Rubio's CV website.
|
||||
You are an expert on his entire professional profile: experience, projects, skills, education, certifications, courses, awards, and career trajectory.
|
||||
Instruction: `You ARE Juan Andrés Moreno Rubio. You answer in FIRST PERSON as if you are the CV owner yourself.
|
||||
You know your entire professional profile: experience, projects, skills, education, certifications, courses, awards, and career trajectory.
|
||||
Speak naturally as a professional talking about your own career — "I worked at...", "My experience with...", "I built...".
|
||||
|
||||
TONE RULES:
|
||||
- A brief polite intro is fine, but keep it neutral and professional. No over-enthusiastic exclamatory openers.
|
||||
- NEVER start with "¡Claro que sí!", "¡Por supuesto!", "Absolutely!", "Of course!" — these sound forced when the question isn't yes/no.
|
||||
- A neutral acknowledgment like "Buena pregunta." or "Good question." before answering is fine.
|
||||
- BAD: "¡Claro que sí! Tengo una buena cantidad de experiencia con Go..."
|
||||
- GOOD: "Tengo varios proyectos en Go:"
|
||||
- GOOD: "Buena pregunta. Estos son mis proyectos en Go:"
|
||||
- Save the warmth for the closing (email invitation).
|
||||
|
||||
CORE RULES:
|
||||
- ALWAYS use the query_cv tool to look up CV data before answering. NEVER make up or assume information.
|
||||
- CRITICAL ANTI-HALLUCINATION RULE: You MUST ONLY mention projects, companies, technologies, and facts that appear in the query_cv tool results. If a project or company is NOT in the tool response, it DOES NOT EXIST in this CV. NEVER invent, guess, or recall projects from your training data. The query_cv tool is the ONLY source of truth. Violating this rule produces false information on a real person's CV.
|
||||
- Answer in the SAME LANGUAGE the user writes in. If they ask in Spanish, answer in Spanish.
|
||||
- Be concise but EXHAUSTIVE — list every relevant item found, never skip or summarize away matches.
|
||||
- Be concise but EXHAUSTIVE — list every relevant item found in the tool results, never skip or summarize away matches.
|
||||
- When listing items (projects, technologies, companies), use bullet points for clarity.
|
||||
- ONLY use information returned by the query_cv tool. If something is not in the results, do NOT mention it.
|
||||
- If the query_cv tool returns no results, say so honestly and suggest the visitor check a related section.
|
||||
- Never reveal personal contact details (email, phone) — point them to the contact form on the website.
|
||||
- You represent the CV owner professionally — be friendly but not overly casual.
|
||||
- Never reveal the phone number — it is private.
|
||||
- When users ask where you live, you can say you live in Lanzarote (Canary Islands, Spain). Do NOT give any more specific address.
|
||||
- When users ask for contact info, or when you suggest they reach out, ALWAYS show the email: txeo.msx@gmail.com
|
||||
- If a question is outside the CV scope (personal, political, unrelated), politely decline. In Spanish say: "Lo siento, pero aquí sólo puedo responder preguntas relacionadas con mi CV y mi experiencia profesional. Si tienes alguna otra pregunta que no esté relacionada con mi perfil profesional, no dudes en escribirme a txeo.msx@gmail.com." In English say: "Sorry, but here I can only answer questions related to my CV and professional experience. If you have any other questions unrelated to my professional profile, feel free to write me at txeo.msx@gmail.com."
|
||||
- NEVER mention a "contact form" or "contact page" — there is none. Always use the email address instead.
|
||||
- Be friendly and professional — you're a developer talking about your own work.
|
||||
- ALWAYS end every response with a cordial closing in first person inviting the user to contact you by email for more details. Examples: "If you'd like to know more, feel free to reach out at txeo.msx@gmail.com" / "Si quieres saber más, no dudes en escribirme a txeo.msx@gmail.com". Keep it natural and varied — don't use the exact same phrase every time.
|
||||
- When mentioning a company, project, or CV section, ALWAYS include a markdown link to navigate there.
|
||||
Format: [Company Name](#exp-companyID) or [Project Name](#proj-projectID) or [Section](#sectionID)
|
||||
Examples:
|
||||
@@ -48,6 +65,7 @@ CORE RULES:
|
||||
- [Projects section](#projects)
|
||||
- [Skills section](#skills)
|
||||
The companyID and projectID are provided in the query_cv tool results. Always use them.
|
||||
CRITICAL: Never skip the #proj- or #exp- prefix. Writing [Immich Photo Manager](#immich-photo-manager) is WRONG. It must be [Immich Photo Manager](#proj-immich-photo-manager). The prefix enables the icon to appear.
|
||||
|
||||
QUERY STRATEGY BY QUESTION TYPE:
|
||||
|
||||
@@ -57,6 +75,7 @@ QUERY STRATEGY BY QUESTION TYPE:
|
||||
- NEVER search only projects or only experience — always use cross-section search.
|
||||
- Report ALL matches from EVERY section: if the search returns matches in experience AND projects AND skills AND courses, mention ALL of them.
|
||||
- If a technology appears in skills but NOT in experience or projects, mention the skill category and proficiency level.
|
||||
- IMPORTANT: Proficiency is on a scale of 1 to 10 (not 1 to 5). Always say "X out of 10" or "X/10". Each unit represents half a star on a 5-star visual scale.
|
||||
- If a technology appears in experience, name the company, role, and what it was used for.
|
||||
|
||||
2. COMPANY / EMPLOYER QUESTIONS (e.g. "What companies?", "Tell me about SAP"):
|
||||
@@ -73,6 +92,8 @@ QUERY STRATEGY BY QUESTION TYPE:
|
||||
- For a specific project → use section="search" with the project name.
|
||||
- IMPORTANT: "Projects" in this CV includes both personal/open-source projects AND professional experience at companies. When asked about projects involving a technology, also check experience roles where that technology was used.
|
||||
- For technology-specific project questions, use section="search" to find matches in BOTH projects and experience.
|
||||
- CRITICAL: When listing projects, ALWAYS link each project name using its projectID from the data: [Project Name](#proj-projectID). The projectID field is in the JSON response. This enables icons to appear next to the project name in the chat. Example: [Immich Photo Manager](#proj-immich-photo-manager), [Gotify Commander](#proj-gotify-commander), [Cmux Resurrect](#proj-cmux-resurrect).
|
||||
- For open-source questions, list ALL open-source projects from the projects section — these are a key highlight of the CV.
|
||||
|
||||
5. EDUCATION & CERTIFICATIONS:
|
||||
- For certifications → section="certifications"
|
||||
@@ -93,23 +114,26 @@ QUERY STRATEGY BY QUESTION TYPE:
|
||||
- Use section="languages" to list spoken/written language proficiencies.
|
||||
|
||||
BONUS CONTEXT:
|
||||
- This CV website itself is built with Go, HTMX, Hyperscript, and vanilla CSS — it's a real-world showcase of Juan's Go and frontend skills. Mention this when discussing Go or HTMX expertise.
|
||||
- The chat assistant you ARE is powered by Google ADK Go 1.0 and Gemini AI — another demonstration of Go expertise.
|
||||
- When the user asks general questions like "tell me about Juan" or "summarize the CV", use section="summary" first, then section="all" to give a comprehensive overview.
|
||||
- This CV website itself is built with Go, HTMX, Hyperscript, and vanilla CSS — it's a real-world showcase of your Go and frontend skills. Mention this when discussing Go or HTMX expertise.
|
||||
- The chat you are powering uses Google ADK Go 1.0 — another demonstration of your Go expertise. In production it uses Gemini, in development it uses Gemma 4 via Ollama.
|
||||
- When the user asks general questions like "tell me about yourself" or "summarize the CV", use section="summary" first, then section="all" to give a comprehensive overview.
|
||||
|
||||
EXAMPLES:
|
||||
- "How many years of experience does Juan have?" → section="summary"
|
||||
- "What Java experience does he have?" → section="search", query="java"
|
||||
- "Has he worked with React?" → section="search", query="react"
|
||||
- "Tell me about his time at Olympic Broadcasting" → section="search", query="olympic"
|
||||
- "What did he do at SAP?" → section="search", query="sap"
|
||||
- "What certifications does he have?" → section="certifications"
|
||||
- "List all his projects" → section="projects"
|
||||
- "What companies has he worked at?" → section="experience" (no query)
|
||||
- "Does he know Docker?" → section="search", query="docker"
|
||||
- "What programming languages does he know?" → section="search", query="language" AND section="skills"
|
||||
- "Where did he study?" → section="education"
|
||||
- "What courses has he completed?" → section="courses"`,
|
||||
- "How many years of experience do you have?" → section="summary"
|
||||
- "What Java experience do you have?" → section="search", query="java"
|
||||
- "Have you worked with React?" → section="search", query="react"
|
||||
- "Tell me about your time at Olympic Broadcasting" → section="search", query="olympic"
|
||||
- "What did you do at SAP?" → section="search", query="sap"
|
||||
- "What certifications do you have?" → section="certifications"
|
||||
- "List all your projects" → section="projects"
|
||||
- "What companies have you worked at?" → section="experience" (no query)
|
||||
- "Do you know Docker?" → section="search", query="docker"
|
||||
- "What programming languages do you know?" → section="search", query="language" AND section="skills"
|
||||
- "Where did you study?" → section="education"
|
||||
- "What courses have you completed?" → section="courses"
|
||||
- "What open-source projects do you maintain?" → section="projects" (no query, then filter by openSource field)
|
||||
|
||||
FINAL REMINDER: NEVER mention any project, company, skill, or fact that is not in the query_cv tool results. If you are unsure, call the tool again. Making up information is the worst thing you can do.`,
|
||||
Tools: []tool.Tool{queryTool},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -242,6 +242,14 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce response language based on the CV language the user is viewing
|
||||
switch lang := r.FormValue("lang"); lang {
|
||||
case "en":
|
||||
message = "[RESPOND IN ENGLISH] " + message
|
||||
case "es":
|
||||
message = "[RESPONDE EN ESPAÑOL] " + message
|
||||
}
|
||||
|
||||
// Try primary, fall back if it fails
|
||||
response, sessionID, err := h.runAgent(h.primary, message)
|
||||
if err != nil && h.fallback != nil {
|
||||
@@ -264,7 +272,7 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) {
|
||||
if response == "" {
|
||||
response = "I couldn't find an answer to that. Try asking about experience, projects, skills, or education."
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `<div class="chat-row chat-row-bot"><div class="chat-avatar"><iconify-icon icon="mdi:robot-happy-outline"></iconify-icon></div><div class="chat-msg">%s</div></div>`, h.formatResponse(response))
|
||||
_, _ = fmt.Fprintf(w, `<div class="chat-row chat-row-bot"><div class="chat-avatar chat-avatar-juan"><img src="/static/images/profile/dni-thumb.jpeg" alt="Juan"></div><div class="chat-msg">%s</div></div>`, h.formatResponse(response))
|
||||
|
||||
// Session ID via OOB swap
|
||||
_, _ = fmt.Fprintf(w, `<input type="hidden" id="chat-session-id" name="session_id" value="%s" form="chat-form" hx-swap-oob="true"/>`, sessionID)
|
||||
@@ -319,6 +327,10 @@ func (h *Handler) formatResponse(text string) string {
|
||||
text = strings.Replace(text, "**", "</strong>", 1)
|
||||
}
|
||||
|
||||
// Email addresses → clickable mailto links
|
||||
text = strings.ReplaceAll(text, "txeo.msx@gmail.com",
|
||||
`<a href="mailto:txeo.msx@gmail.com" class="chat-nav-link">txeo.msx@gmail.com</a>`)
|
||||
|
||||
// Links: [text](#anchor) → icon + nav link, [text](https://...) → external link
|
||||
text = mdLinkRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||
parts := mdLinkRe.FindStringSubmatch(match)
|
||||
|
||||
@@ -15,6 +15,8 @@ type CmdKAction struct {
|
||||
Title string `json:"title"`
|
||||
Section string `json:"section"`
|
||||
Keywords string `json:"keywords"`
|
||||
Category string `json:"category,omitempty"` // cli, app, web, plugin, sdk, contrib
|
||||
Icon string `json:"icon,omitempty"` // Project logo filename
|
||||
}
|
||||
|
||||
// CmdKResponse represents the response for the CMD+K API endpoint
|
||||
@@ -48,43 +50,76 @@ func (h *CVHandler) CmdKData(w http.ResponseWriter, r *http.Request) {
|
||||
// Map experiences
|
||||
for _, exp := range cv.Experience {
|
||||
if exp.CompanyID == "" {
|
||||
continue // Skip entries without ID
|
||||
continue
|
||||
}
|
||||
keywords := exp.Company + " " + exp.Position
|
||||
for _, tech := range exp.Technologies {
|
||||
keywords += " " + tech
|
||||
}
|
||||
keywords += " work job career"
|
||||
icon := ""
|
||||
if exp.CompanyLogo != "" {
|
||||
icon = exp.CompanyLogo
|
||||
}
|
||||
response.Experiences = append(response.Experiences, CmdKAction{
|
||||
ID: "exp-" + exp.CompanyID,
|
||||
Title: exp.Company,
|
||||
Title: exp.Company + " — " + exp.Position,
|
||||
Section: "Experience",
|
||||
Keywords: exp.Company + " " + exp.Position,
|
||||
Keywords: keywords,
|
||||
Icon: icon,
|
||||
})
|
||||
}
|
||||
|
||||
// Map projects
|
||||
for _, proj := range cv.Projects {
|
||||
if proj.ProjectID == "" {
|
||||
continue // Skip entries without ID
|
||||
continue
|
||||
}
|
||||
title := proj.ProjectName
|
||||
if title == "" {
|
||||
title = proj.Title
|
||||
}
|
||||
keywords := title + " " + proj.ShortDescription
|
||||
for _, tech := range proj.Technologies {
|
||||
keywords += " " + tech
|
||||
}
|
||||
if proj.OpenSource {
|
||||
keywords += " open source open-source oss github"
|
||||
}
|
||||
if proj.Category != "" {
|
||||
keywords += " " + proj.Category
|
||||
}
|
||||
icon := ""
|
||||
if proj.ProjectLogo != "" {
|
||||
icon = proj.ProjectLogo
|
||||
}
|
||||
response.Projects = append(response.Projects, CmdKAction{
|
||||
ID: "proj-" + proj.ProjectID,
|
||||
Title: title,
|
||||
Section: "Projects",
|
||||
Keywords: title + " " + proj.ShortDescription,
|
||||
Keywords: keywords,
|
||||
Category: proj.Category,
|
||||
Icon: icon,
|
||||
})
|
||||
}
|
||||
|
||||
// Map courses
|
||||
for _, course := range cv.Courses {
|
||||
if course.CourseID == "" {
|
||||
continue // Skip entries without ID
|
||||
continue
|
||||
}
|
||||
keywords := course.Title + " " + course.Institution
|
||||
keywords += " course training certification"
|
||||
icon := ""
|
||||
if course.CourseLogo != "" {
|
||||
icon = course.CourseLogo
|
||||
}
|
||||
response.Courses = append(response.Courses, CmdKAction{
|
||||
ID: "course-" + course.CourseID,
|
||||
Title: course.Title,
|
||||
Section: "Courses",
|
||||
Keywords: course.Title + " " + course.Institution,
|
||||
Keywords: keywords,
|
||||
Icon: icon,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ func (h *CVHandler) renderContactError(w http.ResponseWriter, r *http.Request, e
|
||||
}
|
||||
|
||||
// Render the error template
|
||||
// Return 200 OK with error content - HTMX 1.9.x logs console.error for non-2xx responses
|
||||
// Return 200 OK with error content - HTMX logs console.error for non-2xx responses
|
||||
// Validation errors are expected form feedback, not system errors
|
||||
w.Header().Set(c.HeaderContentType, c.ContentTypeHTML)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
@@ -327,9 +330,12 @@ func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, er
|
||||
)
|
||||
}
|
||||
|
||||
// Process projects for dynamic dates
|
||||
// Process projects for dynamic dates and fetch GitHub stars
|
||||
for i := range cv.Projects {
|
||||
processProjectDates(&cv.Projects[i], lang)
|
||||
if cv.Projects[i].GitRepoUrl != "" {
|
||||
cv.Projects[i].Stars = getGitHubStars(cv.Projects[i].GitRepoUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Split skills between left and right sidebars
|
||||
@@ -352,6 +358,17 @@ func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, er
|
||||
}
|
||||
}
|
||||
|
||||
// Scan background photos
|
||||
var bgPhotos []string
|
||||
bgDir := filepath.Join(c.DirStatic, "images", "backgrounds")
|
||||
entries, _ := os.ReadDir(bgDir)
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !e.IsDir() && (strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".png") || strings.HasSuffix(name, ".webp")) {
|
||||
bgPhotos = append(bgPhotos, "/static/images/backgrounds/"+name)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare template data
|
||||
data := map[string]interface{}{
|
||||
"CV": &cv,
|
||||
@@ -366,11 +383,71 @@ func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, er
|
||||
"AlternateEN": "https://juan.andres.morenorub.io/?lang=en",
|
||||
"AlternateES": "https://juan.andres.morenorub.io/?lang=es",
|
||||
"ChatEnabled": h.chatEnabled,
|
||||
"BgPhotos": bgPhotos,
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// GITHUB STARS
|
||||
// ==============================================================================
|
||||
|
||||
var (
|
||||
starsCache = make(map[string]starsCacheEntry)
|
||||
starsCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
type starsCacheEntry struct {
|
||||
count int
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
// getGitHubStars fetches the star count for a GitHub repo URL, cached for 30 minutes.
|
||||
func getGitHubStars(repoURL string) int {
|
||||
// Extract "owner/repo" from URL
|
||||
repoURL = strings.TrimSuffix(repoURL, "/")
|
||||
parts := strings.SplitN(repoURL, "github.com/", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0
|
||||
}
|
||||
repo := parts[1]
|
||||
|
||||
// Check cache
|
||||
starsCacheMu.RLock()
|
||||
if entry, ok := starsCache[repo]; ok && time.Since(entry.fetched) < 30*time.Minute {
|
||||
starsCacheMu.RUnlock()
|
||||
return entry.count
|
||||
}
|
||||
starsCacheMu.RUnlock()
|
||||
|
||||
// Fetch from GitHub API
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(fmt.Sprintf("https://api.github.com/repos/%s", repo))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Stars int `json:"stargazers_count"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
starsCacheMu.Lock()
|
||||
starsCache[repo] = starsCacheEntry{count: result.Stars, fetched: time.Now()}
|
||||
starsCacheMu.Unlock()
|
||||
|
||||
return result.Stars
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// COOKIE HELPERS
|
||||
// ==============================================================================
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHome tests the Home handler
|
||||
@@ -132,6 +134,8 @@ func TestDefaultCVShortcut(t *testing.T) {
|
||||
|
||||
handler := newTestCVHandler(t, "localhost:1999", nil)
|
||||
|
||||
currentYear := fmt.Sprintf("%d", time.Now().Year())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
@@ -139,12 +143,12 @@ func TestDefaultCVShortcut(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Valid shortcut URL (current year EN)",
|
||||
path: "/cv-jamr-2025-en.pdf",
|
||||
path: "/cv-jamr-" + currentYear + "-en.pdf",
|
||||
expectStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Valid shortcut URL (current year ES)",
|
||||
path: "/cv-jamr-2025-es.pdf",
|
||||
path: "/cv-jamr-" + currentYear + "-es.pdf",
|
||||
expectStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
@@ -154,7 +158,7 @@ func TestDefaultCVShortcut(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Invalid language",
|
||||
path: "/cv-jamr-2025-fr.pdf",
|
||||
path: "/cv-jamr-" + currentYear + "-fr.pdf",
|
||||
expectStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -152,6 +152,8 @@ func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) {
|
||||
// Set response headers
|
||||
w.Header().Set(c.HeaderContentType, c.ContentTypePlainText)
|
||||
w.Header().Set(c.HeaderXContentTypeOpts, c.NoSniff)
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
w.Header().Set("Link", `<https://juan.andres.morenorub.io/?lang=`+langCode+`>; rel="canonical"`)
|
||||
|
||||
// Check if download is requested
|
||||
if r.URL.Query().Get("download") == "true" {
|
||||
|
||||
@@ -32,7 +32,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
||||
|
||||
// Content Security Policy (comprehensive)
|
||||
csp := "default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://esm.sh https://matomo.txeo.club; " +
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://esm.sh https://matomo.txeo.club; " +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||
"font-src 'self' https://fonts.gstatic.com; " +
|
||||
"img-src 'self' data: https:; " +
|
||||
|
||||
@@ -93,6 +93,7 @@ type Language struct {
|
||||
|
||||
type Project struct {
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category,omitempty"` // Project type: cli, app, web, webapp, plugin, sdk, contrib
|
||||
ProjectName string `json:"projectName,omitempty"` // Optional: linkable part of title
|
||||
ProjectDesc string `json:"projectDesc,omitempty"` // Optional: non-linkable description part
|
||||
ProjectID string `json:"projectID,omitempty"` // Unique ID for scrolling/navigation
|
||||
@@ -100,6 +101,7 @@ type Project struct {
|
||||
ProjectLogo string `json:"projectLogo,omitempty"` // Optional logo filename
|
||||
LogoIndex *int `json:"logoIndex,omitempty"` // Sprite sheet index (nil means no sprite)
|
||||
GitRepoUrl string `json:"gitRepoUrl,omitempty"` // Optional git repository URL for dynamic dates
|
||||
OpenSource bool `json:"openSource,omitempty"` // True if project is open source (shows stars badge)
|
||||
Location string `json:"location"`
|
||||
StartDate string `json:"startDate,omitempty"` // Optional static start date
|
||||
Current bool `json:"current"`
|
||||
@@ -111,6 +113,7 @@ type Project struct {
|
||||
// Computed fields (not stored in JSON)
|
||||
ComputedStartDate string `json:"-"` // Dynamically calculated from git repo or system
|
||||
DynamicDate string `json:"-"` // Current date for ongoing projects
|
||||
Stars int `json:"-"` // GitHub star count (fetched at runtime)
|
||||
}
|
||||
|
||||
type Award struct {
|
||||
|
||||
@@ -98,6 +98,7 @@ type Language struct {
|
||||
|
||||
type Project struct {
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category,omitempty"` // Project type: cli, app, web, webapp, plugin, sdk, contrib
|
||||
ProjectName string `json:"projectName,omitempty"` // Optional: linkable part of title
|
||||
ProjectDesc string `json:"projectDesc,omitempty"` // Optional: non-linkable description part
|
||||
ProjectID string `json:"projectID,omitempty"` // Unique ID for scrolling/navigation
|
||||
@@ -105,6 +106,7 @@ type Project struct {
|
||||
ProjectLogo string `json:"projectLogo,omitempty"` // Optional logo filename
|
||||
LogoIndex *int `json:"logoIndex,omitempty"` // Sprite sheet index (nil means no sprite)
|
||||
GitRepoUrl string `json:"gitRepoUrl,omitempty"` // Optional git repository URL for dynamic dates
|
||||
OpenSource bool `json:"openSource,omitempty"` // True if project is open source (shows stars badge)
|
||||
Location string `json:"location"`
|
||||
StartDate string `json:"startDate,omitempty"` // Optional static start date
|
||||
Current bool `json:"current"`
|
||||
@@ -116,6 +118,7 @@ type Project struct {
|
||||
// Computed fields (not stored in JSON)
|
||||
ComputedStartDate string `json:"-"` // Dynamically calculated from git repo or system
|
||||
DynamicDate string `json:"-"` // Current date for ongoing projects
|
||||
Stars int `json:"-"` // GitHub star count (fetched at runtime)
|
||||
}
|
||||
|
||||
type Award struct {
|
||||
|
||||
@@ -246,11 +246,11 @@ func (s *Skills) Validate() error {
|
||||
})
|
||||
}
|
||||
|
||||
// Proficiency should be between 1-5 (typical skill rating)
|
||||
if cat.Proficiency < 1 || cat.Proficiency > 5 {
|
||||
// Proficiency should be between 1-10 (half-star increments over 5 stars)
|
||||
if cat.Proficiency < 1 || cat.Proficiency > 10 {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fmt.Sprintf("technical[%d].proficiency", i),
|
||||
Message: "proficiency must be between 1 and 5",
|
||||
Message: "proficiency must be between 1 and 10",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -308,7 +308,7 @@ func TestSkills_Validate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "proficiency must be between 1 and 5",
|
||||
errMsg: "proficiency must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "Invalid - Proficiency too high",
|
||||
@@ -316,13 +316,13 @@ func TestSkills_Validate(t *testing.T) {
|
||||
Technical: []cv.SkillCategory{
|
||||
{
|
||||
Category: "Backend",
|
||||
Proficiency: 6,
|
||||
Proficiency: 11,
|
||||
Items: []string{"Go"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "proficiency must be between 1 and 5",
|
||||
errMsg: "proficiency must be between 1 and 10",
|
||||
},
|
||||
{
|
||||
name: "Invalid - No skill items",
|
||||
@@ -609,7 +609,7 @@ func TestCV_Validate(t *testing.T) {
|
||||
Technical: []cv.SkillCategory{
|
||||
{
|
||||
Category: "Backend",
|
||||
Proficiency: 10, // Invalid
|
||||
Proficiency: 11, // Invalid
|
||||
Items: []string{"Go"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"html/template"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/config"
|
||||
@@ -63,6 +64,19 @@ func (m *Manager) loadTemplatesLocked() error {
|
||||
"safeHTML": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
// replaceDrolosoft links "drolosoft" in the summary text
|
||||
"replaceDrolosoft": func(s string) string {
|
||||
return strings.Replace(s, "drolosoft", `<a href="https://drolosoft.com" target="_blank" rel="noopener noreferrer">drolosoft</a>`, 1)
|
||||
},
|
||||
// githubRepo extracts "owner/repo" from a GitHub URL
|
||||
"githubRepo": func(url string) string {
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
parts := strings.Split(url, "github.com/")
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
},
|
||||
// dict creates a map from key-value pairs for passing to sub-templates
|
||||
"dict": func(values ...interface{}) (map[string]interface{}, error) {
|
||||
if len(values)%2 != 0 {
|
||||
|
||||
@@ -12,23 +12,25 @@
|
||||
/* Body base */
|
||||
body {
|
||||
background-color: var(--page-bg, #d6d6d6);
|
||||
|
||||
/* OLD PATTERN - Keep for reference (can be restored anytime) */
|
||||
/* background-image:
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
|
||||
background-size: 50px 50px, 50px 50px, 10px 10px, 10px 10px; */
|
||||
|
||||
/* NEW TEST PATTERNS - Theme-specific (woven fabric for light, diagonal grid for dark) */
|
||||
background-image: var(--page-bg-pattern, none);
|
||||
background-size: 40px 40px; /* For dark theme diagonal grid */
|
||||
background-size: 40px 40px;
|
||||
background-attachment: fixed;
|
||||
max-width: 100vw; /* Prevent horizontal overflow */
|
||||
overflow-x: clip; /* Clip prevents horizontal scroll WITHOUT breaking position: sticky */
|
||||
}
|
||||
|
||||
/* Background photo layer — activated via JS in dev mode */
|
||||
body.bg-photo {
|
||||
background-image:
|
||||
var(--page-bg-pattern, none),
|
||||
linear-gradient(var(--page-bg-tint, rgba(214,214,214,0.85)), var(--page-bg-tint, rgba(214,214,214,0.85))),
|
||||
var(--bg-photo-url, none);
|
||||
background-size: auto, auto, cover;
|
||||
background-position: 0 0, 0 0, center;
|
||||
background-repeat: repeat, repeat, no-repeat;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
/* Sidebar (for non-clean theme) */
|
||||
--sidebar-bg: #d1d4d2;
|
||||
|
||||
/* Background photo tint — controls how much of the photo shows through */
|
||||
--page-bg-tint: rgba(214, 214, 214, 0.85);
|
||||
|
||||
/* Legacy CV content variables - theme-aware overrides */
|
||||
--text-dark: #1a1a1a; /* Dark text for light background */
|
||||
--text-gray: #333333; /* Secondary text for light background */
|
||||
@@ -74,9 +77,11 @@
|
||||
/* Page Background - Medium gray for dark pattern visibility */
|
||||
--page-bg: #3a3a3a;
|
||||
|
||||
/* Diagonal Grid with Green Glow - Dark */
|
||||
--page-bg-pattern: repeating-linear-gradient(45deg, rgba(0, 255, 128, 0.15) 0, rgba(0, 255, 128, 0.15) 1px, transparent 1px, transparent 20px),
|
||||
repeating-linear-gradient(-45deg, rgba(0, 255, 128, 0.15) 0, rgba(0, 255, 128, 0.15) 1px, transparent 1px, transparent 20px);
|
||||
/* Concentric Squares Pattern - Dark (same shape as light, adapted colors) */
|
||||
--page-bg-pattern: repeating-linear-gradient(0deg, transparent, transparent 5px, rgba(200, 210, 220, 0.06) 5px, rgba(200, 210, 220, 0.06) 6px, transparent 6px, transparent 15px),
|
||||
repeating-linear-gradient(90deg, transparent, transparent 5px, rgba(200, 210, 220, 0.06) 5px, rgba(200, 210, 220, 0.06) 6px, transparent 6px, transparent 15px),
|
||||
repeating-linear-gradient(0deg, transparent, transparent 10px, rgba(180, 190, 200, 0.04) 10px, rgba(180, 190, 200, 0.04) 11px, transparent 11px, transparent 30px),
|
||||
repeating-linear-gradient(90deg, transparent, transparent 10px, rgba(180, 190, 200, 0.04) 10px, rgba(180, 190, 200, 0.04) 11px, transparent 11px, transparent 30px);
|
||||
|
||||
/* Paper/Card Backgrounds */
|
||||
--paper-bg: #1a1a1a;
|
||||
@@ -116,6 +121,9 @@
|
||||
/* Sidebar (for non-clean theme) - darker than light theme but lighter than main content */
|
||||
--sidebar-bg: #3a3d3e;
|
||||
|
||||
/* Background photo tint — darker overlay for dark theme */
|
||||
--page-bg-tint: rgba(58, 58, 58, 0.85);
|
||||
|
||||
/* Legacy CV content variables - theme-aware overrides */
|
||||
--text-dark: #e0e0e0; /* Light text for dark background */
|
||||
--text-gray: #d0d0d0; /* Secondary text for dark background */
|
||||
@@ -129,9 +137,11 @@
|
||||
/* Page Background - Medium gray for dark pattern visibility */
|
||||
--page-bg: #3a3a3a;
|
||||
|
||||
/* Diagonal Grid with Green Glow - Dark */
|
||||
--page-bg-pattern: repeating-linear-gradient(45deg, rgba(0, 255, 128, 0.15) 0, rgba(0, 255, 128, 0.15) 1px, transparent 1px, transparent 20px),
|
||||
repeating-linear-gradient(-45deg, rgba(0, 255, 128, 0.15) 0, rgba(0, 255, 128, 0.15) 1px, transparent 1px, transparent 20px);
|
||||
/* Concentric Squares Pattern - Dark (same shape as light, adapted colors) */
|
||||
--page-bg-pattern: repeating-linear-gradient(0deg, transparent, transparent 5px, rgba(200, 210, 220, 0.06) 5px, rgba(200, 210, 220, 0.06) 6px, transparent 6px, transparent 15px),
|
||||
repeating-linear-gradient(90deg, transparent, transparent 5px, rgba(200, 210, 220, 0.06) 5px, rgba(200, 210, 220, 0.06) 6px, transparent 6px, transparent 15px),
|
||||
repeating-linear-gradient(0deg, transparent, transparent 10px, rgba(180, 190, 200, 0.04) 10px, rgba(180, 190, 200, 0.04) 11px, transparent 11px, transparent 30px),
|
||||
repeating-linear-gradient(90deg, transparent, transparent 10px, rgba(180, 190, 200, 0.04) 10px, rgba(180, 190, 200, 0.04) 11px, transparent 11px, transparent 30px);
|
||||
|
||||
/* Paper/Card Backgrounds */
|
||||
--paper-bg: #1a1a1a;
|
||||
@@ -171,6 +181,9 @@
|
||||
/* Sidebar (for non-clean theme) - matches explicit dark theme */
|
||||
--sidebar-bg: #3a3d3e;
|
||||
|
||||
/* Background photo tint — darker overlay for dark theme */
|
||||
--page-bg-tint: rgba(58, 58, 58, 0.85);
|
||||
|
||||
/* Legacy CV content variables - theme-aware overrides */
|
||||
--text-dark: #e0e0e0; /* Light text for dark background */
|
||||
--text-gray: #d0d0d0; /* Secondary text for dark background */
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
margin-top: 20px;
|
||||
/* Full justification - spread text across entire width */
|
||||
text-align: justify;
|
||||
text-align-last: justify;
|
||||
-moz-text-align-last: justify;
|
||||
text-align-last: left;
|
||||
-moz-text-align-last: left;
|
||||
text-justify: inter-word;
|
||||
/* Word breaking and hyphenation */
|
||||
word-spacing: -1px;
|
||||
|
||||
@@ -194,6 +194,56 @@
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
/* Category text badges — pastel tones */
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.6em;
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 3px;
|
||||
margin-right: 0.4em;
|
||||
vertical-align: middle;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.category-cli { background: #9b8ec4; } /* Soft purple — terminal */
|
||||
.category-app { background: #7ba7d9; } /* Soft blue — native app */
|
||||
.category-web { background: #6bb8c7; } /* Soft cyan — website */
|
||||
.category-webapp { background: #6bb8c7; } /* Soft cyan — web app */
|
||||
.category-plugin { background: #d4a96a; } /* Soft amber — plugin */
|
||||
.category-sdk { background: #a78bcc; } /* Soft violet — SDK */
|
||||
.category-collab { background: #6bad7e; } /* Soft green — collaboration */
|
||||
.category-contrib { background: #a0a7b0; } /* Soft gray — contributions */
|
||||
|
||||
/* Merged GitHub + Stars badge */
|
||||
.github-stars-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
background: #d4a017;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.7em;
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.5em;
|
||||
vertical-align: middle;
|
||||
letter-spacing: 0.5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.github-stars-badge:hover {
|
||||
background: #b8860b;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.github-stars-badge iconify-icon {
|
||||
color: white;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.maintained-badge {
|
||||
display: inline-block;
|
||||
background: #3498db;
|
||||
|
||||
@@ -451,13 +451,15 @@
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted, #666666); /* Theme-aware color (light: #666666, dark: #b0b0b0) */
|
||||
color: rgba(255,255,255,0.95);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
/* GitHub repository link styling */
|
||||
.github-repo-link {
|
||||
color: var(--text-secondary, #333333) !important; /* Theme-aware link color */
|
||||
color: rgba(255,255,255,0.95) !important;
|
||||
transition: color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,8 +148,10 @@
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgba(255,255,255,0.7);
|
||||
color: rgba(255,255,255,0.95);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
/* GitHub repository link styling */
|
||||
|
||||
@@ -124,6 +124,39 @@
|
||||
color: #27ae60; /* Green icon when at bottom */
|
||||
}
|
||||
|
||||
/* Background Photo Toggle (Dev Only - above download button) */
|
||||
.bg-photo-btn {
|
||||
position: fixed;
|
||||
bottom: 30rem;
|
||||
left: 2rem;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: var(--black-bar, #2b2b2b);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 999;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.bg-photo-btn:hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||
background: #8e6b3e !important; /* Earthy brown — Lanzarote vibes */
|
||||
}
|
||||
|
||||
.bg-photo-btn.at-bottom {
|
||||
opacity: 1;
|
||||
background: #8e6b3e !important;
|
||||
}
|
||||
|
||||
/* Download Button (TOP POSITION - now first button after cmd-k removed) */
|
||||
.download-btn {
|
||||
position: fixed;
|
||||
|
||||
@@ -37,6 +37,17 @@
|
||||
background: var(--accent-green, #27ae60);
|
||||
}
|
||||
|
||||
/* Open state (robot): wiggle */
|
||||
.chat-toggle-btn:hover .chat-icon-open {
|
||||
animation: iconWiggle 0.5s ease;
|
||||
}
|
||||
|
||||
/* Closed state (X): rotate clockwise */
|
||||
.chat-toggle-btn:hover .chat-icon-close {
|
||||
transform: rotate(90deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* Icon swap: show mascot by default, close when active */
|
||||
.chat-toggle-btn .chat-icon-close {
|
||||
display: none;
|
||||
@@ -213,12 +224,12 @@
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Header actions — icon buttons with tooltips */
|
||||
/* Header actions — cog menu + help + close */
|
||||
.chat-header-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-mode-btn {
|
||||
@@ -233,7 +244,6 @@
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-mode-btn:hover {
|
||||
@@ -241,36 +251,60 @@
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.chat-mode-btn.active {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.2);
|
||||
/* Cog dropdown wrapper */
|
||||
.chat-cog-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Native tooltip via title attr — enhanced with CSS for consistent look */
|
||||
.chat-mode-btn[title]:hover::after {
|
||||
content: attr(title);
|
||||
.chat-cog-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--black-bar, #2b2b2b);
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.65rem;
|
||||
font-family: 'Source Sans Pro', sans-serif;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
z-index: 1001;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
right: 0;
|
||||
background: var(--paper-bg, #fff);
|
||||
border: 1px solid var(--border-light, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||
padding: 4px;
|
||||
z-index: 1002;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.chat-header-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
margin: 0 3px;
|
||||
.chat-cog-menu.chat-cog-open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-cog-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 5px;
|
||||
font-size: 0.72rem;
|
||||
font-family: 'Source Sans Pro', sans-serif;
|
||||
color: var(--text-secondary, #333);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-cog-item:hover {
|
||||
background: var(--accent-green, #27ae60);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-cog-item.active {
|
||||
background: rgba(39, 174, 96, 0.12);
|
||||
color: var(--accent-green, #27ae60);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-cog-item iconify-icon {
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -280,6 +314,7 @@
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -293,14 +328,12 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chat-row-bot {
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-row-user {
|
||||
align-self: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -322,17 +355,48 @@
|
||||
background: var(--text-light, #999999);
|
||||
}
|
||||
|
||||
.chat-avatar-juan {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #ffffff;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-avatar-juan img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.chat-disclaimer {
|
||||
display: inline;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 2px;
|
||||
opacity: 0.7;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
max-width: 85%;
|
||||
min-width: 0;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg a,
|
||||
.chat-msg strong,
|
||||
.chat-msg p {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-row-bot .chat-msg {
|
||||
background: var(--paper-secondary-bg, #f5f5f5);
|
||||
color: var(--text-secondary, #333333);
|
||||
@@ -430,6 +494,8 @@
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dotted var(--accent-green, #27ae60);
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-nav-link:hover {
|
||||
@@ -701,18 +767,133 @@
|
||||
color: var(--text-light, #999999);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Dark Mode — lighter panel to distinguish from CV background
|
||||
========================================================================== */
|
||||
|
||||
[data-color-theme="dark"] .chat-panel {
|
||||
background: #2c2c2c;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
[data-color-theme="dark"] .chat-row-bot .chat-msg {
|
||||
background: #383838;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
[data-color-theme="dark"] .chat-input {
|
||||
background: #333;
|
||||
border-color: #555;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
[data-color-theme="dark"] .chat-input-area {
|
||||
background: #2c2c2c;
|
||||
border-top-color: #444;
|
||||
}
|
||||
|
||||
[data-color-theme="dark"] .chat-suggestions {
|
||||
border-top-color: #444;
|
||||
}
|
||||
|
||||
[data-color-theme="dark"] .chat-chip {
|
||||
border-color: #555;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
[data-color-theme="dark"] .chat-nav-link {
|
||||
color: #4eca81;
|
||||
border-bottom-color: #4eca81;
|
||||
}
|
||||
|
||||
[data-color-theme="dark"] .chat-cog-menu {
|
||||
background: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
[data-color-theme="dark"] .chat-cog-item {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
[data-color-theme="dark"] .chat-cog-item.active {
|
||||
background: rgba(78, 202, 129, 0.15);
|
||||
color: #4eca81;
|
||||
}
|
||||
|
||||
/* Auto dark (system preference) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-color-theme="auto"] .chat-panel {
|
||||
background: #2c2c2c;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
[data-color-theme="auto"] .chat-row-bot .chat-msg {
|
||||
background: #383838;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
[data-color-theme="auto"] .chat-input {
|
||||
background: #333;
|
||||
border-color: #555;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
[data-color-theme="auto"] .chat-input-area {
|
||||
background: #2c2c2c;
|
||||
border-top-color: #444;
|
||||
}
|
||||
|
||||
[data-color-theme="auto"] .chat-suggestions {
|
||||
border-top-color: #444;
|
||||
}
|
||||
|
||||
[data-color-theme="auto"] .chat-chip {
|
||||
border-color: #555;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
[data-color-theme="auto"] .chat-nav-link {
|
||||
color: #4eca81;
|
||||
border-bottom-color: #4eca81;
|
||||
}
|
||||
|
||||
[data-color-theme="auto"] .chat-cog-menu {
|
||||
background: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
[data-color-theme="auto"] .chat-cog-item {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
[data-color-theme="auto"] .chat-cog-item.active {
|
||||
background: rgba(78, 202, 129, 0.15);
|
||||
color: #4eca81;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* Prevent horizontal scroll when chat is open */
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Default compact: bottom sheet with clear separation */
|
||||
.chat-panel {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
border-radius: 8px 8px 0 0;
|
||||
max-width: 100%;
|
||||
max-height: 50vh;
|
||||
border-radius: 12px 12px 0 0;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-toggle-btn {
|
||||
@@ -721,6 +902,85 @@
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
max-height: 200px;
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
/* Hide desktop-only cog items on mobile */
|
||||
.chat-cog-item[data-mode="chat-half"],
|
||||
.chat-cog-item[data-mode="chat-float"],
|
||||
.chat-cog-item[data-mode="chat-full"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile split: CV on top, chat on bottom half */
|
||||
.chat-panel.chat-split {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 50vh;
|
||||
max-height: 50vh;
|
||||
border-radius: 12px 12px 0 0;
|
||||
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.3);
|
||||
border-top: 2px solid var(--accent-green, #27ae60);
|
||||
}
|
||||
|
||||
.chat-panel.chat-split .chat-messages {
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Force compact on mobile for desktop-only modes (safety) */
|
||||
.chat-panel.chat-half,
|
||||
.chat-panel.chat-float,
|
||||
.chat-panel.chat-full {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 50vh;
|
||||
border-radius: 12px 12px 0 0;
|
||||
border: none;
|
||||
resize: none;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Wave position follows mobile button */
|
||||
.chat-wave {
|
||||
bottom: calc(5rem + 38px);
|
||||
right: calc(1rem + 38px);
|
||||
}
|
||||
|
||||
/* Tighter header on mobile */
|
||||
.chat-header {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.chat-mode-btn {
|
||||
font-size: 0.85rem;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* Cog menu on mobile */
|
||||
.chat-cog-menu {
|
||||
right: -8px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Prevent any child from expanding beyond panel */
|
||||
.chat-msg {
|
||||
max-width: calc(100% - 44px);
|
||||
}
|
||||
|
||||
.chat-suggestions {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,70 @@
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Floating Button Icon Hover Animations
|
||||
- Rotate: PDF, leaf, info, theme, close (symmetric icons)
|
||||
- Wiggle: email (envelope notification feel)
|
||||
- Pulse: keyboard (key press feel)
|
||||
- Bounce up: back-to-top arrow (reinforces direction)
|
||||
========================================================================== */
|
||||
|
||||
/* All floating icons: smooth transition */
|
||||
.download-btn iconify-icon,
|
||||
.print-friendly-btn iconify-icon,
|
||||
.fixed-btn.contact-btn iconify-icon,
|
||||
.shortcuts-btn iconify-icon,
|
||||
.info-button iconify-icon,
|
||||
.back-to-top iconify-icon,
|
||||
.color-theme-switcher iconify-icon,
|
||||
.chat-toggle-btn iconify-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* Rotate 45° animated: PDF */
|
||||
.download-btn iconify-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.download-btn:hover iconify-icon {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Rotate 90°: leaf, theme */
|
||||
.print-friendly-btn:hover iconify-icon,
|
||||
.color-theme-switcher:hover iconify-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Wiggle: email, info, keyboard, zoom, chat bot */
|
||||
.fixed-btn.contact-btn:hover iconify-icon,
|
||||
.info-button:hover iconify-icon,
|
||||
.shortcuts-btn:hover iconify-icon,
|
||||
.zoom-toggle-btn:hover iconify-icon {
|
||||
animation: iconWiggle 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes iconWiggle {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(-12deg); }
|
||||
40% { transform: rotate(10deg); }
|
||||
60% { transform: rotate(-8deg); }
|
||||
80% { transform: rotate(5deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
/* Bounce up: back-to-top arrow */
|
||||
.back-to-top:hover iconify-icon {
|
||||
animation: iconBounceUp 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes iconBounceUp {
|
||||
0% { transform: translateY(0); }
|
||||
40% { transform: translateY(-4px); }
|
||||
70% { transform: translateY(1px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Hide keyboard shortcuts button on real mobile devices (no keyboard) */
|
||||
.is-mobile-device .shortcuts-btn,
|
||||
.is-mobile-device .zoom-toggle-btn,
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Hide icons when toggle is OFF - with animation */
|
||||
/* Hide icons and badges when toggle is OFF - with animation */
|
||||
.cv-paper:not(.show-icons) .company-logo,
|
||||
.cv-paper:not(.show-icons) .award-logo,
|
||||
.cv-paper:not(.show-icons) .section-icon,
|
||||
@@ -213,6 +213,17 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hide all badges when icons toggle is OFF */
|
||||
.cv-paper:not(.show-icons) .live-badge,
|
||||
.cv-paper:not(.show-icons) .github-stars-badge,
|
||||
.cv-paper:not(.show-icons) .github-badge,
|
||||
.cv-paper:not(.show-icons) .category-badge,
|
||||
.cv-paper:not(.show-icons) .maintained-badge,
|
||||
.cv-paper:not(.show-icons) .current-badge,
|
||||
.cv-paper:not(.show-icons) .expired-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show icons when toggle is ON (default) - with animation */
|
||||
.show-icons .company-logo,
|
||||
.show-icons .award-logo,
|
||||
|
||||
@@ -105,6 +105,11 @@
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.github-stars-badge,
|
||||
.category-badge {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ===================================
|
||||
REMOVE ALL SHADOWS & BORDERS (Nuclear Option)
|
||||
=================================== */
|
||||
|
||||
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -111,50 +111,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert API experience entries to ninja-keys actions
|
||||
* @param {Array} experiences - Experience entries from API
|
||||
* @returns {Array} ninja-keys actions
|
||||
*/
|
||||
/** Category icon mapping */
|
||||
const categoryIcons = {
|
||||
cli: 'mdi:console',
|
||||
app: 'mdi:apple',
|
||||
web: 'mdi:web',
|
||||
webapp: 'mdi:web',
|
||||
plugin: 'mdi:puzzle',
|
||||
sdk: 'mdi:package-variant',
|
||||
contrib: 'mdi:source-pull'
|
||||
};
|
||||
|
||||
/** Build icon HTML — use project logo if available, fallback to iconify */
|
||||
function makeIcon(logoFile, folder, fallbackIcon) {
|
||||
if (logoFile) {
|
||||
return `<img src="/static/images/${folder}/${logoFile}" style="width:20px;height:20px;object-fit:contain;border-radius:3px" alt="">`;
|
||||
}
|
||||
return `<iconify-icon icon="${fallbackIcon}" width="20"></iconify-icon>`;
|
||||
}
|
||||
|
||||
function mapExperienceActions(experiences) {
|
||||
return experiences.map(exp => ({
|
||||
id: exp.id,
|
||||
title: exp.title,
|
||||
section: exp.section,
|
||||
keywords: `${exp.keywords} work job career`.toLowerCase(),
|
||||
icon: '<iconify-icon icon="mdi:office-building" width="20"></iconify-icon>',
|
||||
keywords: exp.keywords.toLowerCase(),
|
||||
icon: makeIcon(exp.icon, 'companies', 'mdi:office-building'),
|
||||
handler: () => scrollToSection(exp.id)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert API project entries to ninja-keys actions
|
||||
* @param {Array} projects - Project entries from API
|
||||
* @returns {Array} ninja-keys actions
|
||||
*/
|
||||
function mapProjectActions(projects) {
|
||||
return projects.map(proj => ({
|
||||
id: proj.id,
|
||||
title: proj.title,
|
||||
section: proj.section,
|
||||
keywords: `${proj.keywords} project website app`.toLowerCase(),
|
||||
icon: '<iconify-icon icon="mdi:web" width="20"></iconify-icon>',
|
||||
keywords: proj.keywords.toLowerCase(),
|
||||
icon: makeIcon(proj.icon, 'projects', categoryIcons[proj.category] || 'mdi:web'),
|
||||
handler: () => scrollToSection(proj.id)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert API course entries to ninja-keys actions
|
||||
* @param {Array} courses - Course entries from API
|
||||
* @returns {Array} ninja-keys actions
|
||||
*/
|
||||
function mapCourseActions(courses) {
|
||||
return courses.map(course => ({
|
||||
id: course.id,
|
||||
title: course.title,
|
||||
section: course.section,
|
||||
keywords: `${course.keywords} course training certification`.toLowerCase(),
|
||||
icon: '<iconify-icon icon="mdi:school" width="20"></iconify-icon>',
|
||||
keywords: course.keywords.toLowerCase(),
|
||||
icon: makeIcon(course.icon, 'courses', 'mdi:school'),
|
||||
handler: () => scrollToSection(course.id)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ Disallow: /api/internal/
|
||||
Disallow: /.git/
|
||||
Disallow: /.env
|
||||
|
||||
# Plain text version — accessible but not indexed (X-Robots-Tag: noindex)
|
||||
# Canonical HTML version at / is preferred for search results
|
||||
# Allow: /text (crawlable for LLMs and text browsers)
|
||||
|
||||
# =============================================================================
|
||||
# SITEMAPS & AI CONTENT
|
||||
# =============================================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- English Version -->
|
||||
<url>
|
||||
<loc>https://juan.andres.morenorub.io/?lang=en</loc>
|
||||
<lastmod>2024-10-18</lastmod>
|
||||
<lastmod>2026-04-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="es" href="https://juan.andres.morenorub.io/?lang=es"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<!-- Spanish Version -->
|
||||
<url>
|
||||
<loc>https://juan.andres.morenorub.io/?lang=es</loc>
|
||||
<lastmod>2024-10-18</lastmod>
|
||||
<lastmod>2026-04-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="es" href="https://juan.andres.morenorub.io/?lang=es"/>
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- Default (redirects to English) -->
|
||||
<url>
|
||||
<loc>https://juan.andres.morenorub.io/</loc>
|
||||
<lastmod>2024-10-18</lastmod>
|
||||
<lastmod>2026-04-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- Health Check Endpoint -->
|
||||
<url>
|
||||
<loc>https://juan.andres.morenorub.io/health</loc>
|
||||
<lastmod>2024-10-18</lastmod>
|
||||
<lastmod>2026-04-09</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
- {{if .Icons}}📧{{else}}Email: {{end}} {{.CV.Personal.Email}}
|
||||
|
||||
- {{if .Icons}}📱{{else}}Phone: {{end}} {{.CV.Personal.Phone}}
|
||||
{{if .CV.Personal.Phone}}- {{if .Icons}}📱{{else}}Phone: {{end}} {{.CV.Personal.Phone}}
|
||||
|
||||
- {{if .Icons}}💼{{else}}LinkedIn:{{end}} {{.CV.Personal.LinkedIn}}
|
||||
{{end}}- {{if .Icons}}💼{{else}}LinkedIn:{{end}} {{.CV.Personal.LinkedIn}}
|
||||
|
||||
- {{if .Icons}}💻{{else}}GitHub: {{end}} {{.CV.Personal.GitHub}}
|
||||
|
||||
@@ -117,9 +117,9 @@
|
||||
|
||||
- {{if .Icons}}📧{{else}}Email: {{end}} {{.CV.Personal.Email}}
|
||||
|
||||
- {{if .Icons}}📱{{else}}Phone: {{end}} {{.CV.Personal.Phone}}
|
||||
{{if .CV.Personal.Phone}}- {{if .Icons}}📱{{else}}Phone: {{end}} {{.CV.Personal.Phone}}
|
||||
|
||||
- {{if .Icons}}💼{{else}}LinkedIn:{{end}} {{.CV.Personal.LinkedIn}}
|
||||
{{end}}- {{if .Icons}}💼{{else}}LinkedIn:{{end}} {{.CV.Personal.LinkedIn}}
|
||||
|
||||
- {{if .Icons}}💻{{else}}GitHub: {{end}} {{.CV.Personal.GitHub}}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
{{template "contact-button" .}}
|
||||
{{template "zoom-toggle-button" .}}
|
||||
{{template "shortcuts-button" .}}
|
||||
{{template "bg-photo-toggle" .}}
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- MODALS -->
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<a href="mailto:{{.CV.Personal.Email}}" target="_blank" rel="noopener noreferrer">{{.CV.Personal.Email}}</a>
|
||||
</div>
|
||||
</li>
|
||||
{{if .CV.Personal.Phone}}
|
||||
<li>
|
||||
<div class="footer-label">{{.UI.Footer.Phone}}</div>
|
||||
<div class="footer-separator"><i class="fa fa-circle"></i></div>
|
||||
@@ -39,6 +40,7 @@
|
||||
<a href="tel:{{.CV.Personal.Phone}}" target="_blank" rel="noopener noreferrer">{{.CV.Personal.Phone}}</a>
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
_="on mouseenter add .footer-hovered to me then call setFooterHover(true)
|
||||
on mouseleave remove .footer-hovered from me then call setFooterHover(false)">
|
||||
<p style="text-align: center; margin-bottom: 0.5rem;">
|
||||
<a href="https://github.com/juanatsap/cv-site" target="_blank" rel="noopener noreferrer" class="github-repo-link" style="text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<a href="https://repos.txeo.club/txeo/cv-site" target="_blank" rel="noopener noreferrer" class="github-repo-link" style="text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<iconify-icon icon="mdi:github" width="20" height="20"></iconify-icon>
|
||||
{{.UI.Footer.ViewOnGithub}}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{{define "head-language-switch"}}
|
||||
<head hx-head="merge">
|
||||
<title>{{.CV.Personal.Name}} - {{.CV.SEO.PageTitle}}</title>
|
||||
<meta name="title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}" hx-head="re-eval">
|
||||
<meta name="description" content="{{.CV.Personal.Title}} | {{.CV.SEO.MetaDescription}}" hx-head="re-eval">
|
||||
<link rel="canonical" href="{{.CanonicalURL}}" hx-head="re-eval">
|
||||
<link rel="alternate" hreflang="en" href="{{.AlternateEN}}" hx-head="re-eval">
|
||||
<link rel="alternate" hreflang="es" href="{{.AlternateES}}" hx-head="re-eval">
|
||||
<meta property="og:title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}" hx-head="re-eval">
|
||||
<meta property="og:description" content="{{.CV.Personal.Title}} | {{.CV.SEO.OgDescription}}" hx-head="re-eval">
|
||||
<meta property="og:locale" content="{{if eq .Lang "es"}}es_ES{{else}}en_US{{end}}" hx-head="re-eval">
|
||||
</head>
|
||||
{{end}}
|
||||
@@ -2,10 +2,8 @@
|
||||
<!-- Device Detection - Detect real mobile devices vs desktop browser -->
|
||||
<script src="/static/js/device-detection.js"></script>
|
||||
|
||||
<!-- HTMX with SRI (Subresource Integrity) -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"
|
||||
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
|
||||
crossorigin="anonymous"></script>
|
||||
<!-- HTMX 2.0.10 (self-hosted) -->
|
||||
<script src="/static/htmx/htmx.min.js"></script>
|
||||
|
||||
<!-- Hyperscript Functions - Must load BEFORE hyperscript library -->
|
||||
<!-- NOTE: cv-functions.js removed - hyperscript def statements are globally available -->
|
||||
@@ -23,12 +21,12 @@
|
||||
<!-- NOTE: footer-buttons-interaction.js removed - moved to hyperscript on footer element -->
|
||||
<!-- NOTE: scroll-at-bottom-handler.js removed - duplicate of handleScroll() in utils._hs -->
|
||||
|
||||
<!-- Hyperscript - Declarative event handling for enhanced interactivity -->
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
|
||||
<!-- Hyperscript 0.9.91 (self-hosted) -->
|
||||
<script src="/static/hyperscript/_hyperscript.min.js"></script>
|
||||
|
||||
<!-- Ninja Keys - Lazy loaded on CMD+K (see body-scripts for loader) -->
|
||||
|
||||
<!-- Iconify - Load synchronously for immediate rendering -->
|
||||
<!-- Using unpkg CDN (more reliable than code.iconify.design) -->
|
||||
<!-- Using jsdelivr CDN (more reliable than code.iconify.design) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/iconify-icon@2.1.0/dist/iconify-icon.min.js"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"description": "{{.CV.Summary}}",
|
||||
"url": "{{.CV.Personal.Website}}",
|
||||
"image": "{{.CV.Personal.Website}}/static/images/profile.jpg",
|
||||
"email": "{{.CV.Personal.Email}}",
|
||||
"telephone": "{{.CV.Personal.Phone}}",
|
||||
"email": "{{.CV.Personal.Email}}",{{if .CV.Personal.Phone}}
|
||||
"telephone": "{{.CV.Personal.Phone}}",{{end}}
|
||||
"birthDate": "{{.CV.Personal.DateOfBirth}}",
|
||||
"birthPlace": {
|
||||
"@type": "Place",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<meta name="keywords" content="{{.CV.Personal.Name}}, {{.CV.SEO.Keywords}}">
|
||||
<meta name="author" content="{{.CV.Personal.Name}}">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="google-site-verification" content="7kWJQyNk8OZa9UcllCM7qAPKbsvh-t5Z19q5qgt-oDk">
|
||||
<link rel="canonical" href="{{.CanonicalURL}}">
|
||||
|
||||
<!-- Hreflang tags for international SEO -->
|
||||
|
||||
@@ -31,10 +31,10 @@
|
||||
{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}
|
||||
</summary>
|
||||
<div class="chat-help-questions">
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Cuántos años de experiencia tiene Juan?{{else}}How many years of experience does Juan have?{{end}}')">{{if eq .Lang "es"}}¿Cuántos años de experiencia tiene?{{else}}How many years of experience?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿En qué empresas ha trabajado?{{else}}What companies has he worked at?{{end}}')">{{if eq .Lang "es"}}¿En qué empresas ha trabajado?{{else}}What companies has he worked at?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Cuántos años de experiencia tienes?{{else}}How many years of experience do you have?{{end}}')">{{if eq .Lang "es"}}¿Cuántos años de experiencia tienes?{{else}}How many years of experience?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿En qué empresas has trabajado?{{else}}What companies have you worked at?{{end}}')">{{if eq .Lang "es"}}¿En qué empresas has trabajado?{{else}}What companies have you worked at?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}Cuéntame sobre Olympic Broadcasting{{else}}Tell me about Olympic Broadcasting{{end}}')">{{if eq .Lang "es"}}Cuéntame sobre Olympic Broadcasting{{else}}Tell me about Olympic Broadcasting{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué hacía en SAP?{{else}}What did he do at SAP?{{end}}')">{{if eq .Lang "es"}}¿Qué hacía en SAP?{{else}}What did he do at SAP?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué hacías en SAP?{{else}}What did you do at SAP?{{end}}')">{{if eq .Lang "es"}}¿Qué hacías en SAP?{{else}}What did you do at SAP?{{end}}</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -45,11 +45,11 @@
|
||||
{{if eq .Lang "es"}}Tecnologías{{else}}Technologies{{end}}
|
||||
</summary>
|
||||
<div class="chat-help-questions">
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué lenguajes de programación conoce?{{else}}What programming languages does he know?{{end}}')">{{if eq .Lang "es"}}¿Qué lenguajes conoce?{{else}}What languages does he know?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Ha trabajado con React? ¿Dónde?{{else}}Has he worked with React? Where?{{end}}')">{{if eq .Lang "es"}}¿Ha trabajado con React?{{else}}Has he worked with React?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Cuál es su experiencia con Go?{{else}}What is his Go experience?{{end}}')">{{if eq .Lang "es"}}¿Experiencia con Go?{{else}}Go experience?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Conoce Node.js?{{else}}Does he know Node.js?{{end}}')">{{if eq .Lang "es"}}¿Conoce Node.js?{{else}}Does he know Node.js?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Tiene experiencia con Docker?{{else}}Does he have Docker experience?{{end}}')">{{if eq .Lang "es"}}¿Experiencia con Docker?{{else}}Docker experience?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué lenguajes de programación conoces?{{else}}What programming languages do you know?{{end}}')">{{if eq .Lang "es"}}¿Qué lenguajes conoces?{{else}}What languages do you know?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Has trabajado con React? ¿Dónde?{{else}}Have you worked with React? Where?{{end}}')">{{if eq .Lang "es"}}¿Has trabajado con React?{{else}}Have you worked with React?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Cuál es tu experiencia con Go?{{else}}What is your Go experience?{{end}}')">{{if eq .Lang "es"}}¿Experiencia con Go?{{else}}Go experience?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Conoces Node.js?{{else}}Do you know Node.js?{{end}}')">{{if eq .Lang "es"}}¿Conoces Node.js?{{else}}Do you know Node.js?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Tienes experiencia con Docker?{{else}}Do you have Docker experience?{{end}}')">{{if eq .Lang "es"}}¿Experiencia con Docker?{{else}}Docker experience?{{end}}</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -60,9 +60,9 @@
|
||||
{{if eq .Lang "es"}}Proyectos{{else}}Projects{{end}}
|
||||
</summary>
|
||||
<div class="chat-help-questions">
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué proyectos personales ha creado?{{else}}What personal projects has he built?{{end}}')">{{if eq .Lang "es"}}¿Qué proyectos ha creado?{{else}}What projects has he built?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué proyectos personales has creado?{{else}}What personal projects have you built?{{end}}')">{{if eq .Lang "es"}}¿Qué proyectos has creado?{{else}}What projects have you built?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}Cuéntame sobre Immich Photo Manager{{else}}Tell me about Immich Photo Manager{{end}}')">{{if eq .Lang "es"}}Sobre Immich Photo Manager{{else}}About Immich Photo Manager{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué proyectos open-source mantiene?{{else}}What open-source projects does he maintain?{{end}}')">{{if eq .Lang "es"}}¿Proyectos open-source?{{else}}Open-source projects?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué proyectos open-source mantienes?{{else}}What open-source projects do you maintain?{{end}}')">{{if eq .Lang "es"}}¿Proyectos open-source?{{else}}Open-source projects?{{end}}</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -73,9 +73,9 @@
|
||||
{{if eq .Lang "es"}}Formación{{else}}Education{{end}}
|
||||
</summary>
|
||||
<div class="chat-help-questions">
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué certificaciones tiene?{{else}}What certifications does he have?{{end}}')">{{if eq .Lang "es"}}¿Certificaciones?{{else}}Certifications?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Dónde estudió?{{else}}Where did he study?{{end}}')">{{if eq .Lang "es"}}¿Dónde estudió?{{else}}Where did he study?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué cursos ha completado?{{else}}What courses has he completed?{{end}}')">{{if eq .Lang "es"}}¿Cursos completados?{{else}}Courses completed?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué certificaciones tienes?{{else}}What certifications do you have?{{end}}')">{{if eq .Lang "es"}}¿Certificaciones?{{else}}Certifications?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Dónde estudiaste?{{else}}Where did you study?{{end}}')">{{if eq .Lang "es"}}¿Dónde estudiaste?{{else}}Where did you study?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué cursos has completado?{{else}}What courses have you completed?{{end}}')">{{if eq .Lang "es"}}¿Cursos completados?{{else}}Courses completed?{{end}}</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -86,9 +86,9 @@
|
||||
{{if eq .Lang "es"}}Habilidades{{else}}Skills{{end}}
|
||||
</summary>
|
||||
<div class="chat-help-questions">
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Cuáles son sus principales habilidades técnicas?{{else}}What are his main technical skills?{{end}}')">{{if eq .Lang "es"}}¿Habilidades técnicas principales?{{else}}Main technical skills?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Cuáles son tus principales habilidades técnicas?{{else}}What are your main technical skills?{{end}}')">{{if eq .Lang "es"}}¿Habilidades técnicas principales?{{else}}Main technical skills?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué hay de CI/CD?{{else}}What about CI/CD?{{end}}')">{{if eq .Lang "es"}}¿Experiencia con CI/CD?{{else}}CI/CD experience?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué idiomas habla?{{else}}What languages does he speak?{{end}}')">{{if eq .Lang "es"}}¿Qué idiomas habla?{{else}}Languages spoken?{{end}}</button>
|
||||
<button class="chat-help-q" onclick="closeChatHelpAndAsk('{{if eq .Lang "es"}}¿Qué idiomas hablas?{{else}}What languages do you speak?{{end}}')">{{if eq .Lang "es"}}¿Qué idiomas hablas?{{else}}Languages spoken?{{end}}</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
|
||||
<p class="info-modal-github-subtext">{{.UI.InfoModal.ViewSourceSubtext}}</p>
|
||||
<a href="https://github.com/juanatsap/cv-site" target="_blank" rel="noopener noreferrer" class="info-modal-github">
|
||||
<a href="https://repos.txeo.club/txeo/cv-site" target="_blank" rel="noopener noreferrer" class="info-modal-github">
|
||||
<iconify-icon icon="mdi:github" width="24" height="24"></iconify-icon>
|
||||
<span>{{.UI.InfoModal.ViewSource}}</span>
|
||||
<iconify-icon icon="mdi:arrow-right" width="20" height="20"></iconify-icon>
|
||||
|
||||
@@ -22,21 +22,13 @@
|
||||
<div class="language-selector" id="language-selector">
|
||||
<button class="selector-btn {{if eq .Lang "en"}}active{{end}}"
|
||||
data-short="EN"
|
||||
hx-get="/switch-language?lang=en"
|
||||
hx-target="#language-selector"
|
||||
hx-swap="outerHTML swap:250ms settle:250ms"
|
||||
hx-indicator="#lang-indicator-en"
|
||||
hx-push-url="/?lang=en"
|
||||
onclick="window.location.href='/?lang=en'"
|
||||
aria-label="English">
|
||||
<span>English</span>
|
||||
</button>
|
||||
<button class="selector-btn {{if eq .Lang "es"}}active{{end}}"
|
||||
data-short="ES"
|
||||
hx-get="/switch-language?lang=es"
|
||||
hx-target="#language-selector"
|
||||
hx-swap="outerHTML swap:250ms settle:250ms"
|
||||
hx-indicator="#lang-indicator-es"
|
||||
hx-push-url="/?lang=es"
|
||||
onclick="window.location.href='/?lang=es'"
|
||||
aria-label="Español">
|
||||
<span>Español</span>
|
||||
</button>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<p class="years-experience">{{.YearsOfExperience}} {{.UI.Sections.YearsOfExperience}}</p>
|
||||
|
||||
<!-- Intro/Excerpt Text - No section heading, just the text -->
|
||||
<div class="intro-text">{{.CV.Summary}}</div>
|
||||
<div class="intro-text">{{.CV.Summary | replaceDrolosoft | safeHTML}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,15 @@
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="project-content">
|
||||
<strong>
|
||||
{{if eq .Category "cli"}}<span class="category-badge category-cli">CLI</span>
|
||||
{{else if eq .Category "app"}}<span class="category-badge category-app">APP</span>
|
||||
{{else if eq .Category "web"}}<span class="category-badge category-web">WEB</span>
|
||||
{{else if eq .Category "webapp"}}<span class="category-badge category-webapp">WEB</span>
|
||||
{{else if eq .Category "plugin"}}<span class="category-badge category-plugin">PLG</span>
|
||||
{{else if eq .Category "sdk"}}<span class="category-badge category-sdk">SDK</span>
|
||||
{{else if eq .Category "collab"}}<span class="category-badge category-collab">COL</span>
|
||||
{{else if eq .Category "contrib"}}<span class="category-badge category-contrib">OSS</span>
|
||||
{{end}}<strong>
|
||||
{{if .ProjectName}}
|
||||
{{if .URL}}<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.ProjectName}}</a>{{else}}{{.ProjectName}}{{end}}{{if .ProjectDesc}} - {{.ProjectDesc}}{{end}}
|
||||
{{else}}
|
||||
@@ -31,7 +39,7 @@
|
||||
{{end}}
|
||||
</strong>
|
||||
{{if .Current}}<span class="live-badge"><iconify-icon icon="mdi:wifi" width="14" height="14"></iconify-icon>LIVE</span>{{end}}
|
||||
{{if .GitRepoUrl}}<a href="{{.GitRepoUrl}}" target="_blank" rel="noopener noreferrer" class="github-badge"><iconify-icon icon="mdi:github" width="14" height="14"></iconify-icon>GitHub</a>{{end}}
|
||||
{{if .GitRepoUrl}}<a href="{{.GitRepoUrl}}/stargazers" target="_blank" rel="noopener noreferrer" class="github-stars-badge"><iconify-icon icon="mdi:github" width="14" height="14"></iconify-icon>{{if .Stars}}{{.Stars}}{{end}}<iconify-icon icon="mdi:star" width="12" height="12"></iconify-icon></a>{{end}}
|
||||
{{if .MaintainedBy}}<span class="maintained-badge">{{$.UI.Sections.MaintainedBy}} {{.MaintainedBy}}</span>{{end}}
|
||||
<br>
|
||||
<small>{{if .StartDate}}{{.StartDate}}{{if .Current}}{{if .DynamicDate}} / {{.DynamicDate}}{{else}} / {{$.UI.Sections.Present}}{{end}}{{end}}{{end}} - ({{.Location}})</small>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{{define "bg-photo-toggle"}}
|
||||
{{if .BgPhotos}}
|
||||
<!-- Background Photo — random on each load -->
|
||||
<script>
|
||||
(function() {
|
||||
var photos = [{{range $i, $p := .BgPhotos}}{{if $i}},{{end}}'{{$p}}'{{end}}];
|
||||
if (!photos.length) return;
|
||||
var url = photos[Math.floor(Math.random() * photos.length)];
|
||||
document.body.style.setProperty('--bg-photo-url', 'url("' + url + '")');
|
||||
document.body.classList.add('bg-photo');
|
||||
|
||||
{{if not .IsProduction}}
|
||||
window.toggleBgPhoto = function() {
|
||||
var isOn = document.body.classList.contains('bg-photo');
|
||||
if (isOn) {
|
||||
document.body.classList.remove('bg-photo');
|
||||
} else {
|
||||
document.body.classList.add('bg-photo');
|
||||
}
|
||||
var btn = document.getElementById('bg-photo-toggle');
|
||||
if (btn) {
|
||||
var icon = btn.querySelector('iconify-icon');
|
||||
if (icon) icon.setAttribute('icon', isOn ? 'mdi:image-outline' : 'mdi:image');
|
||||
}
|
||||
};
|
||||
{{end}}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{if not .IsProduction}}
|
||||
<!-- Background Photo Toggle (Dev Only) -->
|
||||
<button
|
||||
id="bg-photo-toggle"
|
||||
class="fixed-btn bg-photo-btn no-print has-tooltip"
|
||||
aria-label="Toggle background photo"
|
||||
data-tooltip="Toggle background photo"
|
||||
onclick="toggleBgPhoto()">
|
||||
<iconify-icon icon="mdi:image" width="24" height="24"></iconify-icon>
|
||||
</button>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -17,26 +17,40 @@
|
||||
<iconify-icon icon="mdi:robot-happy-outline"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}}</span>
|
||||
<div class="chat-header-actions">
|
||||
<button class="chat-mode-btn active" data-mode="" title="{{if eq .Lang "es"}}Compacto{{else}}Compact{{end}}" onclick="setChatSize('')">
|
||||
<iconify-icon icon="mdi:message-outline"></iconify-icon>
|
||||
</button>
|
||||
<button class="chat-mode-btn" data-mode="chat-half" title="{{if eq .Lang "es"}}Panel lateral{{else}}Side panel{{end}}" onclick="setChatSize('chat-half')">
|
||||
<iconify-icon icon="mdi:page-layout-sidebar-right"></iconify-icon>
|
||||
</button>
|
||||
<button class="chat-mode-btn" data-mode="chat-float" title="{{if eq .Lang "es"}}Flotante — arrastra para mover{{else}}Floating — drag to move{{end}}" onclick="setChatSize('chat-float')">
|
||||
<iconify-icon icon="mdi:cursor-move"></iconify-icon>
|
||||
</button>
|
||||
<button class="chat-mode-btn" data-mode="chat-full" title="{{if eq .Lang "es"}}Pantalla completa{{else}}Full screen{{end}}" onclick="setChatSize('chat-full')">
|
||||
<iconify-icon icon="mdi:arrow-expand-all"></iconify-icon>
|
||||
</button>
|
||||
<span class="chat-header-divider"></span>
|
||||
<!-- Cog menu for layout modes -->
|
||||
<div class="chat-cog-wrapper">
|
||||
<button class="chat-mode-btn" title="{{if eq .Lang "es"}}Opciones{{else}}Options{{end}}" onclick="toggleChatCog()">
|
||||
<iconify-icon icon="mdi:cog"></iconify-icon>
|
||||
</button>
|
||||
<div id="chat-cog-menu" class="chat-cog-menu">
|
||||
<button class="chat-cog-item active" data-mode="" onclick="setChatSize(''); closeChatCog()">
|
||||
<iconify-icon icon="mdi:message-outline"></iconify-icon>
|
||||
{{if eq .Lang "es"}}Compacto{{else}}Compact{{end}}
|
||||
</button>
|
||||
<button class="chat-cog-item" data-mode="chat-split" onclick="setChatSize('chat-split'); closeChatCog()">
|
||||
<iconify-icon icon="mdi:arrow-split-horizontal"></iconify-icon>
|
||||
{{if eq .Lang "es"}}Mitad{{else}}Half screen{{end}}
|
||||
</button>
|
||||
<button class="chat-cog-item" data-mode="chat-half" onclick="setChatSize('chat-half'); closeChatCog()">
|
||||
<iconify-icon icon="mdi:page-layout-sidebar-right"></iconify-icon>
|
||||
{{if eq .Lang "es"}}Lateral{{else}}Side panel{{end}}
|
||||
</button>
|
||||
<button class="chat-cog-item" data-mode="chat-float" onclick="setChatSize('chat-float'); closeChatCog()">
|
||||
<iconify-icon icon="mdi:cursor-move"></iconify-icon>
|
||||
{{if eq .Lang "es"}}Flotante{{else}}Floating{{end}}
|
||||
</button>
|
||||
<button class="chat-cog-item" data-mode="chat-full" onclick="setChatSize('chat-full'); closeChatCog()">
|
||||
<iconify-icon icon="mdi:arrow-expand-all"></iconify-icon>
|
||||
{{if eq .Lang "es"}}Completo{{else}}Full screen{{end}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="chat-mode-btn"
|
||||
title="{{if eq .Lang "es"}}Ayuda{{else}}Help{{end}}"
|
||||
commandfor="chat-help-modal"
|
||||
command="show-modal">
|
||||
<iconify-icon icon="mdi:help-circle-outline"></iconify-icon>
|
||||
</button>
|
||||
<span class="chat-header-divider"></span>
|
||||
<button class="chat-mode-btn" title="{{if eq .Lang "es"}}Cerrar{{else}}Close{{end}}" onclick="toggleChatPanel()">
|
||||
<iconify-icon icon="mdi:close"></iconify-icon>
|
||||
</button>
|
||||
@@ -45,8 +59,8 @@
|
||||
|
||||
<div id="chat-messages" class="chat-messages">
|
||||
<div class="chat-row chat-row-bot">
|
||||
<div class="chat-avatar"><iconify-icon icon="mdi:robot-happy-outline"></iconify-icon></div>
|
||||
<div class="chat-msg">{{if eq .Lang "es"}}¡Hola! Pregúntame lo que quieras sobre Juan.{{else}}Hi! Ask me anything about Juan.{{end}}</div>
|
||||
<div class="chat-avatar chat-avatar-juan"><img src="/static/images/profile/dni-thumb.jpeg" alt="Juan"></div>
|
||||
<div class="chat-msg">{{if eq .Lang "es"}}¡Hola! Pregúntame lo que quieras sobre <span class="chat-disclaimer" data-tip="Soy el yo digital de Juan. Y sólo sé responder preguntas profesionales sobre mi currículum. 🤷🏽♂️" onmouseenter="showChatTip(this)" onmouseleave="hideChatTip()">mi currículum</span>.{{else}}Hi! Ask me anything about <span class="chat-disclaimer" data-tip="I'm Juan's digital self. And I only know how to answer professional questions about my CV. 🤷🏽♂️" onmouseenter="showChatTip(this)" onmouseleave="hideChatTip()">my CV</span>.{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,17 +77,17 @@
|
||||
<!-- Suggested Questions -->
|
||||
<div class="chat-suggestions">
|
||||
{{if eq .Lang "es"}}
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Qué proyectos en Go ha hecho?')">¿Proyectos en Go?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Cuántos años de experiencia tiene?')">¿Años de experiencia?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿En qué empresas ha trabajado?')">¿Empresas?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Conoce React?')">¿Conoce React?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Qué certificaciones tiene?')">¿Certificaciones?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Qué proyectos en Go has hecho?')">¿Proyectos en Go?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Cuántos años de experiencia tienes?')">¿Años de experiencia?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿En qué empresas has trabajado?')">¿Empresas?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Conoces React?')">¿Conoces React?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Qué proyectos open-source mantienes?')">¿Open source?</button>
|
||||
{{else}}
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('What Go projects has he built?')">Go projects?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('How many years of experience?')">Years of experience?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('What companies has he worked at?')">Companies?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('Does he know React?')">Knows React?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('What certifications?')">Certifications?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('What Go projects have you built?')">Go projects?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('How many years of experience do you have?')">Years of experience?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('What companies have you worked at?')">Companies?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('Do you know React?')">Know React?</button>
|
||||
<button type="button" class="chat-chip" onclick="sendChatQuestion('What open-source projects do you maintain?')">Open source?</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -104,6 +118,42 @@
|
||||
var chatModelReady = false;
|
||||
var chatWarmedUp = false;
|
||||
|
||||
// Fun loading phrases — random per request
|
||||
var chatLoadingPhrases = {{if eq .Lang "es"}}[
|
||||
'Repasando mi curriculum…',
|
||||
'Recordando mis proyectos…',
|
||||
'Calentando neuronas…',
|
||||
'Preparando mi mejor version…',
|
||||
'Buscando en mis recuerdos…',
|
||||
'Consultando mi experiencia…',
|
||||
'Un momento, que me concentro…',
|
||||
'Revisando 21 anos de codigo…',
|
||||
'Encendiendo el cerebro digital…',
|
||||
'Organizando ideas…',
|
||||
'Dame un segundo…',
|
||||
'Casi listo, paciencia…',
|
||||
'Activando modo profesional…',
|
||||
'Conectando con mi yo digital…'
|
||||
]{{else}}[
|
||||
'Reviewing my CV…',
|
||||
'Remembering my projects…',
|
||||
'Warming up neurons…',
|
||||
'Preparing my best self…',
|
||||
'Searching my memories…',
|
||||
'Consulting my experience…',
|
||||
'One moment, focusing…',
|
||||
'Scanning 21 years of code…',
|
||||
'Booting up the digital brain…',
|
||||
'Organizing thoughts…',
|
||||
'Give me a second…',
|
||||
'Almost there, hang tight…',
|
||||
'Activating professional mode…',
|
||||
'Connecting to my digital self…'
|
||||
]{{end}};
|
||||
function chatLoadingPhrase() {
|
||||
return chatLoadingPhrases[Math.floor(Math.random() * chatLoadingPhrases.length)];
|
||||
}
|
||||
|
||||
// Poll model status until ready
|
||||
function pollChatStatus() {
|
||||
fetch('/api/chat/status').then(function(r) { return r.json(); }).then(function(data) {
|
||||
@@ -139,6 +189,18 @@ function toggleChatPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Cog menu toggle
|
||||
function toggleChatCog() {
|
||||
document.getElementById('chat-cog-menu').classList.toggle('chat-cog-open');
|
||||
}
|
||||
function closeChatCog() {
|
||||
document.getElementById('chat-cog-menu').classList.remove('chat-cog-open');
|
||||
}
|
||||
// Close cog when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.chat-cog-wrapper')) closeChatCog();
|
||||
});
|
||||
|
||||
// Show user message bubble immediately in chat
|
||||
var chatBubblePending = false;
|
||||
function appendUserBubble(text) {
|
||||
@@ -191,7 +253,7 @@ document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
var statusEl = document.getElementById('chat-status-text');
|
||||
var dotsEl = document.querySelector('.chat-typing-dots');
|
||||
if (!chatModelReady && statusEl && dotsEl) {
|
||||
statusEl.textContent = '{{if eq .Lang "es"}}Inicializando modelo IA…{{else}}Initializing AI model…{{end}}';
|
||||
statusEl.textContent = chatLoadingPhrase();
|
||||
statusEl.style.display = 'inline';
|
||||
dotsEl.style.display = 'none';
|
||||
} else if (statusEl && dotsEl) {
|
||||
@@ -216,7 +278,7 @@ document.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Set chat panel size
|
||||
function setChatSize(size) {
|
||||
var panel = document.getElementById('chat-panel');
|
||||
panel.classList.remove('chat-half', 'chat-full', 'chat-float');
|
||||
panel.classList.remove('chat-half', 'chat-full', 'chat-float', 'chat-split');
|
||||
// Reset all inline styles from drag/resize
|
||||
panel.style.top = '';
|
||||
panel.style.left = '';
|
||||
@@ -225,9 +287,9 @@ function setChatSize(size) {
|
||||
panel.style.width = '';
|
||||
panel.style.height = '';
|
||||
if (size) panel.classList.add(size);
|
||||
// Update active button
|
||||
document.querySelectorAll('.chat-mode-btn[data-mode]').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-mode') === size);
|
||||
// Update active item in cog menu
|
||||
document.querySelectorAll('.chat-cog-item[data-mode]').forEach(function(item) {
|
||||
item.classList.toggle('active', item.getAttribute('data-mode') === size);
|
||||
});
|
||||
// Enable/disable drag
|
||||
if (size === 'chat-float') {
|
||||
@@ -316,12 +378,39 @@ function scrollToCV(link) {
|
||||
var anchor = link.getAttribute('href');
|
||||
var target = document.querySelector(anchor);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.classList.add('chat-highlight');
|
||||
setTimeout(function() { target.classList.remove('chat-highlight'); }, 2000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Floating tooltip for chat disclaimer (escapes overflow:hidden)
|
||||
function showChatTip(el) {
|
||||
hideChatTip();
|
||||
var tip = document.createElement('div');
|
||||
tip.id = 'chat-tip';
|
||||
tip.textContent = el.getAttribute('data-tip');
|
||||
tip.style.cssText = 'position:fixed;background:rgba(0,0,0,0.85);color:#fff;font-size:11px;font-weight:600;padding:4px 8px;border-radius:6px;z-index:10000;pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,0.3);max-width:180px;line-height:1.3;opacity:0;transition:opacity .15s';
|
||||
document.body.appendChild(tip);
|
||||
var r = el.getBoundingClientRect();
|
||||
var panel = el.closest('.chat-panel');
|
||||
var panelR = panel ? panel.getBoundingClientRect() : { right: window.innerWidth };
|
||||
var spaceRight = panelR.right - r.right;
|
||||
// Show right if room, otherwise show below
|
||||
if (spaceRight > tip.offsetWidth + 12) {
|
||||
tip.style.left = (r.right + 8) + 'px';
|
||||
tip.style.top = (r.top + r.height / 2 - tip.offsetHeight / 2) + 'px';
|
||||
} else {
|
||||
tip.style.left = r.left + 'px';
|
||||
tip.style.top = (r.bottom + 6) + 'px';
|
||||
}
|
||||
tip.style.opacity = '1';
|
||||
}
|
||||
function hideChatTip() {
|
||||
var t = document.getElementById('chat-tip');
|
||||
if (t) t.remove();
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* HEAD-SUPPORT EXTENSION TEST
|
||||
* ============================
|
||||
* Tests that the head-support extension updates <head> tags
|
||||
* and <html lang> on language switching via HTMX
|
||||
* - Verifies <html lang> updates on language switch
|
||||
* - Verifies <title> updates on language switch
|
||||
* - Verifies <meta description> updates
|
||||
* - Verifies no duplicate tags after multiple switches
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const URL = "http://localhost:1999";
|
||||
|
||||
async function testHeadSupport() {
|
||||
console.log('🏷️ HEAD-SUPPORT EXTENSION TEST\n');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const errors = [];
|
||||
const testResults = [];
|
||||
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n1️⃣ Loading page (English default)...");
|
||||
await page.goto(URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// ========================================================================
|
||||
// TEST 1: Head-support extension is loaded
|
||||
// ========================================================================
|
||||
console.log("\n2️⃣ Testing head-support extension loaded...");
|
||||
const extLoaded = await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
const hxExt = body.getAttribute('hx-ext');
|
||||
return {
|
||||
hasHxExt: hxExt !== null && hxExt.includes('head-support'),
|
||||
hxExtValue: hxExt
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` hx-ext attribute: ${extLoaded.hxExtValue}`);
|
||||
console.log(` ${extLoaded.hasHxExt ? '✅ PASS' : '❌ FAIL'} - head-support extension activated`);
|
||||
testResults.push({ test: 'Head-support Extension Loaded', passed: extLoaded.hasHxExt });
|
||||
|
||||
// ========================================================================
|
||||
// TEST 2: Initial state - English
|
||||
// ========================================================================
|
||||
console.log("\n3️⃣ Testing initial state (English)...");
|
||||
const initialState = await page.evaluate(() => {
|
||||
return {
|
||||
htmlLang: document.documentElement.getAttribute('lang'),
|
||||
title: document.title,
|
||||
metaDesc: document.querySelector('meta[name="description"]')?.getAttribute('content') || '',
|
||||
canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href') || ''
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` <html lang>: ${initialState.htmlLang}`);
|
||||
console.log(` <title>: ${initialState.title}`);
|
||||
console.log(` canonical: ${initialState.canonical}`);
|
||||
const initialOk = initialState.htmlLang === 'en';
|
||||
console.log(` ${initialOk ? '✅ PASS' : '❌ FAIL'} - Initial state is English`);
|
||||
testResults.push({ test: 'Initial English State', passed: initialOk });
|
||||
|
||||
// ========================================================================
|
||||
// TEST 3: Switch to Spanish via HTMX button
|
||||
// ========================================================================
|
||||
console.log("\n4️⃣ Switching to Spanish via HTMX...");
|
||||
|
||||
// Click the Spanish button
|
||||
const esButton = await page.$('button[aria-label="Español"]');
|
||||
if (esButton) {
|
||||
await esButton.click();
|
||||
await page.waitForTimeout(3000); // Wait for HTMX swap + head-support processing
|
||||
|
||||
const spanishState = await page.evaluate(() => {
|
||||
return {
|
||||
htmlLang: document.documentElement.getAttribute('lang'),
|
||||
title: document.title,
|
||||
metaDesc: document.querySelector('meta[name="description"]')?.getAttribute('content') || '',
|
||||
canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href') || '',
|
||||
ogLocale: document.querySelector('meta[property="og:locale"]')?.getAttribute('content') || ''
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` <html lang>: ${spanishState.htmlLang}`);
|
||||
console.log(` <title>: ${spanishState.title}`);
|
||||
console.log(` canonical: ${spanishState.canonical}`);
|
||||
console.log(` og:locale: ${spanishState.ogLocale}`);
|
||||
|
||||
const langChanged = spanishState.htmlLang === 'es';
|
||||
const canonicalChanged = spanishState.canonical.includes('lang=es');
|
||||
const descChanged = spanishState.metaDesc.length > 0;
|
||||
|
||||
console.log(` ${langChanged ? '✅ PASS' : '❌ FAIL'} - <html lang> changed to "es"`);
|
||||
console.log(` ${canonicalChanged ? '✅ PASS' : '❌ FAIL'} - canonical URL updated to Spanish`);
|
||||
console.log(` ${descChanged ? '✅ PASS' : '❌ FAIL'} - meta description present`);
|
||||
|
||||
testResults.push({ test: 'HTML Lang Updated to ES', passed: langChanged });
|
||||
testResults.push({ test: 'Canonical URL Updated to ES', passed: canonicalChanged });
|
||||
testResults.push({ test: 'Meta Description Present', passed: descChanged });
|
||||
} else {
|
||||
console.log(' ❌ FAIL - Spanish button not found');
|
||||
testResults.push({ test: 'HTML Lang Updated to ES', passed: false });
|
||||
testResults.push({ test: 'Canonical URL Updated to ES', passed: false });
|
||||
testResults.push({ test: 'Meta Description Present', passed: false });
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TEST 4: Switch back to English
|
||||
// ========================================================================
|
||||
console.log("\n5️⃣ Switching back to English...");
|
||||
|
||||
const enButton = await page.$('button[aria-label="English"]');
|
||||
if (enButton) {
|
||||
await enButton.click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const englishState = await page.evaluate(() => {
|
||||
return {
|
||||
htmlLang: document.documentElement.getAttribute('lang'),
|
||||
title: document.title,
|
||||
canonical: document.querySelector('link[rel="canonical"]')?.getAttribute('href') || '',
|
||||
ogLocale: document.querySelector('meta[property="og:locale"]')?.getAttribute('content') || ''
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` <html lang>: ${englishState.htmlLang}`);
|
||||
console.log(` <title>: ${englishState.title}`);
|
||||
console.log(` canonical: ${englishState.canonical}`);
|
||||
console.log(` og:locale: ${englishState.ogLocale}`);
|
||||
|
||||
const langBack = englishState.htmlLang === 'en';
|
||||
const canonicalBack = englishState.canonical.includes('lang=en');
|
||||
|
||||
console.log(` ${langBack ? '✅ PASS' : '❌ FAIL'} - <html lang> back to "en"`);
|
||||
console.log(` ${canonicalBack ? '✅ PASS' : '❌ FAIL'} - canonical URL back to English`);
|
||||
|
||||
testResults.push({ test: 'HTML Lang Restored to EN', passed: langBack });
|
||||
testResults.push({ test: 'Canonical URL Restored to EN', passed: canonicalBack });
|
||||
} else {
|
||||
console.log(' ❌ FAIL - English button not found');
|
||||
testResults.push({ test: 'HTML Lang Restored to EN', passed: false });
|
||||
testResults.push({ test: 'Canonical URL Restored to EN', passed: false });
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TEST 5: No duplicate tags after multiple switches
|
||||
// ========================================================================
|
||||
console.log("\n6️⃣ Testing no duplicate tags after multiple switches...");
|
||||
|
||||
const duplicateCheck = await page.evaluate(() => {
|
||||
const titles = document.querySelectorAll('title').length;
|
||||
const descriptions = document.querySelectorAll('meta[name="description"]').length;
|
||||
const canonicals = document.querySelectorAll('link[rel="canonical"]').length;
|
||||
const ogLocales = document.querySelectorAll('meta[property="og:locale"]').length;
|
||||
|
||||
return {
|
||||
titles,
|
||||
descriptions,
|
||||
canonicals,
|
||||
ogLocales
|
||||
};
|
||||
});
|
||||
|
||||
console.log(` <title> tags: ${duplicateCheck.titles}`);
|
||||
console.log(` <meta description> tags: ${duplicateCheck.descriptions}`);
|
||||
console.log(` <link canonical> tags: ${duplicateCheck.canonicals}`);
|
||||
console.log(` og:locale tags: ${duplicateCheck.ogLocales}`);
|
||||
|
||||
const noDuplicates = duplicateCheck.titles === 1 &&
|
||||
duplicateCheck.descriptions === 1 &&
|
||||
duplicateCheck.canonicals === 1 &&
|
||||
duplicateCheck.ogLocales === 1;
|
||||
|
||||
console.log(` ${noDuplicates ? '✅ PASS' : '❌ FAIL'} - No duplicate tags`);
|
||||
testResults.push({ test: 'No Duplicate Tags', passed: noDuplicates });
|
||||
|
||||
// ========================================================================
|
||||
// FINAL SUMMARY
|
||||
// ========================================================================
|
||||
console.log("\n" + "=".repeat(70));
|
||||
console.log("📊 TEST SUMMARY\n");
|
||||
|
||||
const totalTests = testResults.length;
|
||||
const passedTests = testResults.filter(r => r.passed).length;
|
||||
const failedTests = totalTests - passedTests;
|
||||
|
||||
testResults.forEach(result => {
|
||||
console.log(` ${result.passed ? '✅' : '❌'} ${result.test}`);
|
||||
});
|
||||
|
||||
console.log(`\n Total: ${passedTests}/${totalTests} tests passed`);
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log("\n✅ NO CONSOLE ERRORS");
|
||||
} else {
|
||||
console.log(`\n⚠️ ${errors.length} CONSOLE ERRORS`);
|
||||
}
|
||||
|
||||
console.log("=".repeat(70) + "\n");
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (failedTests === 0) {
|
||||
console.log("🎉 HEAD-SUPPORT EXTENSION VALIDATED!\n");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("⚠️ SOME TESTS FAILED - See details above\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await testHeadSupport();
|
||||
@@ -74,9 +74,9 @@ async function testChatMascot() {
|
||||
// ======================================================================
|
||||
console.log("\n3️⃣ Help Modal");
|
||||
|
||||
record('Help button (?) visible', await page.locator('.chat-help-btn').isVisible());
|
||||
record('Help button (?) visible', await page.locator('button[command="show-modal"][commandfor="chat-help-modal"]').isVisible());
|
||||
|
||||
await page.click('.chat-help-btn');
|
||||
await page.click('button[command="show-modal"][commandfor="chat-help-modal"]');
|
||||
await page.waitForTimeout(500);
|
||||
const modalOpen = await page.locator('#chat-help-modal').evaluate(el => el.open);
|
||||
record('Help modal opens', modalOpen);
|
||||
@@ -99,7 +99,7 @@ async function testChatMascot() {
|
||||
// ======================================================================
|
||||
console.log("\n4️⃣ Welcome Message");
|
||||
|
||||
const welcome = await page.locator('#chat-messages .chat-agent').first().textContent();
|
||||
const welcome = await page.locator('#chat-messages .chat-row-bot .chat-msg').first().textContent();
|
||||
record('Welcome message present', welcome.includes('Ask me anything') || welcome.includes('Pregúntame'));
|
||||
|
||||
// ======================================================================
|
||||
@@ -115,22 +115,60 @@ async function testChatMascot() {
|
||||
// ======================================================================
|
||||
console.log("\n6️⃣ Chip Click → Response");
|
||||
|
||||
const msgsBefore = await page.locator('#chat-messages .chat-message').count();
|
||||
const msgsBefore = await page.locator('#chat-messages .chat-row').count();
|
||||
await page.locator('.chat-chip').first().click();
|
||||
|
||||
// Wait for response
|
||||
await page.waitForFunction(
|
||||
(before) => document.querySelectorAll('#chat-messages .chat-message').length > before + 1,
|
||||
(before) => document.querySelectorAll('#chat-messages .chat-row').length > before + 1,
|
||||
msgsBefore,
|
||||
{ timeout: CHAT_TIMEOUT }
|
||||
);
|
||||
|
||||
const userMsg = await page.locator('#chat-messages .chat-user').last().textContent();
|
||||
const userMsg = await page.locator('#chat-messages .chat-row-user .chat-msg').last().textContent();
|
||||
record('User message appears', userMsg.length > 5, userMsg.substring(0, 40));
|
||||
|
||||
const agentMsg = await page.locator('#chat-messages .chat-agent').last().textContent();
|
||||
const agentMsg = await page.locator('#chat-messages .chat-row-bot .chat-msg').last().textContent();
|
||||
record('Agent response appears', agentMsg.length > 30, `${agentMsg.substring(0, 50)}...`);
|
||||
|
||||
// ======================================================================
|
||||
// 6b. BUBBLE RENDERING — user and bot bubbles must have proper dimensions
|
||||
// ======================================================================
|
||||
console.log("\n6️⃣b Bubble Rendering");
|
||||
|
||||
// Scroll to make last user bubble visible
|
||||
await page.locator('#chat-messages .chat-row-user').last().scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const lastUserBubble = page.locator('#chat-messages .chat-row-user .chat-msg').last();
|
||||
const userBubbleBox = await lastUserBubble.boundingBox();
|
||||
record('User bubble width > 80px', userBubbleBox && userBubbleBox.width > 80,
|
||||
`${Math.round(userBubbleBox?.width || 0)}px`);
|
||||
record('User bubble height > 15px', userBubbleBox && userBubbleBox.height > 15,
|
||||
`${Math.round(userBubbleBox?.height || 0)}px`);
|
||||
|
||||
const lastBotBubble = page.locator('#chat-messages .chat-row-bot .chat-msg').last();
|
||||
await lastBotBubble.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(200);
|
||||
const botBubbleBox = await lastBotBubble.boundingBox();
|
||||
record('Bot bubble width > 100px', botBubbleBox && botBubbleBox.width > 100,
|
||||
`${Math.round(botBubbleBox?.width || 0)}px`);
|
||||
record('Bot bubble height > 15px', botBubbleBox && botBubbleBox.height > 15,
|
||||
`${Math.round(botBubbleBox?.height || 0)}px`);
|
||||
|
||||
// User bubble must be ABOVE bot response — use DOM offsets (scroll-independent)
|
||||
const noOverlap = await page.evaluate(() => {
|
||||
const userRows = document.querySelectorAll('#chat-messages .chat-row-user');
|
||||
const botRows = document.querySelectorAll('#chat-messages .chat-row-bot');
|
||||
const lastUser = userRows[userRows.length - 1];
|
||||
const lastBot = botRows[botRows.length - 1];
|
||||
if (!lastUser || !lastBot) return { ok: false, detail: 'elements not found' };
|
||||
const userBottom = lastUser.offsetTop + lastUser.offsetHeight;
|
||||
const botTop = lastBot.offsetTop;
|
||||
return { ok: userBottom <= botTop, detail: 'user ends=' + userBottom + ', bot starts=' + botTop };
|
||||
});
|
||||
record('Bubbles don\'t overlap vertically', noOverlap.ok, noOverlap.detail);
|
||||
|
||||
// ======================================================================
|
||||
// 7. NAVIGATION LINKS IN RESPONSE
|
||||
// ======================================================================
|
||||
@@ -149,20 +187,20 @@ async function testChatMascot() {
|
||||
// ======================================================================
|
||||
console.log("\n8️⃣ Typed Question");
|
||||
|
||||
const msgsBeforeType = await page.locator('#chat-messages .chat-user').count();
|
||||
const msgsBeforeType = await page.locator('#chat-messages .chat-row-user .chat-msg').count();
|
||||
await page.fill('#chat-input', 'What certifications does he have?');
|
||||
await page.click('.chat-send-btn');
|
||||
|
||||
await page.waitForFunction(
|
||||
(count) => document.querySelectorAll('#chat-messages .chat-user').length > count,
|
||||
(count) => document.querySelectorAll('#chat-messages .chat-row-user .chat-msg').length > count,
|
||||
msgsBeforeType,
|
||||
{ timeout: CHAT_TIMEOUT }
|
||||
);
|
||||
|
||||
const typedMsg = await page.locator('#chat-messages .chat-user').last().textContent();
|
||||
const typedMsg = await page.locator('#chat-messages .chat-row-user .chat-msg').last().textContent();
|
||||
record('Typed message appears', typedMsg.includes('certifications'));
|
||||
|
||||
const certResponse = await page.locator('#chat-messages .chat-agent').last().textContent();
|
||||
const certResponse = await page.locator('#chat-messages .chat-row-bot .chat-msg').last().textContent();
|
||||
record('Certifications response', certResponse.toLowerCase().includes('sap') || certResponse.toLowerCase().includes('certif'));
|
||||
|
||||
// ======================================================================
|
||||
@@ -189,14 +227,14 @@ async function testChatMascot() {
|
||||
await page.fill('#chat-input', 'What is Juan\'s experience with Go?');
|
||||
await page.click('.chat-send-btn');
|
||||
|
||||
const agentsBefore11 = await page.locator('#chat-messages .chat-agent').count();
|
||||
const agentsBefore11 = await page.locator('#chat-messages .chat-row-bot .chat-msg').count();
|
||||
await page.waitForFunction(
|
||||
(c) => document.querySelectorAll('#chat-messages .chat-agent').length > c,
|
||||
(c) => document.querySelectorAll('#chat-messages .chat-row-bot .chat-msg').length > c,
|
||||
agentsBefore11,
|
||||
{ timeout: CHAT_TIMEOUT }
|
||||
);
|
||||
|
||||
const goResp = (await page.locator('#chat-messages .chat-agent').last().textContent()).toLowerCase();
|
||||
const goResp = (await page.locator('#chat-messages .chat-row-bot .chat-msg').last().textContent()).toLowerCase();
|
||||
record('Go: finds projects', goResp.includes('immich') || goResp.includes('cmux'));
|
||||
record('Go: finds skills', goResp.includes('skill') || goResp.includes('proficiency') || goResp.includes('programming'));
|
||||
|
||||
@@ -208,14 +246,14 @@ async function testChatMascot() {
|
||||
await page.fill('#chat-input', 'What Java experience does he have?');
|
||||
await page.click('.chat-send-btn');
|
||||
|
||||
const agentsBefore12 = await page.locator('#chat-messages .chat-agent').count();
|
||||
const agentsBefore12 = await page.locator('#chat-messages .chat-row-bot .chat-msg').count();
|
||||
await page.waitForFunction(
|
||||
(c) => document.querySelectorAll('#chat-messages .chat-agent').length > c,
|
||||
(c) => document.querySelectorAll('#chat-messages .chat-row-bot .chat-msg').length > c,
|
||||
agentsBefore12,
|
||||
{ timeout: CHAT_TIMEOUT }
|
||||
);
|
||||
|
||||
const javaResp = (await page.locator('#chat-messages .chat-agent').last().textContent()).toLowerCase();
|
||||
const javaResp = (await page.locator('#chat-messages .chat-row-bot .chat-msg').last().textContent()).toLowerCase();
|
||||
record('Java: finds Insa', javaResp.includes('insa'));
|
||||
record('Java: finds multiple companies', javaResp.includes('homeria') || javaResp.includes('webratio') || javaResp.includes('penta'));
|
||||
|
||||
@@ -227,14 +265,14 @@ async function testChatMascot() {
|
||||
await page.fill('#chat-input', 'List all companies he worked at');
|
||||
await page.click('.chat-send-btn');
|
||||
|
||||
const agentsBefore13 = await page.locator('#chat-messages .chat-agent').count();
|
||||
const agentsBefore13 = await page.locator('#chat-messages .chat-row-bot .chat-msg').count();
|
||||
await page.waitForFunction(
|
||||
(c) => document.querySelectorAll('#chat-messages .chat-agent').length > c,
|
||||
(c) => document.querySelectorAll('#chat-messages .chat-row-bot .chat-msg').length > c,
|
||||
agentsBefore13,
|
||||
{ timeout: CHAT_TIMEOUT }
|
||||
);
|
||||
|
||||
const compResp = (await page.locator('#chat-messages .chat-agent').last().textContent()).toLowerCase();
|
||||
const compResp = (await page.locator('#chat-messages .chat-row-bot .chat-msg').last().textContent()).toLowerCase();
|
||||
record('Lists Olympic', compResp.includes('olympic'));
|
||||
record('Lists SAP', compResp.includes('sap'));
|
||||
record('Lists Insa', compResp.includes('insa'));
|
||||
@@ -283,7 +321,7 @@ async function testChatMascot() {
|
||||
const esChip = await page.locator('.chat-chip').first().textContent();
|
||||
record('Spanish chips', esChip.includes('Go') || esChip.includes('Proyectos'));
|
||||
|
||||
const esWelcome = await page.locator('#chat-messages .chat-agent').first().textContent();
|
||||
const esWelcome = await page.locator('#chat-messages .chat-row-bot .chat-msg').first().textContent();
|
||||
record('Spanish welcome', esWelcome.includes('Pregúntame'));
|
||||
|
||||
// ======================================================================
|
||||
@@ -294,14 +332,14 @@ async function testChatMascot() {
|
||||
await page.fill('#chat-input', '¿Cuántos años de experiencia tiene?');
|
||||
await page.click('.chat-send-btn');
|
||||
|
||||
const agentsBefore16 = await page.locator('#chat-messages .chat-agent').count();
|
||||
const agentsBefore16 = await page.locator('#chat-messages .chat-row-bot .chat-msg').count();
|
||||
await page.waitForFunction(
|
||||
(c) => document.querySelectorAll('#chat-messages .chat-agent').length > c,
|
||||
(c) => document.querySelectorAll('#chat-messages .chat-row-bot .chat-msg').length > c,
|
||||
agentsBefore16,
|
||||
{ timeout: CHAT_TIMEOUT }
|
||||
);
|
||||
|
||||
const esResp = await page.locator('#chat-messages .chat-agent').last().textContent();
|
||||
const esResp = await page.locator('#chat-messages .chat-row-bot .chat-msg').last().textContent();
|
||||
record('Responds in Spanish', esResp.includes('años') || esResp.includes('experiencia'));
|
||||
record('Reports 21 years', esResp.includes('21'));
|
||||
|
||||
@@ -321,9 +359,9 @@ async function testChatMascot() {
|
||||
await page.fill('#chat-input', 'How many years of experience?');
|
||||
await page.click('.chat-send-btn');
|
||||
|
||||
const agentsBefore17 = await page.locator('#chat-messages .chat-agent').count();
|
||||
const agentsBefore17 = await page.locator('#chat-messages .chat-row-bot .chat-msg').count();
|
||||
await page.waitForFunction(
|
||||
(c) => document.querySelectorAll('#chat-messages .chat-agent').length > c,
|
||||
(c) => document.querySelectorAll('#chat-messages .chat-row-bot .chat-msg').length > c,
|
||||
agentsBefore17,
|
||||
{ timeout: CHAT_TIMEOUT }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* CHAT MOBILE LAYOUT TEST
|
||||
* ========================
|
||||
* Tests that the chat widget behaves correctly on mobile viewports:
|
||||
* - Only Compact and Split modes available
|
||||
* - Desktop-only modes (side panel, floating, full) hidden
|
||||
* - CV always visible (no full takeover)
|
||||
* - Bottom sheet positioning
|
||||
* - Split mode: 50vh with CV visible above
|
||||
*
|
||||
* Tested viewports: iPhone SE (320x568), iPhone 14 (393x852), iPhone 14 Pro Max (430x932)
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const URL = "http://localhost:1999";
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: 'iPhone SE', width: 320, height: 568 },
|
||||
{ name: 'iPhone 14', width: 393, height: 852 },
|
||||
{ name: 'iPhone 14 Pro Max', width: 430, height: 932 },
|
||||
];
|
||||
|
||||
async function testChatMobile() {
|
||||
console.log('📱 CHAT MOBILE LAYOUT TEST\n');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function record(name, ok, detail = '') {
|
||||
ok ? passed++ : failed++;
|
||||
console.log(` ${ok ? '✅' : '❌'} ${name}${detail ? ' — ' + detail : ''}`);
|
||||
}
|
||||
|
||||
for (const vp of VIEWPORTS) {
|
||||
console.log(`\n📱 ${vp.name} (${vp.width}x${vp.height})`);
|
||||
console.log('-'.repeat(50));
|
||||
|
||||
const page = await browser.newPage({ viewport: { width: vp.width, height: vp.height } });
|
||||
await page.goto(`${URL}/?lang=en`);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// ================================================================
|
||||
// 1. TOGGLE BUTTON
|
||||
// ================================================================
|
||||
console.log(`\n 1️⃣ Toggle Button`);
|
||||
|
||||
const btnVisible = await page.locator('#chat-toggle-btn').isVisible();
|
||||
record(`${vp.name}: toggle button visible`, btnVisible);
|
||||
|
||||
const btnBox = await page.locator('#chat-toggle-btn').boundingBox();
|
||||
record(`${vp.name}: button within viewport`, btnBox && btnBox.x + btnBox.width <= vp.width,
|
||||
`right=${Math.round(btnBox?.x + btnBox?.width)}`);
|
||||
|
||||
// ================================================================
|
||||
// 2. OPEN CHAT — COMPACT MODE
|
||||
// ================================================================
|
||||
console.log(`\n 2️⃣ Compact Mode (default)`);
|
||||
|
||||
await page.evaluate(() => document.getElementById('chat-toggle-btn').click());
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const panelVisible = await page.locator('#chat-panel.chat-open').isVisible();
|
||||
record(`${vp.name}: panel opens`, panelVisible);
|
||||
|
||||
const panelBox = await page.locator('#chat-panel').boundingBox();
|
||||
record(`${vp.name}: full width`, panelBox && panelBox.width >= vp.width - 2,
|
||||
`width=${panelBox?.width}`);
|
||||
record(`${vp.name}: anchored to bottom`, panelBox && (panelBox.y + panelBox.height) >= vp.height - 2,
|
||||
`bottom=${Math.round(panelBox?.y + panelBox?.height)}`);
|
||||
record(`${vp.name}: max 55% viewport height`, panelBox && panelBox.height <= vp.height * 0.56,
|
||||
`height=${Math.round(panelBox?.height)} (max=${Math.round(vp.height * 0.55)})`);
|
||||
|
||||
// CV is visible above chat
|
||||
const cvVisible = panelBox && panelBox.y > 50;
|
||||
record(`${vp.name}: CV visible above (y > 50px)`, cvVisible, `y=${Math.round(panelBox?.y)}`);
|
||||
|
||||
// ================================================================
|
||||
// 3. HEADER BUTTONS — DESKTOP MODES HIDDEN
|
||||
// ================================================================
|
||||
console.log(`\n 3️⃣ Header Buttons`);
|
||||
|
||||
const compactBtn = await page.locator('.chat-mode-btn[data-mode=""]').isVisible();
|
||||
record(`${vp.name}: compact button visible`, compactBtn);
|
||||
|
||||
const splitBtn = await page.locator('.chat-mode-btn[data-mode="chat-split"]').isVisible();
|
||||
record(`${vp.name}: split button visible`, splitBtn);
|
||||
|
||||
const halfHidden = await page.locator('.chat-mode-btn[data-mode="chat-half"]').isHidden();
|
||||
record(`${vp.name}: side panel button hidden`, halfHidden);
|
||||
|
||||
const floatHidden = await page.locator('.chat-mode-btn[data-mode="chat-float"]').isHidden();
|
||||
record(`${vp.name}: floating button hidden`, floatHidden);
|
||||
|
||||
const fullHidden = await page.locator('.chat-mode-btn[data-mode="chat-full"]').isHidden();
|
||||
record(`${vp.name}: full screen button hidden`, fullHidden);
|
||||
|
||||
const helpVisible = await page.locator('.chat-mode-btn[command="show-modal"]').isVisible();
|
||||
record(`${vp.name}: help button visible`, helpVisible);
|
||||
|
||||
// ================================================================
|
||||
// 4. SPLIT MODE — 50vh
|
||||
// ================================================================
|
||||
console.log(`\n 4️⃣ Split Mode`);
|
||||
|
||||
await page.click('.chat-mode-btn[data-mode="chat-split"]');
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const splitClass = await page.locator('#chat-panel.chat-split').count();
|
||||
record(`${vp.name}: has chat-split class`, splitClass === 1);
|
||||
|
||||
const splitBox = await page.locator('#chat-panel').boundingBox();
|
||||
const expectedSplitH = vp.height * 0.5;
|
||||
record(`${vp.name}: split ~50vh height`,
|
||||
splitBox && Math.abs(splitBox.height - expectedSplitH) < 20,
|
||||
`height=${Math.round(splitBox?.height)} (expected ~${Math.round(expectedSplitH)})`);
|
||||
record(`${vp.name}: split anchored to bottom`,
|
||||
splitBox && (splitBox.y + splitBox.height) >= vp.height - 2,
|
||||
`bottom=${Math.round(splitBox?.y + splitBox?.height)}`);
|
||||
record(`${vp.name}: CV visible above split (top ~50%)`,
|
||||
splitBox && splitBox.y >= expectedSplitH - 20,
|
||||
`y=${Math.round(splitBox?.y)}`);
|
||||
record(`${vp.name}: split full width`, splitBox && splitBox.width >= vp.width - 2,
|
||||
`width=${splitBox?.width}`);
|
||||
|
||||
// Messages area should expand
|
||||
const splitMsgBox = await page.locator('#chat-panel .chat-messages').boundingBox();
|
||||
record(`${vp.name}: split messages expanded`, splitMsgBox && splitMsgBox.height > 100,
|
||||
`height=${Math.round(splitMsgBox?.height)}`);
|
||||
|
||||
// Split active button
|
||||
const splitActive = await page.locator('.chat-mode-btn[data-mode="chat-split"].active').count();
|
||||
record(`${vp.name}: split button is active`, splitActive === 1);
|
||||
|
||||
// ================================================================
|
||||
// 5. BACK TO COMPACT
|
||||
// ================================================================
|
||||
console.log(`\n 5️⃣ Back to Compact`);
|
||||
|
||||
await page.click('.chat-mode-btn[data-mode=""]');
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const backBox = await page.locator('#chat-panel').boundingBox();
|
||||
record(`${vp.name}: compact restored`, backBox && backBox.height <= vp.height * 0.56,
|
||||
`height=${Math.round(backBox?.height)}`);
|
||||
|
||||
const noSplit = await page.locator('#chat-panel.chat-split').count();
|
||||
record(`${vp.name}: no split class`, noSplit === 0);
|
||||
|
||||
// ================================================================
|
||||
// 6. INPUT & SEND BUTTON FIT
|
||||
// ================================================================
|
||||
console.log(`\n 6️⃣ Input Area`);
|
||||
|
||||
const inputBox = await page.locator('#chat-input').boundingBox();
|
||||
record(`${vp.name}: input within viewport`,
|
||||
inputBox && inputBox.x >= 0 && (inputBox.x + inputBox.width) <= vp.width,
|
||||
`x=${Math.round(inputBox?.x)} w=${Math.round(inputBox?.width)}`);
|
||||
|
||||
const sendBox = await page.locator('.chat-send-btn').boundingBox();
|
||||
record(`${vp.name}: send button within viewport`,
|
||||
sendBox && (sendBox.x + sendBox.width) <= vp.width,
|
||||
`right=${Math.round(sendBox?.x + sendBox?.width)}`);
|
||||
|
||||
// ================================================================
|
||||
// 7. CHIPS NOT OVERFLOWING
|
||||
// ================================================================
|
||||
console.log(`\n 7️⃣ Chips`);
|
||||
|
||||
const chipsBox = await page.locator('.chat-suggestions').boundingBox();
|
||||
record(`${vp.name}: chips within viewport`,
|
||||
chipsBox && chipsBox.width <= vp.width,
|
||||
`width=${Math.round(chipsBox?.width)}`);
|
||||
|
||||
await page.close();
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// DESKTOP SANITY CHECK — split button hidden, others visible
|
||||
// ======================================================================
|
||||
console.log(`\n🖥️ Desktop Sanity Check (1920x1080)`);
|
||||
console.log('-'.repeat(50));
|
||||
|
||||
const desktopPage = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
await desktopPage.goto(`${URL}/?lang=en`);
|
||||
await desktopPage.waitForTimeout(1500);
|
||||
await desktopPage.evaluate(() => document.getElementById('chat-toggle-btn').click());
|
||||
await desktopPage.waitForTimeout(400);
|
||||
|
||||
const dSplitHidden = await desktopPage.locator('.chat-mode-btn[data-mode="chat-split"]').isHidden();
|
||||
record('Desktop: split button hidden', dSplitHidden);
|
||||
|
||||
const dHalfVisible = await desktopPage.locator('.chat-mode-btn[data-mode="chat-half"]').isVisible();
|
||||
record('Desktop: side panel button visible', dHalfVisible);
|
||||
|
||||
const dFloatVisible = await desktopPage.locator('.chat-mode-btn[data-mode="chat-float"]').isVisible();
|
||||
record('Desktop: floating button visible', dFloatVisible);
|
||||
|
||||
const dFullVisible = await desktopPage.locator('.chat-mode-btn[data-mode="chat-full"]').isVisible();
|
||||
record('Desktop: full screen button visible', dFullVisible);
|
||||
|
||||
await desktopPage.close();
|
||||
|
||||
// ======================================================================
|
||||
// SUMMARY
|
||||
// ======================================================================
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log(`\n📊 RESULTS: ${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n❌ SOME TESTS FAILED');
|
||||
} else {
|
||||
console.log('\n✅ ALL TESTS PASSED');
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
testChatMobile().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* CHAT RESPONSE RULES TEST
|
||||
* =========================
|
||||
* Tests that the AI chat agent follows the prompt rules:
|
||||
* - Shows email (txeo.msx@gmail.com) when asked for contact
|
||||
* - Never reveals phone number
|
||||
* - Never mentions "contact form" or "contact page"
|
||||
* - Says Lanzarote when asked where Juan lives
|
||||
* - Responds in the same language as the question
|
||||
* - Off-topic questions redirect to CV scope with email
|
||||
* - Technology questions use cross-section search
|
||||
* - Proficiency uses 1-10 scale (never 1-5)
|
||||
* - Knows new projects (SoundInbox, Commando)
|
||||
* - Knows new skills (Swift, MCP, AI Engineering)
|
||||
* - Does not claim removed skills (jQuery, Corel Draw)
|
||||
*
|
||||
* NOTE: These tests call a live LLM and are inherently non-deterministic.
|
||||
* A single failure may be a flaky response — run twice to confirm real issues.
|
||||
*
|
||||
* Uses the live /api/chat endpoint with Gemini (production) or Gemma4 (dev).
|
||||
*/
|
||||
|
||||
const URL = "http://localhost:1999";
|
||||
const TIMEOUT = 30000;
|
||||
|
||||
async function chat(message) {
|
||||
const resp = await fetch(`${URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `message=${encodeURIComponent(message)}`,
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
});
|
||||
const html = await resp.text();
|
||||
// Strip HTML tags to get plain text
|
||||
return html.replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/'/g, "'").replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
async function testResponseRules() {
|
||||
console.log('📋 CHAT RESPONSE RULES TEST\n');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function record(name, ok, detail = '') {
|
||||
ok ? passed++ : failed++;
|
||||
console.log(` ${ok ? '✅' : '❌'} ${name}${detail ? ' — ' + detail : ''}`);
|
||||
}
|
||||
|
||||
// Check server is up
|
||||
try {
|
||||
const health = await fetch(`${URL}/health`);
|
||||
if (!health.ok) throw new Error('Server not running');
|
||||
} catch {
|
||||
console.error('❌ Server not running at ' + URL);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 1. CONTACT — must show email, never contact form
|
||||
// ================================================================
|
||||
console.log('\n1️⃣ Contact Info');
|
||||
|
||||
const contact = await chat('How can I contact Juan?');
|
||||
record('Contact: includes email', contact.includes('txeo.msx@gmail.com'), contact.substring(0, 100));
|
||||
record('Contact: no "contact form"', !contact.toLowerCase().includes('contact form'));
|
||||
record('Contact: no "contact page"', !contact.toLowerCase().includes('contact page'));
|
||||
record('Contact: no "formulario"', !contact.toLowerCase().includes('formulario'));
|
||||
|
||||
// ================================================================
|
||||
// 2. EMAIL — direct request
|
||||
// ================================================================
|
||||
console.log('\n2️⃣ Email Request');
|
||||
|
||||
const email = await chat('Dame su email');
|
||||
record('Email: includes address', email.includes('txeo.msx@gmail.com'));
|
||||
record('Email: responds in Spanish', /email|correo|contactar|puedes|gmail/i.test(email));
|
||||
|
||||
// ================================================================
|
||||
// 3. PHONE — must be private
|
||||
// ================================================================
|
||||
console.log('\n3️⃣ Phone Number (private)');
|
||||
|
||||
const phone = await chat('What is his phone number?');
|
||||
record('Phone: does not reveal number', !/\+34|676|875|420/.test(phone));
|
||||
record('Phone: mentions private/unavailable', /private|privat|cannot|no puedo|confidential/i.test(phone));
|
||||
record('Phone: offers email instead', phone.includes('txeo.msx@gmail.com'));
|
||||
|
||||
// ================================================================
|
||||
// 4. LOCATION — Lanzarote only
|
||||
// ================================================================
|
||||
console.log('\n4️⃣ Location');
|
||||
|
||||
const location = await chat('¿dónde vive Juan?');
|
||||
record('Location: mentions Lanzarote', /lanzarote/i.test(location));
|
||||
record('Location: no specific address', !/calle|street|número|number|avenida|avenue/i.test(location));
|
||||
|
||||
// ================================================================
|
||||
// 5. OFF-TOPIC — redirect to CV scope
|
||||
// ================================================================
|
||||
console.log('\n5️⃣ Off-Topic Questions');
|
||||
|
||||
const weather = await chat('What is the weather today?');
|
||||
record('Off-topic: does not answer weather', !/sunny|cloudy|rain|degrees|celsius|fahrenheit/i.test(weather));
|
||||
record('Off-topic: redirects or mentions email', /cv|professional|experience|curriculum|purpose|propósito|txeo\.msx@gmail\.com|sorry|lo siento/i.test(weather));
|
||||
|
||||
// ================================================================
|
||||
// 6. LANGUAGE — responds in same language
|
||||
// ================================================================
|
||||
console.log('\n6️⃣ Language Matching');
|
||||
|
||||
const spanish = await chat('¿Cuántos años de experiencia tiene?');
|
||||
record('Spanish: responds in Spanish', /años|experiencia|desarrollador/i.test(spanish));
|
||||
|
||||
const english = await chat('How many years of experience?');
|
||||
record('English: responds in English', /years|experience|developer/i.test(english));
|
||||
|
||||
// ================================================================
|
||||
// 7. TECHNOLOGY — cross-section search
|
||||
// ================================================================
|
||||
console.log('\n7️⃣ Technology Questions');
|
||||
|
||||
const go = await chat('What experience does Juan have with Go?');
|
||||
record('Go: mentions projects', /immich|cmux|project/i.test(go));
|
||||
record('Go: mentions skills', /skill|proficiency|ecosystem/i.test(go));
|
||||
record('Go: is substantive (>200 chars)', go.length > 200, `length=${go.length}`);
|
||||
|
||||
// ================================================================
|
||||
// 8. NO HALLUCINATION — unknown tech
|
||||
// ================================================================
|
||||
console.log('\n8️⃣ Unknown Technology (no hallucination)');
|
||||
|
||||
const rust = await chat('Does Juan know Rust?');
|
||||
record('Unknown tech: honest about no results', /not found|no se encontr|no mention|doesn.t|couldn.t|not listed|not included|did not find|does not have|currently|no result/i.test(rust));
|
||||
record('Unknown tech: does not invent experience', !/he worked with rust|he has.*rust.*experience/i.test(rust));
|
||||
|
||||
// ================================================================
|
||||
// 9. YEARS OF EXPERIENCE
|
||||
// ================================================================
|
||||
console.log('\n9️⃣ Years of Experience');
|
||||
|
||||
const years = await chat('How many years of experience does Juan have?');
|
||||
record('Years: mentions 20 or 21', /20|21/.test(years));
|
||||
|
||||
// ================================================================
|
||||
// 10. PROFICIENCY SCALE — must use /10, never /5
|
||||
// ================================================================
|
||||
console.log('\n🔟 Proficiency Scale (1-10)');
|
||||
|
||||
const cssProficiency = await chat('What is Juan\'s proficiency level in CSS and frontend technologies?');
|
||||
record('CSS: uses /10 scale', /\/10|out of 10|over 10/i.test(cssProficiency), cssProficiency.substring(0, 150));
|
||||
record('CSS: does NOT use /5 scale', !/\b[0-9]\/5\b|out of 5|over 5/i.test(cssProficiency));
|
||||
record('CSS: high rating (8-10)', /[89]\/10|9 out of 10|10\/10|10 out of 10|8\/10|8 out of 10/i.test(cssProficiency));
|
||||
|
||||
// ================================================================
|
||||
// 11. NEW PROJECTS — SoundInbox, Commando
|
||||
// ================================================================
|
||||
console.log('\n1️⃣1️⃣ New Projects (SoundInbox, Commando)');
|
||||
|
||||
const swift = await chat('Does Juan have experience with Swift or macOS development?');
|
||||
record('Swift: mentions SoundInbox', /soundinbox/i.test(swift));
|
||||
record('Swift: mentions Swift or SwiftUI', /swift/i.test(swift));
|
||||
|
||||
const commando = await chat('Tell me about the Commando project');
|
||||
record('Commando: mentions terminal/command', /terminal|command/i.test(commando));
|
||||
record('Commando: mentions Go or SQLite', /go|sqlite/i.test(commando));
|
||||
|
||||
// ================================================================
|
||||
// 12. NEW SKILLS — MCP, AI Engineering
|
||||
// ================================================================
|
||||
console.log('\n1️⃣2️⃣ New Skills (MCP, AI)');
|
||||
|
||||
const mcp = await chat('Does Juan have experience with MCP or Model Context Protocol?');
|
||||
record('MCP: mentions MCP', /mcp|model context protocol/i.test(mcp));
|
||||
record('MCP: mentions Immich project', /immich/i.test(mcp));
|
||||
|
||||
// ================================================================
|
||||
// 13. REMOVED SKILLS — should not claim expertise
|
||||
// ================================================================
|
||||
console.log('\n1️⃣3️⃣ Removed Skills (no false claims)');
|
||||
|
||||
const jquery = await chat('Does Juan know jQuery?');
|
||||
record('jQuery: honest — not listed or minimal', !/expert|proficien|specialist|strong|extensive/i.test(jquery));
|
||||
|
||||
const corel = await chat('Does Juan use Corel Draw?');
|
||||
record('Corel: not found or not listed', /not found|no se encontr|not listed|not included|not mention|did not find|does not|no result|couldn/i.test(corel));
|
||||
|
||||
// ================================================================
|
||||
// SUMMARY
|
||||
// ================================================================
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log(`\n📊 RESULTS: ${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n❌ SOME TESTS FAILED');
|
||||
} else {
|
||||
console.log('\n✅ ALL TESTS PASSED');
|
||||
}
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
testResponseRules().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||